Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/powercontext/service/adapters/launchd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion src/powercontext/service/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/native/test_personal_service_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
46 changes: 46 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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)
Expand All @@ -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))

Expand Down
Loading