Skip to content

Commit e81196e

Browse files
authored
Merge pull request #449 from koic/avoid_blocking_sse_writes_under_session_mutex
Perform SSE stream writes outside the session mutex
2 parents a1b7b00 + 5b935eb commit e81196e

2 files changed

Lines changed: 200 additions & 77 deletions

File tree

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 106 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -196,80 +196,107 @@ def send_notification(method, params = nil, session_id: nil, related_request_id:
196196
}
197197
notification[:params] = params if params
198198

199+
if session_id
200+
deliver_targeted_notification(notification, session_id, related_request_id)
201+
else
202+
deliver_broadcast_notification(notification)
203+
end
204+
end
205+
206+
def deliver_targeted_notification(notification, session_id, related_request_id)
207+
# JSON response mode returns a single JSON object as the POST response,
208+
# so request-scoped notifications (e.g. progress, log) cannot be delivered
209+
# alongside it. Session-scoped standalone notifications
210+
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
211+
return false if @enable_json_response && related_request_id
212+
199213
streams_to_close = []
200214

201-
result = @mutex.synchronize do
202-
if session_id
203-
# JSON response mode returns a single JSON object as the POST response,
204-
# so request-scoped notifications (e.g. progress, log) cannot be delivered
205-
# alongside it. Session-scoped standalone notifications
206-
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
207-
next false if @enable_json_response && related_request_id
208-
209-
# Send to specific session
210-
if (session = @sessions[session_id])
211-
stream = active_stream(session, related_request_id: related_request_id)
212-
end
213-
next false unless stream
215+
# Resolve the target stream under the lock, then write outside it: a stalled SSE reader
216+
# must not block every other session that needs `@mutex`.
217+
stream = @mutex.synchronize do
218+
next unless (session = @sessions[session_id])
219+
220+
if session_expired?(session)
221+
cleanup_and_collect_stream(session_id, streams_to_close)
222+
next
223+
end
224+
225+
active_stream(session, related_request_id: related_request_id)
226+
end
227+
228+
close_streams(streams_to_close)
229+
return false unless stream
230+
231+
write_notification(stream, notification, session_id, related_request_id)
232+
end
233+
234+
def deliver_broadcast_notification(notification)
235+
streams_to_close = []
236+
237+
# Snapshot the connected streams under the lock, then write to each outside it.
238+
targets = @mutex.synchronize do
239+
expired_session_ids = []
240+
241+
collected = @sessions.filter_map do |session_id, session|
242+
next unless (stream = session[:get_sse_stream])
214243

215244
if session_expired?(session)
216-
cleanup_and_collect_stream(session_id, streams_to_close)
217-
next false
245+
expired_session_ids << session_id
246+
next
218247
end
219248

220-
begin
221-
send_to_stream(stream, notification)
222-
true
223-
rescue *STREAM_WRITE_ERRORS => e
224-
MCP.configuration.exception_reporter.call(
225-
e,
226-
{ session_id: session_id, error: "Failed to send notification" },
227-
)
228-
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
229-
session[:post_request_streams].delete(related_request_id)
230-
streams_to_close << stream
231-
else
232-
cleanup_and_collect_stream(session_id, streams_to_close)
233-
end
234-
false
235-
end
236-
else
237-
# Broadcast to all connected SSE sessions
238-
sent_count = 0
239-
failed_sessions = []
249+
[session_id, stream]
250+
end
240251

241-
@sessions.each do |sid, session|
242-
next unless (stream = session[:get_sse_stream])
252+
expired_session_ids.each do |session_id|
253+
cleanup_and_collect_stream(session_id, streams_to_close)
254+
end
243255

244-
if session_expired?(session)
245-
failed_sessions << sid
246-
next
247-
end
256+
collected
257+
end
248258

249-
begin
250-
send_to_stream(stream, notification)
251-
sent_count += 1
252-
rescue *STREAM_WRITE_ERRORS => e
253-
MCP.configuration.exception_reporter.call(
254-
e,
255-
{ session_id: sid, error: "Failed to send notification" },
256-
)
257-
failed_sessions << sid
258-
end
259-
end
259+
close_streams(streams_to_close)
260260

261-
# Clean up failed sessions
262-
failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
261+
targets.count do |session_id, stream|
262+
write_notification(stream, notification, session_id, nil)
263+
end
264+
end
265+
266+
# Writes a notification to an SSE stream without holding `@mutex`. On a write error,
267+
# drops the broken stream and returns false; on success returns true.
268+
def write_notification(stream, notification, session_id, related_request_id)
269+
send_to_stream(stream, notification)
270+
true
271+
rescue *STREAM_WRITE_ERRORS => e
272+
MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
273+
drop_broken_stream(session_id, stream, related_request_id)
274+
false
275+
end
276+
277+
# Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
278+
# a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
279+
# under `@mutex`, and the affected streams are closed outside it.
280+
def drop_broken_stream(session_id, stream, related_request_id)
281+
streams_to_close = []
263282

264-
sent_count
283+
@mutex.synchronize do
284+
session = @sessions[session_id]
285+
if related_request_id && session&.dig(:post_request_streams, related_request_id)
286+
session[:post_request_streams].delete(related_request_id)
287+
streams_to_close << stream
288+
else
289+
cleanup_and_collect_stream(session_id, streams_to_close)
265290
end
266291
end
267292

268-
streams_to_close.each do |stream|
293+
close_streams(streams_to_close)
294+
end
295+
296+
def close_streams(streams)
297+
streams.each do |stream|
269298
close_stream_safely(stream)
270299
end
271-
272-
result
273300
end
274301

275302
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
@@ -299,27 +326,25 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil,
299326
request = { jsonrpc: "2.0", id: request_id, method: method }
300327
request[:params] = params if params
301328

302-
sent = false
303-
304-
@mutex.synchronize do
329+
# Register the pending response and resolve the stream under the lock, but perform
330+
# the write outside it so a stalled reader cannot block every other session on `@mutex`.
331+
stream = @mutex.synchronize do
305332
unless (session = @sessions[session_id])
306333
raise "Session not found: #{session_id}."
307334
end
308335

309336
@pending_responses[request_id] = { queue: queue, session_id: session_id }
310337

311-
if (stream = active_stream(session, related_request_id: related_request_id))
312-
begin
313-
send_to_stream(stream, request)
314-
sent = true
315-
rescue *STREAM_WRITE_ERRORS
316-
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
317-
session[:post_request_streams].delete(related_request_id)
318-
close_stream_safely(stream)
319-
else
320-
cleanup_session_unsafe(session_id)
321-
end
322-
end
338+
active_stream(session, related_request_id: related_request_id)
339+
end
340+
341+
sent = false
342+
if stream
343+
begin
344+
send_to_stream(stream, request)
345+
sent = true
346+
rescue *STREAM_WRITE_ERRORS
347+
drop_broken_stream(session_id, stream, related_request_id)
323348
end
324349
end
325350

@@ -1164,11 +1189,15 @@ def session_active_with_stream?(session_id)
11641189
end
11651190

