diff --git a/src/powercontext/service/adapters/launchd.py b/src/powercontext/service/adapters/launchd.py index bba1c557a..92b492703 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 0d6ceaefc..85d5ae7b4 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 bf0c0a1e4..e58d3b02b 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 a6e8a97c7..b95c1eebc 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))