mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 04:21:31 +08:00
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>
58 lines
2.2 KiB
TypeScript
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);
|
|
});
|
|
});
|