docs(spec): correct launchd behaviour after reading crate source

Reading service-manager-0.11.0/src/launchd.rs invalidated three claims:

- install() always writes Disabled:true alongside KeepAlive, so a freshly
  installed agent does NOT start at login. install must be followed by
  start(), which strips the key and reloads.
- The crate's stop() runs `launchctl stop`, which a KeepAlive service
  survives. start/stop are now implemented as launchctl load/unload on the
  plist path; the crate handles install/uninstall/status only.
- status() returns Stopped(None) unconditionally on macOS, so the reason
  string the spec planned to surface does not exist.

Also corrects the RestartPolicy mapping (OnFailure emits a KeepAlive
dictionary, not a boolean) and records the launchctl-print risk to verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
This commit is contained in:
2026-07-31 22:15:27 +02:00
co-authored by Claude Opus 5
parent 3a434657bc
commit 5b088f119c
@@ -80,7 +80,10 @@ XML, the daemon gains its own log file (see below).
**Accepted limitation.** Failures occurring before the daemon's logger exists — **Accepted limitation.** Failures occurring before the daemon's logger exists —
missing binary, dyld error, malformed plist — appear in neither `xy.log` nor missing binary, dyld error, malformed plist — appear in neither `xy.log` nor
`xy service status` beyond a bare `Stopped`. Documented recourse: `xy service status`. This is worse than first assumed: `LaunchdServiceManager::
status()` returns `ServiceStatus::Stopped(None)` unconditionally
(`launchd.rs:288`), so the `Option<String>` reason carried by the enum is always
`None` on macOS and cannot be surfaced. Documented recourse:
launchctl print gui/$UID/se.aceofba.xy launchctl print gui/$UID/se.aceofba.xy
@@ -128,10 +131,12 @@ Two new modules in `crates/xy`, Rust-2018 layout (`foo.rs` + `foo/`):
| `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` | | `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` |
| `username` | `None` — runs as the invoking user | — | | `username` | `None` — runs as the invoking user | — |
launchd collapses `RestartPolicy` to a `KeepAlive` boolean: `Never` maps to Verified against `service-manager-0.11.0/src/launchd.rs`. `Always` emits
false, `Always` and `OnFailure` both map to true, and `delay_secs` is ignored. `KeepAlive: true` (a plain boolean); `OnFailure` instead emits a `KeepAlive`
`Always` is chosen for the explicit `KeepAlive: true` it produces; the delay is *dictionary* with `SuccessfulExit: false`; `Never` omits the key. `delay_secs`
carried only so the value stays meaningful if this spec is extended to systemd. has no launchd equivalent and is discarded with a `log::warn!`. `Always` is
chosen deliberately: the daemon should come back regardless of how it exited.
`delay_secs` is set to `None`, since passing a value only produces a warning.
The label is the reverse-DNS form of the `git.aceofba.se` remote. It lives on The label is the reverse-DNS form of the `git.aceofba.se` remote. It lives on
`AgentSpec` rather than being a constant so tests can install under a distinct `AgentSpec` rather than being a constant so tests can install under a distinct
@@ -164,6 +169,49 @@ want.
Result: `~/.local/state/xy/logs/` becomes uniform — `xy.log` for the daemon, Result: `~/.local/state/xy/logs/` becomes uniform — `xy.log` for the daemon,
`<server>.log` per supervised server, all rotated by the same code. `<server>.log` per supervised server, all rotated by the same code.
## launchd mechanics
Verified by reading `service-manager-0.11.0/src/launchd.rs`. Three behaviours
constrain the CLI and are not obvious from the crate's public documentation.
**`install()` deliberately produces a disabled agent.** Whenever `KeepAlive` is
present, `make_plist` also writes `Disabled: true` (`launchd.rs:440`) so that
`install()` never auto-starts, for cross-platform consistency. A `Disabled`
LaunchAgent does not start at login either, so **install alone does not deliver
start-on-login**. The crate's `start()` is what removes the `Disabled` key,
rewrites the plist, and reloads (`launchd.rs:179-194`). `xy service install`
therefore always calls `install()` *then* `start()`; after that the on-disk
plist is permanently free of `Disabled` and `RunAtLoad` works at next login.
There is no way to opt out: setting `LaunchdInstallConfig::keep_alive =
Some(true)` still takes the `has_keep_alive` branch that writes `Disabled`.
**The crate's `stop()` is unusable for our agent.** It runs `launchctl stop
<label>` (`launchd.rs:208`), which a `KeepAlive: true` service simply survives —
the crate's own doc comment says to call `uninstall` instead. Since uninstalling
would discard the `PATH` snapshot, `xy service start` and `xy service stop` are
implemented directly against `launchctl` on the plist path:
- `stop``launchctl unload <plist>`
- `start``launchctl load <plist>`
This pair is symmetric, and `start` works because `install` already stripped
`Disabled`. The crate is used for `install`, `uninstall` and `status` only.
**`install()`/`uninstall()` use the legacy verbs**, `launchctl load` and
`launchctl remove` — not `bootstrap`/`bootout`. CLI output says "loaded" rather
than "bootstrapped" to match what actually happens.
### Risk to verify during implementation
`status()` calls `launchctl print <bare-label>`, but user agents normally
require the `gui/$UID/<label>` form. The crate compensates with a two-pass
trick: on exit code 64 it scans stderr for a suggested fully-qualified label and
retries (`launchd.rs:235-276`). This is fragile and version-sensitive. Task 3
verifies it empirically against a real installed agent; if it proves unreliable,
the fallback is to run `launchctl print gui/$UID/<label>` ourselves and parse the
`state = running` line, which is what the crate is approximating anyway.
## CLI ## CLI
xy service install [--force] xy service install [--force]
@@ -180,23 +228,24 @@ Result: `~/.local/state/xy/logs/` becomes uniform — `xy.log` for the daemon,
unloadable one. With `--force`, uninstalls first, which re-snapshots `PATH` and unloadable one. With `--force`, uninstalls first, which re-snapshots `PATH` and
re-resolves `current_exe()`; this is also the upgrade path after installing a re-resolves `current_exe()`; this is also the upgrade path after installing a
new binary or adding a toolchain. Warns and proceeds on a build-tree program new binary or adding a toolchain. Warns and proceeds on a build-tree program
path. On success it writes the plist *and* bootstraps, leaving a running path. On success it calls `install()` then `start()` — see *launchd
daemon. mechanics* — leaving the plist free of `Disabled` and the daemon running.
- **`uninstall`** — stops, then removes the plist. Not-installed is not an - **`uninstall`** — delegates to the crate, which runs `launchctl remove` and
error: prints `not installed`, exits 0, matching how `xy stop` already reports deletes the plist. Not-installed is not an error: prints `not installed`,
`not running`. exits 0, matching how `xy stop` already reports `not running`.
- **`start`** / **`stop`** — bootstrap and bootout, leaving the plist in place. - **`start`** / **`stop`** — `launchctl load` and `launchctl unload` on the
Both exit 1 if the agent is not installed. `stop` on an already-stopped agent plist path, leaving the plist in place. Both exit 1 if the agent is not
exits 0. installed. `stop` on an already-stopped agent exits 0. Note that `stop` lasts
only until the next login, since `RunAtLoad` remains set; to disable
start-on-login permanently, use `uninstall`.
- **`status`** — reports label, plist path, state, program path, snapshotted - **`status`** — reports label, plist path, state, program path, snapshotted
`PATH`, and log path. Never fails on state, only on an inability to query. `PATH`, and log path. Never fails on state, only on an inability to query.
State is `running` / `stopped` / `not installed`, taken directly from State is `running` / `stopped` / `not installed`, taken directly from
`ServiceStatus`; the reason string from `Stopped` is surfaced verbatim, being `ServiceStatus`. There is deliberately no separate "loaded" line, because the
the main window into launch failures. There is deliberately no separate crate's API cannot distinguish a loaded-but-stopped agent from an unloaded
"loaded" line, because the crate's API cannot distinguish a loaded-but-stopped one, and no reason string, because macOS always yields `Stopped(None)`. The
agent from an unloaded one. The pid is read from the existing pid is read from the existing `paths.pidfile`; the crate does not expose one.
`paths.pidfile`; the crate does not expose one. The snapshot date is the The snapshot date is the plist's mtime, not a value stored inside it.
plist's mtime, not a value stored inside it.
Sample output: Sample output: