From bdaad4cfe95d823d886a1eb7b3737c083e8c0d59 Mon Sep 17 00:00:00 2001 From: JefBronze Date: Tue, 1 Sep 2026 20:37:02 -0400 Subject: [PATCH] fix(channel): exit when parent dies instead of spinning on EPIPE When the Claude Code session that spawned the MCP server exits, the server's stdio sockets close. Every stderr/stdout write then throws EPIPE; the uncaughtException handler called log(), which wrote to the dead stderr and threw again, producing an infinite exception loop at 100% CPU. Three such orphans were found pinned for days on a fanless MacBook Air, triggering repeated thermal-emergency sleeps. - log(): no-op once stdio is known dead; swallow write errors - attach error handlers to stdin/stdout/stderr for EPIPE/ECONNRESET/EIO/EBADF - shut down gracefully on stdin end/close - poll process.ppid and shut down if reparented (parent SIGKILLed) - uncaughtException: treat stdio errors as parent death, never re-log - hard exit after 3s if graceful shutdown hangs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TxRbndHbg5wB5BQ591TZAP --- channel/server.mjs | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/channel/server.mjs b/channel/server.mjs index e1c03e9..e3e6108 100644 --- a/channel/server.mjs +++ b/channel/server.mjs @@ -92,7 +92,10 @@ function decryptText(data) { // ─── Logging (structured JSON → stderr; stdout is MCP transport) ───────────── +let stdioDead = false; // set once our stdio pipes are gone (parent exited) + function log(level, msg, data = {}) { + if (stdioDead) return; const entry = { ts: new Date().toISOString(), level, @@ -100,7 +103,11 @@ function log(level, msg, data = {}) { msg, ...data, }; - process.stderr.write(JSON.stringify(entry) + "\n"); + try { + process.stderr.write(JSON.stringify(entry) + "\n"); + } catch { + stdioDead = true; + } } // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -1138,11 +1145,45 @@ async function init() { }, 3000); } +// ─── Parent-Death Detection ───────────────────────────────────────────────── +// When the Claude Code session that spawned us dies, our stdio sockets close. +// Without these guards every stdout/stderr write throws EPIPE, the +// uncaughtException handler tries to log (another EPIPE), and the orphaned +// process spins at 100% CPU forever. + +const PARENT_PID = process.ppid; +const STDIO_ERROR_CODES = new Set(["EPIPE", "ECONNRESET", "EIO", "EBADF"]); + +function onParentGone(reason) { + if (stdioDead) return; + stdioDead = true; + // Hard exit if graceful shutdown hangs (e.g. filesystem stalls). + setTimeout(() => process.exit(1), 3000).unref(); + gracefulShutdown(reason).catch(() => process.exit(1)); +} + +for (const stream of [process.stdin, process.stdout, process.stderr]) { + stream.on("error", (err) => { + if (err && STDIO_ERROR_CODES.has(err.code)) onParentGone(`stdio ${err.code}`); + }); +} +process.stdin.on("end", () => onParentGone("stdin closed")); +process.stdin.on("close", () => onParentGone("stdin closed")); + +// POSIX reparents orphans to pid 1; poll for that as a belt-and-braces check. +setInterval(() => { + if (process.ppid !== PARENT_PID) onParentGone(`parent ${PARENT_PID} exited`); +}, HEARTBEAT_INTERVAL_MS).unref(); + // ─── Global Error Handlers ────────────────────────────────────────────────── // Without these, any unhandled error silently kills the Node.js process, // dropping the MCP connection with no trace. process.on("uncaughtException", (err) => { + if (err && STDIO_ERROR_CODES.has(err.code)) { + onParentGone(`uncaught ${err.code}`); + return; + } log("error", "uncaughtException", { error: err.message, stack: err.stack }); });