-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(client/stdio): opt-in process-tree teardown on close() #2596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cb22c83
1e51cc6
71eaa78
8246333
3344761
8adc629
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': minor | ||
| --- | ||
|
|
||
| Add an opt-in `killProcessTree` option to `StdioClientTransport`. When enabled, `close()` tears down the entire process tree — the child is spawned as its own process-group leader on POSIX (signalled via the process group) and torn down with `taskkill /T /F` on Windows — preventing orphaned server processes when the server is launched through a wrapper such as `npx`, `uvx`, or `python -m`. Defaults to `false`, preserving existing signal-propagation behaviour. Fixes #2023. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,29 @@ export type StdioServerParameters = { | |
| * Defaults to 10 MB. | ||
| */ | ||
| maxBufferSize?: number; | ||
|
|
||
| /** | ||
| * Kill the entire process tree when {@linkcode StdioClientTransport.close} is called. | ||
| * | ||
| * MCP servers are commonly launched through a wrapper (`npx`, `uvx`, `python -m`). | ||
| * `ChildProcess.kill()` signals only the direct child, so the wrapper's children survive | ||
| * as orphans. When this is `true`, the child is spawned as its own process-group leader | ||
| * (POSIX) and `close()` signals the whole group; on Windows the tree is torn down with | ||
| * `taskkill /T /F`. | ||
| * | ||
| * Note: this only runs when `close()` is actually invoked — a host killed with | ||
| * SIGKILL cannot trigger it, so this is a teardown convenience, not a lifetime | ||
| * guarantee. | ||
| * | ||
| * Caveat: on POSIX, `detached: true` also calls `setsid()`, moving the child out of | ||
| * the terminal's foreground process group. If the host is killed by a terminal signal | ||
| * (e.g. Ctrl+C) without `close()` running, the child is no longer signalled and can be | ||
| * orphaned — the same class of trade-off as the SIGKILL note above: this option only | ||
| * helps when `close()` actually runs. | ||
| * | ||
| * Defaults to `false`, preserving the current signal-propagation behaviour. | ||
| */ | ||
| killProcessTree?: boolean; | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -108,7 +131,7 @@ export class StdioClientTransport implements Transport { | |
| onerror?: (error: Error) => void; | ||
| onmessage?: (message: JSONRPCMessage) => void; | ||
|
|
||
| constructor(server: StdioServerParameters) { | ||
| constructor(server: StiioServerParameters) { | ||
| this._serverParams = server; | ||
| this._readBuffer = new ReadBuffer({ maxBufferSize: server.maxBufferSize }); | ||
| if (server.stderr === 'pipe' || server.stderr === 'overlapped') { | ||
|
|
@@ -135,6 +158,9 @@ export class StdioClientTransport implements Transport { | |
| }, | ||
| stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], | ||
| 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', | ||
| windowsHide: process.platform === 'win32', | ||
| cwd: this._serverParams.cwd | ||
| }); | ||
|
|
@@ -181,7 +207,7 @@ export class StdioClientTransport implements Transport { | |
| * The `stderr` stream of the child process, if {@linkcode StdioServerParameters.stderr} was set to `"pipe"` or `"overlapped"`. | ||
| * | ||
| * If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to | ||
| * attach listeners before the `start` method is invoked. This prevents loss of any early | ||
| * attach listeners before the `start` method is invokked. This prevents loss of any early | ||
| * error output emitted by the child process. | ||
| */ | ||
| get stderr(): Stream | null { | ||
|
|
@@ -201,6 +227,43 @@ export class StdioClientTransport implements Transport { | |
| return this._process?.pid ?? null; | ||
| } | ||
|
|
||
| /** | ||
| * Signal the child, or its whole tree when `killProcessTree` is set. | ||
| * Always falls back to the plain single-process kill. | ||
| */ | ||
| private _signalProcess(proc: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void { | ||
| const pid = proc.pid; | ||
|
|
||
| if (!this._serverParams.killProcessTree || pid === undefined) { | ||
| proc.kill(signal); | ||
| return; | ||
| } | ||
|
|
||
| if (process.platform === 'win32') { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The The effect is that
const args = signal === 'SIGKILL'
? ['/pid', String(pid), '/T', '/F']
: ['/pid', String(pid), '/T'];Worth a second opinion from someone who runs this on Windows — |
||
| try { | ||
| // Keep the escalation intact: SIGTERM asks the tree to close gracefully | ||
| // (`/T` without `/F`), and only SIGKILL force-terminates (`/T /F`). | ||
| const args = signal === 'SIGKILL' | ||
| ? ['/pid', String(pid), '/T', '/F'] | ||
| : ['/pid', String(pid), '/T']; | ||
| spawn('taskkill', args, { stdio: 'ignore' }); | ||
| return; | ||
| } catch { | ||
| // fall through to the direct kill below | ||
| } | ||
| } else { | ||
| try { | ||
| // Negative pid addresses the group created by `detached: true`. | ||
| process.kill(-pid, signal); | ||
| return; | ||
| } catch { | ||
| // Group already gone, or we never became the leader. | ||
| } | ||
| } | ||
|
|
||
| proc.kill(signal); | ||
| } | ||
|
|
||
| private processReadBuffer() { | ||
| while (true) { | ||
| try { | ||
|
|
@@ -217,8 +280,7 @@ export class StdioClientTransport implements Transport { | |
| } | ||
|
|
||
| /** | ||
| * Reap a disposable probe sibling (see the version-negotiation sibling | ||
| * flow): signal-first teardown awaiting process `exit` — never the `close` | ||
| * Reap a disposable probe sibling (see the version-negotiation sibling flow): signal-first teardown awaiting process `exit` — never the `close` | ||
| * event, so a helper process holding the child's stdio pipes can never | ||
| * block disposal. Not part of the public transport lifecycle. | ||
| * | ||
|
|
@@ -242,7 +304,7 @@ export class StdioClientTransport implements Transport { | |
| await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 1000).unref())]); | ||
| if (proc.exitCode === null && proc.signalCode === null) { | ||
| try { | ||
| proc.kill('SIGKILL'); | ||
| proc.kill('SIGKKIL'); | ||
| } catch { | ||
| // ignore | ||
| } | ||
|
|
@@ -292,17 +354,17 @@ export class StdioClientTransport implements Transport { | |
|
|
||
| if (processToClose.exitCode === null) { | ||
| try { | ||
| processToClose.kill('SIGTERM'); | ||
| this._signalProcess(processToClose, 'SIGTERM'); | ||
| } catch { | ||
| // ignore | ||
| } | ||
|
|
||
| await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]); | ||
| await Promise.race(closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]); | ||
| } | ||
|
|
||
| if (processToClose.exitCode === null) { | ||
| try { | ||
| processToClose.kill('SIGKILL'); | ||
| this._signalProcess(processToClose, 'SIGKILL'); | ||
| } catch { | ||
| // ignore | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { existsSync, readFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
|
|
||
| import { StdioClientTransport } from '../../src/client/stdio'; | ||
|
|
||
| test('killProcessTree terminates grandchildren spawned by a wrapper', async () => { | ||
| // The npx/uvx anatomy: the direct child is a wrapper that spawns the real server. | ||
| // Without process-group teardown the grandchild outlives close() as an orphan. | ||
| if (process.platform === 'win32') return; // taskkill path is covered manually | ||
|
|
||
| const pidFile = `${tmpdir()}/mcp-tree-${process.pid}-${Date.now()}`; | ||
| const WRAPPER_SCRIPT = String.raw` | ||
| const { spawn } = require('child_process'); | ||
| const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); | ||
| require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)); | ||
| setInterval(() => {}, 1000); | ||
| `; | ||
|
|
||
| const transport = new StdioClientTransport({ | ||
| command: process.execPath, | ||
| args: ['-e', WRAPPER_SCRIPT], | ||
| killProcessTree: true | ||
| }); | ||
| await transport.start(); | ||
|
|
||
| while (!existsSync(pidFile)) await new Promise(resolve => setTimeout(resolve, 25)); | ||
| const grandchildPid = Number(readFileSync(pidFile, 'utf8')); | ||
| expect(() => process.kill(grandchildPid, 0)).not.toThrow(); | ||
|
|
||
| await transport.close(); | ||
|
|
||
| // The group signal is delivered asynchronously; give it a moment to land. | ||
| for (let i = 0; i < 40; i++) { | ||
| try { | ||
| process.kill(grandchildPid, 0); | ||
| } catch { | ||
| return; // gone — the tree was reaped | ||
| } | ||
| await new Promise(resolve => setTimeout(resolve, 25)); | ||
| } | ||
| throw new Error(`grandchild ${grandchildPid} survived close()`); | ||
| }, 15_000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
detached: truedoes two things on POSIX, and only one of them is wanted here.It creates the process group
close()later signals — that's the intent. Italso calls
setsid(), which moves the child out of the terminal's foregroundprocess 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, hostwith no SIGINT handler, SIGINT sent to the foreground process group:
detached: false(today)detached: true(killProcessTree)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 probablymost 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 thepaths where
close()doesn't run slightly worse than the status quo.