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:
co-authored by
Claude Opus 4.8
parent
d0e649969a
commit
0b1a2eed97
+5
-2
@@ -50,6 +50,7 @@ import {
|
||||
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
|
||||
import { getDaemonSocketPath } from './daemon-paths';
|
||||
import { supervisionLostReason } from './ppid-watchdog';
|
||||
import { treatStdinFailureAsShutdown } from './stdin-teardown';
|
||||
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
|
||||
|
||||
/**
|
||||
@@ -330,8 +331,10 @@ export class MCPServer {
|
||||
// Detect parent-process death — same logic as pre-refactor. When stdin
|
||||
// closes we go through StdioTransport's `process.exit(0)` already, but
|
||||
// SIGKILL of the parent doesn't reliably close stdin on Linux (#277).
|
||||
process.stdin.on('end', () => this.stop());
|
||||
process.stdin.on('close', () => this.stop());
|
||||
// Also treat a stdin `'error'` (a socket-backed stdin can fail with
|
||||
// ECONNRESET/hangup instead of a clean close) as shutdown, and destroy the
|
||||
// stream so a hung fd can't busy-spin the event loop (#799).
|
||||
treatStdinFailureAsShutdown(() => this.stop());
|
||||
|
||||
this.mode = 'direct';
|
||||
this.installSignalHandlers();
|
||||
|
||||
+14
-4
@@ -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 */ }
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Treat a stdin failure as a shutdown signal — issue #799.
|
||||
*
|
||||
* An MCP stdio server's lifeline is its stdin: when the host/client goes away,
|
||||
* stdin should end and the server should exit. The server paths listened for
|
||||
* `'end'` and `'close'` — but NOT `'error'`.
|
||||
*
|
||||
* That gap bites with a socket-backed stdin, which is the shape VS Code /
|
||||
* Claude Code use (a socketpair, not a pipe). When the client dies, the socket
|
||||
* can surface as an `'error'` (ECONNRESET / hangup) rather than a clean
|
||||
* `'close'`. With no `'error'` listener, Node escalates it to the process-wide
|
||||
* `uncaughtException` handler, which logs and keeps running — so the server
|
||||
* orphans instead of exiting. Worse, on Linux a `POLLHUP` socket fd left
|
||||
* registered in epoll wakes the event loop continuously, pinning a core at
|
||||
* 100% CPU (the spin reported in #799); once the main thread spins, the
|
||||
* `setInterval` PPID watchdog can't even fire, so the orphan runs forever.
|
||||
*
|
||||
* Fix: listen for `'error'` as well, and DESTROY the stdin stream on any
|
||||
* terminal event so the fd leaves epoll and can't keep churning, then run the
|
||||
* caller's shutdown. Fires `onTerminal` at most once — callers' shutdowns are
|
||||
* already re-entry-guarded, but the single-shot guard also keeps `destroy()`'s
|
||||
* follow-on `'close'` from re-invoking it.
|
||||
*
|
||||
* `stream` is injectable for tests; it defaults to `process.stdin`.
|
||||
*/
|
||||
export function treatStdinFailureAsShutdown(
|
||||
onTerminal: () => void,
|
||||
stream: NodeJS.ReadableStream = process.stdin
|
||||
): void {
|
||||
let fired = false;
|
||||
const fire = (): void => {
|
||||
if (fired) return;
|
||||
fired = true;
|
||||
// Drop the fd from epoll so a hung/half-closed socket can't keep waking
|
||||
// the loop. Best-effort: the stream may already be torn down.
|
||||
try {
|
||||
(stream as Partial<{ destroy(): void }>).destroy?.();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
onTerminal();
|
||||
};
|
||||
stream.on('end', fire);
|
||||
stream.on('close', fire);
|
||||
stream.on('error', fire);
|
||||
}
|
||||
+14
-2
@@ -286,12 +286,24 @@ export class StdioTransport extends LineBasedJsonRpcTransport {
|
||||
await this.handleLine(line);
|
||||
});
|
||||
|
||||
this.rl.on('close', () => {
|
||||
// readline 'close' fires on a clean stdin EOF. But a socket-backed stdin
|
||||
// (the VS Code stdio shape) can fail with an 'error' (ECONNRESET/hangup)
|
||||
// that readline doesn't surface as 'close' — unhandled, it escalated to
|
||||
// the global uncaughtException handler (which keeps running), orphaning
|
||||
// the server and, on Linux, busy-spinning a POLLHUP fd at 100% CPU. Treat
|
||||
// 'error' as terminal too, and destroy stdin so the fd leaves epoll (#799).
|
||||
let closed = false;
|
||||
const onStreamEnd = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try { process.stdin.destroy(); } catch { /* already gone */ }
|
||||
this.opts.onClose();
|
||||
if (this.opts.exitOnClose) {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.rl.on('close', onStreamEnd);
|
||||
process.stdin.on('error', onStreamEnd);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
Reference in New Issue
Block a user