11# frozen_string_literal: true
22
33require "json"
4+ require "set"
45require_relative "../../result_type"
56require_relative "../../transport"
67
@@ -48,6 +49,20 @@ class InvalidJsonError < StandardError; end
4849 # pass `max_listen_subscriptions: nil` to opt out.
4950 DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
5051
52+ # Bound on the bytes of resource URIs one `subscriptions/listen` request may name in `resourceSubscriptions`
53+ # (the sum of their byte lengths after duplicates are dropped). The URIs are held for the life of the stream,
54+ # so without a bound each stream could retain a request body's worth of URIs and the retained total would be
55+ # `max_listen_subscriptions` times `max_request_bytes`. 64 KiB names about a thousand ordinary URIs;
56+ # pass `max_resource_subscription_bytes: nil` to opt out. The specification sets no limit, nor do the TypeScript
57+ # and Python SDKs; this is the same kind of local safety limit as `max_request_bytes`.
58+ DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES = 64 * 1024
59+
60+ # Bound on the number of distinct resource URIs one `subscriptions/listen` request may name. A byte bound does
61+ # not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however
62+ # short it is, so the count is bounded as well. Like `MAX_JSON_NESTING`, it is a structural limit applied
63+ # whatever `max_resource_subscription_bytes` is, including `nil`.
64+ MAX_RESOURCE_SUBSCRIPTION_URIS = 1_024
65+
5166 # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
5267 UNSET_IDLE_TIMEOUT = Object . new . freeze
5368 private_constant :UNSET_IDLE_TIMEOUT
@@ -125,6 +140,10 @@ class InvalidJsonError < StandardError; end
125140 # @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
126141 # streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
127142 # the cap.
143+ # @param max_resource_subscription_bytes [Integer, nil] bound on the total byte length of the distinct
144+ # resource URIs one `subscriptions/listen` request names in `resourceSubscriptions`; a request over
145+ # the bound is rejected with HTTP 400 and JSON-RPC `-32602`.
146+ # Defaults to `DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES` (64 KiB), and `nil` disables the bound.
128147 # @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
129148 # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
130149 # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
@@ -150,6 +169,7 @@ def initialize(
150169 session_request_validator : nil ,
151170 max_request_bytes : DEFAULT_MAX_REQUEST_BYTES ,
152171 max_listen_subscriptions : DEFAULT_MAX_LISTEN_SUBSCRIPTIONS ,
172+ max_resource_subscription_bytes : DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES ,
153173 listen_keepalive_interval : DEFAULT_LISTEN_KEEPALIVE_INTERVAL ,
154174 serve_subscriptions_listen : true ,
155175 server_to_client_request_timeout : DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
@@ -170,8 +190,9 @@ def initialize(
170190 @pending_responses = { }
171191
172192 # Maps a key the transport mints for each `subscriptions/listen` stream to
173- # `{ request_id: listen_request_id, stream: stream_object, filter: honored_subscription_filter, active: boolean,
174- # write_mutex: Mutex, keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
193+ # `{ request_id: listen_request_id, stream: stream_object, filter: honored_list_changed_flags,
194+ # resource_uris: Set_of_honored_resource_uris, active: boolean, write_mutex: Mutex,
195+ # keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
175196 # unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
176197 # two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
177198 # so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
@@ -218,6 +239,12 @@ def initialize(
218239
219240 @max_listen_subscriptions = max_listen_subscriptions
220241
242+ if !max_resource_subscription_bytes . nil? && !( max_resource_subscription_bytes . is_a? ( Integer ) && max_resource_subscription_bytes > 0 )
243+ raise ArgumentError , "max_resource_subscription_bytes must be a positive Integer or nil"
244+ end
245+
246+ @max_resource_subscription_bytes = max_resource_subscription_bytes
247+
221248 if !listen_keepalive_interval . nil? && !( listen_keepalive_interval . is_a? ( Numeric ) && listen_keepalive_interval > 0 )
222249 raise ArgumentError , "listen_keepalive_interval must be a positive number or nil"
223250 end
@@ -876,6 +903,15 @@ def handle_subscriptions_listen(body)
876903 )
877904 end
878905
906+ if ( problem = resource_subscriptions_problem ( filter ) )
907+ return json_rpc_error_response (
908+ status : 400 ,
909+ code : JsonRpcHandler ::ErrorCode ::INVALID_PARAMS ,
910+ message : "Invalid params: subscriptions/listen #{ problem } " ,
911+ id : request_id ,
912+ )
913+ end
914+
879915 # Best-effort cap check before committing to the SSE response; the registration inside
880916 # `listen_sse_body` re-checks atomically for the race between two concurrent listens
881917 # crossing the cap together.
@@ -946,10 +982,18 @@ def listen_sse_body(request_id, honored)
946982 ListenStreamBody . new do |stream |
947983 subscription_key = SecureRandom . uuid
948984 subscription = nil
985+ # The entry keeps the list-changed flags of the honored filter and the URIs as a frozen Set, both built ahead of the registry lock:
986+ # delivery looks each notification's URI up in constant time, so neither the matching cost nor the lock hold grows with
987+ # the number of URIs a stream named. The entry does not keep the Array the acknowledgement echoes; that one lives only as long as
988+ # the host keeps this response body.
989+ entry_filter = honored . reject { |name , _value | name == :resourceSubscriptions }
990+ resource_uris = Set . new ( honored [ :resourceSubscriptions ] || [ ] ) . freeze
991+
949992 @mutex . synchronize do
950993 unless @max_listen_subscriptions && @listen_subscriptions . size >= @max_listen_subscriptions
951994 subscription = {
952- request_id : request_id , stream : stream , filter : honored , active : false , write_mutex : Mutex . new , keepalive_wakeup : ConditionVariable . new
995+ request_id : request_id , stream : stream , filter : entry_filter , resource_uris : resource_uris , active : false ,
996+ write_mutex : Mutex . new , keepalive_wakeup : ConditionVariable . new ,
953997 }
954998 @listen_subscriptions [ subscription_key ] = subscription
955999 end
@@ -1068,12 +1112,36 @@ def honored_filter(filter)
10681112
10691113 subscriptions = filter [ :resourceSubscriptions ]
10701114 if capability_flag? ( capabilities , :resources , :subscribe ) && subscriptions . is_a? ( Array ) && !subscriptions . empty?
1071- honored [ :resourceSubscriptions ] = subscriptions
1115+ honored [ :resourceSubscriptions ] = subscriptions . uniq
10721116 end
10731117
10741118 honored
10751119 end
10761120
1121+ # Checks a listen filter's `resourceSubscriptions` against the schema, which types it as an optional array of strings,
1122+ # so a member that is present but `null` is refused like any other non-array; against `MAX_RESOURCE_SUBSCRIPTION_URIS`;
1123+ # and against `max_resource_subscription_bytes`, returning the problem to report or `nil`. Duplicates are dropped before
1124+ # the bounds are measured, since they are dropped before storage too. The checks run whether or not the server declares
1125+ # the `subscribe` capability: a request that violates the schema is invalid regardless of what would be honored,
1126+ # and the bounds are limits on the request itself, like `max_request_bytes`, not on what the server would retain from it.
1127+ def resource_subscriptions_problem ( filter )
1128+ return unless filter . key? ( :resourceSubscriptions )
1129+
1130+ subscriptions = filter [ :resourceSubscriptions ]
1131+ unless subscriptions . is_a? ( Array ) && subscriptions . all? { |uri | uri . is_a? ( String ) }
1132+ return "`resourceSubscriptions` must be an array of strings"
1133+ end
1134+
1135+ distinct = subscriptions . uniq
1136+ if distinct . size > MAX_RESOURCE_SUBSCRIPTION_URIS
1137+ return "`resourceSubscriptions` exceeds #{ MAX_RESOURCE_SUBSCRIPTION_URIS } URIs"
1138+ end
1139+ return unless @max_resource_subscription_bytes
1140+ return if distinct . sum ( &:bytesize ) <= @max_resource_subscription_bytes
1141+
1142+ "`resourceSubscriptions` exceeds #{ @max_resource_subscription_bytes } bytes"
1143+ end
1144+
10771145 # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
10781146 # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
10791147 # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
@@ -1091,24 +1159,16 @@ def deliver_to_listen_subscriptions(method, params)
10911159 field = LISTEN_FILTER_FIELDS [ method ]
10921160 return if field . nil? && method != Methods ::NOTIFICATIONS_RESOURCES_UPDATED
10931161
1094- # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
1095- # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1096- matched = @mutex . synchronize do
1097- @listen_subscriptions . filter_map do |subscription_key , subscription |
1098- # An inactive entry has not finished writing its acknowledgement yet;
1099- # delivering to it would put a notification ahead of the acknowledgement.
1100- next unless subscription [ :active ]
1101-
1102- hit = if field
1103- subscription [ :filter ] [ field ]
1104- else
1105- uris = subscription [ :filter ] [ :resourceSubscriptions ]
1106- uri = params . is_a? ( Hash ) ? params [ :uri ] || params [ "uri" ] : nil
1107- uris . is_a? ( Array ) && uris . include? ( uri )
1108- end
1162+ # Only the snapshot of active entries is taken under `@mutex`, one pass over the entries as before;
1163+ # the matching and the stream writes happen outside it, so neither the number of URIs the streams named
1164+ # nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
1165+ # An inactive entry has not finished writing its acknowledgement yet;
1166+ # delivering to it would put a notification ahead of the acknowledgement.
1167+ candidates = @mutex . synchronize { @listen_subscriptions . select { |_key , subscription | subscription [ :active ] } }
11091168
1110- [ subscription_key , subscription ] if hit
1111- end
1169+ uri = params . is_a? ( Hash ) ? params [ :uri ] || params [ "uri" ] : nil
1170+ matched = candidates . select do |_key , subscription |
1171+ field ? subscription [ :filter ] [ field ] : subscription [ :resource_uris ] . include? ( uri )
11121172 end
11131173
11141174 matched . each do |subscription_key , subscription |
0 commit comments