A stdio MCP server's lifeline is stdin: when the host/client goes away, stdin should end and the server should exit. The server paths listened for stdin 'end'/'close' but NOT 'error'. That gap bites with a socket-backed stdin — the shape VS Code / Claude Code use (a socketpair, not a pipe). On client death the socket can surface as an 'error' (ECONNRESET/hangup) instead of a clean 'close'. Unhandled, it escalated to the process-wide uncaughtException handler, which logs and keeps running — so the server orphaned instead of exiting. On Linux a POLLHUP socket fd left registered in epoll then wakes the event loop continuously, pinning a core at 100% CPU; once the main thread spins, the setInterval PPID watchdog can't even fire, so the orphan runs forever (the report's 28+ minutes). Add treatStdinFailureAsShutdown(): listen for 'error' as well as 'end'/'close', and DESTROY the stdin stream on any terminal event so the fd leaves epoll and can't churn, then run the path's shutdown. Wired into the live paths — startDirect, the local-handshake proxy, and StdioTransport — plus the legacy pipe proxy. Fires once (re-entry guard). Note: this is hardening for a class of failure that matches every piece of the report's evidence (socket stdin, userspace main-thread spin, high involuntary context switches, watchdog never firing), but the exact 100% CPU spin could not be reproduced in Docker (Linux) across /dev/null EOF, socket peer-death (RST/FIN), the reporter's 0.9.7 bundle, and the npx chain — all exited cleanly — so the trigger is environment-specific. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
47 lines
1.6 KiB
TypeScript
47 lines
1.6 KiB
TypeScript
/**
|
|
* #799 — a socket-backed stdin that fails must shut the server down, not
|
|
* orphan/busy-spin. treatStdinFailureAsShutdown is the shared guard.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { PassThrough } from 'stream';
|
|
import { treatStdinFailureAsShutdown } from '../src/mcp/stdin-teardown';
|
|
|
|
describe('treatStdinFailureAsShutdown (#799)', () => {
|
|
it("treats a stdin 'error' (ECONNRESET/hangup) as a shutdown signal", () => {
|
|
const s = new PassThrough();
|
|
let calls = 0;
|
|
treatStdinFailureAsShutdown(() => { calls++; }, s);
|
|
|
|
// No extra 'error' listener would throw here — the guard registers one.
|
|
s.emit('error', new Error('read ECONNRESET'));
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("also fires on 'end' and on 'close'", () => {
|
|
for (const ev of ['end', 'close'] as const) {
|
|
const s = new PassThrough();
|
|
let calls = 0;
|
|
treatStdinFailureAsShutdown(() => { calls++; }, s);
|
|
s.emit(ev);
|
|
expect(calls, `event ${ev}`).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('destroys the stream so a hung fd leaves epoll', () => {
|
|
const s = new PassThrough();
|
|
treatStdinFailureAsShutdown(() => { /* noop */ }, s);
|
|
s.emit('error', new Error('boom'));
|
|
expect(s.destroyed).toBe(true);
|
|
});
|
|
|
|
it('fires onTerminal at most once, even across error → close', () => {
|
|
const s = new PassThrough();
|
|
let calls = 0;
|
|
treatStdinFailureAsShutdown(() => { calls++; }, s);
|
|
s.emit('error', new Error('boom')); // fire() also destroys → emits 'close'
|
|
s.emit('close'); // must not double-fire
|
|
s.emit('end');
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|