Skip to content

fix(transport): send SIGTERM before killing a child process on graceful shutdown - #1379

Open
fayerman-source wants to merge 2 commits into
modelcontextprotocol:mainfrom
fayerman-source:fix/child-process-sigterm
Open

fayerman-source wants to merge 2 commits into
modelcontextprotocol:mainfrom
fayerman-source:fix/child-process-sigterm

Conversation

@fayerman-source

@fayerman-source fayerman-source commented Oct 18, 2026 •

Copy link
Copy Markdown

Refs #347

Motivation and Context

#347 asked for the full stdio shutdown sequence from the spec (Basic > Lifecycle > Shutdown > stdio): close the server's input stream, wait, send SIGTERM if it doesn't exit, then SIGKILL if it still doesn't exit. TokioChildProcess::graceful_shutdown (#364) does the first two steps, then calls kill() (SIGKILL on Unix). A server that doesn't exit on EOF never gets a catchable signal, so cleanup in its signal handler or exit path is skipped.

After the first wait times out, graceful_shutdown now sends SIGTERM through ChildWrapper::signal on Unix and waits up to MAX_WAIT_ON_DROP_SECS again before killing. With a process-group wrapper the signal reaches the whole group. Non-Unix platforms keep wait-then-kill, since they have no SIGTERM. A failed signal call is logged and falls through to kill().

libc moves from a Linux-only to a cfg(unix) target dependency for libc::SIGTERM. It is already in the tree on every Unix target through tokio and process-wrap, so there is no new crate.

The child now stays in its ChildWithCleanup guard until it has exited or been killed, so cancelling the shutdown future partway (for example under a caller's timeout) still leaves the child to be killed when the transport is dropped. Previously it was taken out of the guard before the first wait.

Drop for ChildWithCleanup still kills directly. It runs in a sync context and can't wait out grace periods, so it's left as is.

How Has This Been Tested?

  • test_tokio_child_process_graceful_shutdown_sends_sigterm_before_kill (Unix): the child is sh -c with a TERM trap that writes a marker file and exits, and a loop that ignores stdin EOF. The test asserts that the marker exists after graceful_shutdown and that shutdown finished before the second timeout. It fails on main with "child was killed without receiving SIGTERM first".
  • test_tokio_child_process_cancelled_graceful_shutdown_still_cleans_up (Unix): the child ignores both stdin EOF and SIGTERM, the shutdown is cancelled by a timeout during the second wait, and the test checks the process is gone after the transport is dropped. It fails without the guard change (the child survives).
  • The existing test_tokio_child_process_graceful_shutdown and test_tokio_child_process_drop still pass.

Validation:

  • cargo test -p rmcp --features client,transport-child-process --lib transport::child_process: 4 passed.
  • cargo test --all-features and cargo test -p rmcp --lib --no-default-features: passed.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo clippy -p rmcp --lib --no-default-features --features transport-child-process -- -D warnings and cargo +nightly fmt --all -- --check: clean.

Breaking Changes

None. The public API is unchanged. A child that ignores both stdin EOF and SIGTERM now takes up to 2 × MAX_WAIT_ON_DROP_SECS (6 s) to be killed, up from 3 s.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

…ul shutdown

The MCP stdio shutdown sequence is: close the server's stdin and wait,
send SIGTERM if it doesn't exit, then SIGKILL if it still doesn't exit.
`TokioChildProcess::graceful_shutdown` closed stdin and waited, then went
straight to `kill()`, so a server that doesn't exit on EOF never got a
catchable signal to clean up before SIGKILL.

On Unix, send SIGTERM after the first wait and wait again before killing.
Non-Unix platforms keep wait-then-kill. `libc` moves from a Linux-only to
a Unix target dependency; it is already in the tree there through tokio
and process-wrap.

Refs modelcontextprotocol#347
@fayerman-source
fayerman-source requested a review from a team as a code owner October 18, 2026 12:23
@github-actions github-actions Bot added T-config Configuration file changes T-core Core library changes T-transport Transport layer changes labels Oct 18, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T02:54:53.584118Z ef78654 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions
github-actions Bot requested a review from connor4312 October 18, 2026 12:23
@github-actions

Copy link
Copy Markdown
Contributor

@connor4312 is on triage rotation this week and is the reviewer for this one.

Labels come from the PR labeler. Please review the change.

Automated triage. Labels are a first pass and the assigned maintainer has the final say.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32405fc515

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

#[cfg(unix)]
match child.signal(libc::SIGTERM) {
Ok(()) => {
if wait_for_exit(&mut child).await?.is_some() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve child cleanup during the second wait

When a caller bounds the public shutdown future with a timeout between the two waits (for example, timeout(Duration::from_secs(5), child.graceful_shutdown()) for a default child that ignores SIGTERM), this newly added await is cancelled after child has already been removed from ChildWithCleanup. Dropping the local Box<dyn ChildWrapper> does not run the transport's kill-on-drop task, and the now-empty TokioChildProcess cannot kill it later, so the subprocess is left running; before this change that timeout exceeded the single three-second wait and normally observed the forced kill. Keep the child in a cleanup guard across this await and disarm it only after wait/kill completes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. The gap was already there during the first wait, but the second wait made it much easier to hit. graceful_shutdown now keeps the child in ChildWithCleanup through every await and clears it only after the child has exited or been killed. If the future is cancelled partway, dropping the transport still kills the child. That also covers a transport.close() error, which used to drop the child without killing it.

Added test_tokio_child_process_cancelled_graceful_shutdown_still_cleans_up: the child ignores both stdin EOF and SIGTERM, graceful_shutdown is cancelled by a timeout during the second wait, and the test checks that the process is gone after the transport is dropped. It fails on the previous commit (the child survives).

…utdown completes

graceful_shutdown took the child out of ChildWithCleanup before its waits,
so a caller that cancelled the shutdown future (for example with a
timeout during the second wait) dropped the child without killing it.
The child now stays in the guard and is cleared only after it has exited
or been killed; a cancelled shutdown leaves it to the guard's drop kill.

Adds a Unix test that cancels the shutdown during the second wait and
checks the child is gone after the transport is dropped.
@fayerman-source

Copy link
Copy Markdown
Author

@codex review ef78654, which keeps the child in its cleanup guard until it has exited or been killed. Please check whether it resolves your earlier finding about cancellation during the second wait.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: ef78654ce6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant