Compare commits

..
41 Commits
Author SHA1 Message Date
logaritmiskandClaude Opus 5 c3fe2e414b chore(deps): update the lockfile
Clears RUSTSEC-2026-0190 (anyhow 1.0.102, unsoundness in
Error::downcast_mut, patched >= 1.0.103) by taking anyhow 1.0.104.
`cargo audit` is clean afterwards.

Also drops the WASM component toolchain from the tree — wit-bindgen,
wit-component, wit-parser, wasm-encoder, wasmparser, wasm-metadata,
wasip2/wasip3 — which entered via tempfile -> getrandom 0.4.2 and go
away with getrandom 0.4.3. 153 -> 129 crate dependencies.

Notable: tokio 1.52.3 -> 1.53.1. The stdin keep-open work depends on
Child::wait() dropping its own stdin handle; the RealChild test covers
that and still passes.

etcetera 0.10 -> 0.11 and nix 0.30 -> 0.31 are deliberately NOT taken
here: both need manifest edits, and both are load-bearing (XDG path
resolution, setpgid and signals).

143 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 10:37:21 +02:00
logaritmiskandClaude Opus 5 ace5bd6e87 Merge feat/stdin-mode: keep a supervised child's stdin open
Adds a per-server `stdin` mode (inherit / null / keep-open) so stdio-first
MCP servers that also speak HTTP can be supervised without exiting on EOF.

Closes #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:55:33 +02:00
logaritmiskandClaude Opus 5 e4eb1cc6a2 docs(readme): document the stdin option
Refs #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:51:05 +02:00
logaritmiskandClaude Opus 5 38e9af1e3d test(stdin): pin keep-open through the daemon
Covers the parser -> supervisor -> spawn wiring the unit tests miss, and
characterizes the reported symptom: without `keep-open` the reader goes
Stopped, with it the server stays Running.

The harness now gives the daemon /dev/null on stdin. That is what
launchd does, and without it these tests prove nothing — the runner's
own stdin blocks on read, so an unfixed child looks identical to a
fixed one.

Refs #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:51:01 +02:00
logaritmiskandClaude Opus 5 b500882257 feat(supervisor): let a server keep its stdin open
Stdio-first MCP servers exit the moment they see EOF on fd 0. Under the
launchd agent the daemon's stdin is /dev/null, so a server that also
speaks HTTP still shuts down seconds after binding its port, and there
was no way to ask xy for anything else.

Adds a per-server `stdin` mode: `inherit` (the default, unchanged),
`null`, or `keep-open`. Under `keep-open` the child gets a pipe whose
write end RealChild holds and never writes to, so a read blocks.

The handle has to live on RealChild rather than on the TokioChild:
`Child::wait()` opens with `drop(self.stdin.take())`, so leaving it
where tokio put it reproduces the original bug the instant supervision
starts.

Refs #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
2026-09-07 09:50:42 +02:00
logaritmiskandClaude Opus 5 7f605f361e test(uptime): assert on the wire value, not the rendered table
The test read uptime with split_whitespace().nth(4), which assumed the
column held a single token. That held only because the test measures
about a second of uptime; once the formatter emits two tokens above a
minute, raising the sleep would have shifted every later column while
the assertion kept passing.

Call the list RPC directly and assert on ServerSummary::uptime_secs.
The daemon produces that value and the test only reads it, so the
assertion no longer depends on a layout the project wrote itself.

