Skip to content

fix: destroy descendant processes on StdioClientTransport.closeGracefully#966

Open
k-krawczyk wants to merge 1 commit into
modelcontextprotocol:mainfrom
k-krawczyk:fix/496-destroy-process-tree
Open

fix: destroy descendant processes on StdioClientTransport.closeGracefully#966
k-krawczyk wants to merge 1 commit into
modelcontextprotocol:mainfrom
k-krawczyk:fix/496-destroy-process-tree

Conversation

@k-krawczyk
Copy link
Copy Markdown

Fixes #496

Summary

StdioClientTransport.closeGracefully() previously called process.destroy() on the root process only. When the configured command is a wrapper such as npx (POSIX) or cmd.exe /c npx.cmd ... (Windows), the wrapper has already forked child processes (e.g. node.exe, the MCP server itself); destroying only the wrapper leaves those descendants orphaned. On Windows this prevents the JVM from shutting down cleanly — the symptom reported in the issue.

Fix

Walk ProcessHandle.descendants() (JDK 9+, available since the repo's Java 17 baseline) and destroy each descendant before destroying the root. This avoids the OS-specific taskkill /pid X /F /T approach suggested on the issue: same effect, one cross-platform code path, no shelling out.

The descendant snapshot is best-effort — processes forked concurrently with the destroy call may be missed, but the wider tree generally dies with its parent.

Extracted as a package-private static helper destroyProcessTree(Process) for testability.

Tests

New mcp-core/.../StdioClientTransportDestroyTreeTests with three cases:

  • destroyProcessTreeTerminatesRootAndDescendants — spawns sh -c 'sleep 60 & sleep 60 & wait' (two child sleeps), captures descendant ProcessHandles, calls the helper, then awaits !isAlive() on every captured handle. POSIX-only via @DisabledOnOs(WINDOWS) since CI runs on Ubuntu.
  • destroyProcessTreeTerminatesRootWithNoDescendants — single sleep 60, ensures the root path still works when there is nothing beneath it.
  • destroyProcessTreeIsSafeOnAlreadyTerminatedProcess — runs a command that exits immediately, then calls the helper to assert it doesn't throw on a dead Process / ProcessHandle.

Verification

./mvnw verify green locally on macOS across all 11 modules (BUILD SUCCESS, 0 failures, 0 errors). This includes StdioMcpSyncClientTests and StdioMcpAsyncClientTests, which exercise the close path through a real npx subprocess.

Caveat

I'm on macOS, so the original Windows symptom is verified only by reasoning about the JDK API, not exercised end-to-end. ProcessHandle.descendants() is a JDK-level abstraction that works across platforms, but I'd appreciate a reviewer with Windows confirming the report from #496 is resolved. Happy to switch to explicit taskkill instead if maintainers prefer the explicit route.

…ully

When `closeGracefully()` is called, the previous implementation invoked
only `process.destroy()` on the root process. With wrapper commands such
as `npx` (POSIX) or `cmd.exe /c npx.cmd ...` (Windows), the wrapper has
already forked child processes (`node.exe`, the MCP server itself);
destroying only the wrapper leaves those descendants orphaned. On Windows
this prevents the JVM from shutting down cleanly.

Walk `ProcessHandle.descendants()` (JDK 9+, available since the repo's
Java 17 baseline) and destroy each descendant before destroying the root.
This avoids the OS-specific `taskkill /pid X /F /T` approach suggested on
the issue: same effect, one cross-platform code path, no shelling out.

The descendant snapshot is best-effort — processes forked concurrently
with the destroy call may be missed, but the wider tree generally dies
with its parent.

Fixes modelcontextprotocol#496
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.

io.modelcontextprotocol.client.transport.StdioClientTransport#closeGracefully

1 participant