11661191
def send_keepalive_ping(session_id)
1167-
@mutex.synchronize do
1168-
if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
1169-
send_ping_to_stream(@sessions[session_id][:get_sse_stream])
1170-
end
1192+
# Resolve the stream under the lock, then write outside it so a stalled reader
1193+
# cannot block every other session on `@mutex`.
1194+
stream = @mutex.synchronize do
1195+
session = @sessions[session_id]
1196+
session && session[:get_sse_stream]
11711197
end
1198+
return unless stream
1199+
1200+
send_ping_to_stream(stream)
11721201
rescue *STREAM_WRITE_ERRORS => e
11731202
MCP.configuration.exception_reporter.call(
11741203
e,

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,62 @@ def string
13371337
assert @transport.instance_variable_get(:@sessions).key?(session_id2)
13381338
end
13391339

1340+
test "send_notification to a session writes outside the mutex" do
1341+
session_id = initialize_test_session
1342+
writes = install_mutex_probe_stream(session_id)
1343+
1344+
result = @transport.send_notification("test", { message: "hi" }, session_id: session_id)
1345+
1346+
assert result
1347+
assert_equal 1, writes[:total]
1348+
assert_equal 0, writes[:under_mutex], "notification write must not hold @mutex"
1349+
end
1350+
1351+
test "send_notification broadcast writes outside the mutex" do
1352+
session_id1 = initialize_test_session(id: "1")
1353+
session_id2 = initialize_test_session(id: "2")
1354+
writes1 = install_mutex_probe_stream(session_id1)
1355+
writes2 = install_mutex_probe_stream(session_id2)
1356+
1357+
sent_count = @transport.send_notification("broadcast", { message: "hi" }, **{})
1358+
1359+
assert_equal 2, sent_count
1360+
assert_equal 0, writes1[:under_mutex], "broadcast write must not hold @mutex"
1361+
assert_equal 0, writes2[:under_mutex], "broadcast write must not hold @mutex"
1362+
end
1363+
1364+
test "send_keepalive_ping writes outside the mutex" do
1365+
session_id = initialize_test_session
1366+
writes = install_mutex_probe_stream(session_id)
1367+
1368+
@transport.send(:send_keepalive_ping, session_id)
1369+
1370+
assert_equal 1, writes[:total]
1371+
assert_equal 0, writes[:under_mutex], "keepalive write must not hold @mutex"
1372+
end
1373+
1374+
test "send_request writes outside the mutex" do
1375+
session_id = initialize_test_session
1376+
writes = install_mutex_probe_stream(session_id)
1377+
1378+
result_queue = Queue.new
1379+
thread = Thread.new do
1380+
result_queue.push(@transport.send_request("ping", nil, session_id: session_id))
1381+
end
1382+
1383+
# Wait until the request has been written to the probe stream.
1384+
Thread.pass until writes[:total].positive?
1385+
1386+
request_id = @transport.instance_variable_get(:@pending_responses).keys.first
1387+
@transport.send(:handle_response, { jsonrpc: "2.0", id: request_id, result: {} }, session_id: session_id)
1388+
1389+
result_queue.pop
1390+
thread.join
1391+
1392+
assert_equal 1, writes[:total]
1393+
assert_equal 0, writes[:under_mutex], "server-to-client request write must not hold @mutex"
1394+
end
1395+
13401396
test "send_keepalive_ping handles Errno::ECONNRESET gracefully" do
13411397
# Create and initialize a session.
13421398
init_request = create_rack_request(
@@ -5028,6 +5084,44 @@ def string
50285084

50295085
private
50305086

5087+
def initialize_test_session(id: "init")
5088+
init_request = create_rack_request(
5089+
"POST",
5090+
"/",
5091+
{ "CONTENT_TYPE" => "application/json" },
5092+
{ jsonrpc: "2.0", method: "initialize", id: id }.to_json,
5093+
)
5094+
@transport.handle_request(init_request)[1]["Mcp-Session-Id"]
5095+
end
5096+
5097+
# Installs a stream that records, on every write, whether `@mutex` was held at that moment.
5098+
# `mutex.try_lock` fails while the transport still holds the lock, so a nonzero `writes[:under_mutex]`
5099+
# means a blocking write is being performed under the lock.
5100+
def install_mutex_probe_stream(session_id, related_request_id: nil)
5101+
mutex = @transport.instance_variable_get(:@mutex)
5102+
writes = { total: 0, under_mutex: 0 }
5103+
stream = Object.new
5104+
stream.define_singleton_method(:write) do |_data|
5105+
writes[:total] += 1
5106+
if mutex.try_lock
5107+
mutex.unlock
5108+
else
5109+
writes[:under_mutex] += 1
5110+
end
5111+
end
5112+
stream.define_singleton_method(:flush) {}
5113+
stream.define_singleton_method(:close) {}
5114+
5115+
session = @transport.instance_variable_get(:@sessions)[session_id]
5116+
if related_request_id
5117+
(session[:post_request_streams] ||= {})[related_request_id] = stream
5118+
else
5119+
session[:get_sse_stream] = stream
5120+
end
5121+
5122+
writes
5123+
end
5124+
50315125
def create_rack_request(method, path, headers, body = nil)
50325126
default_accept = case method
50335127
when "POST"

0 commit comments

Comments
 (0)