fix(mcp): treat a stdin 'error' as shutdown so the server can't orphan/spin (#799) (#805)

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>
This commit is contained in:
Colby Mchenry
2026-06-11 12:04:51 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d0e649969a
commit 0b1a2eed97
6 changed files with 126 additions and 8 deletions
+14 -4
View File
@@ -23,6 +23,7 @@ import * as net from 'net';
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
import { DaemonClientHello, DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
import { supervisionLostReason } from './ppid-watchdog';
import { treatStdinFailureAsShutdown } from './stdin-teardown';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
import { SERVER_INSTRUCTIONS } from './server-instructions';
@@ -298,8 +299,11 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
}
}
});
process.stdin.on('end', shutdown);
process.stdin.on('close', shutdown);
// Shut down when stdin ends/closes — and also on a stdin `'error'`, which a
// socket-backed stdin (the VS Code stdio shape) can emit on client death
// instead of a clean close; destroying the stream stops a hung fd from
// busy-spinning the event loop (#799).
treatStdinFailureAsShutdown(shutdown);
startPpidWatchdogNoSocket(shutdown);
// ---- daemon connection (background) ----
@@ -459,10 +463,16 @@ function pipeUntilClose(socket: net.Socket): Promise<void> {
try { socket.end(); } catch { /* ignore */ }
done();
});
process.stdin.on('close', () => {
// 'close' and 'error' both tear down: a socket-backed stdin can fail with
// an 'error' (ECONNRESET/hangup) rather than a clean close; destroying it
// stops a hung fd from busy-spinning the event loop (#799).
const teardown = () => {
try { process.stdin.destroy(); } catch { /* ignore */ }
try { socket.destroy(); } catch { /* ignore */ }
done();
});
};
process.stdin.on('close', teardown);
process.stdin.on('error', teardown);
socket.on('data', (chunk) => {
try { process.stdout.write(chunk); } catch { /* ignore */ }