fix: prevent orphaned processes via stdin/ppid/onclose lifecycle guards#77
fix: prevent orphaned processes via stdin/ppid/onclose lifecycle guards#77PatrickSys merged 2 commits intomasterfrom
Conversation
Adds 4 missing lifecycle guards to main() that caused orphaned node processes to accumulate (~27GB RAM from 41 orphans on a 32GB machine): - stdin end/close listeners: detect client exit via pipe closure - server.onclose: handle graceful MCP protocol disconnect - PPID polling (5s, unref'd): cross-platform parent death detection - SIGHUP handler: terminal close on Unix + Windows console close
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Greptile SummaryThis PR adds four lifecycle guards to Key changes:
Issues found:
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| src/index.ts | Adds four orphan-process lifecycle guards to main(): PPID polling, stdin end/close, server.onclose, and SIGHUP. The PPID guard has a correctness bug (bare catch swallows EPERM, making the server exit even when the parent is alive with a permission mismatch), and the stdin/server.onclose handlers have a narrow race where watcher cleanup may be skipped. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[MCP Client spawns server] --> B[main starts]
B --> C[Setup PPID guard - setInterval 5s unrefed]
B --> D[server.connect transport]
D --> E[Register stdin end and close handlers]
D --> F[Set server.onclose handler]
D --> G[Register SIGHUP handler]
C -->|every 5s| H{process.kill parentPid 0}
H -->|success - parent alive| C
H -->|throws ESRCH - parent dead| EXIT[process.exit 0]
H -->|throws EPERM - bug - parent alive no permission| EXIT
E -->|stdin pipe breaks| EXIT
F -->|graceful MCP disconnect| EXIT
G -->|terminal closed| EXIT2[stopAllWatchers then process.exit 0]
EXIT -->|relies on exit listener registered later| WATCH[stopAllWatchers via exit event]
EXIT2 --> WATCH
Last reviewed commit: "fix: prevent orphane..."
| const parentGuard = setInterval(() => { | ||
| try { | ||
| process.kill(parentPid, 0); | ||
| } catch { | ||
| process.exit(0); | ||
| } | ||
| }, 5_000); |
There was a problem hiding this comment.
Bare catch swallows
EPERM, falsely kills server
process.kill(pid, 0) throws two distinct errors:
ESRCH— process does not exist → correct to exitEPERM— process exists but we lack permission to signal it → incorrect to exit
The bare catch block catches both, meaning if the parent process is still alive but running in a slightly different permission context (e.g., after a setuid call, or on certain Linux configurations where a parent can have a different UID), the server will exit spuriously every 5 seconds.
The fix is to only exit on ESRCH:
| const parentGuard = setInterval(() => { | |
| try { | |
| process.kill(parentPid, 0); | |
| } catch { | |
| process.exit(0); | |
| } | |
| }, 5_000); | |
| const parentGuard = setInterval(() => { | |
| try { | |
| process.kill(parentPid, 0); | |
| } catch (err: unknown) { | |
| if ((err as NodeJS.ErrnoException).code === 'ESRCH') { | |
| process.exit(0); | |
| } | |
| } | |
| }, 5_000); |
| // Detect stdin pipe closure — the primary signal that the MCP client is gone. | ||
| // StdioServerTransport only listens for 'data'/'error', never 'end'. | ||
| process.stdin.on('end', () => process.exit(0)); | ||
| process.stdin.on('close', () => process.exit(0)); | ||
|
|
||
| // Handle graceful MCP protocol-level disconnect. | ||
| // Fires after SDK internal cleanup when transport.close() is called. | ||
| server.onclose = () => process.exit(0); |
There was a problem hiding this comment.
New exit paths bypass watcher cleanup window
The stdin and server.onclose handlers call process.exit(0) directly, relying on process.once('exit', stopAllWatchers) (registered at line 1668) to clean up watchers. However, stopAllWatchers is a const that is not yet declared at this point in main().
If either handler fires after initProject() starts a watcher (around line 1648) but before process.once('exit', stopAllWatchers) is registered (line 1668), the exit listener won't be in place and watchers will be abandoned rather than stopped gracefully. The window is small but real — initProject() is awaited, so the event loop can yield during that call.
One safe pattern is to hoist the stopAllWatchers registration to right after server.connect(transport), before these handlers are set, so the exit listener is always in place when the handlers could possibly fire.
- PPID poll: only exit on ESRCH (process gone), ignore EPERM (process alive but different UID — setuid, Linux security modules, etc.) - Hoist stopAllWatchers + exit/signal listeners to before stdin/onclose handlers, closing the race window where process.exit() could fire during initProject() before the cleanup listener was registered
Summary
main()insrc/index.tsthat caused orphaned node processes to accumulate when MCP clients exit without clean shutdownStdioServerTransportnever listens for stdinend/close, no parent-death detection, no SIGHUP handlerChanges (~27 lines added, no new dependencies)
process.stdin.on('end'/'close')server.oncloseunref'd)process.once('SIGHUP')Why not
transport.onclose?The MCP SDK's
Protocol.connect()overwritestransport.onclosewith its own wrapper. Setting it afterserver.connect(transport)would break SDK internal cleanup.server.oncloseis the correct hook — it fires after SDK cleanup completes.Why no singleton/PID-file guard?
Would prevent legitimate multi-session use (two Claude Code tabs on the same project). PPID polling already catches orphans within 5 seconds, making a singleton redundant.
Test plan
pnpm build— no type errorspnpm test— all 312 tests passmain())