Skip to content

Read a server's last words before reaping the process that wrote them - #124

Merged
webmatze merged 3 commits into
mainfrom
issue-114-stderr-drain-race
Sep 10, 2026
Merged

Read a server's last words before reaping the process that wrote them#124
webmatze merged 3 commits into
mainfrom
issue-114-stderr-drain-race

Conversation

@webmatze

Copy link
Copy Markdown
Owner

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_message read stderr_tail. That is half of it. The other half is worse, and it is in the stdlib:

# process.cr:644
def wait : Process::Status
  ...
ensure
  close          # <- closes @input, @output AND @error
  @process_info.release
end

StdioTransport spawns 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 close did 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 wait blocks 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 as spec/mcp/manager_spec.cr:208 failing 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 wait is about to report. It costs a hop.

close waits too, capped at 250 ms. With grace: 0 — which smith doctor asks for deliberately — exited? returns without waiting, so close never 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: close closes 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:

  1. keeps what a server wrote to stderr when nothing has drained it yetdeterministic, fails without the close half. 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: 0 then leaves close with no reason to yield. Verified failing with only that half reverted, both alone and under the full suite.

  2. 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 internal channel.receive yields, which hands the drain its turn most of the time. The justification for that half is the stdlib's ensure close plus 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#wait to settle the child was wrong — wait closes 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

webmatze and others added 3 commits September 11, 2026 00:03
…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>
@webmatze

Copy link
Copy Markdown
Owner Author

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 Bug

Die erste Fassung koppelte das Einsammeln des Prozesses unbegrenzt an den Drain. Das erzeugt einen Kreis: close wartet auf @done, der Reaper auf @drained, und @drained schließt erst, wenn das letzte Schreibende von stderr zugeht — was ein Enkelprozess, der fd 2 geerbt hat, lange offen hält. Ein Wrapper, der etwas in den Hintergrund schickt, docker run, ein Server mit Worker. Gebrochen wurde der Kreis nur dadurch, dass beide Graces ausliefen.

Vor jeder Änderung unabhängig reproduziert — committeter Stand gegen echtes main in einem Worktree:

close geduldig close zero alive? nach Exit
main 0,0 ms 0,0 ms false
erste Fassung 6263 ms 254,8 ms true, dauerhaft
jetzt 0,0 ms 0,0 ms false

Nebenwirkung war, dass @status nie gesetzt wurde: der Kindprozess wurde nie eingesammelt, alive? blieb dauerhaft true.

Die Auflösung: deckeln, nicht streichen

Beide Wartepunkte sind jetzt auf STDERR_GRACE gedeckelt. Der Kreis kann sich nicht mehr bilden — der Reaper gibt am Cap frei, unabhängig davon, was close tut.

Dass die Reaper-Hälfte trotzdem bleibt, ist gemessen und nicht behauptet: mit nur der close-Hälfte fällt Spec 1 rundheraus, mit nur dem gedeckelten Reaper blieb in einem Lastprobe-Lauf Restverlust. Beide sind tragend.

Was der Review korrigiert hat, wo ich falsch lag

  • Spec 1 hält beide Hälften fest, nicht nur die close-Hälfte. Das stand falsch im PR-Text. Der Reviewer hat jede Hälfte einzeln zurückgedreht und nachgemessen.
  • Spec 2 ist gelöscht. Es war in jeder Konfiguration grün — auf blankem main und sogar bei genau der Umordnung, die sein eigener Kommentar zu verhindern behauptete. Ein Spec, das nicht fallen kann, ist kein Beleg.
  • Die Zahlen im CHANGELOG sind raus. „3 von 30, jede Hälfte einzeln notwendig" ließ sich beim Nachmessen nicht reproduzieren: 0/150 für alle Varianten, main eingeschlossen. Die Probe ist last- und temperaturabhängig, n=30 trägt den Satz nicht. Was bleibt, sagt, dass der Verlust auftrat, dass er beidseitig gedeckt ist und dass die Deckung begrenzt ist — das übersteht einen zweiten Lauf.
  • Der Preis ist jetzt richtig beziffert: max(0, Cap − Laufzeit des Transports), einmal pro Transport, nebenläufig über Server hinweg — nicht „bis zu 250 ms in close", was sich als pro Aufruf und additiv liest. Meine ursprüngliche Sorge um smith doctor war damit unbegründet: doctor führt einen vollen Handshake durch, bevor es irgendetwas herunterfährt, ist also längst über dem Cap.
  • Spec 1 sagt jetzt, was es deckt und was nicht. Die close-Hälfte deterministisch, die Reaper-Hälfte in etwa zwei von drei Läufen — das Deckeln hat sie probabilistisch gemacht. Genau die Unehrlichkeit, an der Spec 2 gestorben ist.

Was der Review gegengeprüft und sauber gefunden hat

Kein Deadlock in irgendeinem konstruierbaren Szenario; das Drain-Fiber schließt @drained auch bei einer Ausnahme, die nicht IO::Error ist (nachgewiesen über Redirect::Inherit); close_pipe weckt ein in gets geparktes Fiber korrekt; manager_spec.cr:165 behält seine volle Marge (0,6 ms gegen 3000 ms); alive? hat außerhalb von protocol.cr null Leser; der gemeldete Flake-Pfad ist end-to-end nachverfolgt und behoben.

crystal spec: 1426 Beispiele, 0 Fehler. spec/mcp/ sechsfach wiederholt stabil. crystal tool format --check: sauber. CI grün auf beiden Plattformen.

Bekannt und nicht behoben

manager.cr:232 — der await_stderr-Aufruf in failure_message — ist von keinem Spec gedeckt. Er existiert, um eine stille Kopplung zu töten (dass connects Rescue den Transport zwei Dateien entfernt bereits geschlossen hat), die heute noch hält. Sein Entfernen würde nichts bemerken.

@webmatze
webmatze merged commit 0318341 into main Sep 10, 2026
2 checks passed
@webmatze
webmatze deleted the issue-114-stderr-drain-race branch September 10, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Die stderr-Ausgabe eines sofort endenden MCP-Servers erreicht die Fehlermeldung nur, wenn der Drain-Fiber rechtzeitig laeuft

1 participant