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
8.5 KiB
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
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:
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:
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:
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
Failedwith no spawn and an untouched retry window (the regression assertion for the reported bug);Stopduring a wait cancels promptly rather than after the timeout;Shutdownduring a wait returns. - Formatting:
waitingin the list table;xy statusrenders the description and a growing elapsed value. - Integration: a server whose
wait-forpath can never exist reachesFailedafter 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:
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.