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:
@@ -80,7 +80,10 @@ XML, the daemon gains its own log file (see below).
|
||||
|
||||
**Accepted limitation.** Failures occurring before the daemon's logger exists —
|
||||
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
|
||||
|
||||
@@ -128,10 +131,12 @@ Two new modules in `crates/xy`, Rust-2018 layout (`foo.rs` + `foo/`):
|
||||
| `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` |
|
||||
| `username` | `None` — runs as the invoking user | — |
|
||||
|
||||
launchd collapses `RestartPolicy` to a `KeepAlive` boolean: `Never` maps to
|
||||
false, `Always` and `OnFailure` both map to true, and `delay_secs` is ignored.
|
||||
`Always` is chosen for the explicit `KeepAlive: true` it produces; the delay is
|
||||
carried only so the value stays meaningful if this spec is extended to systemd.
|
||||
Verified against `service-manager-0.11.0/src/launchd.rs`. `Always` emits
|
||||
`KeepAlive: true` (a plain boolean); `OnFailure` instead emits a `KeepAlive`
|
||||
*dictionary* with `SuccessfulExit: false`; `Never` omits the key. `delay_secs`
|
||||
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
|
||||
`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,
|
||||
`<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
|
||||
|
||||
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
|
||||
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
|
||||
path. On success it writes the plist *and* bootstraps, leaving a running
|
||||
daemon.
|
||||
- **`uninstall`** — stops, then removes the plist. Not-installed is not an
|
||||
error: prints `not installed`, exits 0, matching how `xy stop` already reports
|
||||
`not running`.
|
||||
- **`start`** / **`stop`** — bootstrap and bootout, leaving the plist in place.
|
||||
Both exit 1 if the agent is not installed. `stop` on an already-stopped agent
|
||||
exits 0.
|
||||
path. On success it calls `install()` then `start()` — see *launchd
|
||||
mechanics* — leaving the plist free of `Disabled` and the daemon running.
|
||||
- **`uninstall`** — delegates to the crate, which runs `launchctl remove` and
|
||||
deletes the plist. Not-installed is not an error: prints `not installed`,
|
||||
exits 0, matching how `xy stop` already reports `not running`.
|
||||
- **`start`** / **`stop`** — `launchctl load` and `launchctl unload` on the
|
||||
plist path, leaving the plist in place. Both exit 1 if the agent is not
|
||||
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
|
||||
`PATH`, and log path. Never fails on state, only on an inability to query.
|
||||
State is `running` / `stopped` / `not installed`, taken directly from
|
||||
`ServiceStatus`; the reason string from `Stopped` is surfaced verbatim, being
|
||||
the main window into launch failures. There is deliberately no separate
|
||||
"loaded" line, because the crate's API cannot distinguish a loaded-but-stopped
|
||||
agent from an unloaded one. The pid is read from the existing
|
||||
`paths.pidfile`; the crate does not expose one. The snapshot date is the
|
||||
plist's mtime, not a value stored inside it.
|
||||
`ServiceStatus`. There is deliberately no separate "loaded" line, because the
|
||||
crate's API cannot distinguish a loaded-but-stopped agent from an unloaded
|
||||
one, and no reason string, because macOS always yields `Stopped(None)`. The
|
||||
pid is read from the existing `paths.pidfile`; the crate does not expose one.
|
||||
The snapshot date is the plist's mtime, not a value stored inside it.
|
||||
|
||||
Sample output:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user