Skip to content

Shut down on stdin EOF/close so host death can't orphan LSP children - #3

Merged
carldaws merged 9 commits into
carldaws:mainfrom
Bigzhangbig:fix/issue2-stdin-eof-shutdown
Sep 25, 2026
Merged

carldaws merged 9 commits into
carldaws:mainfrom
Bigzhangbig:fix/issue2-stdin-eof-shutdown

Conversation

@Bigzhangbig

Copy link
Copy Markdown
Contributor

Fixes #2.

Problem

When the MCP host dies (SIGKILL, crash, or closing the stdio pipe), squiggles stayed alive holding every LSP server it spawned — the whole tree got reparented and kept consuming memory. src/index.ts only handled SIGINT/SIGTERM, and the MCP TS SDK's StdioServerTransport never surfaces stdin EOF (it only wires data/error listeners), so nobody reacted to the one shutdown signal guaranteed to arrive.

Fix

In src/index.ts:

  • Watch process.stdin end/close and server.onclose — per your note on the issue, the SDK reassigns transport.onclose inside connect(), so the server hook is the safe one. All routed to the existing shutdownAll() path.
  • An idempotency guard so SIGTERM racing EOF runs shutdown exactly once; all five triggers share one rejection-safe wrapper.
  • Suppress EPIPE on stdout/stderr: after host death those pipes are dead, and the resulting unhandled error event used to kill the process mid-shutdown, before kill() could reap the LSP children. Found by an adversarial review pass and confirmed with a realistic fixture (LSP that answers initialize and hangs on shutdown): pre-fix, host SIGKILL leaked the child 2/2; with suppression, reaped 2/2.

Tests

Two integration tests in src/__tests__/index.shutdown.test.ts, plus a dummy LSP fixture that answers initialize only — so the only way to reap it is kill():

  1. Heavy: MCP handshake → tools/call triggers a lazy LSP spawn → host closes stdin → assert both squiggles and the LSP exit (~0.6 s).
  2. Light: no-LSP project → close stdin immediately → assert exit and that the Shutting down... log line appears (~0.2 s).

Both are verified to fail on the pre-fix build. The light test's log assertion is load-bearing: on a no-LSP project the pre-fix build also exits 0 by accident (Node's event loop drains once every fd is idle), so a bare exit-code check can't tell the builds apart.

A host that dies (SIGKILL) or closes the stdio pipe leaves squiggles and
every LSP server it spawned alive indefinitely. SIGINT/SIGTERM were the
only shutdown triggers, and the MCP TS SDK never surfaces stdin EOF —
StdioServerTransport only wires 'data'/'error' listeners.

Watch process.stdin end/close and server.onclose (the SDK reassigns
transport.onclose inside connect(), so hooking the transport would
clobber its handler), guarded so shutdown runs exactly once; all five
triggers share one rejection-safe wrapper.

Also suppress EPIPE on stdout/stderr: after host death those pipes are
dead, and the resulting 'error' event used to kill the process
mid-shutdown, before the LSP children could be reaped.

Fixes carldaws#2
Spawn squiggles with a dummy LSP config, trigger lazy LSP spawn via a
tools/call, close the host stdin, and assert both processes exit. The
dummy fixture answers initialize only (it never answers shutdown), so
the only path to reaping it is squiggles's kill() — exactly the path
that used to be skipped when EPIPE killed the parent mid-shutdown.

Verified the test fails on the pre-fix build (squiggles hangs past
15 s, LSP orphaned) and passes on the fixed build.
shutdown log line appears, not just a clean exit.

The previous form of the test passed on the pre-fix build too, because
on a project with no LSPs Node's event loop drains naturally once stdin
is gone and the process exits 0 by accident — never entering our
shutdown path. Asserting that stderrBuf contains 'Shutting down...'
catches the actual regression: only the fixed build takes the explicit
shutdown path.
- raceWithTimeout helper: exit-listener timeouts are always cleared when
  the child wins the race (previously the 15s/5s timers outlived the
  test and fired into a finished run)
- wait-for-connect polling: the interval is cleared on the timeout path
  too, not just on success
- replace the fixed 300ms reap sleep with a deadline-bounded poll
- heavy test's finally now also kills a leftover dummy LSP on failure
- spawn and dummy-LSP config use process.execPath instead of "node"
Interpolating bare paths into a double-quoted YAML flow scalar breaks
the moment a path contains a quote or backslash. Use the same escaping
as renderConfig in init.ts (JSON.stringify per element) — JSON string
escaping is a valid YAML flow scalar.
Copilot AI lite review requested due to automatic review settings September 25, 2026 05:05

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Critical shutdown-listener and test execution/coverage issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity

Open (2)
What changed in this PR

Adds host-disconnect shutdown handling to prevent orphaned LSP child processes.

Changes:

  • Handles stdin, transport, and signal shutdowns idempotently.
  • Suppresses broken-pipe errors during shutdown.
  • Adds integration tests and a dummy LSP fixture.
File Description
src/​index.ts Adds shutdown triggers and EPIPE handling.
src/​__tests__/​index.shutdown.test.ts Tests stdin-close shutdown and LSP cleanup.
src/​__tests__/​__shutdown_fixtures__/​dummy-lsp.mjs Provides a hanging LSP test fixture.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/__tests__/index.shutdown.test.ts
Comment thread src/index.ts
@carldaws

Copy link
Copy Markdown
Owner

Thanks for this, it's a great fix, and the tests that fail on the old build made it easy to review.

I've pushed two commits on top to match the repo's style:

  • 377e2c4 removes the comments from the shutdown wiring, since I don't like comments
  • da45948 simplifies the integration tests: events.once with AbortSignal.timeout instead of the timeout helper, readline for the output, one shared waitFor, and cleanup moved to afterEach. Same two tests, same coverage.

The Copilot notes were already handled by your later commits, so I've resolved them. I'll squash-merge once CI is green.

@carldaws
carldaws merged commit 1698fe2 into carldaws:main Sep 25, 2026
2 checks passed
@Bigzhangbig
Bigzhangbig deleted the fix/issue2-stdin-eof-shutdown branch September 25, 2026 15:56
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.

LSP children leak as orphans when host dies: stdin EOF/close never triggers shutdown

3 participants