Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/probe-close-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
---

Handle a connection that closes during the `server/discover` version-negotiation probe.

On stdio, a server process that exits on the unrecognized probe is a legacy signal — symmetric with the stdio probe-timeout rule. Servers built on SDKs that terminate on any pre-`initialize` request (the official Rust SDK, rmcp, is the prominent example) previously connected fine under `mode: 'legacy'` but hard-failed under `mode: 'auto'`; `StdioClientTransport` now restarts itself (respawning the server from its retained parameters) and the client completes the plain `initialize` handshake there — the respawned process sees bytes identical to a plain legacy connect. A transport that was closed locally, or that cannot restart, rejects with a typed negotiation error instead: a deliberately terminated server is never resurrected. On HTTP-class transports a mid-probe close keeps surfacing as the same typed connect error as any other probe transport failure — an ambiguous drop is never era evidence.

`StdioClientTransport` restart mechanics: child events are generation-scoped (a slow-dying child's late events cannot touch its successor), the read buffer is cleared on child exit, and with `stderr: 'pipe'` the stream spans restarts — it ends exactly once, on `close()`, not on a child's exit.

Check warning on line 10 in .changeset/probe-close-handling.md

View check run for this annotation

Claude / Claude Code Review

Stale PR description: directs callers to a nonexistent SdkErrorCode.EraProbeConnectionClosed and describes a superseded mechanism

The PR description matches an earlier iteration of this change, not the shipped code, in four consumer-visible ways — most importantly, the Breaking Changes section tells callers to match `error.code === SdkErrorCode.EraProbeConnectionClosed`, an enum member that exists nowhere in the codebase (the shipped HTTP mid-probe close rejects with the generic `SdkError(EraNegotiationFailed)`). The changeset and docs are correct; please refresh the PR description before merge since it will likely become
Comment on lines +1 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The PR description matches an earlier iteration of this change, not the shipped code, in four consumer-visible ways — most importantly, the Breaking Changes section tells callers to match error.code === SdkErrorCode.EraProbeConnectionClosed, an enum member that exists nowhere in the codebase (the shipped HTTP mid-probe close rejects with the generic SdkError(EraNegotiationFailed)). The changeset and docs are correct; please refresh the PR description before merge since it will likely become the merge-commit body and reviewers/consumers will follow its (currently unusable) migration advice.

Extended reasoning...

What is stale. The PR description describes a superseded revision of this change and now contradicts the shipped implementation in four ways:

  1. Nonexistent error code in the migration advice. The Breaking Changes section says callers "should switch to error.code === SdkErrorCode.EraProbeConnectionClosed (HTTP)". A grep over the entire checkout finds zero hits for EraProbeConnectionClosed — the SdkErrorCode enum's negotiation code is EraNegotiationFailed, and the shipped HTTP mid-probe close routes through classifyProbeOutcome's closed row → classifyNetworkErrorSdkError(EraNegotiationFailed) (packages/client/src/client/probeClassifier.ts, pinned by the new probeClassifier.test.ts rows and documented in both docs/protocol-versions.md and docs/migration/support-2026-07-28.md). A caller following the description's advice writes a predicate that cannot even compile. Earlier bot review comments in the timeline confirm a prior iteration did add that enum member — it was dropped in the rework without the description being updated.

  2. Wrong restart mechanism. The description says negotiateEra "skips arming the one-shot start() pass-through so the Protocol.connect() handover's unconditional start() respawns the server". The shipped code does the opposite: on the verdict.restart branch, negotiateEra awaits transport[TRANSPORT_RESTART]() itself (packages/client/src/client/versionNegotiation.ts), and window.release() — which does arm the one-shot pass-through that absorbs Protocol.connect()'s start() — runs on every success path. Relatedly, the "Additional context" claim that "no new option or transport method is needed" contradicts the diff, which adds the TRANSPORT_RESTART symbol and RestartableTransport interface to packages/core-internal/src/shared/transport.ts.

  3. Inverted stderr limitation. The description's "Known limitation: with stderr: 'pipe', the stderr stream ends at the first child's exit and does not carry a restarted process's output" is the exact opposite of the shipped behavior: start() pipes with { end: false } (packages/client/src/client/stdio.ts), the getter JSDoc says the stream spans restarts and ends exactly once in close(), and both stdioRestart.test.ts and the integration test pin that both children's banners reach the shared stream.

  4. Wrong custom-transport contract. The description says "custom stdio-shaped transports get the same behavior if their start() supports being called again after close()". The shipped design instead gates restart on the TRANSPORT_RESTART capability symbol — a stdio-shaped transport with a perfectly re-callable start() but no capability gets the typed 'cannot restart' error, pinned by the "stdio-shaped transport WITHOUT the restart capability: typed error" test in versionNegotiation.test.ts.

Step-by-step proof for the most harmful claim (#1). (a) A caller on mode: 'auto' over HTTP wants to handle a mid-probe drop; per the description they write if (error instanceof SdkError && error.code === SdkErrorCode.EraProbeConnectionClosed) retry(). (b) TypeScript rejects the code outright (EraProbeConnectionClosed is not a member of SdkErrorCode); if they hard-code a string instead, the predicate never matches. (c) The actual mid-probe HTTP close rejects with SdkError(EraNegotiationFailed) and the message "Version negotiation probe failed: Connection closed during the version negotiation probe" — exactly what probeClassifier.test.ts's HTTP-close rows pin. The migration advice is unusable as written.

Why this is a nit, not blocking. Everything that actually ships is internally consistent: the changeset (.changeset/probe-close-handling.md), both docs pages, the JSDoc, and the tests all match the code. The only stale artifact is the PR description itself — likely a leftover merge-commit body from the earlier iteration. Per this repo's own Documentation & Changesets convention, in-diff prose is what must match the implementation, and it does. No runtime behavior is wrong and no published artifact misleads consumers; the harm is confined to PR metadata (which will likely become the merge-commit body and the reference reviewers/consumers read).

How to fix. Rewrite the PR description to match the shipped design before merge: drop the EraProbeConnectionClosed migration advice (HTTP closes stay EraNegotiationFailed), describe the TRANSPORT_RESTART capability + in-negotiateEra restart mechanism, replace the stderr "known limitation" with the actual spans-restarts { end: false } behavior, and state that custom transports opt in via the capability symbol rather than a re-callable start().

12 changes: 10 additions & 2 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,16 @@ error. Probe timeouts are **transport-aware**: on **stdio** a server that does n
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
on the same stream (some legacy servers never respond to unknown pre-`initialize`
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
a dead HTTP server is never misreported as legacy. One browser-specific exception: an
opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because
a dead HTTP server is never misreported as legacy. A mid-probe **connection close** is
transport-aware the same way: on **stdio** a server process that exits on the
unrecognized probe is treated as legacy — the transport restarts itself (respawning
the server) and the client falls back to `initialize` there (stdio servers built on
SDKs that terminate on any pre-`initialize` request behave this way); on **HTTP** a
mid-probe close rejects with the same typed connect error as any probe transport
failure (`SdkError(EraNegotiationFailed)`) — an ambiguous drop is never misreported
as legacy. A transport closed locally during the probe is never restarted: `connect()`
rejects with the same typed error. One browser-specific exception: an opaque
CORS/preflight `TypeError` during the probe falls back to the legacy era, because
deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers.

```typescript
Expand Down
2 changes: 2 additions & 0 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const cli = new Client(

A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize` on the same stream; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers.

A connection that closes mid-probe follows the same transport split. On stdio a server process that exits on the unrecognized probe is a legacy server — stdio servers built on SDKs that terminate on any pre-`initialize` request behave exactly this way (the official Rust SDK, rmcp, is the prominent example) — so the transport restarts itself, respawning the server, and `connect()` falls back to `initialize` there; the respawned process sees the same bytes a `mode: 'legacy'` connect would have sent. On HTTP a mid-probe close is ambiguous — a proxy drop or a crash, never era evidence — so `connect()` rejects with the same typed `SdkError(EraNegotiationFailed)` as any probe transport failure. A transport closed locally during the probe is never restarted.

The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`.

::: warning
Expand Down
32 changes: 28 additions & 4 deletions packages/client/src/client/probeClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Probe outcome classifier (pure module): maps the outcome of the connect-time
* `server/discover` probe onto one of four verdicts — modern era, the
* spec-mandated `-32022` corrective continuation, legacy fallback (the plain
* 2025 `initialize` handshake on the same connection), or a typed connect error.
* 2025 `initialize` handshake — on the same connection, or on a restarted
* transport when a stdio close consumed it), or a typed connect error.
*
* The classifier is deliberately conservative: anything it does not positively
* recognize as modern resolves to the legacy fallback, and a network outage is a
Expand Down Expand Up @@ -48,6 +49,8 @@ export type ProbeOutcome =
| { kind: 'network-error'; error: unknown }
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
| { kind: 'auth-required'; error: Error }
/** The transport reported close while the probe awaited its reply. */
| { kind: 'closed' }
/** No response arrived within the probe timeout. */
| { kind: 'timeout'; timeoutMs: number };

Expand Down Expand Up @@ -82,8 +85,13 @@ export type ProbeVerdict =
* arms a loop guard on the second rejection, throwing `error`.
*/
| { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError }
/** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */
| { kind: 'legacy' }
/**
* Definitive legacy signal or unrecognized shape: perform the plain legacy
* `initialize` handshake on the same connection. `restart` marks the one
* row where the same connection no longer exists (stdio close): the
* handshake needs the transport restarted first.
*/
| { kind: 'legacy'; restart?: true }
/** Typed connect error — never converted to an era verdict. */
| { kind: 'error'; error: Error };

Expand Down Expand Up @@ -141,6 +149,21 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi
// handshake an auth-gated modern server as legacy.
return { kind: 'error', error: outcome.error };
}
case 'closed': {
if (context.transportKind === 'stdio') {
// A stdio child that exits on the unrecognized probe instead of
// answering is a legacy signal — the same backward-compatibility
// rule as the timeout row below (SDKs like the official Rust one
// terminate the server on ANY pre-initialize request). The
// stream died with the process, so the fallback needs the
// transport restarted.
return { kind: 'legacy', restart: true };
}
// On HTTP a mid-probe close is an ambiguous network condition
// (proxy drop, crash, redeploy) — the same typed connect error as
// any probe transport failure, never an era verdict.
return classifyNetworkError(new Error('Connection closed during the version negotiation probe'), context);
}
case 'timeout': {
if (context.transportKind === 'stdio') {
// Per the stdio transport's backward-compatibility rule, a probe
Expand Down Expand Up @@ -253,7 +276,8 @@ function isOpaqueFetchTypeError(error: unknown): boolean {
return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError');
}

function describeError(error: unknown): string {
/** Human-readable rendering of an unknown thrown value for probe diagnostics. */
export function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

Expand Down
89 changes: 71 additions & 18 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import type { Stream } from 'node:stream';
import { PassThrough } from 'node:stream';

import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core-internal';
import type { JSONRPCMessage, RestartableTransport } from '@modelcontextprotocol/core-internal';
import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage, TRANSPORT_RESTART } from '@modelcontextprotocol/core-internal';
import spawn from 'cross-spawn';

export type StdioServerParameters = {
Expand Down Expand Up @@ -98,11 +98,15 @@
*
* This transport is only available in Node.js environments.
*/
export class StdioClientTransport implements Transport {
export class StdioClientTransport implements RestartableTransport {
private _process?: ChildProcess;
private _readBuffer: ReadBuffer;
private _serverParams: StdioServerParameters;
private _stderrStream: PassThrough | null = null;
/** Monotonic child generation; every child's event handlers check it so a dead child's late events cannot touch a successor. */
private _generation = 0;
/** `true` after a local {@linkcode close} (caller shutdown or the transport's own error recovery); cleared by {@linkcode start}. */
private _closedLocally = false;

onclose?: () => void;
onerror?: (error: Error) => void;
Expand All @@ -118,16 +122,26 @@

/**
* Starts the server process and prepares to communicate with it.
*
* After the child process exits, `start()` may be called again: a fresh
* process is spawned from the same {@linkcode StdioServerParameters}.
*/
async start(): Promise<void> {
if (this._process) {
Comment thread
claude[bot] marked this conversation as resolved.
throw new Error(
'StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.'
);
}
this._closedLocally = false;
if (this._stderrStream?.writableEnded) {
// close() ended the previous life's aggregate stream; a caller
// start() after close() begins a new life with a fresh stream.
this._stderrStream = new PassThrough();
}

Check warning on line 140 in packages/client/src/client/stdio.ts

View check run for this annotation

Claude / Claude Code Review

stderr attach-before-start() contract silently broken for a post-close() second life

The `stderr` getter's JSDoc still promises callers can attach listeners before `start()` to avoid losing early output, but for the `close()` → `start()` second life this PR documents as supported, `start()` swaps in a fresh `PassThrough` (stdio.ts:136-140) after `close()` ended the old one — so listeners attached in the documented pre-start window bind to the dead stream and the new child's stderr (including exactly the promised early output) flows to the fresh, unobserved stream. Fix by recreat
Comment on lines +136 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The stderr getter's JSDoc still promises callers can attach listeners before start() to avoid losing early output, but for the close()start() second life this PR documents as supported, start() swaps in a fresh PassThrough (stdio.ts:136-140) after close() ended the old one — so listeners attached in the documented pre-start window bind to the dead stream and the new child's stderr (including exactly the promised early output) flows to the fresh, unobserved stream. Fix by recreating the fresh PassThrough at the end of close() (right after ending the old one) so the getter hands out the next life's stream between close() and start(), or scope the attach-before-start guarantee to the first life in the getter/start() JSDoc.

Extended reasoning...

What the bug is. close() now ends the aggregate stderr PassThrough (stdio.ts:304-310) but leaves the ended stream in _stderrStream; the replacement is only created inside the next start() via the writableEnded swap (stdio.ts:136-140). Meanwhile the stderr getter's JSDoc (unchanged in intent by this PR) still promises: "a PassThrough stream is returned immediately, allowing callers to attach listeners before the start method is invoked. This prevents loss of any early error output emitted by the child process." For the close()-then-start() second life that this PR itself establishes as supported (the new start() JSDoc: "After the child process exits, start() may be called again"; the _closedLocally reset; the stdioRestart.test.ts second-life tests), that promise is false: in the window between close() and start() — exactly where the getter tells callers to attach — the getter returns the dead, ended previous-life stream.\n\nThe code path. Every exit path funnels through the same three lines: close() runs this._stderrStream.end(), start() checks this._stderrStream?.writableEnded and assigns this._stderrStream = new PassThrough(), and the getter returns this._stderrStream as-is. Nothing forwards data from the old stream to the new one, and nothing warns the caller that the reference they captured is stale.\n\nStep-by-step proof.\n1. new StdioClientTransport({ command, stderr: 'pipe' }) → constructor creates PassThrough P1.\n2. First life: start(), session runs, await transport.close()close() unpipes and calls P1.end(); _stderrStream still points at P1.\n3. Caller begins a second life and follows the getter's documented pattern: reads transport.stderr (gets P1, now ended) and attaches 'data' listeners before calling start().\n4. start() sees P1.writableEnded === true and swaps in fresh PassThrough P2; the new child's stderr pipes into P2.\n5. Every byte of the new child's stderr — including exactly the "early error output" the getter promises to preserve — goes to the unobserved P2. The caller's listeners on P1 see nothing, silently. (Verifiers reproduced this deterministically: the stream grabbed before start() is not the current stream, and its listener receives zero data.)\n\nWhy the PR's own tests don't catch it. The second-life stderr test (\"a caller start() after close() begins a fresh stream\" in stdioRestart.test.ts) attaches its second-life listener only after await transport.start() — the documented attach-before-start ordering is never exercised for a second life, because the current API makes it impossible to satisfy. The getter's other new claim ("the stream spans restarts … it ends exactly once, when close is called") is likewise false for the second life, whose stream is never the one close() ended.\n\nWhy nit, not blocking. (a) The SDK's own version-negotiation restart never hits the swap: TRANSPORT_RESTART fires after a child exit, which never ends the stream, so writableEnded is false and no swap occurs — only the manual close()-then-start() path is affected. (b) The data is delayed, not destroyed: the fresh PassThrough buffers (with pipe backpressure) until read, so re-fetching transport.stderr after start() recovers every byte. (c) Pre-PR this exact scenario was strictly worse (the default end: true pipe would write into an already-ended stream), so the PR is a net improvement with a residual doc/contract inconsistency.\n\nHow to fix. Either recreate the fresh PassThrough at the end of close(), immediately after ending the old one — then the getter hands out the next life's stream during the close()start() window and the attach-before-start contract holds for every life (the writableEnded check in start() can stay as a belt-and-suspenders guard) — or amend the stderr getter/start() JSDoc to state that after close() listeners must be (re)attached only after the next start(), explicitly forfeiting the early-output guarantee for restarted lives.

const generation = ++this._generation;

return new Promise((resolve, reject) => {
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
const proc = spawn(this._serverParams.command, this._serverParams.args ?? [], {
// merge default env with server env because mcp server needs some env vars
env: {
...getDefaultEnvironment(),
Expand All @@ -138,26 +152,39 @@
windowsHide: process.platform === 'win32',
cwd: this._serverParams.cwd
});
this._process = proc;

this._process.on('error', error => {
proc.on('error', error => {
reject(error);
this.onerror?.(error);
if (this._generation === generation) {
this.onerror?.(error);
}
});

this._process.on('spawn', () => {
proc.on('spawn', () => {
resolve();
});

this._process.on('close', _code => {
proc.on('close', _code => {
if (this._generation !== generation) {
return;
}
this._process = undefined;
// Drop any partial trailing line so a restarted transport starts with a clean stream.
this._readBuffer.clear();
this.onclose?.();
});

Check warning on line 176 in packages/client/src/client/stdio.ts

View check run for this annotation

Claude / Claude Code Review

Dead child's stale partial stdout line can survive into a restarted transport's read buffer

In the window between close() resolving and the next start(), a SIGKILLed child's late stdout flush still passes the generation guard (close() never bumps `_generation`, and the SIGKILL path returns without awaiting the child's 'close' event) and repopulates the shared `_readBuffer`; since start() bumps `_generation` without clearing the buffer, a stale trailing partial line survives into the new life while the old child's now-stale 'close' handler is generation-blocked from running the compensa
Comment on lines 165 to 176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In the window between close() resolving and the next start(), a SIGKILLed child's late stdout flush still passes the generation guard (close() never bumps _generation, and the SIGKILL path returns without awaiting the child's 'close' event) and repopulates the shared _readBuffer; since start() bumps _generation without clearing the buffer, a stale trailing partial line survives into the new life while the old child's now-stale 'close' handler is generation-blocked from running the compensating clear. The restarted child's first JSON-RPC message then concatenates onto the stale partial and is silently skipped by the framing layer (e.g. a hung initialize). One-line fix: clear _readBuffer at the top of start() (symmetric with the _stderrStream recreation in the same block), or bump _generation in close().

Extended reasoning...

What the bug is. The generation-scoping this PR adds covers events landing after the next start(), but not the window between close() resolving and that start(). close() clears _readBuffer at its end but never bumps _generation, and its SIGKILL branch returns without awaiting closePromise a third time — so against a child that ignores stdin-EOF and SIGTERM, close() resolves while the dead child's pipe-buffered stdout is still unflushed. When that flush lands, the stdout 'data' handler's guard (this._generation !== generation) passes — no new start() has run, so the generation still matches — and the dead child's bytes are appended to the just-cleared shared buffer (complete lines are even delivered via onmessage after close() resolved); a trailing partial line stays.

Why the new guard makes the residue sticky. start() bumps _generation but does not clear _readBuffer — asymmetric with the _stderrStream recreation the same block performs for the new life. When the old child's 'close' event finally fires after the restart, the new stale-generation guard (packages/client/src/client/stdio.ts:169-171) returns early, deliberately skipping the _readBuffer.clear() at line 174 that would otherwise have wiped the stale partial. Pre-PR, that unguarded handler would have performed the compensating clear (at the cost of other, worse mutations); post-PR nothing does.

Impact. The restarted child's first stdout chunk is appended after the stale partial line, so its first JSON-RPC message parses as <stale-partial><new-json> — invalid JSON. ReadBuffer.readMessage() consumes the line and continues on SyntaxError (packages/core-internal/src/shared/stdio.ts:42-44, the deliberate skip for non-JSON debug output), so the corrupted line is silently dropped: no onerror, no teardown — just the restarted session's first message (e.g. the initialize response) vanishing, hanging the reconnect until timeout.

Step-by-step proof.

  1. start() spawns child A — a server that ignores stdin-EOF and SIGTERM and has an unterminated stdout line buffered in the kernel pipe.
  2. await transport.close(): both 2s races elapse, SIGKILL is sent, close() clears _readBuffer and returns — child A's stdout flush and 'close' event are still pending. _generation is unchanged.
  3. Node flushes A's dying stdout. The 'data' handler's generation check passes (still the same generation), so A's bytes — including the trailing partial line — land in the shared _readBuffer.
  4. The caller restarts (the manual close()-then-start() contract the new start() JSDoc documents and the restart-provenance test pins as 'a fresh caller start() after close() is a new life'). start() bumps _generation; the stale partial survives.
  5. Child A's 'close' event fires; the stale-generation guard returns early — the compensating _readBuffer.clear() never runs.
  6. Child B's first message concatenates onto the partial, fails JSON.parse, and is silently skipped — e.g. the initialize response is lost and connect() hangs until timeout.

The ordering in steps 3–5 (data flush before start(), 'close' after it) is exactly the lag shape the PR's own lingering-predecessor test constructs — its fixture just never writes stdout in the window.

Why the SDK's own flows are unaffected — and why this is a nit. The version-negotiation TRANSPORT_RESTART flow is safe: the child exits on its own, so the same-generation 'close' handler runs and clears the buffer before the restart. A back-to-back await close(); await start() is also safe (the generation bumps before any pending I/O macrotask fires). The exposure needs a child that survives stdin-EOF and SIGTERM through the ~4s escalation to SIGKILL, with a pending partial stdout line, on the manual reuse path with an async gap before the restart — narrow, but the PR newly documents and test-pins that path as supported, which is what makes it worth flagging.

Fix (one line). Clear _readBuffer at the top of start() — symmetric with the _stderrStream recreation in the same block — or bump _generation in close() so a locally-closed child's late data/close events are inert (close() already performed the cleanup itself).

Relation to the earlier review comments. The prior comments at this file targeted the then-unguarded data/error/close handlers injecting after start(); this revision fixes that with the generation checks. This finding is the residual pre-start() window those checks cannot cover, plus the guard now suppressing the old compensating late clear — a distinct mechanism with a distinct fix.


this._process.stdin?.on('error', error => {
this.onerror?.(error);
proc.stdin?.on('error', error => {
if (this._generation === generation) {
this.onerror?.(error);
}
});

this._process.stdout?.on('data', chunk => {
proc.stdout?.on('data', chunk => {
if (this._generation !== generation) {
return;
}
try {
this._readBuffer.append(chunk);
this.processReadBuffer();
Comment thread
claude[bot] marked this conversation as resolved.
Expand All @@ -167,22 +194,39 @@
}
});

this._process.stdout?.on('error', error => {
this.onerror?.(error);
proc.stdout?.on('error', error => {
if (this._generation === generation) {
this.onerror?.(error);
}
});

if (this._stderrStream && this._process.stderr) {
this._process.stderr.pipe(this._stderrStream);
if (this._stderrStream && proc.stderr) {
// end: false — the aggregate stream spans children; it ends in close(), never on a child's exit.
proc.stderr.pipe(this._stderrStream, { end: false });
Comment on lines 201 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The { end: false } pipe change means the stderr PassThrough is now never ended on any code path — not on child exit and not even on final transport close(), which never touches _stderrStream — so every stderr: 'pipe' consumer (including plain mode: 'legacy' clients that never opt into version negotiation) loses the end-of-stream signal: code awaiting 'end'/stream.finished(), for-await-of loops, and .pipe(dest) log destinations now hang or never close where pre-PR they terminated on child exit. At minimum close() (deliberate final teardown — the SDK's own restart flow never goes through it) should end/destroy _stderrStream, and this consumer-facing behavior change on the default path needs a docs/migration entry (it is currently documented only in the changeset and JSDoc).

Extended reasoning...

What changed. Pre-PR, start() piped the child's stderr into the shared PassThrough with the Node default end: true: when the child exited, its stderr readable emitted 'end' and pipe() ended the PassThrough. Consumers holding transport.stderr saw 'end' fire, for await loops terminated, stream.finished() resolved, and transport.stderr.pipe(dest) closed dest (flushing log files). Post-PR, the pipe is unconditionally { end: false } (stdio.ts:189-195) — necessary for the restart path, since piping a respawned child into an already-ended PassThrough raises ERR_STREAM_WRITE_AFTER_END — but nothing was added to end the stream anywhere else. close() (stdio.ts:246-283) ends stdin, escalates SIGTERM→SIGKILL, and clears _readBuffer, but never touches _stderrStream; the field appears only in the constructor, start(), and the stderr getter. So after this PR there is no code path that ever ends the stream, including deliberate final teardown via client.close().\n\nWho is affected. The { end: false } pipe is unconditional, so this hits every stderr: 'pipe' consumer — including plain mode: 'legacy' clients that never touch version negotiation. That contradicts the PR description's "Breaking Changes: None / mode: 'legacy' (the default) is byte-untouched" — true of wire bytes, but not of the transport's observable stream semantics on the default path, shipped as a patch release.\n\nStep-by-step proof. (1) new StdioClientTransport({ command, stderr: 'pipe' }) — no versionNegotiation option at all. (2) Consumer collects a crashed server's stderr the natural way: const chunks = []; transport.stderr.on('data', c => chunks.push(c)); transport.stderr.on('end', () => report(chunks)); — or equivalently for await (const c of transport.stderr), or transport.stderr.pipe(fs.createWriteStream(log)). (3) The server crashes, or the session ends and the caller runs await client.close(). (4) Pre-PR: child exit → pipe() (default end: true) ends the PassThrough → 'end' fires, the loop terminates, the log file is closed. (5) Post-PR: the child exits and close() returns, but the PassThrough is still open — 'end' never fires, the for await never returns, the write stream is never closed. The consumer hangs indefinitely.\n\nWhy the documented substitute doesn't work. The new JSDoc says to "key teardown on onclose instead", but onclose is not a stream-level signal — for await, stream.finished(), and .pipe(dest) cannot consume it — and in auto mode it is not even a reliable end signal: the PR's own test asserts presetCloses === 1 mid-connect() when the probe-killed child exits, while the stream goes on carrying the respawned child's output. So there is no reliable end-of-stream signal left at all.\n\nHow to fix. End (or destroy) _stderrStream in close(): a local close() is the caller-initiated, terminal teardown — the SDK's own restart flow never goes through it (the child exits on its own during the probe, and the classifier treats a local mid-probe close as a typed error with no respawn), so ending the stream there cannot break the restart feature. One fix-shape caveat: a caller who manually does close()connect() again would then hit write-after-end on the reused PassThrough, so the robust variant is to also recreate the PassThrough per start() when the previous one was ended (or expose a fresh one via the stderr getter). Either way, final teardown regains its 'end' signal.\n\nDocs gap. This consumer-facing behavior change on the default path is documented only in the changeset and JSDoc. Per the repo's own convention (CLAUDE.md § Breaking Changes; REVIEW.md checklist: behavior changes need a docs/migration/ entry), it needs an entry in docs/migration/upgrade-to-v2.md — it affects legacy-mode v1-style usage, not only the 2026 negotiation feature. A grep of docs/migration/ finds no mention of the stderr end-semantics change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code half of this is now addressed well: close() ends _stderrStream exactly once (with an unpipe guard against a SIGKILLed child's late flush), start() recreates the PassThrough when the previous life's stream was ended by close() — so a manual close()connect() cannot hit write-after-end — and the new stdioRestart.test.ts "stderr: 'pipe' contract" block pins all of it. Final teardown regains its 'end' signal.

One item from the original comment is still open: the docs/migration entry. The end-of-stream semantics on the default stderr: 'pipe' path did change for every consumer (including plain mode: 'legacy' clients): pre-PR the stream ended on child exit; now it ends only on close(), so code keying on 'end'/stream.finished()/.pipe(dest) to detect a crashed server must now go through transport.close() (e.g. from onclose) to get the signal. That behavior change is currently documented only in the changeset and the stderr getter JSDoc — a grep of docs/migration/ finds no mention (the only StdioClientTransport entries in upgrade-to-v2.md are the import-path move, maxBufferSize, and windowsHide). Per CLAUDE.md § Breaking Changes, a short note in the stdio-transport section of docs/migration/upgrade-to-v2.md — what changed, why (the restart seam), and the oncloseclose() migration for crash-detection consumers — would close this out.

}
});
}

/**
* Version-negotiation close-fallback (internal, keyed by
* `TRANSPORT_RESTART`): respawn the server after the child died out from
* under the connection. Refuses after a local {@linkcode close} — a
* deliberately terminated server is never resurrected.
*/
async [TRANSPORT_RESTART](): Promise<void> {
if (this._closedLocally) {
throw new Error('StdioClientTransport was closed locally; refusing to respawn the server process');
}
await this.start();
}

/**
* The `stderr` stream of the child process, if {@linkcode StdioServerParameters.stderr} was set to `"pipe"` or `"overlapped"`.
*
* If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to
* attach listeners before the `start` method is invoked. This prevents loss of any early
* error output emitted by the child process.
* error output emitted by the child process. The stream spans restarts — a child's exit
* never ends it; it ends exactly once, when {@linkcode close} is called.
*/
get stderr(): Stream | null {
if (this._stderrStream) {
Expand Down Expand Up @@ -217,8 +261,9 @@
}

async close(): Promise<void> {
if (this._process) {
const processToClose = this._process;
this._closedLocally = true;
const processToClose = this._process;
if (processToClose) {
this._process = undefined;

const closePromise = new Promise<void>(resolve => {
Expand Down Expand Up @@ -255,6 +300,14 @@
}

this._readBuffer.clear();

if (this._stderrStream !== null && !this._stderrStream.writableEnded) {
// The aggregate stderr stream ends exactly once, here. Unpipe first:
// a SIGKILLed child may still flush after we gave up waiting, and a
// write to an ended stream would error.
processToClose?.stderr?.unpipe(this._stderrStream);
this._stderrStream.end();
}
}

send(message: JSONRPCMessage): Promise<void> {
Expand Down
Loading
Loading