Skip to content

Commit e02c9e7

Browse files
committed
End a listen stream's keepalive thread when its slot is freed
## Motivation and Context Each `subscriptions/listen` stream has a keepalive thread that sleeps for the configured interval and then writes a comment frame, so a silently dropped peer is noticed and its slot freed. The thread checked for its entry only between sleeps, so when another path removed the entry, a failed notification write or the transport closing, the thread slept out the rest of its interval, up to 15 seconds by default, before it noticed and exited. How many such threads existed therefore depended on how quickly clients opened and dropped listen streams, not on `max_listen_subscriptions`, which bounds only the entries. The TypeScript SDK stops its keepalive timer the moment it tears a stream down. The wait between pings is now a condition variable wait under the registry lock, and whatever removes an entry signals it under the same lock. The presence check and the wait cannot be separated by a removal, so a thread waiting out its interval wakes and exits as soon as its entry goes, whichever path removed it; one already past the wait, in a ping, finishes that write first. Pings still happen outside the lock, and a dropped peer is still detected by the ping's write failure as before. The ping itself is now written under the stream's write mutex, as notification delivery and the closing result are, and skipped once the transport has marked the entry closed. It used to be written outside that mutex, so a comment frame could follow the closing result, or land between the bytes of a notification on a stream that does not serialize its writes itself. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` open listen streams with a 30 second interval, free their slots through a failed delivery write and through `close`, and check that every keepalive thread ends within a bound far below that interval. Against the previous library the threads are still alive at that bound. Two more tests hold a stream's write mutex and check that the ping waits for it, and mark an entry closed and check that the ping writes nothing. ## Breaking Changes None.
1 parent 6da3009 commit e02c9e7

2 files changed

Lines changed: 159 additions & 20 deletions

