Files
codegraph/__tests__/stdout-teardown.test.ts
T
Colby MchenryandClaude Opus 5.5 e431491972 fix(mcp): a session whose host stops reading its output shuts down cleanly (#3060)
Issue #2991: a host can stop reading an MCP stdio server's output while
it keeps the server's stdin open and stays alive. The next response the
server wrote then failed (EPIPE), and with no listener on stdout Node
handed the error to the last-resort fatal handler, which exits 1 at
once. That skipped the shutdown that stops the engine, releases the
writer lock and waits for query workers still starting, which on
Windows can crash a process that exits under them. Stdin, the host's
PID and the liveness watchdog all still look healthy, so nothing else
noticed.

A new treatStdoutFailureAsShutdown, the twin of the stdin teardown from
issue #799, routes a stdout 'error' (and the 'close' after it) into the
owner's own shutdown, at most once, destroys stdout so later writes are
dropped, and keeps its listener so a write queued before the failure
cannot escape during the shutdown. Backpressure is left alone. It is
wired into the direct-mode transport and into the local-handshake proxy,
whose shutdown closes only this session's daemon connection and stops
the engines it owns. The legacy socket proxy (runProxy) has had no
caller since PR #580 and is left as it is.

Validation: __tests__/mcp-stdout-failure.test.ts runs a real
direct-mode serve --mcp, and the local proxy with an in-process engine
and with a stand-in shared daemon. After the test destroys its end of
stdout and sends one more request, main exits 1 in all three, the
server with "Uncaught exception: Error: EPIPE"; with the fix all three
exit 0, the server's writer.pid is released, the proxy's engine stop
runs, and the daemon closes only the proxy's connection and still
answers its other client. __tests__/stdout-teardown.test.ts covers the
guard itself. proxy-shutdown, stdin-teardown and mcp-initialize pass.

Fixes #2991

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:48:05 +00:00

58 lines
2.2 KiB
TypeScript

/**
* #2991 — a stdout that fails (the host stopped reading it) must shut the
* server down through its own shutdown, not the fatal handler.
* treatStdoutFailureAsShutdown is the shared guard; the real processes are
* covered by mcp-stdout-failure.test.ts.
*/
import { describe, it, expect } from 'vitest';
import { PassThrough } from 'stream';
import { treatStdoutFailureAsShutdown } from '../src/mcp/stdout-teardown';
describe('treatStdoutFailureAsShutdown (#2991)', () => {
it.each(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED'])("treats a stdout 'error' (%s) as a shutdown signal", (code) => {
const s = new PassThrough();
let calls = 0;
treatStdoutFailureAsShutdown(() => { calls++; }, s);
// Without the guard's listener this emit would throw.
s.emit('error', Object.assign(new Error(`write ${code}`), { code }));
expect(calls).toBe(1);
});
it("also fires on 'close'", () => {
const s = new PassThrough();
let calls = 0;
treatStdoutFailureAsShutdown(() => { calls++; }, s);
s.emit('close');
expect(calls).toBe(1);
});
it('destroys the stream so later writes are dropped', () => {
const s = new PassThrough();
treatStdoutFailureAsShutdown(() => { /* noop */ }, s);
s.emit('error', new Error('write EPIPE'));
expect(s.destroyed).toBe(true);
});
it('fires onTerminal at most once, and absorbs a write that fails during the shutdown', async () => {
const s = new PassThrough();
let calls = 0;
treatStdoutFailureAsShutdown(() => { calls++; }, s);
s.emit('error', new Error('write EPIPE')); // fire() also destroys → emits 'close'
s.write('a response queued before the failure');
await new Promise<void>((resolve) => setImmediate(resolve));
s.emit('close');
expect(calls).toBe(1);
});
it('leaves a stream under backpressure alone', async () => {
const s = new PassThrough({ highWaterMark: 1 });
let calls = 0;
treatStdoutFailureAsShutdown(() => { calls++; }, s);
expect(s.write('a response the host has not read yet')).toBe(false);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(calls).toBe(0);
expect(s.destroyed).toBe(false);
});
});