Mutation-proved: replacing the handler's elapsed().as_secs() with 0
fails the test on uptime_secs: Some(0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9BWMXKKNpid7XM6oFbyYq
2026-08-10 14:42:57 +02:00
logaritmiskandClaude Opus 5 29df988d9d feat(cli): render uptime in human units
The UPTIME column printed raw seconds, so a server up for three hours
read as 10549s. Format the two largest non-zero units instead, dropping
the smaller one when it is zero: 49s, 12m 4s, 2h 55m, 6d 3h.

Days is the largest unit and the output never exceeds eight characters,
so the ten-wide column still holds and RESTARTS stays put.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9BWMXKKNpid7XM6oFbyYq
2026-08-10 13:31:14 +02:00
logaritmisk 3dcda14ba5 test(harness): put each restart key on its own line
crates/xy-protocol now rejects a one-line KDL block with more than one
key, and Harness::write_server wrote its restart block on exactly one
line with four keys, so every integration test using it would fail to
parse its config under the new parser behavior. Split onto separate
lines so the intended 10ms/50ms/3 restart config actually takes
effect, instead of silently falling back to the 1s/30s/5 defaults as
it had been doing all along.
2026-08-07 16:29:53 +02:00
logaritmisk 44304d0625 fix(protocol): reject one-line KDL blocks with multiple keys
In KDL, a `{ }` block's children are newline- or semicolon-separated,
so a block like `restart { policy "always" backoff-initial "10ms" }`
written on one line parses as a SINGLE node named `policy` whose
remaining words become extra arguments, not sibling keys. The parser
only ever read the first argument of each child node, so every key
after the first was silently discarded with no error.

Add `single_arg`, applied to every restart/stop/wait-for key that
takes exactly one value (all except `wait-for`'s `args`, which
legitimately takes zero or more), so a one-line block with 2+ keys
now fails to parse instead of silently keeping defaults.

`optional_string_map` (the `env { }` block) has the identical trap
but is out of scope here: it returns a bare BTreeMap with no error
path and would need a signature change to report a config error.
2026-08-07 16:29:48 +02:00
logaritmiskandClaude Opus 5 938cbcbc08 test(wait-for): pin the gate end to end
The spec asked for an integration test that a server whose condition can
never hold reaches failed without ever spawning; every gate test so far
drove SupervisorTask in isolation, which is exactly why the daemon boot
loop's ack await went unnoticed. This asserts both halves: `xy list`
answers well inside the gate, and the gated server fails on timeout
without the sleep server ever reaching running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 16:17:20 +02:00
logaritmiskandClaude Opus 5 516d7d0b86 fix(daemon): keep serving while a gated server waits
The autostart loop awaited each Start ack, and a gated start does not ack
until its wait-for condition resolves. Because the accept loop and the
signal handler are installed after that loop, a server gated on a socket
that is not there yet left the daemon deaf for the whole gate: `xy list`
hung in the backlog and SIGTERM went unhandled. Nothing consumed the ack,
so drop the await and let the gates run concurrently with the accept loop.

Also clear started_at before publishing Waiting. A crash-and-regate kept
the dead process's start instant, so `xy list` showed a growing uptime for
a server that had been down for minutes -- in the one state whose whole
purpose is to explain what is going on.

And log the spawn error when a wait-for command cannot be run at all, so a
misspelled binary is distinguishable from a condition that is merely not
met yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 16:17:20 +02:00
logaritmisk a533da8e80 fix(supervisor): non-vacuous retry-budget test, clear stale uptime, sync gate reconfigure 2026-08-07 16:00:42 +02:00
logaritmisk e556335102 test(cli): pin the exact wait-line layout instead of substring checks 2026-08-07 15:58:16 +02:00
logaritmisk c7a26332e8 docs(readme): document the wait-for readiness gate 2026-08-07 15:52:20 +02:00
logaritmisk 2b84f1d1e0 feat(cli): surface the waiting state and its condition 2026-08-07 15:50:37 +02:00
logaritmisk 9307af46a8 feat(supervisor): gate spawns on the wait-for condition 2026-08-07 15:46:38 +02:00
logaritmisk 4c34e336a2 fix(protocol): reject duplicate command/args keys in wait-for block
A second `command` node silently overwrote the first via a scalar
Option<PathBuf>, and a second `args` node silently overwrote the first
via a plain Vec, so duplicate keys last-won instead of tripping the
exactly-one-condition rule. Track args as Option<Vec<String>> and
error immediately on a repeated command or args key.
2026-08-07 15:41:48 +02:00
logaritmisk 7631eefc67 feat(supervisor): evaluate wait-for conditions 2026-08-07 15:41:06 +02:00
logaritmisk dbf600c1f5 feat(protocol): Waiting state and WaitInfo on StatusDetail 2026-08-07 15:37:12 +02:00
logaritmisk fbb58ebfef feat(protocol): wait-for schema and KDL parsing 2026-08-07 15:33:19 +02:00
logaritmiskandClaude Opus 5 c000a00f05 docs(plan): make tilde expansion injectable rather than env-mutating
The tilde test set HOME via unsafe std::env::set_var. That is sound under
nextest (process per test) but hazardous under cargo test, and the runner
is not something a unit test should depend on. expand_tilde now takes the
home directory as a parameter; parse_wait_for reads $HOME once and passes
it down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 15:28:49 +02:00
logaritmiskandClaude Opus 5 73e17d51f3 docs(plan): implementation plan for the wait-for readiness gate
Six TDD tasks: schema + KDL parsing, the Waiting state and WaitInfo, a
ready module that evaluates one condition, the interruptible gate inside
do_start, CLI surfacing, and the README.

Also corrects the spec: xy-protocol has no etcetera dependency, so ~ is
expanded from $HOME; and in KDL `command`/`args` are sibling nodes inside
the wait-for block, which changes how the parser counts conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 15:10:48 +02:00
logaritmiskandClaude Opus 5 ba04cb1ec9 docs(spec): readiness gate (wait-for) for supervised servers
Docker-backed servers lose a startup race against OrbStack at login and are
marked Failed ~15s in, because a missing docker socket burns the crash-retry
budget. Measured: OrbStack takes 18s to create its socket; the retry cap
trips at 15s.

Adds a per-server wait-for precondition (path / tcp / command) that gates
the spawn without consuming the retry budget, surfaces as a new Waiting
state, and stays interruptible by stop/shutdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 15:06:25 +02:00
logaritmiskandClaude Opus 5 0c8ae3b617 fix(supervisor): compute uptime at read time, not publish time
set_state computed uptime_secs from started_at.elapsed() and pushed the
result into the watch channel. do_start sets started_at immediately before
calling set_state(Running), so the published value was always ~0, and the
list/status handlers copied that frozen snapshot straight out. A server
that stayed healthy reported 0s forever, because the value was only
recomputed on the next state transition.

Status now carries started_at (an Instant, in-process only) and the
handlers derive uptime_secs when they build ServerSummary. The wire type
is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 13:11:01 +02:00
logaritmiskandClaude Opus 5 d75d57ff0b fix(supervisor): don't count the first start as a restart
do_start incremented restart_count on every spawn, including the initial
one, so a server that had never restarted reported 1. do_start now takes a
StartCause: the explicit Start command (which is also how servers auto-start
at daemon boot) is Initial and does not count; the explicit Restart command
and the automatic post-crash respawn are Restart and do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-07 13:09:47 +02:00
logaritmiskandClaude Opus 5 ecff75d514 fix(daemon): log fatal startup errors to daemon.log
A daemon that fails during startup reported the reason via eprintln! in
main's error arm. Under launchd stderr is discarded, so the failure was
invisible: daemon.log was created and left empty — exactly the case the
log file was added for.

Found by the manual acceptance run: the launchd agent loaded correctly
(RunAtLoad fired, runs=9) but every spawn exited 1 because a previously
started daemon held the pidfile, and nothing recorded why.

Fatal errors on the daemon path now go through tracing::error!, reaching
both the log file and stderr. Other subcommands keep eprintln!, since
their stderr is the user's terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-01 09:38:04 +02:00
logaritmiskandClaude Opus 5 7bb80803fe 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
2026-08-01 00:27:34 +02:00
logaritmiskandClaude Opus 5 fd842289e3 docs: correct the false Arc<Mutex> MakeWriter claim
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
2026-08-01 00:13:54 +02:00
logaritmiskandClaude Opus 5 41acc3e21a fix(service): unescape plist XML, surface both launchctl streams, clear status fields when not installed
Three review findings on task 4:
- read_plist_fields now parses the plist properly via the plist crate
  (already a transitive dep of service-manager, promoted to direct) instead
  of slicing raw XML, so a PATH or program path containing & or < no longer
  round-trips as literal &amp;/&lt; through cli::service::render_status.
- launchctl's error path now includes both stdout and stderr, trimmed and
  joined only on non-empty parts, so a failure never surfaces as
  "launchctl load failed: " with nothing after the colon.
- status() now returns identical None fields (program, path_env,
  snapshotted, pid) whether the plist is absent or the plist exists but the
  crate reports NotInstalled (e.g. written but never loaded, or booted out
  of band) - extracted via a shared not_installed_status/build_status split
  so both paths run the same code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-08-01 00:09:59 +02:00
logaritmisk 1d73057444 docs(readme): document xy service verbs 2026-07-31 23:41:17 +02:00
logaritmisk f48221b09c feat(cli): xy service install/uninstall/start/stop/status 2026-07-31 23:39:32 +02:00
logaritmisk 999134657c feat(service): agent status plus launchctl load/unload 2026-07-31 23:35:36 +02:00
logaritmisk f267efc967 feat(service): AgentSpec and launchd install/uninstall 2026-07-31 23:32:46 +02:00
logaritmisk ae37060160 fix(daemon): drop unneeded Arc from daemon_writer's Mutex return type 2026-07-31 23:28:53 +02:00
logaritmisk b69426f4df feat(daemon): write rotating xy.log alongside stderr 2026-07-31 22:57:30 +02:00
logaritmisk f70092f1a0 feat(logs): impl io::Write for RotatingLogWriter 2026-07-31 22:49:36 +02:00
logaritmiskandClaude Opus 5 2ab74c992b docs(plan): drop the launchd integration test
Task 6 is documentation-only. A real launchd cycle test would either
hard-code the live label and risk clobbering a working installation, or
need a test-only --label flag; the manual acceptance section covers the
mechanism instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:48:12 +02:00
logaritmiskandClaude Opus 5 f74eb1e865 docs(spec): drop the unnecessary MakeWriter newtype
tracing-subscriber already impls MakeWriter for Mutex<W: io::Write> and
Arc<W>, so Arc<Mutex<RotatingLogWriter>> satisfies with_writer directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:24:51 +02:00
logaritmiskandClaude Opus 5 dff798a9be docs(plan): implementation plan for start-on-login
Six TDD tasks: io::Write for RotatingLogWriter, the daemon log file and
main.rs reorder, AgentSpec plus launchd install/uninstall, status and
launchctl load/unload, the CLI verbs, and the ignored end-to-end test
plus README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:21:01 +02:00
logaritmiskandClaude Opus 5 5b088f119c 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
2026-07-31 22:15:27 +02:00
logaritmiskandClaude Opus 5 3a434657bc docs(spec): start-on-login via launchd user agent (macOS)
Design for `xy service install|uninstall|start|stop|status`, backed by the
service-manager crate. Covers the launchd environment problem (PATH snapshot
at install time), the daemon's own rotating log file, and the accepted
blind spot for pre-logger launch failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
2026-07-31 22:12:15 +02:00
32 changed files with 6208 additions and 385 deletions
Generated
+347 -330
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -33,3 +33,5 @@ humantime-serde = "1"
async-trait = "0.1"
tempfile = "3"
tokio-test = "0.4"
service-manager = "0.11"
plist = "1"
+63
View File
@@ -24,3 +24,66 @@ Commands:
xy logs <name> [--tail N] [--follow]
Exit codes: 0 success, 1 operational error, 2 daemon unreachable, 3 config invalid.
## Waiting on a dependency
A server can declare a precondition that must hold before it is started:
wait-for {
path "~/.orbstack/run/docker.sock"
timeout "180s"
interval "1s"
}
Exactly one condition per block — `path "<p>"` (the path exists),
`tcp "<host:port>"` (a connection succeeds), or `command "<c>"` with optional
`args` (the process exits 0). `timeout` defaults to 120s and `interval` to 1s.
While waiting the server reports state `waiting`, and `xy status <name>` shows
which condition it is blocked on and for how long. If the timeout expires the
server is marked `failed` and is never spawned.
Waiting does **not** consume the restart budget: a slow dependency costs
patience, not retries. This is what stops a Docker-backed server from being
marked failed at login while the Docker daemon is still starting.
## Servers that read stdin
MCP servers are stdio-first by convention, and several that also speak HTTP
still start their stdio transport unconditionally. Under the launchd agent the
daemon's stdin is `/dev/null`, so such a server sees EOF on its first read and
shuts down seconds after binding its port.
`stdin` picks what the child gets on fd 0:
stdin "keep-open"
- `inherit` (default) — the child inherits the daemon's stdin.
- `null``/dev/null`; a read sees EOF immediately.
- `keep-open` — a pipe that `xy` holds open for the child's lifetime and never
writes to, so a read blocks instead of seeing EOF.
Use `keep-open` for a server you want supervised in HTTP mode that insists on
running its stdio transport anyway.
## Start on login (macOS)
xy service install # write the LaunchAgent, load it, start the daemon
xy service uninstall # unload and remove the agent
xy service start # load an installed agent
xy service stop # unload until the next login
xy service status # show agent state
`xy service install` snapshots the current `PATH` into the agent, because a
launchd agent otherwise inherits only `/usr/bin:/bin:/usr/sbin:/sbin` and
supervised servers would not find their toolchains. Re-run with `--force`
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/daemon.log`. Failures that happen
before the daemon starts logging — a missing binary, a malformed plist — are
visible only to launchd:
launchctl print gui/$UID/se.aceofba.xy
+42
View File
@@ -66,6 +66,19 @@ impl Default for StopConfig {
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StdinMode {
/// The child inherits the daemon's stdin. Today's behaviour.
#[default]
Inherit,
/// The child gets `/dev/null`; a read sees EOF immediately.
Null,
/// The child gets a pipe that `xy` holds open and never writes to,
/// so a read blocks instead of seeing EOF.
KeepOpen,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerConfig {
pub name: String,
@@ -81,6 +94,35 @@ pub struct ServerConfig {
pub restart: RestartConfig,
#[serde(default)]
pub stop: StopConfig,
#[serde(default)]
pub wait_for: Option<WaitForConfig>,
#[serde(default)]
pub stdin: StdinMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WaitCondition {
Path(PathBuf),
Tcp(String),
Command { command: PathBuf, args: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WaitForConfig {
pub condition: WaitCondition,
#[serde(default = "default_wait_timeout", with = "humantime_serde")]
pub timeout: Duration,
#[serde(default = "default_wait_interval", with = "humantime_serde")]
pub interval: Duration,
}
pub fn default_wait_timeout() -> Duration {
Duration::from_secs(120)
}
pub fn default_wait_interval() -> Duration {
Duration::from_secs(1)
}
#[cfg(test)]
+444 -1
View File
@@ -1,4 +1,7 @@
use crate::{ConfigError, RestartConfig, RestartPolicy, ServerConfig, StopConfig};
use crate::{
ConfigError, RestartConfig, RestartPolicy, ServerConfig, StdinMode, StopConfig, WaitCondition,
WaitForConfig, default_wait_interval, default_wait_timeout,
};
use kdl::{KdlDocument, KdlNode};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -27,6 +30,8 @@ pub fn parse_server_config(
let working_dir = optional_string_arg(&doc, "working-dir").map(PathBuf::from);
let restart = parse_restart(&doc, source_path)?;
let stop = parse_stop(&doc, source_path)?;
let wait_for = parse_wait_for(&doc, source_path)?;
let stdin = parse_stdin(&doc, source_path)?;
Ok(ServerConfig {
name: name.to_string(),
@@ -37,6 +42,8 @@ pub fn parse_server_config(
working_dir,
restart,
stop,
wait_for,
stdin,
})
}
@@ -149,6 +156,8 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
for child in children.nodes() {
match child.name().value() {
"policy" => {
single_arg(child, "restart", path)?;
let s = string_arg(child, "policy", path)?;
out.policy = match s.as_str() {
@@ -165,12 +174,18 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
};
}
"backoff-initial" => {
single_arg(child, "restart", path)?;
out.backoff_initial = parse_duration_arg(child, "restart.backoff-initial", path)?;
}
"backoff-max" => {
single_arg(child, "restart", path)?;
out.backoff_max = parse_duration_arg(child, "restart.backoff-max", path)?;
}
"max-retries-per-minute" => {
single_arg(child, "restart", path)?;
let v = child
.entries()
.first()
@@ -201,6 +216,27 @@ fn parse_restart(doc: &KdlDocument, path: &Path) -> Result<RestartConfig, Config
Ok(out)
}
fn parse_stdin(doc: &KdlDocument, path: &Path) -> Result<StdinMode, ConfigError> {
let Some(node) = find_node(doc, "stdin") else {
return Ok(StdinMode::default());
};
single_arg(node, "stdin", path)?;
let s = string_arg(node, "stdin", path)?;
match s.as_str() {
"inherit" => Ok(StdinMode::Inherit),
"null" => Ok(StdinMode::Null),
"keep-open" => Ok(StdinMode::KeepOpen),
other => Err(ConfigError::InvalidValue {
path: path.to_path_buf(),
field: "stdin",
message: format!("unknown stdin mode `{other}`"),
}),
}
}
fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError> {
let Some(node) = find_node(doc, "stop") else {
return Ok(StopConfig::default());
@@ -215,6 +251,8 @@ fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError>
for child in children.nodes() {
match child.name().value() {
"grace" => {
single_arg(child, "stop", path)?;
out.grace = parse_duration_arg(child, "stop.grace", path)?;
}
other => {
@@ -230,6 +268,135 @@ fn parse_stop(doc: &KdlDocument, path: &Path) -> Result<StopConfig, ConfigError>
Ok(out)
}
fn expand_tilde(raw: &str, home: Option<&Path>) -> PathBuf {
let Some(rest) = raw.strip_prefix("~/") else {
return PathBuf::from(raw);
};
match home {
Some(home) => home.join(rest),
None => PathBuf::from(raw),
}
}
fn parse_wait_for(doc: &KdlDocument, path: &Path) -> Result<Option<WaitForConfig>, ConfigError> {
let Some(node) = find_node(doc, "wait-for") else {
return Ok(None);
};
let invalid = |message: String| ConfigError::InvalidValue {
path: path.to_path_buf(),
field: "wait-for",
message,
};
let Some(children) = node.children() else {
return Err(invalid(
"expected a block with exactly one condition".into(),
));
};
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut conditions: Vec<WaitCondition> = Vec::new();
let mut command: Option<PathBuf> = None;
let mut args: Option<Vec<String>> = None;
let mut timeout = default_wait_timeout();
let mut interval = default_wait_interval();
for child in children.nodes() {
match child.name().value() {
"path" => {
single_arg(child, "wait-for", path)?;
let raw = string_arg(child, "wait-for", path)?;
conditions.push(WaitCondition::Path(expand_tilde(&raw, home.as_deref())));
}
"tcp" => {
single_arg(child, "wait-for", path)?;
let addr = string_arg(child, "wait-for", path)?;
conditions.push(WaitCondition::Tcp(addr));
}
"command" => {
single_arg(child, "wait-for", path)?;
if command.is_some() {
return Err(invalid("duplicate key `command`".into()));
}
command = Some(PathBuf::from(string_arg(child, "wait-for", path)?));
}
"args" => {
if args.is_some() {
return Err(invalid("duplicate key `args`".into()));
}
args = Some(
child
.entries()
.iter()
.filter_map(|e| e.value().as_string().map(str::to_string))
.collect(),
);
}
"timeout" => {
single_arg(child, "wait-for", path)?;
timeout = parse_duration_arg(child, "wait-for", path)?;
}
"interval" => {
single_arg(child, "wait-for", path)?;
interval = parse_duration_arg(child, "wait-for", path)?;
}
other => {
return Err(invalid(format!("unknown key `{other}`")));
}
}
}
if let Some(command) = command {
conditions.push(WaitCondition::Command {
command,
args: args.unwrap_or_default(),
});
} else if args.is_some() {
return Err(invalid("`args` requires `command`".into()));
}
match conditions.len() {
1 => Ok(Some(WaitForConfig {
condition: conditions.remove(0),
timeout,
interval,
})),
0 => Err(invalid(
"expected exactly one of `path`, `tcp` or `command`".into(),
)),
n => Err(invalid(format!(
"expected exactly one condition, found {n}"
))),
}
}
fn single_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result<(), ConfigError> {
if node.entries().len() == 1 {
return Ok(());
}
Err(ConfigError::InvalidValue {
path: path.to_path_buf(),
field,
message: format!(
"unexpected extra arguments for key `{}`; put each key on its own line",
node.name().value()
),
})
}
fn string_arg(node: &KdlNode, field: &'static str, path: &Path) -> Result<String, ConfigError> {
node.entries()
.first()
@@ -444,4 +611,280 @@ stop {
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parses_a_path_condition_with_defaults() {
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/var/run/d.sock\" }";
let cfg = parse_server_config("foo", text, p()).unwrap();
let wait = cfg.wait_for.unwrap();
assert_eq!(
wait.condition,
WaitCondition::Path(PathBuf::from("/var/run/d.sock"))
);
assert_eq!(wait.timeout, Duration::from_secs(120));
assert_eq!(wait.interval, Duration::from_secs(1));
}
#[test]
fn expands_a_leading_tilde_against_the_given_home() {
let home = PathBuf::from("/home/someone");
assert_eq!(
expand_tilde("~/.orbstack/run/docker.sock", Some(&home)),
PathBuf::from("/home/someone/.orbstack/run/docker.sock")
);
}
#[test]
fn leaves_non_tilde_paths_and_unknown_home_alone() {
let home = PathBuf::from("/home/someone");
assert_eq!(
expand_tilde("/var/run/d.sock", Some(&home)),
PathBuf::from("/var/run/d.sock")
);
assert_eq!(expand_tilde("~/a", None), PathBuf::from("~/a"));
assert_eq!(
expand_tilde("~weird/a", Some(&home)),
PathBuf::from("~weird/a")
);
}
#[test]
fn parses_a_tcp_condition_with_overrides() {
let text = "command \"/bin/x\"\nport 1\nwait-for { tcp \"127.0.0.1:5432\"\ntimeout \"30s\"\ninterval \"250ms\" }";
let cfg = parse_server_config("foo", text, p()).unwrap();
let wait = cfg.wait_for.unwrap();
assert_eq!(wait.condition, WaitCondition::Tcp("127.0.0.1:5432".into()));
assert_eq!(wait.timeout, Duration::from_secs(30));
assert_eq!(wait.interval, Duration::from_millis(250));
}
#[test]
fn parses_a_command_condition_from_sibling_nodes() {
let text = "command \"/bin/x\"\nport 1\nwait-for { command \"docker\"\nargs \"info\" }";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(
cfg.wait_for.unwrap().condition,
WaitCondition::Command {
command: PathBuf::from("docker"),
args: vec!["info".to_string()],
}
);
}
#[test]
fn absent_wait_for_leaves_no_gate() {
let text = "command \"/bin/x\"\nport 1";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert!(cfg.wait_for.is_none());
}
#[test]
fn wait_for_without_a_condition_fails() {
let text = "command \"/bin/x\"\nport 1\nwait-for { timeout \"5s\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn wait_for_with_two_conditions_fails() {
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/a\"\ntcp \"127.0.0.1:1\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn wait_for_args_without_command_fails() {
let text = "command \"/bin/x\"\nport 1\nwait-for { args \"info\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn unknown_wait_for_key_fails() {
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/a\"\nnope \"x\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn duplicate_command_key_fails() {
let text =
"command \"/bin/x\"\nport 1\nwait-for { command \"/bin/a\"\ncommand \"/bin/b\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn duplicate_args_key_fails() {
let text =
"command \"/bin/x\"\nport 1\nwait-for { command \"docker\"\nargs \"a\"\nargs \"b\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn expand_tilde_alone_is_left_unchanged() {
let home = PathBuf::from("/home/someone");
assert_eq!(expand_tilde("~", Some(&home)), PathBuf::from("~"));
}
#[test]
fn expand_tilde_of_empty_string_is_left_unchanged() {
let home = PathBuf::from("/home/someone");
assert_eq!(expand_tilde("", Some(&home)), PathBuf::from(""));
}
#[test]
fn one_line_restart_block_with_two_keys_fails() {
let text =
"command \"/bin/x\"\nport 1\nrestart { policy \"always\" backoff-initial \"10ms\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "restart",
..
}
));
}
#[test]
fn one_line_wait_for_block_with_two_keys_fails() {
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/x\" timeout \"5s\" }";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue {
field: "wait-for",
..
}
));
}
#[test]
fn one_line_single_key_stop_block_still_parses() {
let text = "command \"/bin/x\"\nport 1\nstop { grace \"30s\" }";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stop.grace, Duration::from_secs(30));
}
#[test]
fn one_line_single_key_wait_for_block_still_parses() {
let text = "command \"/bin/x\"\nport 1\nwait-for { path \"/x\" }";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(
cfg.wait_for.unwrap().condition,
WaitCondition::Path(PathBuf::from("/x"))
);
}
#[test]
fn stdin_defaults_to_inherit() {
let text = "command \"/bin/x\"\nport 1";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::Inherit);
}
#[test]
fn parses_stdin_inherit() {
let text = "command \"/bin/x\"\nport 1\nstdin \"inherit\"";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::Inherit);
}
#[test]
fn parses_stdin_null() {
let text = "command \"/bin/x\"\nport 1\nstdin \"null\"";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::Null);
}
#[test]
fn parses_stdin_keep_open() {
let text = "command \"/bin/x\"\nport 1\nstdin \"keep-open\"";
let cfg = parse_server_config("foo", text, p()).unwrap();
assert_eq!(cfg.stdin, StdinMode::KeepOpen);
}
#[test]
fn unknown_stdin_mode_fails() {
let text = "command \"/bin/x\"\nport 1\nstdin \"maybe\"";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue { field: "stdin", .. }
));
}
#[test]
fn stdin_with_two_args_fails() {
let text = "command \"/bin/x\"\nport 1\nstdin \"null\" \"keep-open\"";
let err = parse_server_config("foo", text, p()).unwrap_err();
assert!(matches!(
err,
ConfigError::InvalidValue { field: "stdin", .. }
));
}
}
+4 -1
View File
@@ -6,7 +6,10 @@ pub mod kdl_parse;
pub mod rpc;
pub mod state;
pub use config::{RestartConfig, RestartPolicy, ServerConfig, StopConfig};
pub use config::{
RestartConfig, RestartPolicy, ServerConfig, StdinMode, StopConfig, WaitCondition,
WaitForConfig, default_wait_interval, default_wait_timeout,
};
pub use error::{ConfigError, RpcErrorCode};
pub use kdl_parse::{load_all_configs, parse_server_config};
pub use state::ServerState;
+9
View File
@@ -20,6 +20,15 @@ pub struct StatusDetail {
#[serde(flatten)]
pub summary: ServerSummary,
pub recent_transitions: Vec<StateTransition>,
#[serde(default)]
pub wait_for: Option<WaitInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaitInfo {
pub description: String,
pub elapsed_secs: u64,
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+12
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "kebab-case")]
pub enum ServerState {
Stopped,
Waiting,
Starting,
Running,
Restarting,
@@ -28,4 +29,15 @@ mod tests {
let s: ServerState = serde_json::from_str("\"failed\"").unwrap();
assert_eq!(s, ServerState::Failed);
}
#[test]
fn waiting_state_round_trips_as_json() {
let json = serde_json::to_string(&ServerState::Waiting).unwrap();
assert_eq!(json, "\"waiting\"");
let back: ServerState = serde_json::from_str(&json).unwrap();
assert_eq!(back, ServerState::Waiting);
}
}
+81 -2
View File
@@ -76,13 +76,18 @@ use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child as TokioChild, Command};
use xy_protocol::{ServerConfig, rpc::LogStream};
use tokio::process::{Child as TokioChild, ChildStdin, Command};
use xy_protocol::{ServerConfig, StdinMode, rpc::LogStream};
pub struct RealChild {
pid: u32,
pgid: Pid,
child: Option<TokioChild>,
/// Write end of the child's stdin pipe under [`StdinMode::KeepOpen`].
/// Never written to; held so the child blocks on read rather than
/// seeing EOF. `TokioChild::wait()` drops the handle it owns, so the
/// pipe must live out here to outlast supervision.
_stdin: Option<ChildStdin>,
}
impl RealChild {
@@ -132,6 +137,16 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
cmd.current_dir(dir);
}
match cfg.stdin {
StdinMode::Inherit => {}
StdinMode::Null => {
cmd.stdin(Stdio::null());
}
StdinMode::KeepOpen => {
cmd.stdin(Stdio::piped());
}
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
@@ -149,6 +164,8 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
let pid = child.id().ok_or_else(|| std::io::Error::other("no pid"))?;
let pgid = Pid::from_raw(pid as i32);
let stdin = child.stdin.take();
if let Some(out) = child.stdout.take() {
spawn_pump(out, sink.clone(), LogStream::Stdout);
}
@@ -161,6 +178,7 @@ pub fn spawn_with_logs(cfg: &ServerConfig, sink: LogSink) -> std::io::Result<Rea
pid,
pgid,
child: Some(child),
_stdin: stdin,
})
}
@@ -208,4 +226,65 @@ mod tests {
child.terminate().unwrap();
ctl.terminate_rx.try_recv().unwrap();
}
use crate::logs::RotatingLogWriter;
use std::time::Duration;
use tempfile::tempdir;
use xy_protocol::{RestartConfig, StdinMode, StopConfig};
fn reader_cfg(stdin: StdinMode, script: &str) -> ServerConfig {
ServerConfig {
name: "reader".to_string(),
command: "/bin/sh".into(),
args: vec!["-c".into(), script.to_string()],
port: 1,
env: Default::default(),
working_dir: None,
restart: RestartConfig::default(),
stop: StopConfig::default(),
wait_for: None,
stdin,
}
}
fn test_sink() -> LogSink {
let dir = tempdir().unwrap();
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
std::mem::forget(dir);
LogSink::new("reader".to_string(), writer, 1024)
}
/// Exits 9 straight away unless fd 0 is a FIFO, so the assertion below
/// cannot be satisfied by whatever stdin the test runner happened to have.
const REQUIRE_PIPE_THEN_READ: &str = "[ -p /dev/stdin ] || exit 9; read line; exit 7";
#[tokio::test]
async fn keep_open_gives_the_child_a_pipe_that_survives_wait() {
let cfg = reader_cfg(StdinMode::KeepOpen, REQUIRE_PIPE_THEN_READ);
let mut child = spawn_with_logs(&cfg, test_sink()).unwrap();
// `TokioChild::wait()` drops its own stdin handle on entry, so this
// blocks only if `RealChild` took the handle and is still holding it.
let outcome = tokio::time::timeout(Duration::from_millis(500), child.wait()).await;
assert!(
outcome.is_err(),
"child should still be blocked on read, got {outcome:?}"
);
child.kill().unwrap();
}
#[tokio::test]
async fn null_stdin_child_sees_eof_immediately() {
let cfg = reader_cfg(StdinMode::Null, "read line; exit 7");
let mut child = spawn_with_logs(&cfg, test_sink()).unwrap();
let code = tokio::time::timeout(Duration::from_millis(2000), child.wait())
.await
.expect("child should exit promptly on EOF")
.unwrap();
assert_eq!(code, Some(7));
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod backoff;
pub mod child;
pub mod logs;
pub mod policy;
mod ready;
pub mod retry_window;
pub mod supervisor;
+53
View File
@@ -69,6 +69,24 @@ impl RotatingLogWriter {
}
}
impl std::io::Write for RotatingLogWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.file.write_all(buf)?;
self.written += buf.len() as u64;
if self.written >= self.max_bytes {
self.rotate()?;
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.file.flush()
}
}
#[derive(Clone)]
pub struct RingBuffer {
inner: Arc<Mutex<RingBufferInner>>,
@@ -260,4 +278,39 @@ mod tests {
assert_eq!(got.stream, LogStream::Stdout);
assert_eq!(sink.ring.snapshot_tail(None).len(), 1);
}
#[test]
fn write_trait_appends_bytes() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemon.log");
let mut writer = RotatingLogWriter::open(&base, 1024, 3).unwrap();
writer.write_all(b"hello\n").unwrap();
writer.flush().unwrap();
let contents = std::fs::read_to_string(&base).unwrap();
assert_eq!(contents, "hello\n");
}
#[test]
fn write_trait_rotates_at_threshold() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemon.log");
let mut writer = RotatingLogWriter::open(&base, 8, 3).unwrap();
writer.write_all(b"0123456789").unwrap();
writer.write_all(b"after\n").unwrap();
writer.flush().unwrap();
let rotated = tmp.path().join("daemon.log.1");
assert!(rotated.exists());
assert_eq!(std::fs::read_to_string(&rotated).unwrap(), "0123456789");
assert_eq!(std::fs::read_to_string(&base).unwrap(), "after\n");
}
}
+159
View File
@@ -0,0 +1,159 @@
use std::process::Stdio;
use std::time::Duration;
use tracing::debug;
use xy_protocol::WaitCondition;
pub(crate) async fn is_ready(condition: &WaitCondition, budget: Duration) -> bool {
match condition {
WaitCondition::Path(p) => p.exists(),
WaitCondition::Tcp(addr) => {
let connect = tokio::net::TcpStream::connect(addr);
matches!(tokio::time::timeout(budget, connect).await, Ok(Ok(_)))
}
WaitCondition::Command { command, args } => {
let mut cmd = tokio::process::Command::new(command);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
match tokio::time::timeout(budget, cmd.status()).await {
Ok(Ok(status)) => status.success(),
Ok(Err(err)) => {
debug!(
command = %command.display(),
error = %err,
"wait-for command could not be run",
);
false
}
Err(_) => false,
}
}
}
}
pub(crate) fn describe(condition: &WaitCondition) -> String {
match condition {
WaitCondition::Path(p) => format!("path {}", p.display()),
WaitCondition::Tcp(addr) => format!("tcp {addr}"),
WaitCondition::Command { command, args } => {
let mut out = format!("command {}", command.display());
for arg in args {
out.push(' ');
out.push_str(arg);
}
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
#[tokio::test]
async fn an_existing_path_is_ready() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("sock");
std::fs::write(&file, b"").unwrap();
assert!(is_ready(&WaitCondition::Path(file), ms(50)).await);
}
#[tokio::test]
async fn a_missing_path_is_not_ready() {
let condition = WaitCondition::Path(PathBuf::from("/definitely/not/here.sock"));
assert!(!is_ready(&condition, ms(50)).await);
}
#[tokio::test]
async fn a_listening_port_is_ready() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
assert!(is_ready(&WaitCondition::Tcp(addr), ms(500)).await);
}
#[tokio::test]
async fn a_closed_port_is_not_ready() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
drop(listener);
assert!(!is_ready(&WaitCondition::Tcp(addr), ms(500)).await);
}
#[tokio::test]
async fn a_command_exiting_zero_is_ready() {
let condition = WaitCondition::Command {
command: PathBuf::from("/usr/bin/true"),
args: vec![],
};
assert!(is_ready(&condition, ms(500)).await);
}
#[tokio::test]
async fn a_command_exiting_nonzero_is_not_ready() {
let condition = WaitCondition::Command {
command: PathBuf::from("/usr/bin/false"),
args: vec![],
};
assert!(!is_ready(&condition, ms(500)).await);
}
#[tokio::test]
async fn a_command_that_cannot_be_run_is_not_ready() {
let condition = WaitCondition::Command {
command: PathBuf::from("/definitely/not/here/docker"),
args: vec![],
};
assert!(!is_ready(&condition, ms(500)).await);
}
#[tokio::test]
async fn a_command_that_outlasts_its_budget_is_not_ready() {
let condition = WaitCondition::Command {
command: PathBuf::from("/bin/sleep"),
args: vec!["5".to_string()],
};
assert!(!is_ready(&condition, ms(100)).await);
}
#[test]
fn descriptions_name_the_condition() {
assert_eq!(
describe(&WaitCondition::Path(PathBuf::from("/a/b.sock"))),
"path /a/b.sock"
);
assert_eq!(
describe(&WaitCondition::Tcp("127.0.0.1:5432".into())),
"tcp 127.0.0.1:5432"
);
assert_eq!(
describe(&WaitCondition::Command {
command: PathBuf::from("docker"),
args: vec!["info".to_string()],
}),
"command docker info"
);
}
}
+576 -17
View File
@@ -22,7 +22,7 @@ pub enum SupervisorCmd {
ack: oneshot::Sender<()>,
},
Reconfigure {
new: ServerConfig,
new: Box<ServerConfig>,
ack: oneshot::Sender<()>,
},
Shutdown {
@@ -43,14 +43,22 @@ pub enum StopAck {
NotRunning,
}
#[derive(Debug, Clone)]
pub struct WaitProgress {
pub description: String,
pub started_at: Instant,
pub timeout: Duration,
}
#[derive(Debug, Clone)]
pub struct Status {
pub state: ServerState,
pub pid: Option<u32>,
pub port: u16,
pub uptime_secs: Option<u64>,
pub started_at: Option<Instant>,
pub restart_count: u32,
pub last_exit: Option<i32>,
pub waiting: Option<WaitProgress>,
}
#[derive(Clone)]
@@ -79,6 +87,7 @@ pub struct SupervisorTask<S: Spawner> {
last_exit: Option<i32>,
started_at: Option<Instant>,
current_pid: Option<u32>,
waiting: Option<WaitProgress>,
}
impl<S: Spawner> SupervisorTask<S> {
@@ -105,19 +114,19 @@ impl<S: Spawner> SupervisorTask<S> {
last_exit: None,
started_at: None,
current_pid: None,
waiting: None,
}
}
fn set_state(&mut self, s: ServerState) {
let uptime_secs = self.started_at.map(|t| t.elapsed().as_secs());
let _ = self.status_tx.send(Status {
state: s,
pid: self.current_pid,
port: self.cfg.port,
uptime_secs,
started_at: self.started_at,
restart_count: self.restart_count,
last_exit: self.last_exit,
waiting: self.waiting.clone(),
});
}
@@ -134,16 +143,30 @@ impl<S: Spawner> SupervisorTask<S> {
if child.is_some() {
let _ = ack.send(StartAck::AlreadyRunning);
} else {
match self.do_start().await {
match self.do_start(StartCause::Initial).await {
Ok(c) => {
child = Some(c);
let _ = ack.send(StartAck::Started);
}
Err(err) => {
Err(StartFailure::Spawn(err)) => {
warn!(name = %self.cfg.name, error = %err, "spawn failed");
self.started_at = None;
self.set_state(ServerState::Failed);
let _ = ack.send(StartAck::SpawnFailed(err.to_string()));
}
Err(StartFailure::WaitTimedOut) => {
warn!(name = %self.cfg.name, "wait-for timed out");
self.started_at = None;
self.set_state(ServerState::Failed);
let _ = ack.send(StartAck::SpawnFailed(
"wait-for timed out".to_string(),
));
}
Err(StartFailure::Cancelled) => {
self.started_at = None;
self.set_state(ServerState::Stopped);
}
Err(StartFailure::Shutdown) => return,
}
}
}
@@ -161,18 +184,29 @@ impl<S: Spawner> SupervisorTask<S> {
self.do_stop(c).await;
}
match self.do_start().await {
match self.do_start(StartCause::Restart).await {
Ok(c) => child = Some(c),
Err(err) => {
Err(StartFailure::Spawn(err)) => {
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
self.started_at = None;
self.set_state(ServerState::Failed);
}
Err(StartFailure::WaitTimedOut) => {
warn!(name = %self.cfg.name, "wait-for timed out");
self.started_at = None;
self.set_state(ServerState::Failed);
}
Err(StartFailure::Cancelled) => {
self.started_at = None;
self.set_state(ServerState::Stopped);
}
Err(StartFailure::Shutdown) => return,
}
let _ = ack.send(());
}
SupervisorCmd::Reconfigure { new, ack } => {
self.cfg = new;
self.cfg = *new;
self.backoff =
Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max);
self.retry_window = RetryWindow::new(
@@ -250,7 +284,7 @@ impl<S: Spawner> SupervisorTask<S> {
Action::RetryNow
}
Some(SupervisorCmd::Reconfigure { new, ack }) => {
self.cfg = new;
self.cfg = *new;
self.backoff = Backoff::new(
self.cfg.restart.backoff_initial,
self.cfg.restart.backoff_max,
@@ -267,12 +301,23 @@ impl<S: Spawner> SupervisorTask<S> {
match action {
Action::RetryNow => {
match self.do_start().await {
match self.do_start(StartCause::Restart).await {
Ok(c) => child = Some(c),
Err(err) => {
Err(StartFailure::Spawn(err)) => {
warn!(name = %self.cfg.name, error = %err, "restart spawn failed");
self.started_at = None;
self.set_state(ServerState::Failed);
}
Err(StartFailure::WaitTimedOut) => {
warn!(name = %self.cfg.name, "wait-for timed out");
self.started_at = None;
self.set_state(ServerState::Failed);
}
Err(StartFailure::Cancelled) => {
self.started_at = None;
self.set_state(ServerState::Stopped);
}
Err(StartFailure::Shutdown) => return,
}
}
Action::Cancel => {
@@ -288,12 +333,98 @@ impl<S: Spawner> SupervisorTask<S> {
}
}
async fn do_start(&mut self) -> std::io::Result<S::Child> {
async fn await_ready(&mut self) -> GateOutcome {
let Some(wait) = self.cfg.wait_for.clone() else {
return GateOutcome::Ready;
};
let started_at = Instant::now();
self.waiting = Some(WaitProgress {
description: crate::ready::describe(&wait.condition),
started_at,
timeout: wait.timeout,
});
self.started_at = None;
self.set_state(ServerState::Waiting);
let outcome = loop {
if crate::ready::is_ready(&wait.condition, wait.interval).await {
break GateOutcome::Ready;
}
if started_at.elapsed() >= wait.timeout {
break GateOutcome::TimedOut;
}
let mut tick = std::pin::pin!(sleep(wait.interval));
let interrupted = tokio::select! {
_ = &mut tick => None,
cmd = self.cmd_rx.recv() => match cmd {
None => Some(GateOutcome::Shutdown),
Some(SupervisorCmd::Stop { ack }) => {
let _ = ack.send(StopAck::NotRunning);
Some(GateOutcome::Cancelled)
}
Some(SupervisorCmd::Shutdown { ack }) => {
let _ = ack.send(());
Some(GateOutcome::Shutdown)
}
Some(SupervisorCmd::Start { ack }) => {
let _ = ack.send(StartAck::Started);
None
}
Some(SupervisorCmd::Restart { ack }) => {
let _ = ack.send(());
None
}
Some(SupervisorCmd::Reconfigure { new, ack }) => {
self.cfg = *new;
self.backoff =
Backoff::new(self.cfg.restart.backoff_initial, self.cfg.restart.backoff_max);
self.retry_window = RetryWindow::new(
Duration::from_secs(60),
self.cfg.restart.max_retries_per_minute,
);
let _ = ack.send(());
None
}
},
};
if let Some(outcome) = interrupted {
break outcome;
}
};
self.waiting = None;
outcome
}
async fn do_start(&mut self, cause: StartCause) -> Result<S::Child, StartFailure> {
match self.await_ready().await {
GateOutcome::Ready => {}
GateOutcome::TimedOut => return Err(StartFailure::WaitTimedOut),
GateOutcome::Cancelled => return Err(StartFailure::Cancelled),
GateOutcome::Shutdown => return Err(StartFailure::Shutdown),
}
self.set_state(ServerState::Starting);
let c = self.spawner.spawn(&self.cfg, self.log_sink.clone()).await?;
let c = self
.spawner
.spawn(&self.cfg, self.log_sink.clone())
.await
.map_err(StartFailure::Spawn)?;
if cause == StartCause::Restart {
self.restart_count = self.restart_count.saturating_add(1);
}
self.started_at = Some(Instant::now());
self.current_pid = Some(c.pid());
self.backoff.reset();
@@ -332,6 +463,26 @@ async fn wait_child<C: ChildHandle>(slot: &mut Option<C>) -> Option<i32> {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartCause {
Initial,
Restart,
}
enum StartFailure {
Spawn(std::io::Error),
WaitTimedOut,
Cancelled,
Shutdown,
}
enum GateOutcome {
Ready,
TimedOut,
Cancelled,
Shutdown,
}
pub struct RealSpawner;
#[async_trait::async_trait]
@@ -348,9 +499,10 @@ mod tests {
use super::*;
use crate::child::MockChild;
use crate::logs::{LogSink, RotatingLogWriter};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
use xy_protocol::{RestartConfig, RestartPolicy, StopConfig};
use xy_protocol::{RestartConfig, RestartPolicy, StopConfig, WaitCondition, WaitForConfig};
struct QueueSpawner {
queue: Arc<Mutex<Vec<MockChild>>>,
@@ -383,9 +535,17 @@ mod tests {
stop: StopConfig {
grace: Duration::from_millis(50),
},
wait_for: None,
stdin: Default::default(),
}
}
fn cfg_with_wait(name: &str, wait: WaitForConfig) -> ServerConfig {
let mut c = cfg(name, RestartPolicy::Never, 5);
c.wait_for = Some(wait);
c
}
fn sink(name: &str) -> LogSink {
let dir = tempdir().unwrap();
let writer = RotatingLogWriter::open(&dir.path().join("s.log"), 1024, 3).unwrap();
@@ -398,9 +558,23 @@ mod tests {
state: ServerState::Stopped,
pid: None,
port: cfg.port,
uptime_secs: None,
started_at: None,
restart_count: 0,
last_exit: None,
waiting: None,
}
}
async fn wait_for_pid(rx: &mut watch::Receiver<Status>, want: u32) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if rx.borrow().pid == Some(want) {
return;
}
tokio::select! {
_ = rx.changed() => {}
_ = tokio::time::sleep_until(deadline) => panic!("never saw pid {want}, last={:?}", rx.borrow().pid),
}
}
}
@@ -448,4 +622,389 @@ mod tests {
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn first_start_is_not_counted_as_a_restart() {
let cfg = cfg("x", RestartPolicy::Never, 5);
let (mock, ctl) = MockChild::new(1);
let queue = Arc::new(Mutex::new(vec![mock]));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
wait_for(&mut status_rx, ServerState::Running).await;
assert_eq!(
status_rx.borrow().restart_count,
0,
"a server started once has not restarted"
);
// do_stop's post-kill wait() is unbounded, and MockChild only reports an
// exit when its controller does; dropping it closes the channel so
// shutdown can finish.
drop(ctl);
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn an_automatic_restart_after_a_crash_is_counted() {
let cfg = cfg("x", RestartPolicy::Always, 5);
let (first, mut ctl) = MockChild::new(1);
let (second, ctl2) = MockChild::new(2);
let queue = Arc::new(Mutex::new(vec![first, second]));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
wait_for(&mut status_rx, ServerState::Running).await;
ctl.exit_tx.take().unwrap().send(Some(1)).unwrap();
wait_for_pid(&mut status_rx, 2).await;
assert_eq!(
status_rx.borrow().restart_count,
1,
"one crash-and-respawn is one restart"
);
drop(ctl2);
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn a_met_condition_spawns_immediately() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("sock");
std::fs::write(&file, b"").unwrap();
let cfg = cfg_with_wait(
"x",
WaitForConfig {
condition: WaitCondition::Path(file),
timeout: Duration::from_millis(500),
interval: Duration::from_millis(10),
},
);
let (mock, ctl) = MockChild::new(1);
let queue = Arc::new(Mutex::new(vec![mock]));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
wait_for(&mut status_rx, ServerState::Running).await;
drop(ctl);
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn a_timed_out_gate_fails_without_spawning() {
let cfg = cfg_with_wait(
"x",
WaitForConfig {
condition: WaitCondition::Path(PathBuf::from("/definitely/not/here.sock")),
timeout: Duration::from_millis(80),
interval: Duration::from_millis(10),
},
);
// An empty queue: any spawn attempt panics, which is the assertion that
// a timed-out gate never reaches the spawner.
let queue = Arc::new(Mutex::new(Vec::new()));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, _ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
wait_for(&mut status_rx, ServerState::Waiting).await;
wait_for(&mut status_rx, ServerState::Failed).await;
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn repeated_gate_timeouts_do_not_pollute_the_retry_window() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("sock");
let mut cfg = cfg("x", RestartPolicy::Always, 2);
cfg.wait_for = Some(WaitForConfig {
condition: WaitCondition::Path(file.clone()),
timeout: Duration::from_millis(20),
interval: Duration::from_millis(5),
});
let (first, mut ctl) = MockChild::new(1);
let (second, ctl2) = MockChild::new(2);
let queue = Arc::new(Mutex::new(vec![first, second]));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, _ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
wait_for(&mut status_rx, ServerState::Waiting).await;
wait_for(&mut status_rx, ServerState::Failed).await;
let (ack_tx, _ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
wait_for(&mut status_rx, ServerState::Waiting).await;
wait_for(&mut status_rx, ServerState::Failed).await;
std::fs::write(&file, b"").unwrap();
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
wait_for(&mut status_rx, ServerState::Running).await;
ctl.exit_tx.take().unwrap().send(Some(1)).unwrap();
wait_for_pid(&mut status_rx, 2).await;
assert_eq!(
status_rx.borrow().state,
ServerState::Running,
"the crash after two gate timeouts must still be within the retry budget"
);
drop(ctl2);
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn stop_during_a_wait_cancels_promptly() {
let cfg = cfg_with_wait(
"x",
WaitForConfig {
condition: WaitCondition::Path(PathBuf::from("/definitely/not/here.sock")),
timeout: Duration::from_secs(30),
interval: Duration::from_millis(10),
},
);
let queue = Arc::new(Mutex::new(Vec::new()));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, _ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
wait_for(&mut status_rx, ServerState::Waiting).await;
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Stop { ack: ack_tx })
.await
.unwrap();
// The 30s timeout would swamp this if the poll loop were not interruptible.
tokio::time::timeout(Duration::from_secs(2), ack_rx)
.await
.expect("stop must not wait for the gate timeout")
.unwrap();
wait_for(&mut status_rx, ServerState::Stopped).await;
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
#[tokio::test]
async fn shutdown_during_a_wait_ends_the_task_promptly() {
let cfg = cfg_with_wait(
"x",
WaitForConfig {
condition: WaitCondition::Path(PathBuf::from("/definitely/not/here.sock")),
timeout: Duration::from_secs(30),
interval: Duration::from_millis(10),
},
);
let queue = Arc::new(Mutex::new(Vec::new()));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, _ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
wait_for(&mut status_rx, ServerState::Waiting).await;
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
// The 30s gate timeout would swamp both of these if Shutdown were only
// observed once the gate resolved.
tokio::time::timeout(Duration::from_millis(2_000), ack_rx)
.await
.expect("shutdown must not wait for the gate timeout")
.unwrap();
tokio::time::timeout(Duration::from_millis(2_000), h)
.await
.expect("the supervisor task must return once shut down")
.unwrap();
}
#[tokio::test]
async fn a_server_waiting_on_its_gate_reports_no_uptime() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("sock");
std::fs::write(&file, b"").unwrap();
let mut cfg = cfg("x", RestartPolicy::Always, 5);
cfg.wait_for = Some(WaitForConfig {
condition: WaitCondition::Path(file.clone()),
timeout: Duration::from_millis(2_000),
interval: Duration::from_millis(10),
});
// Only one child: the gate must never clear again, so a second spawn
// would panic on the empty queue.
let (first, mut ctl) = MockChild::new(1);
let queue = Arc::new(Mutex::new(vec![first]));
let spawner = QueueSpawner { queue };
let (status_tx, mut status_rx) = watch::channel(initial_status(&cfg));
let (cmd_tx, cmd_rx) = mpsc::channel(8);
let task = SupervisorTask::new(cfg, sink("x"), spawner, status_tx, cmd_rx);
let h = tokio::spawn(task.run());
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.unwrap();
assert_eq!(ack_rx.await.unwrap(), StartAck::Started);
wait_for(&mut status_rx, ServerState::Running).await;
assert!(status_rx.borrow().started_at.is_some());
std::fs::remove_file(&file).unwrap();
ctl.exit_tx.take().unwrap().send(Some(1)).unwrap();
wait_for(&mut status_rx, ServerState::Waiting).await;
assert!(
status_rx.borrow().started_at.is_none(),
"a server waiting on its gate has no live process, so it has no uptime"
);
let (ack_tx, ack_rx) = oneshot::channel();
cmd_tx
.send(SupervisorCmd::Shutdown { ack: ack_tx })
.await
.unwrap();
ack_rx.await.unwrap();
h.await.unwrap();
}
}
+6
View File
@@ -16,6 +16,10 @@ path = "src/bin/xy_test_sleep_server.rs"
name = "xy-test-exit-failure"
path = "src/bin/xy_test_exit_failure.rs"
[[bin]]
name = "xy-test-stdin-reader"
path = "src/bin/xy_test_stdin_reader.rs"
[dependencies]
xy-protocol.workspace = true
xy-supervisor.workspace = true
@@ -30,6 +34,8 @@ anyhow.workspace = true
etcetera.workspace = true
nix.workspace = true
humantime.workspace = true
service-manager = { workspace = true }
plist = { workspace = true }
[dev-dependencies]
tempfile.workspace = true
+21
View File
@@ -0,0 +1,21 @@
/// Stands in for a stdio-first MCP server: it comes up, announces itself, then
/// blocks reading stdin and exits the moment it sees EOF.
fn main() {
use std::io::{BufRead, Write};
println!("ready");
std::io::stdout().flush().ok();
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) => {
eprintln!("stdin closed (EOF), shutting down");
std::process::exit(0);
}
Ok(_) => std::process::exit(1),
Err(err) => {
eprintln!("stdin read error: {err}");
std::process::exit(2);
}
}
}
+126 -5
View File
@@ -1,4 +1,4 @@
use xy_protocol::rpc::ServerSummary;
use xy_protocol::rpc::{ServerSummary, WaitInfo};
pub fn list_table(rows: &[ServerSummary]) -> String {
let mut out = String::new();
@@ -7,10 +7,7 @@ pub fn list_table(rows: &[ServerSummary]) -> String {
for r in rows {
let pid = r.pid.map(|p| p.to_string()).unwrap_or_else(|| "-".into());
let up = r
.uptime_secs
.map(|s| format!("{}s", s))
.unwrap_or_else(|| "-".into());
let up = r.uptime_secs.map(uptime).unwrap_or_else(|| "-".into());
out.push_str(&format!(
"{:<20}{:<12}{:<8}{:<8}{:<10}{}\n",
@@ -25,3 +22,127 @@ pub fn list_table(rows: &[ServerSummary]) -> String {
out
}
fn uptime(secs: u64) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
let (major, minor, major_unit, minor_unit) = if secs >= DAY {
(secs / DAY, secs % DAY / HOUR, "d", "h")
} else if secs >= HOUR {
(secs / HOUR, secs % HOUR / MINUTE, "h", "m")
} else if secs >= MINUTE {
(secs / MINUTE, secs % MINUTE, "m", "s")
} else {
return format!("{secs}s");
};
if minor == 0 {
return format!("{major}{major_unit}");
}
format!("{major}{major_unit} {minor}{minor_unit}")
}
pub(crate) fn wait_line(info: &WaitInfo) -> String {
format!(
" wait-for: {}\n {}s elapsed, timeout {}s\n",
info.description, info.elapsed_secs, info.timeout_secs
)
}
#[cfg(test)]
mod tests {
use super::*;
use xy_protocol::ServerState;
#[test]
fn list_renders_the_waiting_state() {
let rows = vec![ServerSummary {
name: "gitea".to_string(),
state: ServerState::Waiting,
pid: None,
port: 8181,
uptime_secs: None,
restart_count: 0,
last_exit: None,
}];
let out = list_table(&rows);
assert!(out.contains("waiting"), "got: {out}");
}
#[test]
fn uptime_under_a_minute_is_bare_seconds() {
assert_eq!(uptime(0), "0s");
assert_eq!(uptime(49), "49s");
assert_eq!(uptime(59), "59s");
}
#[test]
fn uptime_under_an_hour_is_minutes_and_seconds() {
assert_eq!(uptime(60), "1m");
assert_eq!(uptime(724), "12m 4s");
assert_eq!(uptime(3_599), "59m 59s");
}
#[test]
fn uptime_under_a_day_is_hours_and_minutes() {
assert_eq!(uptime(3_600), "1h");
assert_eq!(uptime(10_549), "2h 55m");
assert_eq!(uptime(86_399), "23h 59m");
}
#[test]
fn uptime_of_a_day_or_more_is_days_and_hours() {
assert_eq!(uptime(86_400), "1d");
assert_eq!(uptime(529_200), "6d 3h");
assert_eq!(uptime(2_707_200), "31d 8h");
}
#[test]
fn uptime_drops_a_trailing_zero_unit() {
assert_eq!(uptime(120), "2m");
assert_eq!(uptime(7_200), "2h");
assert_eq!(uptime(518_400), "6d");
}
#[test]
fn list_renders_uptime_in_human_units() {
let rows = vec![ServerSummary {
name: "vestige".to_string(),
state: ServerState::Running,
pid: Some(4821),
port: 3928,
uptime_secs: Some(10_549),
restart_count: 0,
last_exit: None,
}];
let out = list_table(&rows);
assert!(out.contains("2h 55m"), "got: {out}");
assert!(
!out.contains("10549"),
"raw seconds must not leak; got: {out}"
);
}
#[test]
fn status_renders_the_wait_condition_and_elapsed() {
let info = WaitInfo {
description: "path /Users/me/.orbstack/run/docker.sock".to_string(),
elapsed_secs: 43,
timeout_secs: 120,
};
let out = wait_line(&info);
assert_eq!(
out,
" wait-for: path /Users/me/.orbstack/run/docker.sock\n 43s elapsed, timeout 120s\n"
);
}
}
+5
View File
@@ -8,6 +8,7 @@ use xy_protocol::rpc::{
};
mod format;
pub mod service;
async fn connect(paths: &Paths) -> Result<Client> {
match Client::connect(&paths.socket).await {
@@ -57,6 +58,10 @@ pub async fn status(paths: Paths, name: String) -> Result<i32> {
}
};
if let Some(info) = &d.wait_for {
print!("{}", format::wait_line(info));
}
println!("{:#?}", d);
Ok(0)
+225
View File
@@ -0,0 +1,225 @@
use crate::logging;
use crate::paths::Paths;
use crate::service::{self, AgentSpec, AgentState, AgentStatus, DEFAULT_LABEL};
use anyhow::Result;
pub(crate) fn render_status(status: &AgentStatus) -> String {
let mut out = String::new();
out.push_str(&format!(" agent: {} (user)\n", status.label));
out.push_str(&format!(" plist: {}\n", status.plist.display()));
let state = match (&status.state, status.pid) {
(AgentState::Running, Some(pid)) => format!("running (pid {pid})"),
(AgentState::Running, None) => "running".to_string(),
(AgentState::Stopped, _) => "stopped".to_string(),
(AgentState::NotInstalled, _) => "not installed".to_string(),
};
out.push_str(&format!(" state: {state}\n"));
if let Some(program) = &status.program {
out.push_str(&format!(" program: {}\n", program.display()));
}
if let Some(path_env) = &status.path_env {
match status.snapshotted.and_then(snapshot_date) {
Some(date) => out.push_str(&format!(" path: {path_env} (snapshotted {date})\n")),
None => out.push_str(&format!(" path: {path_env}\n")),
}
}
out.push_str(&format!(" log: {}\n", status.log.display()));
out
}
fn snapshot_date(at: std::time::SystemTime) -> Option<String> {
let stamp = humantime::format_rfc3339_seconds(at).to_string();
stamp.split('T').next().map(str::to_string)
}
pub async fn run(paths: Paths, cmd: crate::ServiceCmd) -> Result<i32> {
if let Err(err) = service::ensure_supported() {
eprintln!("xy: {err}");
return Ok(1);
}
match cmd {
crate::ServiceCmd::Install { force } => install(force),
crate::ServiceCmd::Uninstall => uninstall(),
crate::ServiceCmd::Start => toggle(service::start, "loaded"),
crate::ServiceCmd::Stop => toggle(service::stop, "unloaded"),
crate::ServiceCmd::Status => status(&paths),
}
}
fn install(force: bool) -> Result<i32> {
let spec = AgentSpec::for_current_exe()?;
let plist = spec.plist_path()?;
if plist.exists() {
if !force {
eprintln!("xy: agent already installed at {}", plist.display());
eprintln!("xy: pass --force to replace it");
return Ok(1);
}
service::uninstall(&spec.label)?;
}
if service::is_build_tree_path(&spec.program) {
eprintln!(
"xy: warning: pointing the agent at a build-tree binary\n {}\n it will disappear on `cargo clean`",
spec.program.display()
);
}
service::install(&spec)?;
service::start(&spec.label)?;
println!("wrote {}", plist.display());
println!("loaded {}", spec.label);
Ok(0)
}
fn uninstall() -> Result<i32> {
let plist = service::plist_path_for(DEFAULT_LABEL)?;
if !plist.exists() {
println!("not installed");
return Ok(0);
}
service::uninstall(DEFAULT_LABEL)?;
println!("removed {}", plist.display());
Ok(0)
}
fn toggle(action: fn(&str) -> Result<()>, verb: &str) -> Result<i32> {
let plist = service::plist_path_for(DEFAULT_LABEL)?;
if !plist.exists() {
eprintln!("xy: agent is not installed");
return Ok(1);
}
match action(DEFAULT_LABEL) {
Ok(()) => {
println!("{verb} {DEFAULT_LABEL}");
Ok(0)
}
Err(err) => {
eprintln!("xy: {err:#}");
Ok(1)
}
}
}
fn status(paths: &Paths) -> Result<i32> {
let log = logging::daemon_log_path(&paths.log_dir);
let status = service::status(DEFAULT_LABEL, &paths.pidfile, &log)?;
print!("{}", render_status(&status));
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn running_status() -> AgentStatus {
AgentStatus {
label: "se.aceofba.xy".to_string(),
plist: PathBuf::from("/Users/me/Library/LaunchAgents/se.aceofba.xy.plist"),
log: PathBuf::from("/Users/me/.local/state/xy/logs/daemon.log"),
state: AgentState::Running,
program: Some(PathBuf::from("/Users/me/.cargo/bin/xy")),
path_env: Some("/opt/homebrew/bin:/usr/bin".to_string()),
snapshotted: None,
pid: Some(4821),
}
}
#[test]
fn render_status_shows_running_state_with_pid() {
let out = render_status(&running_status());
assert!(out.contains("agent: se.aceofba.xy (user)"));
assert!(out.contains("state: running (pid 4821)"));
assert!(out.contains("program: /Users/me/.cargo/bin/xy"));
assert!(out.contains("path: /opt/homebrew/bin:/usr/bin"));
}
#[test]
fn render_status_omits_pid_when_stopped() {
let mut status = running_status();
status.state = AgentState::Stopped;
status.pid = None;
let out = render_status(&status);
assert!(out.contains("state: stopped"));
assert!(!out.contains("pid"));
}
#[test]
fn render_status_annotates_path_with_snapshot_date() {
let mut status = running_status();
status.snapshotted =
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_753_920_000));
let out = render_status(&status);
assert!(out.contains("path: /opt/homebrew/bin:/usr/bin (snapshotted 2025-07-31)"));
}
#[test]
fn render_status_reports_not_installed_without_program_lines() {
let mut status = running_status();
status.state = AgentState::NotInstalled;
status.program = None;
status.path_env = None;
status.pid = None;
let out = render_status(&status);
assert!(out.contains("state: not installed"));
assert!(!out.contains("program:"));
assert!(!out.contains("path:"));
}
#[test]
fn render_status_shows_the_daemon_log_path() {
let out = render_status(&running_status());
assert!(out.contains(" log: /Users/me/.local/state/xy/logs/daemon.log\n"));
}
#[test]
fn render_status_shows_the_log_path_even_when_not_installed() {
let mut status = running_status();
status.state = AgentState::NotInstalled;
status.program = None;
status.path_env = None;
status.pid = None;
let out = render_status(&status);
assert!(out.contains("log: /Users/me/.local/state/xy/logs/daemon.log"));
}
}
+8 -3
View File
@@ -11,7 +11,7 @@ use xy_ipc::envelope::{Incoming, Request, Response, err_response, ok_response};
use xy_protocol::RpcErrorCode;
use xy_protocol::rpc::{
LogEnd, LogLine, LogsCancelParams, LogsParams, LogsSubscribed, NameOrAll, RestartResult,
ServerSummary, StartResult, StatusDetail, StopResult, methods, notifications,
ServerSummary, StartResult, StatusDetail, StopResult, WaitInfo, methods, notifications,
};
use xy_supervisor::supervisor::{StartAck, StopAck, SupervisorCmd};
@@ -170,7 +170,7 @@ async fn list(reg: &Registry) -> Result<Vec<ServerSummary>, ApiError> {
state: s.state,
pid: s.pid,
port: s.port,
uptime_secs: s.uptime_secs,
uptime_secs: s.started_at.map(|t| t.elapsed().as_secs()),
restart_count: s.restart_count,
last_exit: s.last_exit,
});
@@ -195,11 +195,16 @@ async fn status(reg: &Registry, name: &str) -> Result<StatusDetail, ApiError> {
state: s.state,
pid: s.pid,
port: s.port,
uptime_secs: s.uptime_secs,
uptime_secs: s.started_at.map(|t| t.elapsed().as_secs()),
restart_count: s.restart_count,
last_exit: s.last_exit,
},
recent_transitions: Vec::new(),
wait_for: s.waiting.as_ref().map(|w| WaitInfo {
description: w.description.clone(),
elapsed_secs: w.started_at.elapsed().as_secs(),
timeout_secs: w.timeout.as_secs(),
}),
})
}
+7 -12
View File
@@ -43,9 +43,10 @@ pub fn spawn_supervisor(paths: &Paths, cfg: ServerConfig) -> Result<SupervisorHa
state: ServerState::Stopped,
pid: None,
port: cfg.port,
uptime_secs: None,
started_at: None,
restart_count: 0,
last_exit: None,
waiting: None,
};
let (status_tx, status_rx) = watch::channel(initial_status);
@@ -66,8 +67,6 @@ pub fn spawn_supervisor(paths: &Paths, cfg: ServerConfig) -> Result<SupervisorHa
}
pub async fn run(paths: Paths) -> Result<()> {
paths.ensure_dirs().context("create state dirs")?;
let _pid =
PidFile::acquire(&paths.pidfile).context("another xy daemon appears to be running")?;
@@ -98,16 +97,12 @@ pub async fn run(paths: Paths) -> Result<()> {
)
.await;
let (ack_tx, ack_rx) = oneshot::channel();
let (ack_tx, _ack_rx) = oneshot::channel();
if handle
.tx
.send(SupervisorCmd::Start { ack: ack_tx })
.await
.is_ok()
{
let _ = ack_rx.await;
}
// Deliberately not awaiting the ack: a gated start blocks until its
// wait-for condition resolves, and awaiting here would keep the accept
// loop and the signal handler below from ever being installed.
let _ = handle.tx.send(SupervisorCmd::Start { ack: ack_tx }).await;
}
let registry_for_shutdown = registry.clone();
+46
View File
@@ -0,0 +1,46 @@
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use xy_supervisor::logs::RotatingLogWriter;
const LOG_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
const LOG_FILE_KEEP: usize = 5;
/// Distinct from `{server_name}.log` so a server named `xy` cannot collide with
/// the daemon's own log and give two writers independent rotation counters.
pub(crate) fn daemon_log_path(log_dir: &Path) -> PathBuf {
log_dir.join("daemon.log")
}
pub(crate) fn daemon_writer(log_dir: &Path) -> std::io::Result<Mutex<RotatingLogWriter>> {
let writer =
RotatingLogWriter::open(&daemon_log_path(log_dir), LOG_FILE_MAX_BYTES, LOG_FILE_KEEP)?;
Ok(Mutex::new(writer))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daemon_writer_creates_and_appends_to_daemon_log() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let writer = daemon_writer(tmp.path()).unwrap();
writer.lock().unwrap().write_all(b"line\n").unwrap();
let contents = std::fs::read_to_string(tmp.path().join("daemon.log")).unwrap();
assert_eq!(contents, "line\n");
}
#[test]
fn daemon_log_path_does_not_collide_with_a_server_named_xy() {
let path = daemon_log_path(Path::new("/state/xy/logs"));
assert_eq!(path, PathBuf::from("/state/xy/logs/daemon.log"));
assert_ne!(path, PathBuf::from("/state/xy/logs/xy.log"));
}
}
+70 -12
View File
@@ -2,8 +2,10 @@ use clap::{Parser, Subcommand};
mod cli;
mod daemon;
mod logging;
mod paths;
mod pidfile;
mod service;
#[derive(Debug, Parser)]
#[command(name = "xy", version, about = "HTTP MCP server supervisor")]
@@ -51,27 +53,75 @@ enum Cmd {
#[arg(short = 'f', long)]
follow: bool,
},
/// Manage the launchd start-on-login agent (macOS).
Service {
#[command(subcommand)]
verb: ServiceCmd,
},
}
#[derive(Debug, Subcommand)]
pub enum ServiceCmd {
/// Install the login agent and load it.
Install {
#[arg(long)]
force: bool,
},
/// Remove the login agent.
Uninstall,
/// Load the installed agent.
Start,
/// Unload the agent until the next login.
Stop,
/// Show the agent's state.
Status,
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
let paths = match paths::Paths::resolve() {
Ok(p) => p,
Err(e) => {
eprintln!("xy: failed to resolve XDG paths: {e}");
Err(err) => {
eprintln!("xy: failed to resolve XDG paths: {err}");
return std::process::ExitCode::from(3);
}
};
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let is_daemon = matches!(cli.cmd, Cmd::Daemon);
if is_daemon {
use tracing_subscriber::fmt::writer::MakeWriterExt;
if let Err(err) = paths.ensure_dirs() {
eprintln!("xy: failed to create state dirs: {err}");
return std::process::ExitCode::from(3);
}
let file = match logging::daemon_writer(&paths.log_dir) {
Ok(writer) => writer,
Err(err) => {
eprintln!("xy: failed to open daemon log: {err}");
return std::process::ExitCode::from(3);
}
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_writer(std::io::stderr.and(file))
.init();
} else {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
let result: anyhow::Result<i32> = match cli.cmd {
Cmd::Daemon => daemon::run(paths).await.map(|_| 0),
Cmd::List => cli::list(paths).await,
@@ -81,12 +131,20 @@ async fn main() -> std::process::ExitCode {
Cmd::Restart { all, name } => cli::restart(paths, all, name).await,
Cmd::Reload => cli::reload(paths).await,
Cmd::Logs { name, tail, follow } => cli::logs(paths, name, tail, follow).await,
Cmd::Service { verb } => cli::service::run(paths, verb).await,
};
match result {
Ok(code) => std::process::ExitCode::from(code as u8),
Err(e) => {
eprintln!("xy: {e:#}");
Err(err) => {
// Under launchd the daemon's stderr is discarded, so a fatal
// startup error is only diagnosable if it reaches the log file.
if is_daemon {
tracing::error!("{err:#}");
} else {
eprintln!("xy: {err:#}");
}
std::process::ExitCode::from(1)
}
}
+690
View File
@@ -0,0 +1,690 @@
use anyhow::{Context, Result};
use service_manager::{
LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager,
ServiceStatus, ServiceStatusCtx, ServiceUninstallCtx,
};
use std::path::{Component, Path, PathBuf};
use std::time::SystemTime;
pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy";
pub(crate) struct AgentSpec {
pub label: String,
pub program: PathBuf,
pub args: Vec<String>,
pub path_env: String,
pub working_dir: PathBuf,
}
impl AgentSpec {
pub fn for_current_exe() -> Result<Self> {
let program = std::env::current_exe()
.context("resolve current executable")?
.canonicalize()
.context("canonicalize current executable")?;
let path_env = std::env::var("PATH").context("read PATH")?;
let working_dir = etcetera::home_dir().context("locate home directory")?;
Ok(Self {
label: DEFAULT_LABEL.to_string(),
program,
args: vec!["daemon".to_string()],
path_env,
working_dir,
})
}
pub fn install_ctx(&self) -> Result<ServiceInstallCtx> {
let label: ServiceLabel = self.label.parse().context("parse service label")?;
Ok(ServiceInstallCtx {
label,
program: self.program.clone(),
args: self.args.iter().map(std::ffi::OsString::from).collect(),
contents: None,
username: None,
working_directory: Some(self.working_dir.clone()),
environment: Some(vec![("PATH".to_string(), self.path_env.clone())]),
autostart: true,
restart_policy: RestartPolicy::Always { delay_secs: None },
})
}
pub fn plist_path(&self) -> Result<PathBuf> {
plist_path_for(&self.label)
}
}
pub(crate) fn plist_path_for(label: &str) -> Result<PathBuf> {
let home = etcetera::home_dir().context("locate home directory")?;
Ok(home
.join("Library")
.join("LaunchAgents")
.join(format!("{label}.plist")))
}
pub(crate) fn is_build_tree_path(path: &Path) -> bool {
let mut components = path.components().peekable();
while let Some(component) = components.next() {
if component != Component::Normal("target".as_ref()) {
continue;
}
if matches!(
components.peek(),
Some(Component::Normal(next))
if *next == std::ffi::OsStr::new("debug") || *next == std::ffi::OsStr::new("release")
) {
return true;
}
}
false
}
#[cfg(target_os = "macos")]
pub(crate) fn ensure_supported() -> Result<()> {
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn ensure_supported() -> Result<()> {
anyhow::bail!("start-on-login is macOS-only for now")
}
fn manager() -> LaunchdServiceManager {
LaunchdServiceManager::user()
}
pub(crate) fn install(spec: &AgentSpec) -> Result<()> {
manager()
.install(spec.install_ctx()?)
.context("install launchd agent")
}
pub(crate) fn uninstall(label: &str) -> Result<()> {
let label: ServiceLabel = label.parse().context("parse service label")?;
manager()
.uninstall(ServiceUninstallCtx { label })
.context("uninstall launchd agent")
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AgentState {
NotInstalled,
Stopped,
Running,
}
pub(crate) struct AgentStatus {
pub label: String,
pub plist: PathBuf,
pub log: PathBuf,
pub state: AgentState,
pub program: Option<PathBuf>,
pub path_env: Option<String>,
pub snapshotted: Option<SystemTime>,
pub pid: Option<u32>,
}
pub(crate) fn read_pid(pidfile: &Path) -> Option<u32> {
std::fs::read_to_string(pidfile)
.ok()?
.trim()
.parse::<u32>()
.ok()
}
fn not_installed_status(label: &str, plist: PathBuf, log: PathBuf) -> AgentStatus {
AgentStatus {
label: label.to_string(),
plist,
log,
state: AgentState::NotInstalled,
program: None,
path_env: None,
snapshotted: None,
pid: None,
}
}
fn build_status(
label: &str,
plist: PathBuf,
log: PathBuf,
pidfile: &Path,
state: AgentState,
) -> AgentStatus {
let snapshotted = std::fs::metadata(&plist)
.and_then(|meta| meta.modified())
.ok();
let pid = if state == AgentState::Running {
read_pid(pidfile)
} else {
None
};
let (program, path_env) = read_plist_fields(&plist);
AgentStatus {
label: label.to_string(),
plist,
log,
state,
program,
path_env,
snapshotted,
pid,
}
}
/// The plist on disk is the authority on "installed"; the crate's verdict only
/// distinguishes running from stopped. A plist that exists but is not loaded
/// makes `launchctl print` answer `NotInstalled`, which must not erase the
/// plist-derived facts a user needs to debug that exact state.
fn installed_state(reported: &ServiceStatus) -> AgentState {
match reported {
ServiceStatus::Running => AgentState::Running,
ServiceStatus::Stopped(_) | ServiceStatus::NotInstalled => AgentState::Stopped,
}
}
pub(crate) fn status(label: &str, pidfile: &Path, log: &Path) -> Result<AgentStatus> {
let plist = plist_path_for(label)?;
if !plist.exists() {
return Ok(not_installed_status(label, plist, log.to_path_buf()));
}
let reported = query_state(label)?;
Ok(build_status(
label,
plist,
log.to_path_buf(),
pidfile,
installed_state(&reported),
))
}
fn query_state(label: &str) -> Result<ServiceStatus> {
let parsed: ServiceLabel = label.parse().context("parse service label")?;
manager()
.status(ServiceStatusCtx { label: parsed })
.context("query launchd for agent status")
}
fn read_plist_fields(plist: &Path) -> (Option<PathBuf>, Option<String>) {
let Ok(value) = plist::Value::from_file(plist) else {
return (None, None);
};
let dict = value.as_dictionary();
let program = dict
.and_then(|dict| dict.get("ProgramArguments"))
.and_then(plist::Value::as_array)
.and_then(|args| args.first())
.and_then(plist::Value::as_string)
.map(PathBuf::from);
let path_env = dict
.and_then(|dict| dict.get("EnvironmentVariables"))
.and_then(plist::Value::as_dictionary)
.and_then(|env| env.get("PATH"))
.and_then(plist::Value::as_string)
.map(str::to_string);
(program, path_env)
}
fn launchctl(verb: &str, plist: &Path) -> Result<()> {
let output = std::process::Command::new("launchctl")
.arg(verb)
.arg(plist)
.output()
.with_context(|| format!("run launchctl {verb}"))?;
if !output.status.success() {
return Err(launchctl_error(verb, &output.stdout, &output.stderr));
}
Ok(())
}
fn launchctl_error(verb: &str, stdout: &[u8], stderr: &[u8]) -> anyhow::Error {
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
let detail: Vec<&str> = [stdout.trim(), stderr.trim()]
.into_iter()
.filter(|part| !part.is_empty())
.collect();
anyhow::anyhow!("launchctl {verb} failed: {}", detail.join("; "))
}
/// `service-manager` writes `Disabled: true` into every plist carrying
/// `KeepAlive`, and `launchctl load` honours it while still exiting 0. Removing
/// the key is what actually makes the agent loadable, so do it on every start —
/// that also heals a plist left disabled by an earlier install.
fn enable_plist(plist: &Path) -> Result<()> {
let mut value = plist::Value::from_file(plist)
.with_context(|| format!("read plist {}", plist.display()))?;
let Some(dict) = value.as_dictionary_mut() else {
return Ok(());
};
if dict.remove("Disabled").is_none() {
return Ok(());
}
value
.to_file_xml(plist)
.with_context(|| format!("rewrite plist {}", plist.display()))
}
pub(crate) fn start(label: &str) -> Result<()> {
let plist = plist_path_for(label)?;
enable_plist(&plist)?;
launchctl("load", &plist)?;
// `launchctl load` reports its own failures only on stderr and still exits
// 0, so the load is confirmed by asking launchd whether the job now exists.
if query_state(label)? == ServiceStatus::NotInstalled {
anyhow::bail!(
"launchctl load left {label} unregistered; inspect {} and `launchctl print gui/$UID/{label}`",
plist.display()
);
}
Ok(())
}
pub(crate) fn stop(label: &str) -> Result<()> {
launchctl("unload", &plist_path_for(label)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec() -> AgentSpec {
AgentSpec {
label: "se.aceofba.xy-test".to_string(),
program: PathBuf::from("/usr/local/bin/xy"),
args: vec!["daemon".to_string()],
path_env: "/usr/local/bin:/usr/bin".to_string(),
working_dir: PathBuf::from("/Users/someone"),
}
}
#[test]
fn install_ctx_maps_every_field() {
let ctx = sample_spec().install_ctx().unwrap();
assert_eq!(ctx.label.to_qualified_name(), "se.aceofba.xy-test");
assert_eq!(ctx.program, PathBuf::from("/usr/local/bin/xy"));
assert_eq!(ctx.args, vec![std::ffi::OsString::from("daemon")]);
assert_eq!(ctx.working_directory, Some(PathBuf::from("/Users/someone")));
assert!(ctx.autostart);
assert_eq!(
ctx.environment,
Some(vec![(
"PATH".to_string(),
"/usr/local/bin:/usr/bin".to_string()
)])
);
}
#[test]
fn install_ctx_uses_always_restart_without_delay() {
let ctx = sample_spec().install_ctx().unwrap();
assert!(matches!(
ctx.restart_policy,
RestartPolicy::Always { delay_secs: None }
));
}
#[test]
fn install_ctx_supplies_no_raw_contents() {
let ctx = sample_spec().install_ctx().unwrap();
assert!(ctx.contents.is_none());
assert!(ctx.username.is_none());
}
#[test]
fn build_tree_paths_are_detected() {
assert!(is_build_tree_path(Path::new("/home/me/xy/target/debug/xy")));
assert!(is_build_tree_path(Path::new(
"/home/me/xy/target/release/xy"
)));
}
#[test]
fn installed_paths_are_not_build_tree_paths() {
assert!(!is_build_tree_path(Path::new("/Users/me/.cargo/bin/xy")));
assert!(!is_build_tree_path(Path::new("/usr/local/bin/xy")));
assert!(!is_build_tree_path(Path::new("/opt/targeted/bin/xy")));
}
#[test]
fn plist_path_sits_in_user_launch_agents() {
let path = sample_spec().plist_path().unwrap();
assert!(path.ends_with("Library/LaunchAgents/se.aceofba.xy-test.plist"));
}
#[test]
#[cfg(target_os = "macos")]
fn macos_is_supported() {
assert!(ensure_supported().is_ok());
}
#[test]
#[cfg(not(target_os = "macos"))]
fn other_platforms_are_rejected() {
let err = ensure_supported().unwrap_err().to_string();
assert!(err.contains("macOS-only"));
}
#[test]
fn status_reports_not_installed_when_plist_is_absent() {
let tmp = tempfile::tempdir().unwrap();
let status = status(
"se.aceofba.xy-absent",
&tmp.path().join("xy.pid"),
&tmp.path().join("daemon.log"),
)
.unwrap();
assert!(matches!(status.state, AgentState::NotInstalled));
assert!(status.program.is_none());
assert!(status.path_env.is_none());
assert!(status.snapshotted.is_none());
assert!(status.pid.is_none());
}
#[test]
fn status_carries_the_log_path_through() {
let tmp = tempfile::tempdir().unwrap();
let log = tmp.path().join("daemon.log");
let status = status("se.aceofba.xy-absent", &tmp.path().join("xy.pid"), &log).unwrap();
assert_eq!(status.log, log);
}
#[test]
fn read_pid_parses_a_pidfile() {
let tmp = tempfile::tempdir().unwrap();
let pidfile = tmp.path().join("xy.pid");
std::fs::write(&pidfile, "4821\n").unwrap();
assert_eq!(read_pid(&pidfile), Some(4821));
}
#[test]
fn read_pid_returns_none_for_missing_or_garbage() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nope.pid");
let garbage = tmp.path().join("garbage.pid");
std::fs::write(&garbage, "not-a-pid").unwrap();
assert_eq!(read_pid(&missing), None);
assert_eq!(read_pid(&garbage), None);
}
fn write_agent_plist(path: &Path, program: &str, path_env: Option<&str>) {
let mut dict = plist::Dictionary::new();
dict.insert(
"ProgramArguments".to_string(),
plist::Value::Array(vec![plist::Value::String(program.to_string())]),
);
if let Some(path_env) = path_env {
let mut env = plist::Dictionary::new();
env.insert(
"PATH".to_string(),
plist::Value::String(path_env.to_string()),
);
dict.insert(
"EnvironmentVariables".to_string(),
plist::Value::Dictionary(env),
);
}
plist::Value::Dictionary(dict).to_file_xml(path).unwrap();
}
#[test]
fn read_plist_fields_unescapes_xml_entities() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("agent.plist");
write_agent_plist(
&plist_path,
"/usr/local/bin/xy",
Some("/usr/bin:/opt/a&b:/opt/<c>"),
);
let (program, path_env) = read_plist_fields(&plist_path);
assert_eq!(program, Some(PathBuf::from("/usr/local/bin/xy")));
assert_eq!(path_env, Some("/usr/bin:/opt/a&b:/opt/<c>".to_string()));
}
#[test]
fn read_plist_fields_on_malformed_plist_returns_none_without_panicking() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("broken.plist");
std::fs::write(&plist_path, "<plist><dict><key>ProgramArgum").unwrap();
let (program, path_env) = read_plist_fields(&plist_path);
assert!(program.is_none());
assert!(path_env.is_none());
}
#[test]
fn launchctl_error_includes_both_streams_when_populated() {
let err = launchctl_error(
"unload",
b"out text",
b"Unload failed: 5: Input/output error",
);
let message = err.to_string();
assert!(message.contains("out text"));
assert!(message.contains("Unload failed: 5: Input/output error"));
}
#[test]
fn launchctl_error_omits_dangling_separator_when_stdout_empty() {
let err = launchctl_error("load", b"", b"boom");
assert_eq!(err.to_string(), "launchctl load failed: boom");
}
#[test]
fn an_unloaded_plist_is_stopped_not_missing_and_keeps_its_facts() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("agent.plist");
write_agent_plist(&plist_path, "/usr/local/bin/xy", Some("/usr/bin"));
let pidfile = tmp.path().join("xy.pid");
std::fs::write(&pidfile, "4821").unwrap();
let state = installed_state(&ServiceStatus::NotInstalled);
assert_eq!(state, AgentState::Stopped);
let status = build_status(
"se.aceofba.xy-ghost",
plist_path,
tmp.path().join("daemon.log"),
&pidfile,
state,
);
assert_eq!(status.program, Some(PathBuf::from("/usr/local/bin/xy")));
assert_eq!(status.path_env, Some("/usr/bin".to_string()));
assert!(status.snapshotted.is_some());
assert!(status.pid.is_none());
}
#[test]
fn installed_state_reports_running_only_for_a_running_job() {
assert_eq!(
installed_state(&ServiceStatus::Running),
AgentState::Running
);
assert_eq!(
installed_state(&ServiceStatus::Stopped(None)),
AgentState::Stopped
);
}
fn write_disabled_agent_plist(path: &Path, disabled: Option<bool>) {
let mut env = plist::Dictionary::new();
env.insert(
"PATH".to_string(),
plist::Value::String("/opt/homebrew/bin:/usr/bin".to_string()),
);
let mut dict = plist::Dictionary::new();
dict.insert(
"Label".to_string(),
plist::Value::String("se.x".to_string()),
);
dict.insert(
"ProgramArguments".to_string(),
plist::Value::Array(vec![
plist::Value::String("/usr/local/bin/xy".to_string()),
plist::Value::String("daemon".to_string()),
]),
);
dict.insert(
"EnvironmentVariables".to_string(),
plist::Value::Dictionary(env),
);
dict.insert("KeepAlive".to_string(), plist::Value::Boolean(true));
dict.insert("RunAtLoad".to_string(), plist::Value::Boolean(true));
if let Some(disabled) = disabled {
dict.insert("Disabled".to_string(), plist::Value::Boolean(disabled));
}
plist::Value::Dictionary(dict).to_file_xml(path).unwrap();
}
fn plist_dict(path: &Path) -> plist::Dictionary {
plist::Value::from_file(path)
.unwrap()
.into_dictionary()
.unwrap()
}
#[test]
fn enable_plist_removes_disabled_and_preserves_every_other_key() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("agent.plist");
write_disabled_agent_plist(&plist_path, Some(true));
enable_plist(&plist_path).unwrap();
let dict = plist_dict(&plist_path);
assert!(!dict.contains_key("Disabled"));
assert_eq!(dict.get("Label").unwrap().as_string(), Some("se.x"));
assert_eq!(dict.get("KeepAlive").unwrap().as_boolean(), Some(true));
assert_eq!(dict.get("RunAtLoad").unwrap().as_boolean(), Some(true));
let args: Vec<&str> = dict
.get("ProgramArguments")
.and_then(plist::Value::as_array)
.unwrap()
.iter()
.filter_map(plist::Value::as_string)
.collect();
assert_eq!(args, vec!["/usr/local/bin/xy", "daemon"]);
let path_env = dict
.get("EnvironmentVariables")
.and_then(plist::Value::as_dictionary)
.and_then(|env| env.get("PATH"))
.and_then(plist::Value::as_string);
assert_eq!(path_env, Some("/opt/homebrew/bin:/usr/bin"));
}
#[test]
fn enable_plist_is_a_no_op_when_disabled_is_absent() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("agent.plist");
write_disabled_agent_plist(&plist_path, None);
let before = std::fs::read(&plist_path).unwrap();
enable_plist(&plist_path).unwrap();
let after = std::fs::read(&plist_path).unwrap();
assert_eq!(before, after);
}
#[test]
fn enable_plist_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("agent.plist");
write_disabled_agent_plist(&plist_path, Some(true));
enable_plist(&plist_path).unwrap();
let once = std::fs::read(&plist_path).unwrap();
enable_plist(&plist_path).unwrap();
let twice = std::fs::read(&plist_path).unwrap();
assert_eq!(once, twice);
assert!(!plist_dict(&plist_path).contains_key("Disabled"));
}
#[test]
fn enable_plist_errors_on_an_unreadable_plist() {
let tmp = tempfile::tempdir().unwrap();
let plist_path = tmp.path().join("broken.plist");
std::fs::write(&plist_path, "<plist><dict><key>Disab").unwrap();
let err = enable_plist(&plist_path).unwrap_err().to_string();
assert!(err.contains("read plist"));
}
}
+29 -1
View File
@@ -5,6 +5,8 @@ use std::process::Stdio;
use std::time::Duration;
use tempfile::TempDir;
use tokio::process::{Child, Command};
use xy_ipc::Client;
use xy_protocol::rpc::{ServerSummary, methods};
pub struct Harness {
pub tmp: TempDir,
@@ -35,8 +37,20 @@ impl Harness {
}
pub fn write_server(&self, name: &str, command: &str, port: u16, restart_policy: &str) {
self.write_server_with(name, command, port, restart_policy, "");
}
/// `extra` is appended verbatim as additional top-level KDL.
pub fn write_server_with(
&self,
name: &str,
command: &str,
port: u16,
restart_policy: &str,
extra: &str,
) {
let body = format!(
"command \"{command}\"\nport {port}\nrestart {{ policy \"{restart_policy}\" backoff-initial \"10ms\" backoff-max \"50ms\" max-retries-per-minute 3 }}\nstop {{ grace \"500ms\" }}\n"
"command \"{command}\"\nport {port}\nrestart {{\n policy \"{restart_policy}\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\nstop {{ grace \"500ms\" }}\n{extra}"
);
std::fs::write(self.config_dir.join(format!("{name}.kdl")), body).unwrap();
}
@@ -47,6 +61,7 @@ impl Harness {
.env("XDG_CONFIG_HOME", self.tmp.path().join("config"))
.env("XDG_STATE_HOME", self.tmp.path().join("state"))
.env("XDG_RUNTIME_DIR", self.tmp.path().join("run"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.kill_on_drop(true)
@@ -62,6 +77,16 @@ impl Harness {
}
}
/// The `list` RPC result straight off the wire, before any CLI rendering.
pub async fn list_rpc(&self) -> Vec<ServerSummary> {
let mut client = Client::connect(&self.socket).await.expect("connect daemon");
client
.call_no_params(methods::LIST)
.await
.expect("list rpc")
}
pub async fn run_cli(&self, xy_bin: &PathBuf, args: &[&str]) -> (i32, String, String) {
let out = Command::new(xy_bin)
.args(args)
@@ -87,6 +112,9 @@ pub fn sleep_server_bin() -> PathBuf {
pub fn exit_failure_bin() -> PathBuf {
artifact("xy-test-exit-failure")
}
pub fn stdin_reader_bin() -> PathBuf {
artifact("xy-test-stdin-reader")
}
fn artifact(name: &str) -> PathBuf {
let mut p = std::env::current_exe().unwrap();
+30
View File
@@ -0,0 +1,30 @@
mod common;
use common::*;
#[tokio::test]
async fn fatal_startup_error_is_written_to_daemon_log() {
let xy = xy_bin();
let mut h = Harness::new();
h.start_daemon(&xy).await;
let log = h.state_dir.join("logs/daemon.log");
let before = std::fs::read_to_string(&log).unwrap_or_default();
assert!(
!before.contains("another xy daemon"),
"log already reports contention before the second daemon ran: {before}"
);
let (code, _out, _err) = h.run_cli(&xy, &["daemon"]).await;
assert_eq!(code, 1, "second daemon should exit 1 on pidfile contention");
let after = std::fs::read_to_string(&log).expect("daemon.log should exist");
assert!(
after.contains("another xy daemon"),
"daemon.log must record why the daemon refused to start, got: {after:?}"
);
}
+68
View File
@@ -0,0 +1,68 @@
mod common;
use common::*;
use std::time::Duration;
use xy_protocol::ServerState;
async fn state_of(h: &Harness, name: &str) -> ServerState {
h.list_rpc()
.await
.into_iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("no server named {name}"))
.state
}
async fn settle(h: &Harness, name: &str) -> ServerState {
let mut last = ServerState::Stopped;
for _ in 0..40 {
last = state_of(h, name).await;
if last == ServerState::Running {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
last
}
#[tokio::test]
async fn keep_open_lets_a_stdin_reader_stay_running() {
let xy = xy_bin();
let reader = stdin_reader_bin();
let mut h = Harness::new();
h.write_server_with(
"reader",
reader.to_str().unwrap(),
19_010,
"never",
"stdin \"keep-open\"\n",
);
h.start_daemon(&xy).await;
assert_eq!(settle(&h, "reader").await, ServerState::Running);
// Still up a beat later: it is blocked on read, not merely slow to die.
tokio::time::sleep(Duration::from_millis(500)).await;
assert_eq!(state_of(&h, "reader").await, ServerState::Running);
}
/// Characterizes the reported bug: the daemon's stdin is `/dev/null` under
/// launchd, so by default a stdio-first server sees EOF and exits at once.
#[tokio::test]
async fn without_keep_open_a_stdin_reader_exits_on_eof() {
let xy = xy_bin();
let reader = stdin_reader_bin();
let mut h = Harness::new();
h.write_server("reader", reader.to_str().unwrap(), 19_011, "never");
h.start_daemon(&xy).await;
let mut last = ServerState::Running;
for _ in 0..40 {
last = state_of(&h, "reader").await;
if last == ServerState::Stopped {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(last, ServerState::Stopped);
}
+37
View File
@@ -0,0 +1,37 @@
mod common;
use common::*;
use xy_protocol::ServerState;
#[tokio::test]
async fn uptime_grows_while_a_server_stays_running() {
let xy = xy_bin();
let sleeper = sleep_server_bin();
let mut h = Harness::new();
h.write_server("alpha", sleeper.to_str().unwrap(), 19_101, "always");
h.start_daemon(&xy).await;
for _ in 0..40 {
let rows = h.list_rpc().await;
if rows
.iter()
.any(|r| r.name == "alpha" && r.state == ServerState::Running)
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
let rows = h.list_rpc().await;
let alpha = rows
.iter()
.find(|r| r.name == "alpha")
.unwrap_or_else(|| panic!("no alpha row in: {rows:?}"));
assert!(
alpha.uptime_secs.is_some_and(|secs| secs >= 1),
"a server running for over a second must report at least 1s of uptime; row: {alpha:?}"
);
}
+81
View File
@@ -0,0 +1,81 @@
mod common;
use common::*;
use std::time::Duration;
/// The shared `write_server` helper emits no `wait-for` block, and six other
/// test files depend on its exact output, so this test writes its own config.
fn write_gated_server(h: &Harness, name: &str, command: &str, port: u16) {
// The keys inside a block need their own lines: KDL reads a run of values
// on one line as arguments to the first node, which silently leaves
// `timeout` and `interval` at their two-minute defaults.
let body = format!(
"command \"{command}\"\n\
port {port}\n\
restart {{\n policy \"never\"\n backoff-initial \"10ms\"\n backoff-max \"50ms\"\n max-retries-per-minute 3\n}}\n\
stop {{\n grace \"500ms\"\n}}\n\
wait-for {{\n path \"/definitely/not/here.sock\"\n timeout \"5s\"\n interval \"200ms\"\n}}\n"
);
std::fs::write(h.config_dir.join(format!("{name}.kdl")), body).unwrap();
}
#[tokio::test]
async fn a_gated_server_keeps_the_daemon_responsive_then_fails_without_spawning() {
let xy = xy_bin();
let sleeper = sleep_server_bin();
let mut h = Harness::new();
write_gated_server(&h, "gated", sleeper.to_str().unwrap(), 19_201);
h.start_daemon(&xy).await;
// The socket is bound before the autostart loop, so `start_daemon`
// returning proves nothing; only a completed round trip does.
let waiting = tokio::time::timeout(Duration::from_millis(2_000), async {
loop {
let (code, out, _err) = h.run_cli(&xy, &["list"]).await;
assert_eq!(code, 0, "stdout: {out}");
assert!(
!out.contains("running"),
"a gated server must not spawn; stdout: {out}"
);
if out.contains("waiting") {
return out;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("`xy list` must answer well inside the 5s gate");
assert!(waiting.contains("gated"), "stdout: {waiting}");
let mut last = String::new();
for _ in 0..80 {
let (_code, out, _err) = h.run_cli(&xy, &["list"]).await;
last = out;
assert!(
!last.contains("running"),
"a gated server must reach failed without ever spawning; stdout: {last}"
);
if last.contains("failed") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
last.contains("failed"),
"the gate must give up after its timeout; stdout: {last}"
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
# 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 `daemon.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/daemon.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 — `daemon.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/daemon.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. Code `3` is reachable only before dispatch: `main.rs`
returns it when `Paths::resolve()` fails, which happens ahead of every
subcommand including `xy service`. No `xy service` code path returns `3` itself.
## 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.
@@ -0,0 +1,233 @@
# xy — Readiness Gate (`wait-for`)
**Date:** 2026-08-07
**Status:** Approved — ready for implementation planning
## Problem
Since start-on-login landed, `xy daemon` runs from a launchd agent at login.
Docker-backed servers (`gitea`, `signoz`) race OrbStack and lose.
Measured on this machine 2026-08-07: OrbStack's process started at 12:47:56
and created `~/.orbstack/run/docker.sock` at **12:48:14** — 18 seconds later.
OrbStack is a BTM login item, not a LaunchAgent, so it is not even guaranteed
to begin before the agent does.
Against that, `docker run` fails instantly while the socket is absent, and the
default restart budget is `backoff-initial 1s`, `backoff-max 30s`,
`max-retries-per-minute 5`. Attempts therefore land at t=0, 1, 3, 7, 15s; the
fifth trips the cap and `policy::decide` returns `MarkFailed`, which is
terminal. **A docker-backed server gives up ~15 seconds after the daemon
starts** and stays down until a manual `xy start`.
The 2026-08-07 log shows this nearly firing during a reload: socket error at
10:47:58, recovery at 10:48:15 — 17 seconds, right at the edge.
Widening the retry budget per-server would paper over it. The real defect is
that a server with an unmet *infrastructure dependency* consumes its
crash-retry budget while waiting for something that was never its own fault.
## Goals
- Let a server declare a precondition that must hold before it is spawned.
- Waiting must never consume the crash-retry budget.
- Waiting must be visible and self-explaining, not indistinguishable from a hang.
- Waiting must stay responsive to `stop` / `shutdown`.
## Non-goals (deferred)
- Ordering between supervised servers (`depends-on <name>`). The conditions
here can express it (`tcp 127.0.0.1:3928`) without a dependency graph.
- Liveness probes on an already-running server. This gate is start-time only;
HTTP health probes remain a non-goal from the MVP spec.
- Re-checking the condition while a server runs.
## Decisions
### Three condition kinds
```kdl
wait-for {
path "~/.orbstack/run/docker.sock"
timeout "120s"
interval "1s"
}
```
`path`, `tcp`, and `command` are all supported:
| Kind | Ready when |
|---|---|
| `path "<p>"` | the path exists |
| `tcp "<host:port>"` | a TCP connection succeeds |
| `command "<c>" args "…"` | the process exits 0 |
Exactly one condition per `wait-for` block. Zero or more than one is a parse
error, so `xy reload` exits 3 and changes nothing — matching how invalid
config already behaves.
`path` values get a leading `~/` expanded at parse time from `$HOME`, since the
motivating case is literally `~/.orbstack/run/docker.sock`. `$HOME` rather than
`etcetera::home_dir()` deliberately: `xy-protocol` is a leaf crate with no
directory dependencies, and adding one for a two-line prefix substitution is
not worth it. If `$HOME` is unset the literal path is kept, which simply never
exists and surfaces as a normal timeout. The expanded path is what `xy status`
displays, so there is no ambiguity about what was checked.
Note the KDL shape of the `command` kind: `command` and `args` are **sibling
nodes** inside the block, mirroring the top level, not one node with trailing
arguments:
wait-for {
command "docker"
args "info"
}
so the parser accumulates them separately and combines them after the loop.
`args` without `command` is a parse error.
The `command` kind is bounded by the poll `interval`: each invocation is run
under a timeout of `interval` and a non-exit is treated as not-ready, so a hung
`docker info` cannot wedge the poll loop.
### Timeout marks the server Failed without spawning
On timeout, the server goes to `Failed` and nothing is spawned. `xy status`
names the condition that timed out.
Rejected: spawning anyway on timeout, which reintroduces exactly the
retry-burn this feature removes; and waiting forever, which turns a typo in a
path into a server that sits in `waiting` with nothing ever surfacing.
### Waiting never touches the retry window
`RetryWindow::record` continues to be called only for real process exits. This
is the property that fixes the reported bug: a cold Docker costs patience, not
retry budget.
### Every start is gated
The gate runs for the initial start, the explicit `restart` command, and the
automatic post-crash respawn. If Docker dies and takes the container with it,
the respawn should wait for Docker again rather than burn retries.
### New `Waiting` state, reason on `status` only
`ServerState` gains `Waiting` — an additive enum variant, backward compatible
on the wire. `xy list` shows a plain `waiting` to keep the table narrow;
`xy status <name>` renders what it is blocked on and for how long:
$ xy status gitea
state: waiting
wait-for: path /Users/olsson/.orbstack/run/docker.sock
43s elapsed, timeout 120s
Rejected: reusing `Starting`, which is indistinguishable from a slow-starting
process — the exact ambiguity this feature exists to remove.
Elapsed time is computed at **read time** from a carried `Instant`, never
precomputed at publish time. This is the rule established by the 2026-08-07
uptime bug, where a value snapshotted into the watch channel froze at 0.
## Schema
`xy-protocol/src/config.rs`:
```rust
pub enum WaitCondition {
Path(PathBuf),
Tcp(String),
Command { command: PathBuf, args: Vec<String> },
}
pub struct WaitForConfig {
pub condition: WaitCondition,
pub timeout: Duration, // default 120s
pub interval: Duration, // default 1s
}
```
`ServerConfig` gains `#[serde(default)] pub wait_for: Option<WaitForConfig>`.
Absent `wait-for` means no gate and behaviour identical to today.
## Supervisor
The gate lives inside `do_start`, before the spawn. This is the one structural
change worth calling out: `do_start` currently returns
`std::io::Result<S::Child>`, which cannot express the gate's outcomes. It
becomes:
```rust
enum StartFailure {
Spawn(std::io::Error),
WaitTimedOut,
Cancelled, // Stop arrived while waiting
Shutdown, // Shutdown arrived while waiting
}
async fn do_start(&mut self, cause: StartCause) -> Result<S::Child, StartFailure>
```
All three call sites are updated. `Shutdown` must propagate a `return` from
`run`, exactly as the backoff sleep already does. `Cancelled` settles the
server in `Stopped` — a `stop` issued during a wait leaves it stopped, not
failed, since the user asked for it. `WaitTimedOut` settles in `Failed`.
**The poll loop is interruptible.** It selects over the interval sleep and
`self.cmd_rx`, the same shape as the existing backoff sleep (commit `b366df0`,
"make backoff sleep interruptible by Stop/Shutdown"). Without this, `xy stop`
on a waiting server would block for up to the full timeout.
`Status` carries the wait progress rather than a rendered string:
```rust
pub struct WaitProgress {
pub description: String,
pub started_at: Instant,
pub timeout: Duration,
}
```
with `Status.waiting: Option<WaitProgress>`. Handlers derive `elapsed_secs`
when building the RPC response. `StatusDetail` gains
`wait_for: Option<WaitInfo { description, elapsed_secs, timeout_secs }>`.
## Testing
TDD throughout.
- Config parse tests per condition kind; defaults applied; `~` expanded; zero
conditions and two conditions both rejected.
- Condition checks in isolation: path present/absent; TCP against a listener
bound to an ephemeral port, and against a closed port; command exiting 0,
exiting non-zero, and one that hangs past `interval`.
- Supervisor: condition already met spawns immediately; timeout yields `Failed`
with no spawn **and an untouched retry window** (the regression assertion for
the reported bug); `Stop` during a wait cancels promptly rather than after
the timeout; `Shutdown` during a wait returns.
- Formatting: `waiting` in the list table; `xy status` renders the description
and a *growing* elapsed value.
- Integration: a server whose `wait-for` path can never exist reaches `Failed`
after a short configured timeout without ever spawning.
Timeouts and intervals in tests are milliseconds, so no test waits on wall time.
## Documentation
`README.md` gains a `wait-for` section with the three condition kinds and the
OrbStack example, plus a note that waiting does not consume the restart budget.
## Rollout
Once shipped, `gitea.kdl` and `signoz.kdl` each gain:
```kdl
wait-for {
path "~/.orbstack/run/docker.sock"
timeout "180s"
}
```
Until then those two servers remain vulnerable at every login. The interim
mitigation, if wanted, is `backoff-max "15s"` and `max-retries-per-minute 20`
in both files.