Skip to content

🐛 fix: exit when the MCP client closes stdio, releasing spawned LSP servers (fixes #47) - #53

Open
sudhirj wants to merge 2 commits into
ktnyt:mainfrom
Common-Pattern:fix/exit-on-stdio-close
Open

🐛 fix: exit when the MCP client closes stdio, releasing spawned LSP servers (fixes #47)#53
sudhirj wants to merge 2 commits into
ktnyt:mainfrom
Common-Pattern:fix/exit-on-stdio-close

Conversation

@sudhirj

@sudhirj sudhirj commented Aug 19, 2026

Copy link
Copy Markdown

Fixes #47.

Not Windows-specific

#47 is reported on Windows, but nothing about the cause is platform-dependent — the handler is simply absent, so the behaviour is the same everywhere. Reproduced on Linux (Node 24, cclsp main @ 93414a1) by doing what an exiting MCP client does: close the client's end of stdio, send no signal, and see what is left.

initialize: OK
tool call: OK                       # forces an LSP child to spawn
cclsp pid=4082509, LSP children=['4082520']

--- closing client's end of stdio (no signal sent) ---
cclsp 4082509 exit code: None
cclsp 4082509 alive after stdio close: True
  LSP child 4082520 alive: True     <-- orphaned

With this change, the same script reports exit code: 0 and both processes gone.

(The aliveness check reads /proc/<pid>/stat and treats state Z as dead — an exited-but-unreaped child still has a /proc entry, and checking only for existence reports a zombie as alive. Worth mentioning in case anyone else writes this test.)

Cause

index.ts handled SIGINT and SIGTERM only. A client that exits normally closes the pipe without signalling, so neither fires.

The SDK does not help here: StdioServerTransport subscribes to stdin's data and error events but never end or close, so Server.onclose never fires on EOF. There is no transport-level hook to use, and stdin has to be observed directly.

The cost is what #47 describes — each orphaned server holds its memory (pylsp a few hundred MB), so repeated client invocations accumulate them.

Change

Handlers move into src/shutdown.ts behind installShutdownHandlers(client, proc = process):

  • stdin end and close — the actual fix.
  • SIGHUP alongside the existing SIGINT/SIGTERM. A closing terminal sends it, and it was previously ignored, leaking the same way.
  • Idempotent. stdin emits both end and close, and signals can race, so dispose() must not run twice.
  • proc is taken structurally, so the behaviour is unit-testable without spawning processes.

main().catch(...) now routes through the same path instead of duplicating dispose-then-exit.

Testing

src/shutdown.test.ts, 8 cases: each signal, each stdin event, the multiple-trigger case, and that the returned shutdown function follows the same path.

bun test           203 → 211 pass
bun run lint       Checked 52 files. No fixes applied.
bun run typecheck  clean

Branched from main @ 93414a1, where the suite already reports 203 pass / 5 skip / 19 fail; those 19 are untouched by this change.

Note on scope

This kills the LSP child and exits immediately, matching what SIGINT/SIGTERM already did. It does not wait for children to acknowledge termination or escalate to SIGKILL — a server ignoring SIGTERM would still be orphaned. That seemed like a separate concern, but happy to add it if you would rather this be belt-and-braces.

Independent of #52; either can merge first.

An MCP client that exits simply closes its end of the pipe, usually
without signalling us. cclsp handled SIGINT and SIGTERM only, so on a
plain stdio disconnect it kept running — and every LSP server it had
spawned stayed resident with it. Long-lived servers are not cheap
(pylsp holds a few hundred MB), so across repeated client invocations
the orphans accumulate into real memory pressure.

StdioServerTransport subscribes to stdin's 'data' and 'error' events but
never 'end' or 'close', so Server.onclose does not fire on EOF and the
SDK offers no hook for this. Listen for stdin ending directly.

Handlers now live in src/shutdown.ts behind installShutdownHandlers(),
taking the process structurally so the behaviour is unit-testable, and
guarding against disposing twice — stdin emits both 'end' and 'close',
and signals can race. SIGHUP is covered too, which a terminal closing
sends and which was previously ignored.

Reported for Windows in ktnyt#47, but nothing here is platform-specific: the
missing handler is missing everywhere. Reproduced on Linux by closing
the client's end of stdio and confirming both cclsp and its tsc child
survive; after this change the process exits 0 and takes the child with
it.
Copilot AI lite review requested due to automatic review settings August 19, 2026 10:12
sudhirj added a commit to Common-Pattern/cclsp that referenced this pull request Aug 19, 2026

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.

Pull request overview

This PR fixes a lifecycle leak in cclsp’s MCP stdio mode by ensuring the server exits (and disposes spawned LSP servers) when the MCP client disconnects by closing its end of stdin, not just when POSIX signals are delivered.

Changes:

  • Add centralized shutdown handler installation (installShutdownHandlers) that listens for stdin EOF/close and termination signals.
  • Wire the new shutdown path into index.ts, including routing main().catch(...) through the same shutdown function.
  • Add unit tests covering signals, stdin events, and idempotency for multiple shutdown triggers.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/shutdown.ts Introduces shutdown handler installation for stdin EOF/close + signals, and returns a callable shutdown function.
src/shutdown.test.ts Adds unit tests verifying handler registration, behavior on stdin/signal triggers, and idempotency.
index.ts Switches from inline SIGINT/SIGTERM handling to using installShutdownHandlers, and reuses it for fatal main() errors.

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

Comment thread src/shutdown.ts
`ServerManager.dispose()` kills each child process without a guard, and
`kill()` can throw — EPERM on a process we no longer own. With the call
sitting bare before `proc.exit()`, that throw skipped the exit and left
cclsp resident with every server it spawned, which is the exact leak this
handler exists to close.

Dispose inside a `try`, log the failure, and exit from a `finally`.
sudhirj added a commit to Common-Pattern/cclsp that referenced this pull request Aug 19, 2026
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 orphaned on MCP stdio disconnect (Windows; missing stdin close handler in index.ts)

2 participants