@@ -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
0 commit comments