Skip to content

Let the session reaper finish before the transport closes - #593

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:let_the_session_reaper_finish_before_the_transport_closes
Open

koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:let_the_session_reaper_finish_before_the_transport_closes

Conversation

@koic

@koic koic commented Oct 9, 2026

Copy link
Copy Markdown
Member

Motivation and Context

StreamableHTTPTransport#close stopped the session reaper with Thread#kill. The reaper removes expired sessions under the transport's lock and closes their streams after releasing it, so a kill landing between the two left those streams open with no session left to find them by: nothing closed them afterwards, since close tears down only the sessions still registered.

The reaper now waits between reaps on a condition variable under the lock instead of sleeping, and close sets a flag, signals it, and joins the thread for up to REAPER_JOIN_TIMEOUT seconds before killing it. A reaper waiting leaves its wait at once; one past its removal finishes closing the streams it collected before close goes on to the rest of the transport. The join is bounded so a stream whose close never returns cannot hold the shutdown, and its outcome never decides whether the teardown runs: Thread#join re-raises the exception a thread died with, which close swallows, since the reaper reported what it could when it happened and the sessions still have to go. A spurious wakeup only runs a reap early.

How Has This Been Tested?

Three new tests in test/mcp/server/transports/streamable_http_transport_test.rb: one wakes the reaper while a reaped session's stream blocks inside its close, and checks that close returns only once that close completed and that the stream was closed; one ends the reaper with an exception, through a reap that raises and an exception reporter that raises as well, and checks that close still tears the sessions and their streams down; one makes the join time out at once while the reaper is inside a close that never returns, and checks that close returns and kills it. Against the previous library, with the reaper's sleep stubbed out so that it reaps at once, close returned while the stream was still open and the stream was never closed; the second and the third are what a plain join would have failed, with close re-raising the reaper's exception before any teardown, and never returning.

Breaking Changes

None. close now waits up to five seconds for a reap in progress to finish closing its streams.

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)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

`StreamableHTTPTransport#close` stopped the session reaper with `Thread#kill`. The reaper removes expired sessions
under the transport's lock and closes their streams after releasing it, so a kill landing between the two left those
streams open with no session left to find them by: nothing closed them afterwards, since `close` tears down only
the sessions still registered.

The reaper now waits between reaps on a condition variable under the lock instead of sleeping, and `close` sets
a flag, signals it, and joins the thread for up to `REAPER_JOIN_TIMEOUT` seconds before killing it. A reaper waiting
leaves its wait at once; one past its removal finishes closing the streams it collected before `close` goes on to
the rest of the transport. The join is bounded so a stream whose close never returns cannot hold the shutdown, and
its outcome never decides whether the teardown runs: `Thread#join` re-raises the exception a thread died with, which
`close` swallows, since the reaper reported what it could when it happened and the sessions still have to go.
A spurious wakeup only runs a reap early.

## How Has This Been Tested?

Three new tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: one wakes the reaper while a reaped
session's stream blocks inside its close, and checks that `close` returns only once that close completed and that
the stream was closed; one ends the reaper with an exception, through a reap that raises and an exception reporter
that raises as well, and checks that `close` still tears the sessions and their streams down; one makes the join
time out at once while the reaper is inside a close that never returns, and checks that `close` returns and kills it.
Against the previous library, with the reaper's sleep stubbed out so that it reaps at once, `close` returned while
the stream was still open and the stream was never closed; the second and the third are what a plain join would
have failed, with `close` re-raising the reaper's exception before any teardown, and never returning.

## Breaking Changes

None. `close` now waits up to five seconds for a reap in progress to finish closing its streams.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants