Both the spec and the plan asserted that Arc<Mutex<RotatingLogWriter>> satisfies tracing-subscriber's MakeWriter via impl MakeWriter for Arc<W>. That impl requires &'a W: io::Write, and &Mutex<W> does not implement io::Write. The error reached the implementer and cost a fix round before being caught; the shipped code uses a bare Mutex. Corrections are marked inline so the mistake stays visible rather than being silently erased. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
299 lines
14 KiB
Markdown
299 lines
14 KiB
Markdown
# xy — Start on Login (macOS)
|
||
|
||
**Date:** 2026-07-31
|
||
**Status:** Approved — ready for implementation planning
|
||
|
||
## Problem
|
||
|
||
The daemon must be launched by hand (`xy daemon`) and dies with the terminal
|
||
that started it. The MVP spec (2026-05-25) listed "auto-start at login via
|
||
launchd" as an explicit non-goal; this design delivers it.
|
||
|
||
## Goals
|
||
|
||
- Install a user-level launchd agent that starts `xy daemon` at login and
|
||
restarts it if it dies.
|
||
- Manage that agent from `xy` itself — install, uninstall, start, stop, status.
|
||
- Survive the environment launchd hands a login agent, which is far poorer than
|
||
an interactive shell's.
|
||
- Give the daemon a log file, so "it didn't come up at login" is diagnosable.
|
||
|
||
## Non-goals (deferred)
|
||
|
||
- Linux (systemd) and Windows support. The CLI shape is chosen so these are a
|
||
change inside one module, not a change to the command surface.
|
||
- System-level (root) agents. User agents only.
|
||
- Managing the agent over IPC. `xy service *` deliberately bypasses the daemon.
|
||
|
||
## Decisions
|
||
|
||
### launchd owns the daemon's lifecycle
|
||
|
||
Once installed, the LaunchAgent is the sanctioned way to run the daemon on
|
||
macOS: `RunAtLoad` starts it at login and `KeepAlive` restarts it on crash.
|
||
`xy daemon` remains available for foreground and development use; the existing
|
||
pidfile keeps the two from colliding.
|
||
|
||
### Environment: snapshot `PATH` at install time
|
||
|
||
A LaunchAgent inherits `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing from
|
||
the user's shell. Supervised MCP servers inherit whatever the daemon has, and
|
||
servers launched through toolchain shims (`pnpx`, `bunx`) will not find their
|
||
interpreter under that PATH.
|
||
|
||
`xy service install` therefore captures the invoking shell's `PATH` and writes
|
||
it into the plist's `EnvironmentVariables`. The snapshot is deterministic and
|
||
visible in the plist, at the cost of going stale when a new toolchain is added
|
||
— `xy service install --force` re-snapshots.
|
||
|
||
Rejected: wrapping the daemon in `/bin/zsh -lc`, which makes login-time startup
|
||
depend on shell rc being fast and non-interactive-safe, and adds a process to
|
||
the tree for no gain here.
|
||
|
||
Verified 2026-07-31: `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_RUNTIME_DIR`
|
||
are all unset on this machine, so `etcetera`'s `Xdg` strategy resolves to
|
||
`~/.config` and `~/.local/state` identically for the CLI and for a
|
||
launchd-spawned daemon. There is no path-mismatch risk to design around.
|
||
|
||
### Third-party crate: `service-manager`
|
||
|
||
`service-manager = "0.11"` (469K downloads, 29 reverse deps, updated
|
||
2026-02-18) provides `LaunchdServiceManager::user()` targeting
|
||
`~/Library/LaunchAgents`, an install context carrying `environment`,
|
||
`autostart` and `restart_policy`, and a `status()` returning
|
||
`NotInstalled` / `Running` / `Stopped(Option<String>)`.
|
||
|
||
Rejected alternatives:
|
||
|
||
- **`auto-launch`** (4.6M downloads) is built for GUI applications at login. It
|
||
has no notion of `KeepAlive`, restart policy, or service status — the wrong
|
||
abstraction for a supervised daemon.
|
||
- **Hand-rolled plist + `launchctl`** would re-implement the crate and leave us
|
||
owning modern-vs-legacy `launchctl` compatibility.
|
||
|
||
### Use the crate's generated plist; log from inside the daemon
|
||
|
||
`LaunchdInstallConfig` exposes only `keep_alive` — there is no way to set
|
||
`StandardOutPath` or `StandardErrorPath` through the typed API. Rather than
|
||
bypass it with the `contents: Option<String>` escape hatch and hand-author plist
|
||
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`. 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
|
||
|
||
### Program path: `current_exe()`, with a warning
|
||
|
||
`xy service install` resolves and canonicalizes `std::env::current_exe()`. If
|
||
the result contains a `target/debug` or `target/release` path component, it
|
||
prints a warning that the agent will break on `cargo clean` and proceeds.
|
||
|
||
## Architecture
|
||
|
||
`service-manager` is added to `[workspace.dependencies]` and consumed by the
|
||
`xy` crate alone. `xy-protocol`, `xy-supervisor` and `xy-ipc` are unchanged
|
||
except for one addition to `logs.rs` (below). Nothing crosses the IPC boundary:
|
||
`xy service *` manipulates launchd directly, which is what makes it work when
|
||
the daemon is dead.
|
||
|
||
Two new modules in `crates/xy`, Rust-2018 layout (`foo.rs` + `foo/`):
|
||
|
||
- **`src/service.rs`** — the OS-facing unit. Owns `AgentSpec` and thin
|
||
`install` / `uninstall` / `start` / `stop` / `status` functions that translate
|
||
it into `ServiceInstallCtx` and back out into an `AgentStatus`. Knows about
|
||
launchd; knows nothing about clap or printing. Returns data, never prints, so
|
||
it is testable without a terminal.
|
||
- **`src/cli/service.rs`** — the presentation unit. Parses the verbs, calls into
|
||
`service.rs`, formats human output, maps outcomes to exit codes. Mirrors the
|
||
existing `cli/mod.rs` / `cli/format.rs` split.
|
||
|
||
`main.rs` gains a `Service { #[command(subcommand)] verb: ServiceCmd }` arm.
|
||
|
||
`service.rs` wires `LaunchdServiceManager::user()` under
|
||
`cfg(target_os = "macos")`. On other targets the verbs return
|
||
`start-on-login is macOS-only for now` and exit 1.
|
||
|
||
## The agent
|
||
|
||
| `AgentSpec` field | Value | Plist key |
|
||
|---|---|---|
|
||
| `label` | `se.aceofba.xy` | `Label` |
|
||
| `program` | canonicalized `current_exe()` | `ProgramArguments[0]` |
|
||
| `args` | `["daemon"]` | `ProgramArguments[1..]` |
|
||
| `environment` | `[("PATH", <snapshot>)]` | `EnvironmentVariables` |
|
||
| `working_directory` | `$HOME` | `WorkingDirectory` |
|
||
| `autostart` | `true` | `RunAtLoad` |
|
||
| `restart_policy` | `Always { delay_secs: Some(10) }` | `KeepAlive` |
|
||
| `username` | `None` — runs as the invoking user | — |
|
||
|
||
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
|
||
label.
|
||
|
||
`working_directory` is `$HOME` rather than launchd's default `/`, so that a
|
||
server config using a relative `working_dir` resolves somewhere predictable.
|
||
|
||
## Daemon logging
|
||
|
||
`main.rs` is reordered so `Paths::resolve()` and `ensure_dirs()` run *before*
|
||
`tracing` is initialised. Today `ensure_dirs()` is called inside `daemon::run`,
|
||
which is too late to open a log file for the logger itself.
|
||
|
||
For the `Cmd::Daemon` arm only, the subscriber writes to `stderr.and(file)` via
|
||
`MakeWriterExt`, where the file half is `log_dir/xy.log` backed by the existing
|
||
`xy_supervisor::logs::RotatingLogWriter` (10 MB × 5, the same rotation used for
|
||
per-server logs). No adapter type is needed: `tracing-subscriber` 0.3
|
||
implements `MakeWriter` for `Mutex<W> where W: io::Write`
|
||
(`fmt/writer.rs:808`), so a plain `Mutex<RotatingLogWriter>` satisfies
|
||
`with_writer` once the `io::Write` impl lands.
|
||
|
||
**Corrected 2026-08-01.** An earlier draft of this section claimed
|
||
`Arc<Mutex<RotatingLogWriter>>` also satisfied the bound, via
|
||
`impl MakeWriter for Arc<W>`. That is false: the `Arc` impl
|
||
(`fmt/writer.rs:694`) requires `&'a W: io::Write`, and `&Mutex<W>` does not
|
||
implement `io::Write`. The claim survived into the implementation plan and cost
|
||
a fix round before being caught. The shipped code uses a bare `Mutex`.
|
||
|
||
Every other subcommand keeps stderr-only logging; CLI output does not belong in
|
||
the daemon's log.
|
||
|
||
This requires one targeted addition to existing code:
|
||
`impl std::io::Write for RotatingLogWriter` in `xy-supervisor/src/logs.rs`. The
|
||
type already tracks `written` and rotates, but today exposes only
|
||
`write_line(tag, line)`, which prefixes a tag the daemon's own log does not
|
||
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]
|
||
xy service uninstall
|
||
xy service start
|
||
xy service stop
|
||
xy service status
|
||
|
||
- **`install`** — exits 1 if the plist already exists at
|
||
`~/Library/LaunchAgents/se.aceofba.xy.plist`, directing the user to
|
||
`--force`. Presence of that file is the definition of "installed" throughout;
|
||
the crate's `ServiceStatus::NotInstalled` is treated as corroborating, not
|
||
authoritative, because it cannot distinguish a missing plist from an
|
||
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 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`. 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:
|
||
|
||
$ xy service status
|
||
agent: se.aceofba.xy (user)
|
||
plist: ~/Library/LaunchAgents/se.aceofba.xy.plist
|
||
state: running (pid 4821)
|
||
program: /Users/olsson/.cargo/bin/xy
|
||
path: /opt/homebrew/bin:… (snapshotted 2026-07-31)
|
||
log: ~/.local/state/xy/logs/xy.log
|
||
|
||
### Exit codes
|
||
|
||
Reuses the established scheme, minus the codes that cannot apply. `0` success,
|
||
`1` operational error (launchctl failed, agent missing, permission denied).
|
||
Code `2` (daemon unreachable) is structurally impossible because these commands
|
||
never open the socket; `3` (config invalid) does not arise.
|
||
|
||
## Testing
|
||
|
||
TDD throughout — failing test first, then minimal implementation.
|
||
|
||
- Unit tests in `service.rs` for the `AgentSpec` → `ServiceInstallCtx` mapping:
|
||
label parses, args are `["daemon"]`, the `PATH` snapshot is captured, `$HOME`
|
||
becomes the working directory. No launchd involved.
|
||
- Unit tests for the dev-build path predicate against a table of sample paths.
|
||
- Formatting tests in `cli/service.rs` rendering an `AgentStatus` to expected
|
||
text, mirroring the existing `cli/format.rs` tests.
|
||
- A test for `impl io::Write for RotatingLogWriter` covering byte accounting and
|
||
the rotation threshold. The rotation logic is currently exercised only through
|
||
`write_line`.
|
||
- `tests/service.rs` — a real install → status → stop → start → uninstall cycle,
|
||
marked `#[ignore]` and using the label `se.aceofba.xy-test` so that a stray
|
||
`cargo nextest run` can never install a live agent. Run manually with
|
||
`--ignored`.
|
||
|
||
## Documentation
|
||
|
||
`README.md` gains the five `xy service` verbs, a note that the agent snapshots
|
||
`PATH` at install time and that `--force` re-snapshots, and the
|
||
`launchctl print` recourse for pre-logger failures.
|