Skip to content

Commit e4e5612

Browse files
committed
Let the session reaper finish before the transport closes
## 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.
1 parent c090dd6 commit e4e5612

2 files changed

Lines changed: 181 additions & 2 deletions

File tree

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ def initialize(
347347

348348
@max_stream_lifetime = max_stream_lifetime
349349

350+
# `close` sets the flag and signals the reaper under `@mutex`, so the reaper leaves its wait at once instead of
351+
# being killed in the middle of a reap.
352+
@closing = false
353+
@reaper_wakeup = ConditionVariable.new
350354
start_reaper_thread if @session_idle_timeout
351355
end
352356

@@ -355,6 +359,8 @@ def initialize(
355359
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
356360
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
357361
SESSION_REAP_INTERVAL = 60
362+
# Seconds `close` waits for a reap in progress to finish closing its streams before it kills the reaper.
363+
REAPER_JOIN_TIMEOUT = 5
358364

359365
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
360366
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
@@ -453,8 +459,28 @@ def handle_request(request)
453459
end
454460
end
455461

462+
# The reaper is asked to stop and joined before it is killed: `reap_expired_sessions` removes expired sessions
463+
# under `@mutex` and closes their streams after releasing it, and a kill landing between the two would leave
464+
# those streams open with no session left to find them by. Waking the reaper ends its wait at once, and the join
465+
# lets a reap already past its removal finish its closes before the rest of the transport is torn down.
466+
# The join is bounded, so a stream whose close never returns cannot hold the shutdown, and a reaper that died
467+
# with a `StandardError` does not stop the teardown below: `join` re-raises the exception a thread died with,
468+
# which is not this method's to raise. A reaper still alive after the wait is killed as before, leaving
469+
# the streams of that reap it had not closed yet as they were.
456470
def close
457-
@reaper_thread&.kill
471+
reaper = @mutex.synchronize do
472+
@closing = true
473+
@reaper_wakeup.signal
474+
@reaper_thread
475+
end
476+
477+
begin
478+
reaper&.join(REAPER_JOIN_TIMEOUT)
479+
rescue StandardError
480+
# The reaper is gone either way; the exception that ended it is not this method's to report.
481+
end
482+
483+
reaper.kill if reaper&.alive?
458484
@reaper_thread = nil
459485

460486
teardown_listen_subscriptions
@@ -772,10 +798,18 @@ def cancel_pending_request(request_id, reason: nil)
772798

773799
private
774800

801+
# The wait between reaps is a condition variable wait under `@mutex` rather than a sleep, so `close` can end it
802+
# by signalling; a spurious wakeup only runs a reap early.
775803
def start_reaper_thread
776804
@reaper_thread = Thread.new do
777805
loop do
778-
sleep(SESSION_REAP_INTERVAL)
806+
closing = @mutex.synchronize do
807+
@reaper_wakeup.wait(@mutex, SESSION_REAP_INTERVAL) unless @closing
808+
@closing
809+
end
810+
811+
break if closing
812+
779813
reap_expired_sessions
780814
rescue StandardError => e
781815
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4323,6 +4323,123 @@ def string
43234323
assert_nil transport.instance_variable_get(:@reaper_thread)
43244324
end
43254325

4326+
test "close lets a reap in progress finish closing the streams of the sessions it removed" do
4327+
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01)
4328+
init_request = create_rack_request(
4329+
"POST",
4330+
"/",
4331+
{ "CONTENT_TYPE" => "application/json" },
4332+
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
4333+
)
4334+
session_id = transport.handle_request(init_request)[1]["mcp-session-id"]
4335+
4336+
# A stream whose close blocks until released stands in for a reap caught between removing the session and closing its stream.
4337+
entered = Queue.new
4338+
release = Queue.new
4339+
closed = false
4340+
blocking_stream = Object.new
4341+
blocking_stream.define_singleton_method(:close) do
4342+
entered << :closing
4343+
release.pop
4344+
closed = true
4345+
end
4346+
transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = blocking_stream
4347+
4348+
sleep(0.02) # Wait for the session to expire.
4349+
4350+
# Wake the reaper ahead of its interval; it removes the expired session and blocks inside the close.
4351+
assert wake_reaper_until(transport) { !entered.empty? }, "the reaper did not reach the stream's close"
4352+
assert_empty transport.instance_variable_get(:@sessions)
4353+
4354+
closer = Thread.new { transport.close }
4355+
4356+
refute closer.join(0.1), "close returned while the reaper was still closing a stream"
4357+
4358+
release << :go
4359+
4360+
assert closer.join(2), "close did not return once the reaper had finished"
4361+
assert closed, "the stream of the reaped session was never closed"
4362+
assert_nil transport.instance_variable_get(:@reaper_thread)
4363+
ensure
4364+
release << :go
4365+
transport.close
4366+
end
4367+
4368+
test "close tears the transport down even when the reaper died with an exception" do
4369+
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 3600)
4370+
init_request = create_rack_request(
4371+
"POST",
4372+
"/",
4373+
{ "CONTENT_TYPE" => "application/json" },
4374+
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
4375+
)
4376+
session_id = transport.handle_request(init_request)[1]["mcp-session-id"]
4377+
stream = StringIO.new
4378+
transport.send(:store_stream_for_session, session_id, stream)
4379+
4380+
# A reap that raises, met by an exception reporter that raises as well, ends the reaper thread with an exception,
4381+
# which `Thread#join` would re-raise.
4382+
original_reporter = MCP.configuration.exception_reporter
4383+
MCP.configuration.exception_reporter = ->(_error, _context) { raise "reporter failed" }
4384+
transport.define_singleton_method(:reap_expired_sessions) { raise "reap failed" }
4385+
reaper_thread = transport.instance_variable_get(:@reaper_thread)
4386+
reaper_thread.report_on_exception = false
4387+
4388+
assert wake_reaper_until(transport) { !reaper_thread.alive? }, "the reaper did not die"
4389+
assert_raises(RuntimeError) { reaper_thread.join }
4390+
4391+
transport.close
4392+
4393+
assert_nil transport.instance_variable_get(:@reaper_thread)
4394+
assert_empty transport.instance_variable_get(:@sessions)
4395+
assert stream.closed?
4396+
ensure
4397+
MCP.configuration.exception_reporter = original_reporter if original_reporter
4398+
transport.close
4399+
end
4400+
4401+
test "close kills a reaper still closing a stream once the join timed out" do
4402+
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: 0.01)
4403+
init_request = create_rack_request(
4404+
"POST",
4405+
"/",
4406+
{ "CONTENT_TYPE" => "application/json" },
4407+
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
4408+
)
4409+
session_id = transport.handle_request(init_request)[1]["mcp-session-id"]
4410+
4411+
# A stream whose close never returns while the test runs; the join is made to time out at once instead of after
4412+
# its interval.
4413+
entered = Queue.new
4414+
never = Queue.new
4415+
stuck_stream = Object.new
4416+
stuck_stream.define_singleton_method(:close) do
4417+
entered << :closing
4418+
never.pop
4419+
end
4420+
transport.instance_variable_get(:@sessions)[session_id][:get_sse_stream] = stuck_stream
4421+
4422+
sleep(0.02) # Wait for the session to expire.
4423+
4424+
assert wake_reaper_until(transport) { !entered.empty? }, "the reaper did not reach the stream's close"
4425+
4426+
reaper_thread = transport.instance_variable_get(:@reaper_thread)
4427+
reaper_thread.stubs(:join).returns(nil)
4428+
closer = Thread.new { transport.close }
4429+
4430+
assert closer.join(2), "close did not return after the join timed out"
4431+
assert wait_until_thread_dead(reaper_thread), "the reaper was not killed"
4432+
assert_nil transport.instance_variable_get(:@reaper_thread)
4433+
assert_empty transport.instance_variable_get(:@sessions)
4434+
ensure
4435+
# A failure would otherwise leave the reaper stuck in the close, and the closer with it; releasing the close
4436+
# also keeps the final `transport.close` from blocking on a stream still registered when an early step failed.
4437+
never&.close
4438+
reaper_thread&.kill
4439+
closer&.kill
4440+
transport.close
4441+
end
4442+
43264443
test "reaper thread is not started when session_idle_timeout is nil" do
43274444
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil)
43284445
assert_nil(transport.instance_variable_get(:@reaper_thread))
@@ -7133,6 +7250,34 @@ def wait_until_asleep(threads)
71337250

71347251
private
71357252

7253+
# Signals the reaper's wakeup under the transport's lock until the block holds or the timeout elapses; the signal
7254+
# is repeated because one sent before the reaper reached its wait would be lost. Returns the final outcome.
7255+
def wake_reaper_until(transport, timeout: 2)
7256+
mutex = transport.instance_variable_get(:@mutex)
7257+
wakeup = transport.instance_variable_get(:@reaper_wakeup)
7258+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
7259+
7260+
loop do
7261+
mutex.synchronize { wakeup.signal }
7262+
return true if yield
7263+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
7264+
7265+
sleep(0.005)
7266+
end
7267+
end
7268+
7269+
# Polls until `thread` is no longer alive or the timeout elapses; returns the final outcome.
7270+
def wait_until_thread_dead(thread, timeout: 2)
7271+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
7272+
7273+
loop do
7274+
return true unless thread.alive?
7275+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
7276+
7277+
sleep(0.005)
7278+
end
7279+
end
7280+
71367281
def initialize_test_session(id: "init")
71377282
init_request = create_rack_request(
71387283
"POST",

0 commit comments

Comments
 (0)