Skip to content

Commit b56d007

Browse files
authored
Merge pull request #569 from koic/end_listen_keepalive_threads_with_their_slot
End a listen stream's keepalive thread when its slot is freed
2 parents 6da3009 + e02c9e7 commit b56d007

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)