Skip to content

fix: shut down on stdin EOF and SIGHUP to prevent zombie processes - #44

Merged
konsalex merged 2 commits into
gethopp:mainfrom
dev-hann:fix/zombie-process-shutdown
Aug 10, 2026
Merged

konsalex merged 2 commits into
gethopp:mainfrom
dev-hann:fix/zombie-process-shutdown

Conversation

@dev-hann

@dev-hann dev-hann commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

figma-mcp-bridge accumulates zombie processes. When the MCP client (opencode, Claude Desktop, Cursor, etc.) disconnects without delivering SIGTERM — common on terminal close, IDE crash, or kill -9 — the server never notices and keeps running indefinitely. The election setInterval keeps the Node event loop alive, so the process survives until manually killed or the machine reboots.

Real-world observation: two orphaned figma-mcp-bridge processes found consuming memory after separate opencode sessions ended normally.

Root Cause

StdioServerTransport in @modelcontextprotocol/sdk only registers listeners for stdin data and error — not close or end (upstream issue modelcontextprotocol/typescript-sdk#2002, labeled P1, fix proposed, still open).

Combined with the always-on election setInterval (server/src/election.ts:28, 3–5s), the event loop never drains, so even though the stdin pipe closes when the client dies, the server never exits.

SIGTERM-only handling is insufficient:

  • Windows: SIGTERM is not delivered to child processes when the parent exits.
  • All platforms: when a stdio MCP client closes its connection (not the whole process), no signal is sent — only the pipe closes. The stdin close/end event is the only reliable cross-platform shutdown signal.

Solution

Add shutdown handlers in server/src/index.ts (Pattern A, matching chrome-devtools-mcp and openreplay):

Handler Purpose
process.stdin.on("end" | "close", shutdown) MCP stdio shutdown convention; fires when client closes the pipe
process.on("SIGHUP", shutdown) Terminal/parent hangup
process.on("SIGINT" | "SIGTERM", shutdown) Preserve existing explicit-kill behavior
process.on("uncaughtException", shutdown) Defensive — log + graceful exit
process.on("unhandledRejection", log) Log only; a stray rejection should not kill the server

Shutdown is async-idempotent with a 5s force-exit backstop (unref'd) in case bridge/HTTP cleanup stalls.

What this does NOT do (deliberately)

No idle timeout. A top-level MCP server that self-kills on idle leaves its client with a dead pipe and no respawn path. For example, opencode's MCP client (packages/opencode/src/mcp/index.ts) marks the server failed on pipe close and never respawns — tools silently vanish until a full client restart. stdin EOF already covers every real exit scenario, so idle reaping only hurts UX.

(Idle reaping only makes sense for "bridge child" architectures where a long-lived parent spawns short-lived per-context children — see mksglu/context-mode's lifecycle.ts, which gates the idle reaper on CONTEXT_MODE_BRIDGE_DEPTH>0 and explicitly disables it for top-level servers.)

Testing

No test framework configured (server/package.json has only build and prepublishOnly scripts). Manual integration tests:

Test A — SIGINT (graceful regression):

spawn node dist/index.js → wait 2s → kill -INT $PID

✓ "Shutting down (SIGINT)..." printed, process exited cleanly.

Test B — stdin EOF (core fix):

node dist/index.js < /dev/null

✓ "Shutting down (stdin end)..." printed immediately, process exited.

Test C — parent SIGKILL (real-world scenario):

parent spawns child → kill -KILL $PARENT_PID → wait → pgrep figma-mcp-bridge

✓ Child detected stdin EOF, printed "Shutting down (stdin end)...", exited. pgrep confirmed no surviving processes.

(TypeScript build also clean: npm run build succeeds with no errors.)

References

Checklist

  • Code follows existing style (no comments added per repo convention)
  • npm run build passes
  • Manual tests A/B/C pass
  • Commit message follows Conventional Commits
  • No breaking changes — only adds new handlers, preserves SIGINT/SIGTERM behavior

Summary by CodeRabbit

  • Bug Fixes
    • Improved application shutdown behavior for cleaner and more reliable exits.
    • Added handling for common stop and termination events.
    • Ensured shutdown cleanup runs only once, avoiding duplicate or conflicting exit attempts.
    • Prevented shutdown processes from hanging indefinitely by enforcing a fallback exit.
    • Improved resilience when unexpected errors occur during runtime.
    • Added clearer shutdown reason reporting to support troubleshooting.

StdioServerTransport in @modelcontextprotocol/sdk does not listen for
stdin 'close'/'end' events (upstream issue
modelcontextprotocol/typescript-sdk#2002). When the MCP client (opencode,
Claude Desktop, Cursor, etc.) disconnects without delivering SIGTERM —
common on terminal close, IDE crash, or process kill — the figma-bridge
server never notices and keeps running indefinitely. The election
setInterval keeps the Node event loop alive, so the process becomes a
zombie until manually killed or the machine reboots.

Add shutdown handlers for:
- stdin end/close (MCP stdio shutdown convention; cross-platform signal
  that does not depend on the client sending SIGTERM, which is not
  delivered on Windows and not sent at all when only the pipe closes)
- SIGHUP (terminal/parent hangup)
- uncaughtException (defensive — log + shutdown)
- unhandledRejection (log only; a stray rejection should not kill the
  server, matching chrome-devtools-mcp's behavior)

Shutdown is async-idempotent with a 5s force-exit backstop (unref'd) in
case bridge/HTTP cleanup stalls. SIGINT/SIGTERM handlers preserved.

Idle timeout is deliberately NOT added: a top-level MCP server that
self-kills on idle leaves its client with a dead pipe and (in clients
like opencode) no respawn path, so the tools silently vanish until a
full client restart. The stdin EOF path already covers every real exit
scenario.

References:
- modelcontextprotocol/typescript-sdk#2002
- ChromeDevTools/chrome-devtools-mcp (same pattern, src/bin/chrome-devtools-mcp-main.ts)
- openreplay/openreplay (same pattern, mcp_app/server.ts)
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2cffdd53-2665-42bc-ae29-c73cfdae1f41

📥 Commits

Reviewing files that changed from the base of the PR and between faf75e4 and 8e0c1a6.

📒 Files selected for processing (1)
  • server/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/src/index.ts

📝 Walkthrough

Walkthrough

main starts the node and election before registering lifecycle handlers. Shutdown now closes services and transport once, logs its reason, and force-exits after five seconds. Stdin closure, SIGHUP, and uncaught exceptions trigger shutdown.

Changes

Server lifecycle handling

Layer / File(s) Summary
Shutdown and error handling
server/src/index.ts
The server adds idempotent shutdown, reason logging, service and transport cleanup, a five-second exit timeout, stdin closure handling, SIGHUP handling, and separate handling for uncaught exceptions and unhandled rejections.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: handling stdin EOF and SIGHUP to prevent zombie processes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/src/index.ts`:
- Around line 23-31: Update the shutdown handler around election.stop() and
node.stop() to await the StdioServerTransport.close() promise before completing
normal shutdown, and remove the immediate process.exit(0) from that success
path. Retain the existing force timeout as the fallback exit, and route
transport-close failures to the failure path that exits appropriately.
- Around line 41-44: Update shutdown to accept an exit code, preserving its
existing default behavior, and pass 1 from the uncaughtException handler so that
path exits with failure status instead of process.exit(0). Keep other shutdown
callers unchanged unless they explicitly require a different status.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a0ac8b6-3063-4406-ab40-2e0c972fba77

📥 Commits

Reviewing files that changed from the base of the PR and between cc1c083 and faf75e4.

📒 Files selected for processing (1)
  • server/src/index.ts

Comment thread server/src/index.ts Outdated
Comment thread server/src/index.ts
Address CodeRabbit feedback:

1. Await StdioServerTransport.close() before process.exit so the SDK's
   async cleanup (listener removal, onclose callback, McpServer
   internal cleanup) completes. The 5s force-exit backstop is retained
   as the timeout fallback; process.exit is now only the success path
   after cleanup or the failure path after timeout.

2. shutdown() now accepts an exit code (default 0). The
   uncaughtException handler passes 1 so the process reports failure
   status to any supervisor instead of masquerading as a clean exit.

The transport reference is captured before shutdown is registered so
the closure can reach it; startup races where shutdown fires before
the transport exists are guarded by an explicit null check.
@konsalex

Copy link
Copy Markdown
Contributor

Thanks for the contrib @dev-hann will be part of 0.0.19

@konsalex
konsalex merged commit 1218122 into gethopp:main Aug 10, 2026
2 checks passed
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.

2 participants