fix(service): strip Disabled before load, verify it, and honour the plist

service-manager 0.11 writes Disabled: true into every plist carrying
KeepAlive, and our RestartPolicy::Always guarantees KeepAlive, so every
agent we installed was born disabled. launchctl load honours the key while
still exiting 0, so install reported success on an agent that would never
start — not then and not at the next login.

service::start now removes the Disabled key itself before loading, via a
pure enable_plist() that rewrites nothing when the key is absent and
preserves every other key, including the EnvironmentVariables PATH
snapshot. That makes start self-healing for plists left disabled by an
earlier build. The crate's own start() is still not used, since without
Disabled it degrades to launchctl start, which fails on an unloaded job.

Because launchctl load exits 0 on failure, start also checks a
post-condition: it asks launchd whether the job now exists and reports a
diagnostic if it does not. stop keeps no such check, since a benign unload
of an already-stopped job also prints a failure while exiting 0.

status now treats the plist on disk as the definition of installed, as the
spec says: a plist that exists but is not loaded reports stopped with its
program, PATH and snapshot date intact instead of collapsing to
not-installed with every field cleared. That is precisely the state the
Disabled bug left users in, so it is the state status most needs to
describe.

status also gains the log path the spec always listed, and the daemon's own
log is renamed xy.log -> daemon.log so a supervised server named xy cannot
share a file, and two rotation counters, with the daemon.

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-08-01 00:27:34 +02:00
co-authored by Claude Opus 5
parent fd842289e3
commit 7bb80803fe
6 changed files with 299 additions and 41 deletions
@@ -158,7 +158,7 @@ git commit -m "feat(logs): impl io::Write for RotatingLogWriter"
### Task 2: Daemon log file
The daemon currently logs only to stderr, which launchd discards. Give it `log_dir/xy.log` using the same rotation as per-server logs, and reorder `main.rs` so paths resolve before the logger is built.
The daemon currently logs only to stderr, which launchd discards. Give it `log_dir/daemon.log` using the same rotation as per-server logs, and reorder `main.rs` so paths resolve before the logger is built.
**Files:**
- Create: `crates/xy/src/logging.rs`
@@ -193,7 +193,7 @@ mod tests {
writer.lock().unwrap().write_all(b"line\n").unwrap();
let contents = std::fs::read_to_string(tmp.path().join("xy.log")).unwrap();
let contents = std::fs::read_to_string(tmp.path().join("daemon.log")).unwrap();
assert_eq!(contents, "line\n");
}
}
@@ -224,7 +224,7 @@ const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
const LOG_FILE_KEEP: usize = 5;
pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result<Mutex<RotatingLogWriter>> {
let writer = RotatingLogWriter::open(&log_dir.join("xy.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;
let writer = RotatingLogWriter::open(&log_dir.join("daemon.log"), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;
Ok(Mutex::new(writer))
}
@@ -304,14 +304,14 @@ paths.ensure_dirs().context("create state dirs")?;
```bash
cargo build -p xy
rm -f ~/.local/state/xy/logs/xy.log
rm -f ~/.local/state/xy/logs/daemon.log
./target/debug/xy daemon &
sleep 2
cat ~/.local/state/xy/logs/xy.log
cat ~/.local/state/xy/logs/daemon.log
kill %1
```
Expected: `xy.log` exists and contains a `daemon listening` line.
Expected: `daemon.log` exists and contains a `daemon listening` line.
- [ ] **Step 8: Run the full suite, format, lint**
@@ -1203,7 +1203,7 @@ after installing a new toolchain to refresh the snapshot.
`xy service stop` lasts until the next login. To disable start-on-login
permanently, use `xy service uninstall`.
The daemon writes to `$XDG_STATE_HOME/xy/logs/xy.log`. Failures that happen
The daemon writes to `$XDG_STATE_HOME/xy/logs/daemon.log`. Failures that happen
before the daemon starts logging — a missing binary, a malformed plist — are
visible only to launchd:
@@ -1236,7 +1236,7 @@ cargo install --path crates/xy
xy service install
xy service status # expect: state: running (pid N)
xy list # expect: configured servers, reached via the daemon
tail ~/.local/state/xy/logs/xy.log
tail ~/.local/state/xy/logs/daemon.log
```
Then log out and back in, and confirm `xy service status` still reports `running` with a different pid. That last step is the only real proof that start-on-login works, because it is the only one that exercises `RunAtLoad`.