From e1bed8cd7b8c18acc3ad2108dc45be5b50021cc1 Mon Sep 17 00:00:00 2001 From: sunlight798 <3281498087@qq.com> Date: Mon, 21 Sep 2026 16:14:12 +0800 Subject: [PATCH] fix(service): avoid background throttling on macOS Use Standard scheduling for LaunchAgents and reconcile existing native settings while preserving install idempotency. Add regression coverage for compatible upgrades and the real launchd lifecycle. Refs #1571 --- src/powercontext/service/adapters/launchd.py | 8 +++- src/powercontext/service/controller.py | 4 +- .../native/test_personal_service_lifecycle.py | 34 ++++++++++++++ tests/test_service.py | 46 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/powercontext/service/adapters/launchd.py b/src/powercontext/service/adapters/launchd.py index bba1c557a8..92b4927035 100644 --- a/src/powercontext/service/adapters/launchd.py +++ b/src/powercontext/service/adapters/launchd.py @@ -97,6 +97,10 @@ def inspect(self) -> NativeRegistration: definition, metadata = _definition_and_metadata_from_payload(payload) if definition.definition_version == DEFINITION_VERSION: expected = plistlib.loads(self.render(definition)) + # Intact older definitions remain owned so install can replace + # their background scheduling policy without manual removal. + if payload.get("ProcessType") == "Background": + expected["ProcessType"] = "Background" elif definition.definition_version == _LEGACY_DEFINITION_VERSION: expected = _legacy_payload(self.identifier, definition, metadata) else: @@ -162,7 +166,9 @@ def render(self, definition: ServiceDefinition) -> bytes: "RunAtLoad": True, "KeepAlive": {"PathState": {str(retry_token): True}}, "ThrottleInterval": 5, - "ProcessType": "Background", + # This Server handles user requests. Background scheduling can + # throttle import/database I/O beyond the startup deadline (#1571). + "ProcessType": "Standard", "StandardOutPath": str(log_dir / "server.stdout.log"), "StandardErrorPath": str(log_dir / "server.stderr.log"), "EnvironmentVariables": { diff --git a/src/powercontext/service/controller.py b/src/powercontext/service/controller.py index 0d6ceaefc8..85d5ae7b46 100644 --- a/src/powercontext/service/controller.py +++ b/src/powercontext/service/controller.py @@ -90,7 +90,9 @@ def install(self, *, env_file: Path | None = None, start_on_login: bool = True) self._require_mutable_registration(registration) loaded = self._adapter.loaded_registration() self._require_mutable_manager_registration(loaded) - changed = registration.definition != definition + # Native settings such as launchd's scheduling policy are not + # represented in the shared metadata, but still require a reload. + changed = registration.definition != definition or registration.content != self._adapter.render(definition) loaded_changed = loaded.state is ManagerOwnershipState.OWNED and loaded.definition != definition manager_before = ( self._adapter.manager_state() if loaded.state is ManagerOwnershipState.OWNED else ManagerState.INACTIVE diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index bf0c0a1e4a..e58d3b02bf 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -156,6 +156,7 @@ def test_native_service_definition_matches_running_process(tmp_path: Path) -> No assert payload["ProgramArguments"][0] == os.path.abspath(sys.executable) assert payload["ProgramArguments"][1:3] == ["-m", "powercontext_service_bootstrap"] assert payload["RunAtLoad"] is True + assert payload["ProcessType"] == "Standard" assert "PathState" in payload["KeepAlive"] assert payload["StandardOutPath"].endswith("logs/server.stdout.log") assert payload["StandardErrorPath"].endswith("logs/server.stderr.log") @@ -190,6 +191,39 @@ def test_native_service_definition_matches_running_process(tmp_path: Path) -> No _cleanup(adapter) +@pytest.mark.skipif(sys.platform != "darwin", reason="LaunchAgent scheduling is macOS-specific") +def test_native_service_upgrades_background_scheduling(tmp_path: Path) -> None: + adapter = _native_adapter(suffix="upgrade") + environment = _environment_file(tmp_path) + controller = ServiceController(adapter) + target = f"gui/{_current_uid()}/{adapter.identifier}" + + try: + assert controller.install(env_file=environment).ok + original_pid = re.search(r"^\s*pid = (\d+)\s*$", _run("launchctl", "print", target).stdout, re.MULTILINE) + assert original_pid is not None + original_definition = adapter.inspect().definition + # Represent an existing registration from before the scheduling fix, + # retaining its metadata and the live process until install reconciles it. + payload = plistlib.loads(adapter.artifact_path.read_bytes()) + payload["ProcessType"] = "Background" + adapter.artifact_path.write_bytes(plistlib.dumps(payload)) + + assert controller.install(env_file=environment).ok + + assert adapter.inspect().definition == original_definition + assert plistlib.loads(adapter.artifact_path.read_bytes())["ProcessType"] == "Standard" + upgraded_pid = re.search(r"^\s*pid = (\d+)\s*$", _run("launchctl", "print", target).stdout, re.MULTILINE) + assert upgraded_pid is not None and upgraded_pid.group(1) != original_pid.group(1) + + assert controller.install(env_file=environment).ok + repeated_pid = re.search(r"^\s*pid = (\d+)\s*$", _run("launchctl", "print", target).stdout, re.MULTILINE) + assert repeated_pid is not None and repeated_pid.group(1) == upgraded_pid.group(1) + finally: + _capture_native_failure(adapter, tmp_path) + _cleanup(adapter) + + @pytest.mark.skipif(sys.platform != "win32", reason="Task Scheduler login-trigger behavior is Windows-specific") def test_native_windows_service_can_disable_login_trigger(tmp_path: Path) -> None: adapter = _native_adapter(suffix="manual") diff --git a/tests/test_service.py b/tests/test_service.py index a6e8a97c7c..b95c1eebcd 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -397,6 +397,28 @@ def test_service_install_is_idempotent_when_definition_is_current(tmp_path: Path assert adapter.events == ["enable"] +def test_service_install_reconciles_native_settings_with_unchanged_metadata(tmp_path: Path) -> None: + adapter = FakeAdapter(tmp_path) + controller = ServiceController(adapter, probe=_manager_probe(adapter), sleep=lambda _: None) + controller.install() + assert adapter.definition is not None + definition = adapter.definition + # Native scheduling settings are not part of ServiceDefinition metadata. + adapter.content = b"previous native settings" + adapter.events.clear() + + status = controller.install() + + assert status.ok + assert adapter.definition == definition + assert adapter.content == adapter.render(definition) + assert adapter.events == ["write", "reload", "enable", "start:True"] + + adapter.events.clear() + assert controller.install().ok + assert adapter.events == ["enable"] + + def test_registration_status_does_not_query_manager_availability(tmp_path: Path) -> None: adapter = FakeAdapter(tmp_path) definition = _definition(tmp_path) @@ -764,6 +786,7 @@ def test_launchd_definition_round_trips_with_argument_array_and_logs( assert installed.state is RegistrationState.INSTALLED assert installed.definition == definition assert payload["ProgramArguments"][0] == executable + assert payload["ProcessType"] == "Standard" assert payload["StandardOutPath"].replace("\\", "/").endswith("logs/server.stdout.log") retry_token = Path(definition.data_dir) / "logs" / "launchd-retry.enabled" assert payload["KeepAlive"] == {"PathState": {str(retry_token): True}} @@ -1014,6 +1037,28 @@ def test_windows_uninstall_recovery_uses_scoped_task_commands(tmp_path: Path) -> assert adapter.uninstall_recovery("remove") == 'schtasks.exe /Delete /TN "\\PowerContext Test" /F /HRESULT' +@pytest.mark.parametrize("changed_field", ["ProcessType", "ThrottleInterval", "ProgramArguments"]) +def test_launchd_inspect_accepts_only_an_intact_background_definition(tmp_path: Path, changed_field: str) -> None: + adapter = LaunchdUserAdapter(home=tmp_path, uid=501) + definition = _definition(tmp_path) + payload = plistlib.loads(adapter.render(definition)) + payload["ProcessType"] = "Background" + adapter.write(plistlib.dumps(payload)) + + registration = adapter.inspect() + + assert registration.state is RegistrationState.INSTALLED + assert registration.definition == definition + + payload[changed_field] = { + "ProcessType": "Interactive", + "ThrottleInterval": 1, + "ProgramArguments": ["/bin/sleep", "30"], + }[changed_field] + adapter.artifact_path.write_bytes(plistlib.dumps(payload)) + assert adapter.inspect().state is RegistrationState.INVALID + + def test_launchd_inspect_accepts_only_an_intact_legacy_owned_definition(tmp_path: Path) -> None: adapter = LaunchdUserAdapter(home=tmp_path, uid=501) definition = _definition(tmp_path, definition_version=1) @@ -1029,6 +1074,7 @@ def test_launchd_inspect_accepts_only_an_intact_legacy_owned_definition(tmp_path "60", ] payload["KeepAlive"] = {"SuccessfulExit": False} + payload["ProcessType"] = "Background" adapter.artifact_path.parent.mkdir(parents=True) adapter.artifact_path.write_bytes(plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=True))