xy gives a supervised child no usable stdin, so any program that reads stdin shuts down the moment it starts. This makes a whole class of MCP servers unsupervisable, even when they also expose an HTTP transport.
Root cause
crates/xy-supervisor/src/child.rs configures stdout and stderr but never stdin:
xy logs vestige (note stderr capture works fine — this is purely about stdin):
INFO Vestige MCP Server v2.8.0 starting...
INFO Storage initialized successfully
INFO Using auth token from .../auth_token
INFO Starting MCP server on stdio...
INFO stdin closed (EOF), shutting down
INFO Vestige MCP Server shutting down
INFO HTTP MCP transport listening on http://127.0.0.1:3928/mcp
INFO Dashboard available at http://127.0.0.1:3927
Everything initializes correctly — HTTP binds, the dashboard starts, and a background job runs to completion — and then the process exits because stdio hit EOF. xy list shows the server stopped with RESTARTS 0; nothing is left listening on 3928.
Why it matters
MCP servers are stdio-first by convention. Several that also speak HTTP still start the stdio transport unconditionally, so today they simply cannot be run under xy in HTTP mode. That is the exact case that motivated this report.
Proposed fix
A per-server stdin option, defaulting to today's behaviour:
keep-open — Stdio::piped(), with xy retaining the write end for the child's lifetime and never writing to it, so the child blocks on read instead of seeing EOF
keep-open is a small change: take child.stdin and hold it in RealChild rather than dropping it (dropping the handle closes the pipe and reproduces the bug).
This works, and cleanup is sound because pre_exec already puts the child in its own process group, so signals reach the whole tree. The drawbacks are that xy list reports the wrapper's PID rather than the real server's, and readiness/port checks describe the wrapper.
## Summary
`xy` gives a supervised child no usable stdin, so any program that reads stdin shuts down the moment it starts. This makes a whole class of MCP servers unsupervisable, even when they also expose an HTTP transport.
## Root cause
`crates/xy-supervisor/src/child.rs` configures stdout and stderr but never stdin:
```rust
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
```
With no `.stdin(...)`, the child inherits the daemon's stdin. Under the launchd agent that is `/dev/null`, so the child sees EOF on its first read.
## Reproduction
`vestige-mcp` v2.8.0 supports HTTP (`--http --http-port 3928`) but its `main()` runs `StdioTransport` unconditionally — there is no HTTP-only mode. Config:
```kdl
command "vestige-mcp"
args "--http" "--http-port" "3928"
port 3928
```
`xy logs vestige` (note stderr capture works fine — this is purely about stdin):
```
INFO Vestige MCP Server v2.8.0 starting...
INFO Storage initialized successfully
INFO Using auth token from .../auth_token
INFO Starting MCP server on stdio...
INFO stdin closed (EOF), shutting down
INFO Vestige MCP Server shutting down
INFO HTTP MCP transport listening on http://127.0.0.1:3928/mcp
INFO Dashboard available at http://127.0.0.1:3927
```
Everything initializes correctly — HTTP binds, the dashboard starts, and a background job runs to completion — and then the process exits because stdio hit EOF. `xy list` shows the server `stopped` with `RESTARTS 0`; nothing is left listening on 3928.
## Why it matters
MCP servers are stdio-first by convention. Several that also speak HTTP still start the stdio transport unconditionally, so today they simply cannot be run under `xy` in HTTP mode. That is the exact case that motivated this report.
## Proposed fix
A per-server `stdin` option, defaulting to today's behaviour:
```kdl
command "vestige-mcp"
args "--http" "--http-port" "3928"
stdin "keep-open"
```
- `inherit` (default) — current behaviour
- `null` — explicit `Stdio::null()`
- `keep-open` — `Stdio::piped()`, with `xy` retaining the write end for the child's lifetime and never writing to it, so the child blocks on read instead of seeing EOF
`keep-open` is a small change: take `child.stdin` and hold it in `RealChild` rather than dropping it (dropping the handle closes the pipe and reproduces the bug).
## Current workaround
```kdl
command "sh"
args "-c" "tail -f /dev/null | vestige-mcp --http --http-port 3928"
```
This works, and cleanup is sound because `pre_exec` already puts the child in its own process group, so signals reach the whole tree. The drawbacks are that `xy list` reports the wrapper's PID rather than the real server's, and readiness/port checks describe the wrapper.
Implemented on feat/stdin-mode (b500882, 38e9af1, e4eb1cc), following the proposed design: a per-server stdin mode with inherit (default, unchanged), null, and keep-open.
One detail worth recording
keep-open cannot be done by piping stdin and leaving the handle where tokio put it. tokio::process::Child::wait() opens with drop(self.stdin.take()) (tokio-1.49.0/src/process/mod.rs:1382), and RealChild::wait() is awaited for the child's whole lifetime — so the naive version closes the pipe the instant supervision starts and fails identically to the original bug.
The write end is therefore taken out of the TokioChild and parked on RealChild, exactly as the issue proposed. Confirmed by mutation: leaving the handle in place, the child passes a [ -p /dev/stdin ] check and then exits on EOF anyway. There's a comment on the field so it doesn't get tidied away later.
Testing note
The first keep-open test I wrote passed against unfixed code. Under cargo test the inherited stdin also blocks on read, so the test could not distinguish keep-open from today's inherit — it would have been green in this repo and meaningless. Two changes fix that:
the unit test's child exits 9 unless fd 0 is genuinely a FIFO, so inheriting a blocking stdin no longer satisfies it;
the integration harness spawns the daemon with /dev/null on stdin, which is what launchd does and what makes the reproduction faithful.
Coverage: 6 parser tests (three modes, default, unknown value, multi-arg), 2 RealChild tests, and 2 integration tests through the real daemon covering the parser → supervisor → spawn wiring. The second integration test characterizes the bug as reported — without keep-open the server goes stopped; with it, running. 143 tests pass, clippy clean.
README gained a short section, since the other config features are documented that way.
Not addressed, as agreed: the mode is not surfaced in xy list / status, and the readiness/port probes are untouched. Your tail -f /dev/null workaround is no longer needed for the vestige-mcp case, and xy list will now report the real PID.
Implemented on `feat/stdin-mode` (b500882, 38e9af1, e4eb1cc), following the proposed design: a per-server `stdin` mode with `inherit` (default, unchanged), `null`, and `keep-open`.
## One detail worth recording
`keep-open` cannot be done by piping stdin and leaving the handle where tokio put it. `tokio::process::Child::wait()` opens with `drop(self.stdin.take())` (`tokio-1.49.0/src/process/mod.rs:1382`), and `RealChild::wait()` is awaited for the child's whole lifetime — so the naive version closes the pipe the instant supervision starts and fails identically to the original bug.
The write end is therefore taken out of the `TokioChild` and parked on `RealChild`, exactly as the issue proposed. Confirmed by mutation: leaving the handle in place, the child passes a `[ -p /dev/stdin ]` check and then exits on EOF anyway. There's a comment on the field so it doesn't get tidied away later.
## Testing note
The first `keep-open` test I wrote passed against unfixed code. Under `cargo test` the inherited stdin also blocks on read, so the test could not distinguish `keep-open` from today's `inherit` — it would have been green in this repo and meaningless. Two changes fix that:
- the unit test's child exits 9 unless fd 0 is genuinely a FIFO, so inheriting a blocking stdin no longer satisfies it;
- the integration harness spawns the daemon with `/dev/null` on stdin, which is what launchd does and what makes the reproduction faithful.
Coverage: 6 parser tests (three modes, default, unknown value, multi-arg), 2 `RealChild` tests, and 2 integration tests through the real daemon covering the parser → supervisor → spawn wiring. The second integration test characterizes the bug as reported — without `keep-open` the server goes `stopped`; with it, `running`. 143 tests pass, clippy clean.
README gained a short section, since the other config features are documented that way.
Not addressed, as agreed: the mode is not surfaced in `xy list` / `status`, and the readiness/port probes are untouched. Your `tail -f /dev/null` workaround is no longer needed for the `vestige-mcp` case, and `xy list` will now report the real PID.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
xygives a supervised child no usable stdin, so any program that reads stdin shuts down the moment it starts. This makes a whole class of MCP servers unsupervisable, even when they also expose an HTTP transport.Root cause
crates/xy-supervisor/src/child.rsconfigures stdout and stderr but never stdin:With no
.stdin(...), the child inherits the daemon's stdin. Under the launchd agent that is/dev/null, so the child sees EOF on its first read.Reproduction
vestige-mcpv2.8.0 supports HTTP (--http --http-port 3928) but itsmain()runsStdioTransportunconditionally — there is no HTTP-only mode. Config:xy logs vestige(note stderr capture works fine — this is purely about stdin):Everything initializes correctly — HTTP binds, the dashboard starts, and a background job runs to completion — and then the process exits because stdio hit EOF.
xy listshows the serverstoppedwithRESTARTS 0; nothing is left listening on 3928.Why it matters
MCP servers are stdio-first by convention. Several that also speak HTTP still start the stdio transport unconditionally, so today they simply cannot be run under
xyin HTTP mode. That is the exact case that motivated this report.Proposed fix
A per-server
stdinoption, defaulting to today's behaviour:inherit(default) — current behaviournull— explicitStdio::null()keep-open—Stdio::piped(), withxyretaining the write end for the child's lifetime and never writing to it, so the child blocks on read instead of seeing EOFkeep-openis a small change: takechild.stdinand hold it inRealChildrather than dropping it (dropping the handle closes the pipe and reproduces the bug).Current workaround
This works, and cleanup is sound because
pre_execalready puts the child in its own process group, so signals reach the whole tree. The drawbacks are thatxy listreports the wrapper's PID rather than the real server's, and readiness/port checks describe the wrapper.Implemented on
feat/stdin-mode(b500882,38e9af1,e4eb1cc), following the proposed design: a per-serverstdinmode withinherit(default, unchanged),null, andkeep-open.One detail worth recording
keep-opencannot be done by piping stdin and leaving the handle where tokio put it.tokio::process::Child::wait()opens withdrop(self.stdin.take())(tokio-1.49.0/src/process/mod.rs:1382), andRealChild::wait()is awaited for the child's whole lifetime — so the naive version closes the pipe the instant supervision starts and fails identically to the original bug.The write end is therefore taken out of the
TokioChildand parked onRealChild, exactly as the issue proposed. Confirmed by mutation: leaving the handle in place, the child passes a[ -p /dev/stdin ]check and then exits on EOF anyway. There's a comment on the field so it doesn't get tidied away later.Testing note
The first
keep-opentest I wrote passed against unfixed code. Undercargo testthe inherited stdin also blocks on read, so the test could not distinguishkeep-openfrom today'sinherit— it would have been green in this repo and meaningless. Two changes fix that:/dev/nullon stdin, which is what launchd does and what makes the reproduction faithful.Coverage: 6 parser tests (three modes, default, unknown value, multi-arg), 2
RealChildtests, and 2 integration tests through the real daemon covering the parser → supervisor → spawn wiring. The second integration test characterizes the bug as reported — withoutkeep-openthe server goesstopped; with it,running. 143 tests pass, clippy clean.README gained a short section, since the other config features are documented that way.
Not addressed, as agreed: the mode is not surfaced in
xy list/status, and the readiness/port probes are untouched. Yourtail -f /dev/nullworkaround is no longer needed for thevestige-mcpcase, andxy listwill now report the real PID.🤖 Generated with Claude Code
https://claude.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F