Add a way to keep a child's stdin open — stdio-first MCP servers exit immediately on EOF #2

Closed
opened 2026-09-07 07:32:04 +00:00 by logaritmisk · 1 comment
Owner

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:

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:

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:

command "vestige-mcp"
args "--http" "--http-port" "3928"
stdin "keep-open"
  • inherit (default) — current behaviour
  • null — explicit Stdio::null()
  • keep-openStdio::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

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.

## 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.
Author
Owner

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.ai/code/session_01TWLFEoRaRafJm1SpdhWQ6F

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
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logaritmisk/xy#2