File tree

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

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,10 @@ def initialize(
171171

172172
# Maps a key the transport mints for each `subscriptions/listen` stream to
173173
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_subscription_filter, active: boolean,
174-
# write_mutex: Mutex }` (SEP-2575). The request id is the client's, unique only among that client's own
175-
# in-flight requests, so it stamps `subscriptionId` but cannot serve as the key: two clients may pick the same one.
174+
# write_mutex: Mutex, keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
175+
# unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
176+
# two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
177+
# so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
176178
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
177179
# which is a follow-up.
178180
@listen_subscriptions = {}
@@ -944,7 +946,7 @@ def listen_sse_body(request_id, honored)
944946
rejected = true
945947
else
946948
@listen_subscriptions[subscription_key] = {
947-
request_id: request_id, stream: stream, filter: honored, active: false, write_mutex: Mutex.new,
949+
request_id: request_id, stream: stream, filter: honored, active: false, write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new
948950
}
949951
end
950952
end
@@ -986,12 +988,27 @@ def activate_listen_subscription(subscription_key)
986988
# connection is detected and its slot freed, rather than held until the next fan-out write.
987989
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
988990
# cannot corrupt an interleaved notification's JSON.
991+
#
992+
# The wait between pings is a condition variable wait under `@mutex`, not a plain sleep:
993+
# the presence check and the wait happen under the same lock that removals signal from,
994+
# so a removal cannot slip in between them and a thread waiting out its interval wakes
995+
# at once when its entry goes, whichever path removed it. A thread already past the wait,
996+
# in a ping, finishes that write first and then finds its entry gone.
989997
def start_listen_keepalive_thread(subscription_key, request_id)
990998
return unless @listen_keepalive_interval
991999

9921000
Thread.new do
993-
while listen_subscription_active?(subscription_key)
994-
sleep(@listen_keepalive_interval)
1001+
loop do
1002+
registered = @mutex.synchronize do
1003+
subscription = @listen_subscriptions[subscription_key]
1004+
next false unless subscription
1005+
1006+
subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
1007+
1008+
@listen_subscriptions.key?(subscription_key)
1009+
end
1010+
break unless registered
1011+
9951012
send_listen_keepalive_ping(subscription_key)
9961013
end
9971014
rescue *STREAM_WRITE_ERRORS
@@ -1010,20 +1027,20 @@ def start_listen_keepalive_thread(subscription_key, request_id)
10101027
end
10111028
end
10121029

1013-
def listen_subscription_active?(subscription_key)
1014-
@mutex.synchronize { @listen_subscriptions.key?(subscription_key) }
1015-
end
1016-
1017-
# Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
1018-
# every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
1030+
# Resolves the entry under the registry lock, then writes outside it so a stalled reader cannot
1031+
# block every other subscription on `@mutex`. The write itself holds the stream's write mutex,
1032+
# like notification delivery and the closing result: the comment frame then cannot land between
1033+
# the bytes of a notification or after the closing result, and once teardown has marked
1034+
# the entry closed the ping is skipped. A write error propagates to end the keepalive loop.
10191035
def send_listen_keepalive_ping(subscription_key)
1020-
stream = @mutex.synchronize do
1021-
subscription = @listen_subscriptions[subscription_key]
1022-
subscription && subscription[:stream]
1023-
end
1024-
return unless stream
1036+
subscription = @mutex.synchronize { @listen_subscriptions[subscription_key] }
1037+
return unless subscription
10251038

1026-
send_ping_to_stream(stream)
1039+
subscription[:write_mutex].synchronize do
1040+
next if subscription[:closed]
1041+
1042+
send_ping_to_stream(subscription[:stream])
1043+
end
10271044
end
10281045

10291046
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
@@ -1108,7 +1125,12 @@ def deliver_to_listen_subscriptions(method, params)
11081125
end
11091126

11101127
def remove_listen_subscription(subscription_key)
1111-
@mutex.synchronize { @listen_subscriptions.delete(subscription_key) }
1128+
@mutex.synchronize do
1129+
subscription = @listen_subscriptions.delete(subscription_key)
1130+
subscription[:keepalive_wakeup].signal if subscription
1131+
1132+
subscription
1133+
end
11121134
end
11131135

11141136
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
@@ -1117,6 +1139,11 @@ def teardown_listen_subscriptions
11171139
removed = @mutex.synchronize do
11181140
subscriptions = @listen_subscriptions.dup
11191141
@listen_subscriptions.clear
1142+
1143+
subscriptions.each_value do |subscription|
1144+
subscription[:keepalive_wakeup].signal
1145+
end
1146+
11201147
subscriptions
11211148
end
11221149

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

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6113,7 +6113,12 @@ def string
61136113
# the registry insert and the acknowledgement write, which happens outside the lock.
61146114
io = StringIO.new
61156115
@transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = {
6116-
request_id: "listen-1", stream: io, filter: { toolsListChanged: true }, active: false, write_mutex: Mutex.new
6116+
request_id: "listen-1",
6117+
stream: io,
6118+
filter: { toolsListChanged: true },
6119+
active: false,
6120+
write_mutex: Mutex.new,
6121+
keepalive_wakeup: ConditionVariable.new,
61176122
}
61186123

61196124
@server.notify_tools_list_changed
@@ -6321,7 +6326,7 @@ def string
63216326
end
63226327
stream.define_singleton_method(:flush) {}
63236328
@transport.instance_variable_get(:@listen_subscriptions)["listen-1"] = {
6324-
request_id: "listen-1", stream: stream, filter: {}, write_mutex: Mutex.new,
6329+
request_id: "listen-1", stream: stream, filter: {}, write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
63256330
}
63266331

63276332
@transport.send(:send_listen_keepalive_ping, "listen-1")
@@ -6347,6 +6352,113 @@ def string
63476352
transport.close
63486353
end
63496354

6355+
test "listen keepalive ends with its slot when a delivery write fails" do
6356+
# A long interval: were the thread still sleeping it out after the slot is freed, the join below would time out.
6357+
transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 30)
6358+
before = Thread.list
6359+
io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport)
6360+
keepalive_threads = Thread.list - before
6361+
assert_equal 1, keepalive_threads.size
6362+
wait_until_asleep(keepalive_threads)
6363+
io.define_singleton_method(:write) do |_data|
6364+
raise Errno::EPIPE
6365+
end
6366+
6367+
transport.send_notification("notifications/tools/list_changed", nil, **{})
6368+
6369+
assert_empty transport.instance_variable_get(:@listen_subscriptions)
6370+
assert(keepalive_threads.all? { |thread| thread.join(5) }, "the keepalive thread outlived its freed slot")
6371+
assert_predicate io, :closed?
6372+
ensure
6373+
# Whatever the assertions did, the streams and their threads must not outlive the test.
6374+
transport.close
6375+
6376+
keepalive_threads.each do |thread|
6377+
thread.join(5)
6378+
end
6379+
end
6380+
6381+
test "listen keepalive ends with its slot on transport close" do
6382+
transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 30)
6383+
before = Thread.list
6384+
open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport)
6385+
open_listen_stream(id: "listen-2", notifications: { toolsListChanged: true }, transport: transport)
6386+
keepalive_threads = Thread.list - before
6387+
assert_equal 2, keepalive_threads.size
6388+
wait_until_asleep(keepalive_threads)
6389+
6390+
transport.close
6391+
6392+
assert(keepalive_threads.all? { |thread| thread.join(5) }, "a keepalive thread outlived the transport")
6393+
ensure
6394+
transport.close
6395+
6396+
keepalive_threads.each do |thread|
6397+
thread.join(5)
6398+
end
6399+
end
6400+
6401+
test "listen keepalive writes under the stream's write mutex" do
6402+
# Holding the mutex a delivery or the closing result would hold makes the ping wait its turn,
6403+
# so a comment frame cannot land between the bytes of another message.
6404+
transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 30)
6405+
before = Thread.list
6406+
io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport)
6407+
keepalive_threads = Thread.list - before
6408+
subscription_key, subscription = transport.instance_variable_get(:@listen_subscriptions).first
6409+
written_before = io.string.dup
6410+
6411+
subscription[:write_mutex].lock
6412+
ping_thread = Thread.new { transport.send(:send_listen_keepalive_ping, subscription_key) }
6413+
wait_until_asleep([ping_thread])
6414+
assert_equal written_before, io.string, "the ping must wait for the stream's write mutex"
6415+
subscription[:write_mutex].unlock
6416+
6417+
assert(ping_thread.join(5), "the ping did not finish once the mutex was released")
6418+
assert_match(/\A: ping /, io.string.delete_prefix(written_before))
6419+
ensure
6420+
subscription[:write_mutex].unlock if subscription && subscription[:write_mutex].owned?
6421+
6422+
transport.close
6423+
6424+
keepalive_threads.each do |thread|
6425+
thread.join(5)
6426+
end
6427+
end
6428+
6429+
test "listen keepalive does not write once the transport marked the stream closed" do
6430+
# Teardown marks the entry closed and writes the result under the write mutex; a ping that resolved
6431+
# the entry just before must find the flag and skip, or a comment frame follows the final message.
6432+
transport = StreamableHTTPTransport.new(@server, listen_keepalive_interval: 30)
6433+
before = Thread.list
6434+
io = open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport)
6435+
keepalive_threads = Thread.list - before
6436+
subscription_key, subscription = transport.instance_variable_get(:@listen_subscriptions).first
6437+
written_before = io.string.dup
6438+
subscription[:closed] = true
6439+
6440+
transport.send(:send_listen_keepalive_ping, subscription_key)
6441+
6442+
assert_equal written_before, io.string
6443+
ensure
6444+
transport.close
6445+
6446+
keepalive_threads.each do |thread|
6447+
thread.join(5)
6448+
end
6449+
end
6450+
6451+
# A freshly started keepalive thread may not have reached its wait yet; the removal has to land while
6452+
# the thread is asleep for the test to say anything about waking it. The bound is generous for
6453+
# a starved CI runner while staying far below the 30 second interval these tests use.
6454+
def wait_until_asleep(threads)
6455+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
6456+
until threads.all? { |thread| thread.status == "sleep" }
6457+
flunk("keepalive threads did not reach their wait") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
6458+
sleep(0.005)
6459+
end
6460+
end
6461+
63506462
test "listen keepalive is not started when the interval is nil" do
63516463
# The set of threads, not their count: `Thread.list` is process-wide, so a thread another test left running
63526464
# that finishes between the two samples moves the count in the direction this assertion does not care about.

0 commit comments

Comments
 (0)