Skip to content

Commit 7735046

Browse files
authored
Merge pull request #595 from koic/bound_the_listen_teardown_on_transport_close
Bound the listen teardown on transport close
2 parents a391d32 + 03867b0 commit 7735046

3 files changed

Lines changed: 198 additions & 27 deletions

File tree

‎docs/_server/subscriptions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,7 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(
8888

8989
A stream stays open until the client closes the connection; on graceful shutdown via `transport.close`,
9090
each open stream receives its `subscriptions/listen` response before closing.
91+
`close` waits at most 5 seconds in all for those responses to be written: a stream whose client stopped reading
92+
is closed without one when that time is up, and the client re-sends its `subscriptions/listen` request.
9193

9294
See [Notifications](/server/notifications/) for the notification types themselves and their session scoping.

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

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,9 @@ def initialize(
361361
SESSION_REAP_INTERVAL = 60
362362
# Seconds `close` waits for a reap in progress to finish closing its streams before it kills the reaper.
363363
REAPER_JOIN_TIMEOUT = 5
364+
# How long `close` waits in all for the closing results of the open `subscriptions/listen` streams
365+
# before closing the streams still open without one.
366+
LISTEN_TEARDOWN_TIMEOUT = 5
364367

365368
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
366369
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
@@ -1453,8 +1456,14 @@ def close_removed_listen_stream(subscription)
14531456
end
14541457

14551458
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1456-
# before the stream closes.
1457-
def teardown_listen_subscriptions
1459+
# before the stream closes. The results are written on a helper thread that is waited for at most `timeout`
1460+
# seconds in all, because a peer that stopped reading stalls the write of its result, or a delivery already
1461+
# holding the stream's write mutex, for as long as its socket buffers stay full. Once the time is up, every stream
1462+
# still open is closed from here: closing a Ruby IO ends a write stalled on it with an `IOError` the helper rescues,
1463+
# while a stream object of another kind may keep the helper blocked, which no longer holds `close`. Either way
1464+
# the stream goes without its result, like one whose acknowledgement was never written. The helper is left
1465+
# to finish on its own rather than killed, so a write mutex is never dropped in the middle of a write.
1466+
def teardown_listen_subscriptions(timeout: LISTEN_TEARDOWN_TIMEOUT)
14581467
removed = @mutex.synchronize do
14591468
subscriptions = @listen_subscriptions.dup
14601469
@listen_subscriptions.clear
@@ -1465,35 +1474,72 @@ def teardown_listen_subscriptions
14651474

14661475
subscriptions
14671476
end
1477+
return if removed.empty?
14681478

1469-
removed.each_value do |subscription|
1470-
# Marking the entry closed and writing the result under the stream's write mutex orders
1471-
# this against in-flight deliveries and against the acknowledgement write: each one either lands
1472-
# before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1473-
subscription[:write_mutex].synchronize do
1474-
subscription[:closed] = true
1479+
# Whichever side takes an entry out of `pending` closes its stream: the helper after the write, or `close` once
1480+
# the time is up. Claiming before closing keeps a stream from being closed by both in either order.
1481+
pending = removed.dup
1482+
pending_mutex = Mutex.new
1483+
writer = Thread.new do
1484+
removed.each do |subscription_key, subscription|
1485+
write_listen_closing_result(subscription)
1486+
close_stream_safely(subscription[:stream]) if pending_mutex.synchronize { pending.delete(subscription_key) }
1487+
end
1488+
end
14751489

1476-
# A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1477-
# the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1478-
next unless subscription[:active]
1490+
begin
1491+
writer.join(timeout)
1492+
rescue StandardError
1493+
# A helper that died with an exception has stopped writing; the streams it did not finish are closed below.
1494+
end
14791495

1480-
begin
1481-
send_to_stream(subscription[:stream], {
1482-
jsonrpc: "2.0",
1483-
id: subscription[:request_id],
1484-
result: {
1485-
# `SubscriptionsListenResult` is served at the transport layer and never
1486-
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1487-
# stamped at its construction site.
1488-
resultType: ResultType::COMPLETE,
1489-
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1490-
},
1491-
})
1492-
rescue *STREAM_WRITE_ERRORS
1493-
nil
1494-
end
1496+
left = pending_mutex.synchronize do
1497+
subscriptions = pending.values
1498+
pending.clear
1499+
subscriptions
1500+
end
1501+
left.each { |subscription| close_stream_safely(subscription[:stream]) }
1502+
end
1503+
1504+
# Marking the entry closed and writing the result under the stream's write mutex orders
1505+
# this against in-flight deliveries and against the acknowledgement write: each one either lands
1506+
# before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1507+
# The stream is left open for the caller to close.
1508+
def write_listen_closing_result(subscription)
1509+
subscription[:write_mutex].synchronize do
1510+
subscription[:closed] = true
1511+
1512+
# A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1513+
# the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1514+
next unless subscription[:active]
1515+
1516+
begin
1517+
send_to_stream(subscription[:stream], {
1518+
jsonrpc: "2.0",
1519+
id: subscription[:request_id],
1520+
result: {
1521+
# `SubscriptionsListenResult` is served at the transport layer and never
1522+
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1523+
# stamped at its construction site.
1524+
resultType: ResultType::COMPLETE,
1525+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1526+
},
1527+
})
1528+
rescue *STREAM_WRITE_ERRORS
1529+
nil
14951530
end
1496-
close_stream_safely(subscription[:stream])
1531+
end
1532+
rescue StandardError => e
1533+
# Reported rather than raised: on the helper thread an error would end the teardown of every stream after this one,
1534+
# and `close` is past waiting for it by the time a stalled write fails. A reporter that raises is dropped for
1535+
# the same reason.
1536+
begin
1537+
MCP.configuration.exception_reporter.call(
1538+
e,
1539+
{ subscription_id: subscription[:request_id], error: "Failed to close listen stream" },
1540+
)
1541+
rescue StandardError
1542+
nil
14971543
end
14981544
end
14991545

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6927,6 +6927,129 @@ def wait_until_blocked_or_done(thread)
69276927
assert_equal events, sse_events(io)
69286928
end
69296929

6930+
test "listen teardown returns once its timeout passed behind a held write mutex" do
6931+
# A delivery stalled in its write holds the stream's write mutex for as long as the peer leaves
6932+
# the socket buffers full; the test holds the mutex in that delivery's place.
6933+
io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
6934+
closes = 0
6935+
io.define_singleton_method(:close) do
6936+
closes += 1
6937+
super()
6938+
end
6939+
registry = @transport.instance_variable_get(:@listen_subscriptions)
6940+
entry = registry.values.first
6941+
before = Thread.list
6942+
6943+
entry[:write_mutex].lock
6944+
begin
6945+
teardown = Thread.new { @transport.send(:teardown_listen_subscriptions, timeout: 0.1) }
6946+
6947+
refute_nil teardown.join(5), "the teardown did not return while the write mutex stayed held"
6948+
assert_predicate io, :closed?
6949+
assert_empty registry
6950+
ensure
6951+
entry[:write_mutex].unlock
6952+
end
6953+
6954+
# Released, the helper finds the stream closed and ends without a result written or a second close.
6955+
wait_until { (Thread.list - before).empty? }
6956+
assert_nil sse_events(io).find { |event| event.dig("result", "resultType") == "complete" }
6957+
assert_equal 1, closes
6958+
end
6959+
6960+
test "listen teardown ends a stalled closing result write by closing its stream" do
6961+
gate = Queue.new
6962+
reached = Queue.new
6963+
stalled = Class.new(StringIO) do
6964+
attr_accessor :stall
6965+
attr_reader :closes
6966+
6967+
# Blocks like a write to a socket whose peer stopped reading, until the stream is closed from another thread.
6968+
define_method(:write) do |data|
6969+
return super(data) unless stall
6970+
6971+
reached.push(true)
6972+
gate.pop
6973+
raise IOError, "closed stream"
6974+
end
6975+
6976+
define_method(:close) do
6977+
@closes = (@closes || 0) + 1
6978+
gate.close
6979+
super()
6980+
end
6981+
end.new
6982+
response = @transport.handle_request(modern_rack_request(
6983+
modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }),
6984+
))
6985+
response[2].call(stalled)
6986+
stalled.stall = true
6987+
before = Thread.list
6988+
6989+
begin
6990+
teardown = Thread.new { @transport.send(:teardown_listen_subscriptions, timeout: 0.5) }
6991+
6992+
# The helper is parked in the stalled write, with the stream still open, before the timeout runs out.
6993+
wait_until { !reached.empty? }
6994+
refute_predicate stalled, :closed?
6995+
6996+
refute_nil teardown.join(5), "the teardown did not return while the result write stalled"
6997+
assert_predicate stalled, :closed?
6998+
ensure
6999+
# A failed step must not leave the helper parked in the stalled write for the rest of the suite.
7000+
gate.close
7001+
end
7002+
7003+
# The close ended the write, so the helper finished on its own, without closing the stream again.
7004+
wait_until { (Thread.list - before).empty? }
7005+
assert_nil sse_events(stalled).find { |event| event.dig("result", "resultType") == "complete" }
7006+
assert_equal 1, stalled.closes
7007+
end
7008+
7009+
test "transport close reports a closing result write failing unexpectedly and still closes the other streams" do
7010+
failing = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
7011+
healthy = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true })
7012+
failing.define_singleton_method(:write) { |_data| raise "boom" }
7013+
reported = []
7014+
original_reporter = MCP.configuration.exception_reporter
7015+
MCP.configuration.exception_reporter = ->(error, context) { reported << [error, context] }
7016+
7017+
begin
7018+
@transport.close
7019+
ensure
7020+
MCP.configuration.exception_reporter = original_reporter
7021+
end
7022+
7023+
assert_predicate failing, :closed?
7024+
assert_predicate healthy, :closed?
7025+
assert_equal "complete", sse_events(healthy).last.dig("result", "resultType")
7026+
assert_equal 1, reported.size
7027+
assert_equal "boom", reported.first[0].message
7028+
assert_equal({ subscription_id: "listen-1", error: "Failed to close listen stream" }, reported.first[1])
7029+
end
7030+
7031+
test "transport close tears down even when the exception reporter raises on a failed closing result write" do
7032+
failing = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true })
7033+
healthy = open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true })
7034+
failing.define_singleton_method(:write) { |_data| raise "boom" }
7035+
session_stream = StringIO.new
7036+
open_sse_session(@transport, io: session_stream)
7037+
original_reporter = MCP.configuration.exception_reporter
7038+
MCP.configuration.exception_reporter = ->(_error, _context) { raise "reporter failed" }
7039+
7040+
begin
7041+
@transport.close
7042+
ensure
7043+
MCP.configuration.exception_reporter = original_reporter
7044+
end
7045+
7046+
assert_predicate failing, :closed?
7047+
assert_predicate healthy, :closed?
7048+
assert_equal "complete", sse_events(healthy).last.dig("result", "resultType")
7049+
assert_predicate session_stream, :closed?
7050+
assert_empty @transport.instance_variable_get(:@sessions)
7051+
end
7052+
69307053
test "subscriptions/listen honoring reads the capability flags, not capability presence" do
69317054
# A server declaring `tools` without `listChanged: true` promises no
69327055
# list-changed delivery, so the acknowledgement omits the type.

0 commit comments

Comments
 (0)