Skip to content

fix: detect harness death via parent PID watchdog - #20

Merged
jgalea merged 2 commits into
mainfrom
fix/parent-pid-watchdog
May 30, 2026
Merged

jgalea merged 2 commits into
mainfrom
fix/parent-pid-watchdog

Conversation

@jgalea

@jgalea jgalea commented May 28, 2026 •

Copy link
Copy Markdown
Owner

This PR closes the silent-disconnect gap when the Claude Code harness dies abruptly (SIGKILL, crash, OS reap) without firing any of our existing shutdown hooks.

Background

~/.mailbox-mcp/debug.log shows ~14 entries where heartbeats stop cold with no termination event recorded — no signal, no transport-close, no stdin event, no exception. The existing handlers all require the harness to tear down gracefully. They can't catch the case where the parent process is killed without warning and we get reparented to PID 1 (launchd on macOS).

Change

Capture process.ppid at startup. Every 30s in the heartbeat, check if it changed. If yes, the harness is gone — log a reparented event and exit cleanly via the existing shutdownClean path.

Also includes ppid= in every alive heartbeat so we can correlate orphan processes with their original parent in future triage.

Why this works

process.ppid is updated by the kernel when the parent dies. Reparenting to PID 1 is observable, doesn't require a signal, and doesn't depend on the SDK. Catches the gap left by SIGKILL-uncatchable signal handlers.

Test plan

  • Restart Claude Code, confirm start log line includes ppid=
  • Confirm heartbeats every 30s include ppid= value
  • Force-kill Claude Code (kill -9) from another terminal, confirm mailbox-mcp exits within 30s and writes a reparented event to debug.log

Follow-up commit: don't exit on transient stdio backpressure errors

This is the fix for the reported symptom (server dropping intermittently mid-session, needing a manual /mcp reconnect).

Root cause: the process.stdout/process.stdin error handlers called process.exit(0) on any stream error. Transient backpressure errors (EAGAIN/EWOULDBLOCK), which Node surfaces on a busy pipe while flushing a large search_emails/read_email response, were treated as fatal. The server exited cleanly (exit code=0, so the lifecycle log showed nothing abnormal) and the client saw a dropped transport.

Diagnosis note: across the full ~/.mailbox-mcp/debug.log history there are zero uncaughtException/unhandledRejection/fatal/non-zero-exit events. The existing process-level guards and provider-cache eviction are already working — no crash path remains. The only mid-session drop vector left was these stream handlers exiting on non-fatal errors, which leaves a clean exit code=0 and never registers as a crash.

Change: classify stream errors. Only a genuinely broken pipe (EPIPE, ECONNRESET, ERR_STREAM_DESTROYED, ERR_STREAM_WRITE_AFTER_END) exits. Transient errors are logged (stdin-error-transient/stdout-error-transient) and the server keeps serving — the SDK transport recovers on the next read/write. shutdownClean is now idempotent so overlapping end/close/error events can't double-fire the drain timer.

Verification: npm run build clean, vitest 220/220 passing.

jgalea added 2 commits May 29, 2026 00:14
Existing shutdown handlers (signals, stdin events, transport-close) all rely
on the harness gracefully tearing down. When the harness dies abruptly
(SIGKILL'd, crashed, OS reap) none of them fire — the child is reparented to
PID 1 (launchd on macOS) and heartbeats continue indefinitely as a zombie.

Capture ppid at startup, check it every 30s in the heartbeat, exit cleanly
when reparented. Logs a "reparented" event for triage.

Also includes ppid in the alive heartbeat so we can correlate orphan
processes with their original parent next time it happens.

Verified against ~14 silent-disconnect entries in ~/.mailbox-mcp/debug.log
where heartbeats stop with no termination event recorded.
The stdout/stdin error handlers called process.exit(0) on any stream
error. Transient backpressure errors (EAGAIN/EWOULDBLOCK) — which Node
surfaces on a busy pipe while flushing a large search_emails/read_email
response — were treated as fatal. The server exited cleanly (exit
code=0, so the lifecycle log showed nothing abnormal) and the client
saw a dropped transport, forcing a manual /mcp reconnect mid-session.

Classify stream errors: only a genuinely broken pipe (EPIPE,
ECONNRESET, ERR_STREAM_DESTROYED, ERR_STREAM_WRITE_AFTER_END) exits.
Transient errors are logged (stdin/stdout-error-transient) and the
server keeps serving — the SDK transport recovers on the next read/write.
shutdownClean is now idempotent so overlapping end/close/error events
can't double-fire the drain timer.

Diagnosis: across the full ~/.mailbox-mcp/debug.log history there are
zero uncaughtException/unhandledRejection/fatal/non-zero-exit events —
every prior resilience handler (process-level guards, provider-cache
eviction on auth/connection errors) is already working. The only
remaining mid-session drop vector was the stream error handlers exiting
on non-fatal errors, which leave a clean exit code=0 and so never showed
up as a crash. This is the targeted fix for that path.
@jgalea
jgalea merged commit 8a70282 into main May 30, 2026
1 check passed
@jgalea
jgalea deleted the fix/parent-pid-watchdog branch May 30, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant