Skip to content

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

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

fayerman-source wants to merge 1 commit into
modelcontextprotocol:mainfrom
fayerman-source:fix/child-process-sigterm

Conversation

@fayerman-source

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.

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".
  • 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: 3 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-18T12:28:20.020724Z 32405fc PR opened
ℹ️ 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 👍 / 👎.

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