|
2 | 2 |
|
3 | 3 | require "json" |
4 | 4 | require "set" |
| 5 | +require "timeout" |
5 | 6 | require_relative "../../result_type" |
6 | 7 | require_relative "../../transport" |
7 | 8 |
|
@@ -188,6 +189,13 @@ class InvalidJsonError < StandardError; end |
188 | 189 | # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its |
189 | 190 | # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`. |
190 | 191 | # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`. |
| 192 | + # @param stream_write_timeout [Numeric, nil] seconds a notification, a server-to-client request, a listen |
| 193 | + # acknowledgement or a keepalive may take to be written to an SSE stream before the stream is dropped as stalled. |
| 194 | + # These writes are made on behalf of every stream in turn, so a peer that stops reading would otherwise hold |
| 195 | + # the writer, and the streams after it, for as long as its buffers stay full. The response of a request is not bounded: |
| 196 | + # it is written on that request's own thread and may be large. The bound is per stream, so several stalled streams delay |
| 197 | + # a notification by the sum of their waits, once each. Closing a stream is given the same number of seconds, once. |
| 198 | + # Defaults to `DEFAULT_STREAM_WRITE_TIMEOUT` (10); pass `nil` to wait without limit. |
191 | 199 | # @param token_verifier [#verify, nil] enables built-in OAuth 2.1 bearer enforcement: anything responding to |
192 | 200 | # `verify(token) -> MCP::Server::OAuth::AccessToken | nil` (see `MCP::Server::OAuth::TokenVerifier` for |
193 | 201 | # the contract and the built-in JWT/introspection implementations). Every POST, GET, and DELETE is verified |
@@ -227,6 +235,7 @@ def initialize( |
227 | 235 | listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL, |
228 | 236 | serve_subscriptions_listen: true, |
229 | 237 | server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT, |
| 238 | + stream_write_timeout: DEFAULT_STREAM_WRITE_TIMEOUT, |
230 | 239 | token_verifier: nil, |
231 | 240 | required_scopes: [], |
232 | 241 | resource_metadata: nil, |
@@ -325,6 +334,15 @@ def initialize( |
325 | 334 |
|
326 | 335 | @server_to_client_request_timeout = server_to_client_request_timeout |
327 | 336 |
|
| 337 | + unless stream_write_timeout.nil? || (stream_write_timeout.is_a?(Numeric) && stream_write_timeout.positive?) |
| 338 | + raise ArgumentError, "stream_write_timeout must be a positive number or nil" |
| 339 | + end |
| 340 | + |
| 341 | + @stream_write_timeout = stream_write_timeout |
| 342 | + |
| 343 | + # Keyed by identity and weak, so a stream is not kept alive for having been recorded here. |
| 344 | + @streams_with_abandoned_close = ObjectSpace::WeakMap.new |
| 345 | + |
328 | 346 | if token_verifier |
329 | 347 | @oauth_authenticator = OAuth::Authenticator.new( |
330 | 348 | token_verifier: token_verifier, |
@@ -358,6 +376,14 @@ def initialize( |
358 | 376 | REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze |
359 | 377 | REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze |
360 | 378 | STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze |
| 379 | + |
| 380 | + # Raised from a write the transport pushes onto an SSE stream when it has not finished within `stream_write_timeout:` seconds. |
| 381 | + # An `IOError`, so every path that drops a stream on a failed write drops this one the same way. |
| 382 | + class StreamWriteTimeoutError < IOError; end |
| 383 | + |
| 384 | + # A write blocks only once the peer's socket buffers are full, which takes a reader already hundreds of kilobytes behind; |
| 385 | + # one that still has not taken the frame after this long is dropped whether it stopped reading or is only that slow. |
| 386 | + DEFAULT_STREAM_WRITE_TIMEOUT = 10 |
361 | 387 | SESSION_REAP_INTERVAL = 60 |
362 | 388 | # Seconds `close` waits for a reap in progress to finish closing its streams before it kills the reaper. |
363 | 389 | REAPER_JOIN_TIMEOUT = 5 |
@@ -633,7 +659,7 @@ def deliver_broadcast_notification(notification) |
633 | 659 | # Writes a notification to an SSE stream without holding `@mutex`. On a write error, |
634 | 660 | # drops the broken stream and returns false; on success returns true. |
635 | 661 | def write_notification(stream, notification, session_id, related_request_id) |
636 | | - send_to_stream(stream, notification) |
| 662 | + push_to_stream(stream, notification) |
637 | 663 | true |
638 | 664 | rescue *STREAM_WRITE_ERRORS => e |
639 | 665 | MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" }) |
@@ -736,7 +762,7 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil, |
736 | 762 | sent = false |
737 | 763 | if stream |
738 | 764 | begin |
739 | | - send_to_stream(stream, request) |
| 765 | + push_to_stream(stream, request) |
740 | 766 | sent = true |
741 | 767 | rescue *STREAM_WRITE_ERRORS |
742 | 768 | drop_broken_stream(session_id, stream, related_request_id) |
@@ -880,6 +906,41 @@ def send_ping_to_stream(stream) |
880 | 906 | stream.flush |
881 | 907 | end |
882 | 908 |
|
| 909 | + # `send_to_stream` and `send_ping_to_stream` for the frames the transport pushes onto a stream on its own: a notification, |
| 910 | + # a server-to-client request, a listen acknowledgement, a keepalive. One thread writes them to stream after stream, |
| 911 | + # and a keepalive write holds its stream's write mutex, so a peer that stopped reading would stall the notifications of |
| 912 | + # every other client, and each thread that sends one, for as long as its buffers stay full. |
| 913 | + # The write is given `stream_write_timeout:` seconds; past that it fails as a write to a broken stream does, and the caller drops the stream. |
| 914 | + # |
| 915 | + # The deadline may cut a frame short, so the stream is closed here, before the error leaves: a listen stream is written under its write mutex, |
| 916 | + # which the error releases on its way out, ahead of the caller's removal of the entry, and a writer waiting on that mutex would otherwise put |
| 917 | + # its frame after the cut one. Closed first, every later write fails as on any closed stream. The entry of a listen stream, passed as `subscription`, |
| 918 | + # is marked closed as well, still under its write mutex, so those writers skip it even when the close itself had to be abandoned. |
| 919 | + # |
| 920 | + # |
| 921 | + # The deadline interrupts the write with the exception `Timeout` raises on its own, which no `rescue` of an `IOError` or a `StandardError` inside |
| 922 | + # a stream implementation catches, and becomes `StreamWriteTimeoutError` only here, where it leaves the write. |
| 923 | + def push_to_stream(stream, data, subscription: nil) |
| 924 | + within_stream_write_timeout(stream, subscription) { send_to_stream(stream, data) } |
| 925 | + end |
| 926 | + |
| 927 | + def push_ping_to_stream(stream, subscription: nil) |
| 928 | + within_stream_write_timeout(stream, subscription) { send_ping_to_stream(stream) } |
| 929 | + end |
| 930 | + |
| 931 | + def within_stream_write_timeout(stream, subscription, &block) |
| 932 | + return yield unless @stream_write_timeout |
| 933 | + |
| 934 | + begin |
| 935 | + Timeout.timeout(@stream_write_timeout, &block) |
| 936 | + rescue Timeout::Error |
| 937 | + subscription[:closed] = true if subscription |
| 938 | + close_stream_safely(stream) |
| 939 | + |
| 940 | + raise StreamWriteTimeoutError, "SSE stream write did not finish within #{@stream_write_timeout} seconds" |
| 941 | + end |
| 942 | + end |
| 943 | + |
883 | 944 | # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575): |
884 | 945 | # a single POST/JSON exchange with no session. The modern path never consults |
885 | 946 | # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts |
@@ -1284,7 +1345,7 @@ def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil) |
1284 | 1345 | acknowledged = subscription[:write_mutex].synchronize do |
1285 | 1346 | next false if subscription[:closed] |
1286 | 1347 |
|
1287 | | - send_to_stream(stream, acknowledgement) |
| 1348 | + push_to_stream(stream, acknowledgement, subscription: subscription) |
1288 | 1349 |
|
1289 | 1350 | # Set on the entry itself, not through the registry: a concurrent close may already have cleared the registry |
1290 | 1351 | # while its result write waits on this mutex, and that write must still find the stream acknowledged. |
@@ -1365,7 +1426,7 @@ def send_listen_keepalive_ping(subscription_key) |
1365 | 1426 | subscription[:write_mutex].synchronize do |
1366 | 1427 | next if subscription[:closed] |
1367 | 1428 |
|
1368 | | - send_ping_to_stream(subscription[:stream]) |
| 1429 | + push_ping_to_stream(subscription[:stream], subscription: subscription) |
1369 | 1430 | end |
1370 | 1431 | end |
1371 | 1432 |
|
@@ -1507,7 +1568,7 @@ def deliver_to_listen_subscriptions(method, params) |
1507 | 1568 | subscription[:write_mutex].synchronize do |
1508 | 1569 | next if subscription[:closed] |
1509 | 1570 |
|
1510 | | - send_to_stream(subscription[:stream], notification) |
| 1571 | + push_to_stream(subscription[:stream], notification, subscription: subscription) |
1511 | 1572 | end |
1512 | 1573 | rescue *STREAM_WRITE_ERRORS => e |
1513 | 1574 | MCP.configuration.exception_reporter.call( |
@@ -1597,6 +1658,10 @@ def teardown_listen_subscriptions(timeout: LISTEN_TEARDOWN_TIMEOUT) |
1597 | 1658 | # The stream is left open for the caller to close. |
1598 | 1659 | def write_listen_closing_result(subscription) |
1599 | 1660 | subscription[:write_mutex].synchronize do |
| 1661 | + # Already marked by a write that ran into its deadline: the last frame on the stream may be cut short, |
| 1662 | + # and the stream may still be open because its close was abandoned, so the result must not follow it. |
| 1663 | + next if subscription[:closed] |
| 1664 | + |
1600 | 1665 | subscription[:closed] = true |
1601 | 1666 |
|
1602 | 1667 | # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes |
@@ -1905,8 +1970,21 @@ def cleanup_and_collect_stream(session_id, streams_to_close) |
1905 | 1970 | removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream } |
1906 | 1971 | end |
1907 | 1972 |
|
| 1973 | + # A close can block on the same full buffers a write does (a TLS stream writes its closing alert, a buffered one flushes), |
| 1974 | + # and it runs on the threads that deliver to every stream, in places under a stream's write mutex, |
| 1975 | + # so it gets `stream_write_timeout:` seconds as well. One that has not finished by then is abandoned, |
| 1976 | + # and not tried again: the path that drops a stream after its write timed out closes it a second time, |
| 1977 | + # which would wait out the same block once more. |
1908 | 1978 | def close_stream_safely(stream) |
1909 | | - stream&.close |
| 1979 | + return unless stream |
| 1980 | + return stream.close unless @stream_write_timeout |
| 1981 | + return if @streams_with_abandoned_close.key?(stream) |
| 1982 | + |
| 1983 | + begin |
| 1984 | + Timeout.timeout(@stream_write_timeout) { stream.close } |
| 1985 | + rescue Timeout::Error |
| 1986 | + @streams_with_abandoned_close[stream] = true |
| 1987 | + end |
1910 | 1988 | rescue StandardError |
1911 | 1989 | # Ignore close-related errors from already closed/broken streams. |
1912 | 1990 | end |
@@ -2728,7 +2806,7 @@ def detach_get_stream(session) |
2728 | 2806 | def send_keepalive_ping(session_id, stream) |
2729 | 2807 | return unless session_holds_stream?(session_id, stream) |
2730 | 2808 |
|
2731 | | - send_ping_to_stream(stream) |
| 2809 | + push_ping_to_stream(stream) |
2732 | 2810 | rescue *STREAM_WRITE_ERRORS => e |
2733 | 2811 | MCP.configuration.exception_reporter.call( |
2734 | 2812 | e, |
|
0 commit comments