feat(client/stdio): opt-in process-tree teardown on close() - #2596
feat(client/stdio): opt-in process-tree teardown on close()#2596siddheshbandgar wants to merge 6 commits into
Conversation
StdioClientTransport.close() signals only the direct child, so servers launched through a wrapper (npx/uvx/python -m) leave the real server orphaned; on Windows ChildProcess.kill() cannot terminate a tree at all. Add an opt-in `killProcessTree` option: on POSIX the child leads its own process group (detached) and close() signals the group; on Windows teardown goes through `taskkill /T /F`. Both fall back to the plain kill. Defaults to false. Fixes modelcontextprotocol#2023
🦋 Changeset detectedLatest commit: 8adc629 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
Hi maintainers — friendly bump on this one. 👋 This PR addresses #2023 (orphaned server processes when No pressure — but if you'd prefer a different direction (e.g. a consumer-supplied cleanup hook, different defaults/naming), happy to iterate. Would appreciate a review whenever you get a chance. Thanks! |
|
This is the approach that came out best when I measured the alternatives, so I ran a matrix over spawn shape × cleanup strategy × how the parent process
The first row is #2023: the shim exits, the real server reparents, and the The residual is the last column. One smaller thing from the same runs, on the documentation rather than the code: All of the above is macOS / Node 24; I haven't run it on Linux or Windows, and Harness is public if it's useful for the PR's tests — it includes no-cleanup |
|
Thanks for the detailed matrix — really useful, and I appreciate you sharing the Agreed on the SIGKILL residual. I'll add a line to the Also good catch on the I'll push both doc tweaks to the PR. Thanks again for the thorough review. |
ettalin
left a comment
There was a problem hiding this comment.
Read through the implementation properly this time rather than just the option
docs. Three things, one of which I was able to measure.
The short version: the POSIX path does what it says, but detached: true has a
second effect that works against the option in one common path, and the Windows
branch has an error-handling gap that could take the host process down.
None of this is a blocker on an opt-in flag. Details inline.
| shell: false, | ||
| // Own process group, so close() can signal the whole tree. Windows has no | ||
| // process groups in this sense; `taskkill /T` covers it there. | ||
| detached: this._serverParams.killProcessTree === true && process.platform !== 'win32', |
There was a problem hiding this comment.
detached: true does two things on POSIX, and only one of them is wanted here.
It creates the process group close() later signals — that's the intent. It
also calls setsid(), which moves the child out of the terminal's foreground
process group.
That second effect reverses the option's intent when the host dies from a
terminal signal without reaching close(). Measured on macOS / Node 24, host
with no SIGINT handler, SIGINT sent to the foreground process group:
| spawn | after Ctrl+C |
|---|---|
detached: false (today) |
host dead, child died |
detached: true (killProcessTree) |
host dead, child survived as an orphan |
Today the child shares the host's process group, so the terminal's SIGINT
reaches both and nothing is orphaned. With the option enabled the child no
longer receives it, so anything that kills the host without running close()
now leaks the very process the option exists to reap.
Hosts that trap SIGINT and await close() are unaffected — which is probably
most of them. But it is a real trade rather than a pure win, and it belongs
next to the SIGKILL note you just added, since it is the same class of caveat:
the option only helps when close() actually runs, and enabling it makes the
paths where close() doesn't run slightly worse than the status quo.
| return; | ||
| } | ||
|
|
||
| if (process.platform === 'win32') { |
There was a problem hiding this comment.
The signal parameter is unused on this branch, so both phases of close()
are the same call. close() deliberately escalates — stdin.end(), 2s, then
SIGTERM, 2s, then SIGKILL — but on Windows the SIGTERM phase already runs
taskkill /T /F, which is the forceful one.
The effect is that killProcessTree: true removes graceful shutdown on Windows:
a server that would have flushed state or released a lock on SIGTERM gets
terminated instead, and the 2s grace window is spent waiting on a tree that was
already force-killed.
/T without /F for the SIGTERM phase and /T /F only for SIGKILL would keep
the escalation intact:
const args = signal === 'SIGKILL'
? ['/pid', String(pid), '/T', '/F']
: ['/pid', String(pid), '/T'];Worth a second opinion from someone who runs this on Windows — /T alone
declines rather than force-terminates when a child has no window to close, so
the SIGKILL phase still has to do the real work.
|
Summary of the review above, since the inline comments sit in Files changed and are easy to miss:
Only (1) is measured. (2) and (3) are read-not-run — I have no Windows machine. Worth noting the 14 green checks don't cover them either: every job in None of it blocks an opt-in flag. |
Fixes #2023.
Problem
StdioClientTransport.close()signals only the direct child. When a server is launched through a wrapper —npx,uvx,python -m— the wrapper dies and the real server is orphaned. On WindowsChildProcess.kill()cannot terminate a tree at all.Change
Adds an opt-in
killProcessTreeoption toStdioServerParameters(Options A/B from the issue, combined):detached: trueso it leads its own process group, andclose()signals-pid, reaching every descendant.taskkill /pid <pid> /T /F.proc.kill(signal)if the group/taskkill call fails.Default is
false, so signal propagation and Ctrl-C behaviour are unchanged for anyone who doesn't opt in — this is deliberate, sincedetached: trueby default would turn a crashed host into a permanent orphan factory (the trade-off called out as Option B in the issue).Test
stdioKillProcessTree.test.tsspawns a wrapper that forks a long-lived grandchild, records its pid, closes the transport, and asserts the grandchild is reaped. Skipped on Windows in CI; thetaskkillpath was verified manually.Happy to change the default, or move to Option C (a consumer-supplied cleanup hook), if you'd prefer a different direction.
A changeset is included (
client: minor).