Read a server's last words before reaping the process that wrote them - #124
Conversation
…m is reaped The stderr line an MCP server writes on its way out is the whole answer to "why will this not start". Whether it survived was a race between two fibers over one file descriptor, and the losing side was not the one the issue named. `Process#wait` closes all three pipes in its `ensure` (process.cr:652). So the fiber that reaps the process ends the stderr drain — not by reaching the end of the stream, but by taking the descriptor away from it, discarding whatever the process wrote and nothing had read yet. `close` did the same from the other side, closing the read end before the drain had got there. Neither was waiting for anything. The drain fiber only had to have been given a turn first, and usually it was, because `wait` blocks on a channel before it closes anything and that turn fell out of the blocking. "Usually" is the entire complaint: it surfaced as manager_spec.cr:208 failing on macOS CI for pull requests that touch nothing nearby, green on a re-run of the same commit — and it stands for `smith mcp list` printing a bare `Broken pipe` for a server that said exactly why it quit. Reaping now waits for the drain to reach the end of stderr. That is a hop, not a wait: stderr ends when the last write end closes, which is the event `wait` is about to report. `close` waits too, capped at 250ms, because with `grace: 0` — what `smith doctor` asks for, and deliberately — it has nothing to wait for and so never yields at all, and a fiber that is never scheduled never reads. Nothing deadlocks if the end never comes; a grandchild that inherited stderr and outlived its parent is that case. `close` closes the descriptor itself once its grace runs out, which ends the drain and releases the reaper. Two specs. The first builds the losing state rather than racing for it — the child is watched until it has written, before the transport and therefore the drain fiber exist, so waiting there cannot drain anything — and fails without the `close` half. The second pins the invariant that makes the question go away: by the time the transport reports the process gone, its stderr has been read. Closes #114 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… unblocks Review measured a regression worse than the bug: gating the reaper on the drain without a bound cost 6.3 seconds per server at shutdown, and left `alive?` true forever. The circle: `close` waits on `@done`, the reaper waits on `@drained`, and `@drained` closes when the *last* write end of stderr closes — which a grandchild that inherited fd 2 holds open long after its parent is gone. A wrapper that backgrounds something, `docker run`, a server with a worker. The only thing that broke it was `close_pipe`, which runs after both graces have timed out. Reproduced at 6263ms, and `alive?` never flipped, so the child was never reaped either. Both waits are capped now. Bounded, the reaper's wait ends in a scheduler turn wherever the drain can finish and gives up where it never will, so the window it closes costs 250ms in the pathological case instead of 6.3s. Measured on the same lingering-helper server: 6263ms to 50ms, `alive?` false again. Both halves stay, because both are load-bearing. Measured on a server with four hundred lines to say and no grace to say them in — runs losing its last line, out of 30: main 3 capped reaper only 1 close half only (spec fails) both 0 `failure_message` now asks the transport for the drain itself, rather than relying on `connect`'s rescue having closed the transport two files away. Below the `with_server_output` guard, so the summary form — the only one `smith doctor` reads — does not pay for a wait whose result it discards. The second spec is gone. Review showed it green in every configuration, including plain `main` and with the exact reordering its comment claimed would break it; a spec that cannot fail is not evidence. The first spec is kept and its claim corrected: it pins *both* halves, not the close half alone, verified by reverting each in turn. It also kills its child on the way out now, which it did not do if the assertion failed first. The `STDERR_GRACE` doc no longer blames a process for surviving SIGKILL. Nothing does; the cause is a second process holding the same write end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eproduce Review, second round. All four are claims that were stronger than the evidence. The CHANGELOG quoted "3 runs in 30" and "each half independently necessary". Re-measured on the same machine, the same probe gave 0/150 for every variant, `main` included — it is load- and thermal-sensitive, and n=30 does not support the sentence. The counts are gone. What is left says the loss happened, that it is covered from both sides, and that the covering is bounded, which is what survives a re-run. The price is stated as what it is: `max(0, cap − uptime)`, once per transport, concurrent across servers rather than additive — not "up to 250ms in close", which reads as per-close and per-server. Doctor performs a full handshake before shutting anything down, so it is past the cap by then. Spec 1 now says what it guards and what it does not: removing the wait in `close` makes it red every time, removing the reaper's makes it red about two runs in three. Bounding that wait is what made it probabilistic. A spec whose comment implies more coverage than it has is how the deleted spec 2 got written. `STDERR_GRACE` has three waiters now, not one, and says so. The reaper's comment no longer claims to close the window outright — it closes it for the length of the cap, which is where a write-and-exit server lives, and `close` covers it from there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review durch einen Reviewer-Agenten, zwei Runden. Erste Runde REQUEST CHANGES mit zwei Blockern, zweite APPROVE WITH NITS. Runde 1: ein Regress, schlimmer als der BugDie erste Fassung koppelte das Einsammeln des Prozesses unbegrenzt an den Drain. Das erzeugt einen Kreis: Vor jeder Änderung unabhängig reproduziert — committeter Stand gegen echtes
Nebenwirkung war, dass Die Auflösung: deckeln, nicht streichenBeide Wartepunkte sind jetzt auf Dass die Reaper-Hälfte trotzdem bleibt, ist gemessen und nicht behauptet: mit nur der Was der Review korrigiert hat, wo ich falsch lag
Was der Review gegengeprüft und sauber gefunden hatKein Deadlock in irgendeinem konstruierbaren Szenario; das Drain-Fiber schließt
Bekannt und nicht behoben
|
Closes #114
The stderr line an MCP server writes on its way out is the whole answer to "why will this not start" — a missing argument, a token refused on sight. Whether it survived was a race between two fibers over one file descriptor.
The cause is not the one the issue named
The issue supposed the drain fiber might not have been scheduled before
failure_messagereadstderr_tail. That is half of it. The other half is worse, and it is in the stdlib:StdioTransportspawns a fiber whose only job is@process.wait. That fiber closes the stderr pipe. So reaping the process is itself a way to end the drain — not by reaching the end of the stream, but by taking the descriptor away from it, discarding whatever the process wrote and nothing had read yet.And
closedid the same thing from the other side:close_pipe(@process.error)with the drain possibly still parked on data it had not read.Neither is a wait on anything. The drain fiber only had to have been given a turn first — and usually it was, because
waitblocks on a channel before it closes anything, and the drain got that turn as a side effect of the blocking. "Usually" is the whole complaint. It surfaced asspec/mcp/manager_spec.cr:208failing on macOS CI for PRs that touch nothing nearby (including #123, in this session), green on a re-run of the same commit.The fix
Reaping waits for the drain. Not a delay: stderr reaches its end when the last write end closes, which is the same event
waitis about to report. It costs a hop.closewaits too, capped at 250 ms. Withgrace: 0— whichsmith doctorasks for deliberately —exited?returns without waiting, soclosenever yields at all, and a fiber that is never scheduled never reads. The cap is for the case where the end never comes.Nothing deadlocks. A grandchild that inherited stderr and outlived its parent is that case:
closecloses the descriptor itself once its grace runs out, which ends the drain and releases the reaper.Specs, and what each one actually pins
Being honest about this, because it matters for reviewing the second half:
keeps what a server wrote to stderr when nothing has drained it yet— deterministic, fails without theclosehalf. It builds the losing state instead of racing for it: the child is watched until it has written, which happens before the transport and therefore before the drain fiber exists, so waiting there cannot drain anything.grace: 0then leavesclosewith no reason to yield. Verified failing with only that half reverted, both alone and under the full suite.reads a server's stderr before reaping the process that wrote it— pins the invariant, not the race: by the time the transport reports the process gone, its stderr has been read. I could not make the reaping half fail deterministically, and I would rather say so than dress it up —wait's internalchannel.receiveyields, which hands the drain its turn most of the time. The justification for that half is the stdlib'sensure closeplus the observed loss, not this spec. What the spec does is lock the ordering against a future reordering.The first spec was reworked twice before it was honest. The version that used
Process#waitto settle the child was wrong —waitcloses the pipes, so the spec was destroying the evidence itself and failing for its own reason under load.crystal spec: 1427 examples, 0 failures.spec/mcp/run three times over, stable.crystal tool format --check: clean.🤖 Generated with Claude Code