Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix: exit on stdin EOF / SIGTERM so the stdio server isn't orphaned#41

Merged
bvisible merged 1 commit into
bvisible:mainfrom
LegendaryGatz:fix/stdio-lifecycle-orphan-cleanup
Jun 9, 2026
Merged

fix: exit on stdin EOF / SIGTERM so the stdio server isn't orphaned#41
bvisible merged 1 commit into
bvisible:mainfrom
LegendaryGatz:fix/stdio-lifecycle-orphan-cleanup

Conversation

@LegendaryGatz

Copy link
Copy Markdown
Contributor

Problem

Running as a stdio MCP server, mcp-ssh-manager is never terminated when the host ends a session: it's reparented to init and leaks. Each orphan holds ~83 MB plus live keepalive timers. They accumulate one-per-session (I found 28 ≈ 2.3 GB after ~1.5 days of normal use).

Root cause

The only shutdown handler was process.on('SIGINT'). A stdio MCP host tears the server down by closing the child's stdin (EOF) and/or sending SIGTERM — not SIGINT (interactive Ctrl-C only). So normal teardown signalled nothing. The keepalive and 10-min cleanup setIntervals are also ref'd, keeping the event loop alive regardless.

Fix

  • Extract shutdown into an idempotent shutdown(reason) and register it on SIGTERM, SIGHUP, and stdin 'end'/'close', in addition to the existing SIGINT.
  • .unref() the per-connection keepalive interval and the periodic cleanup interval, so process lifetime tracks the transport, not a timer.
  • Flush stdout best-effort before exiting, guarded by a short unref'd timer that forces exit — teardown can never hang if the host has stopped reading.
  • Wrap ssh.dispose() in try/catch so one bad connection can't block the rest.

Testing

  • node --check src/index.js passes.
  • Runtime reproduction, before vs. after. Booted the server, then closed its stdin to simulate host teardown, measuring time-to-exit:
    • Before (this revision reverted): still alive 6000 ms after stdin close (hard-timeout; the orphan bug).
    • After: exits cleanly (code=0) 11 ms after stdin close, logging 🔌 Closing SSH connections (stdin ended)....
  • Static review by a 6-model panel (Opus/Sonnet/Haiku/Grok/Gemini/Codex); their truncation/SIGHUP feedback is folded into this revision.

Out of scope (follow-up)

tunnel-manager.js (setInterval(monitorTunnels, …)) and session-manager.js have module-level intervals that are also never unref'd. The forced exit here makes them harmless for this bug, but they're worth the same treatment in a follow-up.


Developed with Claude Code, then reviewed and verified end-to-end on my side: I reproduced the leak, confirmed the root cause in the source, and ran the before/after exit test above. Happy to iterate on anything.

A stdio MCP server is torn down by its host closing stdin (and/or SIGTERM/
SIGHUP), not by SIGINT — which only arrives on an interactive Ctrl-C. Handling
SIGINT alone meant the process was never signalled on normal teardown: it was
reparented to init and leaked one node process (~83MB, plus active keepalive
timers) per session. Over a day of normal use this accumulated into dozens of
orphaned processes consuming gigabytes of RAM.

Fix:
- Extract shutdown into an idempotent shutdown() and trigger it on SIGTERM,
  SIGHUP, and stdin 'end'/'close' (the actual MCP teardown signals), in
  addition to the existing SIGINT.
- unref() the keepalive and periodic-cleanup intervals so a timer alone can
  never keep the event loop alive after the transport has closed.
- Flush stdout best-effort before exiting, with a short unref'd timer that
  forces exit so teardown can never hang if the host has stopped reading.
@bvisible bvisible merged commit b0bf3a9 into bvisible:main Jun 9, 2026
bvisible added a commit that referenced this pull request Jun 9, 2026
…40)

- Add tests/test-lifecycle.js: black-box teardown tests asserting the server
  exits cleanly on stdin EOF, SIGTERM, SIGINT and overlapping signals (regression
  guard for the orphan-process bug).
- Extend test-server-config-manager.js: laziness (no reload without change),
  reload-failure keeps last valid config + recovery, deleted-file robustness.
- Wire test:lifecycle into the npm test chain.
bvisible added a commit that referenced this pull request Jun 9, 2026
- feat: live SSH config hot reload (#40, @EnjoySR)
- fix: exit on stdin EOF / SIGTERM so the stdio server isn't orphaned (#41, @LegendaryGatz)
- test: new lifecycle suite + extended hot-reload edge cases
@bvisible

bvisible commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Merged and shipped in v3.6.0 🎉 Thanks @LegendaryGatz — excellent diagnosis and a clean, idempotent fix.

I reproduced the before/after on my side:

  • Before (main): still alive 6 s after stdin EOF → had to SIGKILL (the orphan).
  • After: exits cleanly in ~4–16 ms on stdin EOF, SIGTERM and SIGINT, with the right handler firing each time.

I also added a black-box regression test (tests/test-lifecycle.js, wired into npm test as test:lifecycle) covering stdin EOF / SIGTERM / SIGINT and overlapping signals, so this can't silently regress.

Your follow-up note on the module-level intervals in tunnel-manager.js / session-manager.js is noted — the forced exit makes them harmless for this bug, but they deserve the same unref() treatment in a follow-up.

bvisible added a commit that referenced this pull request Jun 9, 2026
…he event loop

Follow-up to #41. tunnel-manager.js (monitorTunnels, 30s) and session-manager.js
(session cleanup, 5min) registered module-level setInterval timers that were never
unref'd, so importing either module kept the Node event loop alive on its own.
The forced-exit in #41 already made these harmless for the orphan bug, but unref'ing
them restores proper teardown hygiene: process lifetime tracks the stdio transport,
not a background timer.

Verified: before, importing either module never exits; after, both exit naturally.
bvisible added a commit that referenced this pull request Jun 9, 2026
- fix: unref module-level monitor/cleanup intervals (follow-up to #41)
- docs: remove MseeP.ai security badge from README footer
@LegendaryGatz LegendaryGatz deleted the fix/stdio-lifecycle-orphan-cleanup branch June 13, 2026 03:38
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