stdin-teardown.ts 1.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546
  1. /**
  2. * Treat a stdin failure as a shutdown signal — issue #799.
  3. *
  4. * An MCP stdio server's lifeline is its stdin: when the host/client goes away,
  5. * stdin should end and the server should exit. The server paths listened for
  6. * `'end'` and `'close'` — but NOT `'error'`.
  7. *
  8. * That gap bites with a socket-backed stdin, which is the shape VS Code /
  9. * Claude Code use (a socketpair, not a pipe). When the client dies, the socket
  10. * can surface as an `'error'` (ECONNRESET / hangup) rather than a clean
  11. * `'close'`. With no `'error'` listener, Node escalates it to the process-wide
  12. * `uncaughtException` handler, which logs and keeps running — so the server
  13. * orphans instead of exiting. Worse, on Linux a `POLLHUP` socket fd left
  14. * registered in epoll wakes the event loop continuously, pinning a core at
  15. * 100% CPU (the spin reported in #799); once the main thread spins, the
  16. * `setInterval` PPID watchdog can't even fire, so the orphan runs forever.
  17. *
  18. * Fix: listen for `'error'` as well, and DESTROY the stdin stream on any
  19. * terminal event so the fd leaves epoll and can't keep churning, then run the
  20. * caller's shutdown. Fires `onTerminal` at most once — callers' shutdowns are
  21. * already re-entry-guarded, but the single-shot guard also keeps `destroy()`'s
  22. * follow-on `'close'` from re-invoking it.
  23. *
  24. * `stream` is injectable for tests; it defaults to `process.stdin`.
  25. */
  26. export function treatStdinFailureAsShutdown(
  27. onTerminal: () => void,
  28. stream: NodeJS.ReadableStream = process.stdin
  29. ): void {
  30. let fired = false;
  31. const fire = (): void => {
  32. if (fired) return;
  33. fired = true;
  34. // Drop the fd from epoll so a hung/half-closed socket can't keep waking
  35. // the loop. Best-effort: the stream may already be torn down.
  36. try {
  37. (stream as Partial<{ destroy(): void }>).destroy?.();
  38. } catch {
  39. /* already gone */
  40. }
  41. onTerminal();
  42. };
  43. stream.on('end', fire);
  44. stream.on('close', fire);
  45. stream.on('error', fire);
  46. }