ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
# Pinning a request to the server session it belongs to: a payload whose
|
|
7
|
+
# meaning is session-scoped (task ids, input request keys) must never be
|
|
8
|
+
# written into the session that replaced the one it was built in, and the
|
|
9
|
+
# transports establish (and so may re-establish) their session inside the
|
|
10
|
+
# very request that carries it. Mixed into the JSON-RPC transports, which
|
|
11
|
+
# call {#check_session_pin!} immediately before the wire.
|
|
12
|
+
module SessionPin
|
|
13
|
+
# Fiber-local key of the session pins in effect (see #pinned_to_session).
|
|
14
|
+
SESSION_PINS = :mcp_client_session_pins
|
|
15
|
+
# Fiber-local key of the extra pre-write guards (see #guarded_writes).
|
|
16
|
+
WRITE_GUARDS = :mcp_client_write_guards
|
|
17
|
+
|
|
18
|
+
# Run the block with every request this thread sends through this server
|
|
19
|
+
# pinned to `epoch` (a {MCPClient::ServerBase#session_epoch} reading):
|
|
20
|
+
# the transport refuses to write once that session has ended, however the
|
|
21
|
+
# reconnect that ended it got in — a lazy `ensure_initialized` /
|
|
22
|
+
# `ensure_connected` inside the very request, a transport retry, a
|
|
23
|
+
# concurrent cleanup. A session-scoped payload (the tasks extension's
|
|
24
|
+
# `inputResponses`, whose task ids and input keys are per session and
|
|
25
|
+
# reusable) must never reach the session that replaced the one it was
|
|
26
|
+
# built in, where it could answer an unrelated request.
|
|
27
|
+
# @param epoch [Integer, nil] the session the requests belong to (nil: no pin)
|
|
28
|
+
# @return [Object] the block's value
|
|
29
|
+
def pinned_to_session(epoch)
|
|
30
|
+
return yield if epoch.nil?
|
|
31
|
+
|
|
32
|
+
previous = Thread.current[SESSION_PINS]
|
|
33
|
+
pins = {}.compare_by_identity
|
|
34
|
+
previous&.each { |server, pinned| pins[server] = pinned }
|
|
35
|
+
pins[self] = epoch
|
|
36
|
+
Thread.current[SESSION_PINS] = pins
|
|
37
|
+
begin
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
Thread.current[SESSION_PINS] = previous
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Run the block with `guard` called immediately before every request this
|
|
45
|
+
# thread writes through this server, at the very point the session pin is
|
|
46
|
+
# checked (see {#check_session_pin!}). A request whose payload a
|
|
47
|
+
# concurrent answer can invalidate — the tasks extension's task ids, which
|
|
48
|
+
# a fresh CreateTaskResult hands to a different task — is guarded there
|
|
49
|
+
# rather than before the request is built: everything the client records
|
|
50
|
+
# up to the wire is seen, so a decision taken earlier cannot leave the
|
|
51
|
+
# request going out for a task that no longer exists. Only one guard is in
|
|
52
|
+
# force per server; a nested one replaces it for the duration of its block.
|
|
53
|
+
# @param guard [#call] raises to refuse the write
|
|
54
|
+
# @return [Object] the block's value
|
|
55
|
+
def guarded_writes(guard)
|
|
56
|
+
previous = Thread.current[WRITE_GUARDS]
|
|
57
|
+
guards = {}.compare_by_identity
|
|
58
|
+
previous&.each { |server, guarded| guards[server] = guarded }
|
|
59
|
+
guards[self] = guard
|
|
60
|
+
Thread.current[WRITE_GUARDS] = guards
|
|
61
|
+
begin
|
|
62
|
+
yield
|
|
63
|
+
ensure
|
|
64
|
+
Thread.current[WRITE_GUARDS] = previous
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Run the block with this server's pin — and its pre-write guard — lifted
|
|
69
|
+
# for this thread: the request that establishes the session replacing an
|
|
70
|
+
# ended one is not part of the session it replaces, and the pin (whose
|
|
71
|
+
# epoch the end of that session has just invalidated) would otherwise
|
|
72
|
+
# refuse the very handshake the caller is in the middle of performing.
|
|
73
|
+
# @return [Object] the block's value
|
|
74
|
+
def unpinned_session
|
|
75
|
+
previous = Thread.current[SESSION_PINS]
|
|
76
|
+
guarded = Thread.current[WRITE_GUARDS]
|
|
77
|
+
return yield if (previous.nil? || !previous.key?(self)) && (guarded.nil? || !guarded.key?(self))
|
|
78
|
+
|
|
79
|
+
Thread.current[SESSION_PINS] = without_self(previous)
|
|
80
|
+
Thread.current[WRITE_GUARDS] = without_self(guarded)
|
|
81
|
+
begin
|
|
82
|
+
yield
|
|
83
|
+
ensure
|
|
84
|
+
Thread.current[SESSION_PINS] = previous
|
|
85
|
+
Thread.current[WRITE_GUARDS] = guarded
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Refuse a request whose session has ended (see #pinned_to_session), or
|
|
90
|
+
# which the caller's own guard turns down (see #guarded_writes).
|
|
91
|
+
# Transports call this as late as they can, immediately before the
|
|
92
|
+
# request goes on the wire, so nothing of an ended session is written.
|
|
93
|
+
# @return [void]
|
|
94
|
+
# @raise [MCPClient::Errors::SessionChangedError]
|
|
95
|
+
def check_session_pin!
|
|
96
|
+
Thread.current[WRITE_GUARDS]&.[](self)&.call
|
|
97
|
+
pinned = Thread.current[SESSION_PINS]&.[](self)
|
|
98
|
+
return if pinned.nil?
|
|
99
|
+
|
|
100
|
+
current = respond_to?(:session_epoch) ? session_epoch : nil
|
|
101
|
+
return if current.nil? || current == pinned
|
|
102
|
+
|
|
103
|
+
raise MCPClient::Errors::SessionChangedError,
|
|
104
|
+
"The server session the request belongs to ended before it was sent (session #{pinned} is over)"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# A copy of a per-server fiber-local map without this server's entry.
|
|
110
|
+
# @return [Hash, nil]
|
|
111
|
+
def without_self(entries)
|
|
112
|
+
return entries if entries.nil?
|
|
113
|
+
|
|
114
|
+
copy = {}.compare_by_identity
|
|
115
|
+
entries.each { |server, value| copy[server] = value unless server.equal?(self) }
|
|
116
|
+
copy
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
class Subscription
|
|
7
|
+
# One subscription's notification dispatcher: the queue the transport
|
|
8
|
+
# reader fills, the thread the listeners run on, and the policy that keeps
|
|
9
|
+
# the queue bounded.
|
|
10
|
+
#
|
|
11
|
+
# Listeners never run on the transport's reader. On stdio that is the
|
|
12
|
+
# single stdout reader, so a listener reacting to an update with a request
|
|
13
|
+
# of its own (re-reading the resource that changed, say) would otherwise
|
|
14
|
+
# wait for a response only the thread it is blocking could deliver — and
|
|
15
|
+
# every other message would wait with it. Enqueuing therefore never blocks
|
|
16
|
+
# and never waits for a listener.
|
|
17
|
+
#
|
|
18
|
+
# The queue is filled by the peer and drained by the host, so it needs a
|
|
19
|
+
# ceiling, and overflow has to discard something. There are two ceilings,
|
|
20
|
+
# because a queue bounded by count alone is not bounded in memory: the
|
|
21
|
+
# method name and params of every queued notification are retained until
|
|
22
|
+
# its listener has run, and the peer chooses how big they are. So there is
|
|
23
|
+
# a count ceiling
|
|
24
|
+
# ({MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS}) and a byte budget
|
|
25
|
+
# ({MCPClient::Subscription::MAX_PENDING_NOTIFICATION_BYTES}).
|
|
26
|
+
#
|
|
27
|
+
# Two rules keep the queue honest, and they are the same rule seen from
|
|
28
|
+
# each end:
|
|
29
|
+
#
|
|
30
|
+
# 1. **Every queued notification is charged exactly what it retains** —
|
|
31
|
+
# its method name as well as its params, since the entry keeps both.
|
|
32
|
+
# {#pending_bytes} is the sum of the queue, always, because the queue
|
|
33
|
+
# and its totals are only ever changed together (see {#push} and
|
|
34
|
+
# {#discard}).
|
|
35
|
+
# 2. **Every eviction removes an entry whose removal relieves the pressure
|
|
36
|
+
# that caused it.** Each pressure has its own candidates: the byte
|
|
37
|
+
# budget admits only the entries charged against it, the count ceiling
|
|
38
|
+
# admits every entry (each is one of the count), and the oversized slot
|
|
39
|
+
# admits only its occupant. So overflow always makes progress and a
|
|
40
|
+
# signal is never spent on pressure that discarding it cannot relieve.
|
|
41
|
+
#
|
|
42
|
+
# Earlier revisions decided these two by rules that disagreed — a payload
|
|
43
|
+
# exempt from the charge but not from eviction — and the queue could throw
|
|
44
|
+
# away the only notice of a resource and still be over budget.
|
|
45
|
+
#
|
|
46
|
+
# *Which* of the candidates goes is chosen by identity — the
|
|
47
|
+
# notification's method together with the resource URI or task id it names
|
|
48
|
+
# — never by arrival order alone: one stream can carry a mixed filter, and
|
|
49
|
+
# dropping the oldest entry would throw away the only queued update for a
|
|
50
|
+
# quiet resource to keep newer ones for a busy one, with nothing left to
|
|
51
|
+
# tell the listener to re-read the quiet one. Since every MCP notification
|
|
52
|
+
# is a "look again" signal about state the host re-reads for itself, a
|
|
53
|
+
# second notice of the same thing is redundant and the first notice of a
|
|
54
|
+
# thing is not. So overflow gives up, in order of preference:
|
|
55
|
+
#
|
|
56
|
+
# 1. the oldest candidate of the same identity as the arriving
|
|
57
|
+
# notification — the listener still gets the newest word on it;
|
|
58
|
+
# 2. otherwise the oldest candidate of whichever identity has the most
|
|
59
|
+
# queued, so nothing loses its only notice while something else has a
|
|
60
|
+
# spare;
|
|
61
|
+
# 3. only when every candidate names a different thing, the oldest — the
|
|
62
|
+
# queue is then full of distinct signals and one must go.
|
|
63
|
+
#
|
|
64
|
+
# A notification whose payload is larger than the whole byte budget is not
|
|
65
|
+
# charged against it. It is held in a slot of its own instead, and there is
|
|
66
|
+
# only ever one such slot: a second oversized payload takes it from the
|
|
67
|
+
# first, which is the only thing that ever displaces one. So such a payload
|
|
68
|
+
# is neither lost for being large nor able to displace what the budget
|
|
69
|
+
# holds — nothing else is charged to its slot, and discarding it would free
|
|
70
|
+
# nothing the budget is short of — while what the queue retains stays
|
|
71
|
+
# within the budget plus one peer-sized payload.
|
|
72
|
+
class NotificationDispatcher
|
|
73
|
+
# One queued notification: what it is about, who wants it, what it says,
|
|
74
|
+
# what it costs to hold on to, and whether that cost is the budget's or
|
|
75
|
+
# its own slot's.
|
|
76
|
+
Queued = Struct.new(:key, :listeners, :method_name, :params, :bytes, :oversized)
|
|
77
|
+
|
|
78
|
+
# @param owner [MCPClient::Subscription] the subscription it serves
|
|
79
|
+
def initialize(owner)
|
|
80
|
+
@owner = owner
|
|
81
|
+
@mutex = Mutex.new
|
|
82
|
+
@ready = ConditionVariable.new
|
|
83
|
+
@buffer = []
|
|
84
|
+
@bytes = 0
|
|
85
|
+
@oversized_bytes = 0
|
|
86
|
+
@dropped = 0
|
|
87
|
+
@warned_about_drops = false
|
|
88
|
+
@stopped = false
|
|
89
|
+
start_thread
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Integer] notifications waiting for the listeners
|
|
93
|
+
def pending
|
|
94
|
+
@mutex.synchronize { @buffer.size }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Integer] bytes retained by the notifications waiting for the
|
|
98
|
+
# listeners
|
|
99
|
+
def pending_bytes
|
|
100
|
+
@mutex.synchronize { @bytes }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Integer] notifications discarded because the listeners could
|
|
104
|
+
# not keep up with the peer
|
|
105
|
+
def dropped
|
|
106
|
+
@mutex.synchronize { @dropped }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Queue one notification for the listeners, making room for it first.
|
|
110
|
+
# @param listeners [Array<Proc>] the listeners to run
|
|
111
|
+
# @param method [String] notification method
|
|
112
|
+
# @param params [Hash, nil] notification params
|
|
113
|
+
# @return [void]
|
|
114
|
+
def deliver(listeners, method, params)
|
|
115
|
+
# Measured before the lock is taken: sizing a large payload must not
|
|
116
|
+
# hold up the reader thread that is delivering the next one.
|
|
117
|
+
bytes = payload_bytesize(method, params)
|
|
118
|
+
entry = Queued.new(identity(method, params), listeners, method, params, bytes, bytes > byte_capacity)
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
return if @stopped
|
|
121
|
+
|
|
122
|
+
make_room(entry)
|
|
123
|
+
push(entry)
|
|
124
|
+
@ready.signal
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# End the dispatcher after everything already queued has been delivered.
|
|
129
|
+
# @return [void]
|
|
130
|
+
def stop
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
@stopped = true
|
|
133
|
+
@ready.broadcast
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# What a notification is *about*: two notifications with the same
|
|
140
|
+
# identity say the same thing about the same resource or task, so the
|
|
141
|
+
# newer one carries everything the older one did.
|
|
142
|
+
# @param method [String] notification method
|
|
143
|
+
# @param params [Hash, nil] notification params
|
|
144
|
+
# @return [Array(String, String, nil)]
|
|
145
|
+
def identity(method, params)
|
|
146
|
+
named = params.is_a?(Hash) ? (params['uri'] || params['taskId']) : nil
|
|
147
|
+
[method, named]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# What holding a notification costs, measured as the JSON the peer sent
|
|
151
|
+
# for it: the parsed objects are larger but proportional, and the peer
|
|
152
|
+
# decides the size either way.
|
|
153
|
+
#
|
|
154
|
+
# Everything the entry retains is charged, the method name included. It
|
|
155
|
+
# is not decoration on the params: the entry keeps it to call the
|
|
156
|
+
# listeners with, and keeps it again inside the identity the eviction
|
|
157
|
+
# policy is keyed by. Charging only the params let a peer tag `{}` with
|
|
158
|
+
# a multi-megabyte method name for two bytes apiece and put
|
|
159
|
+
# {MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS} of them behind a
|
|
160
|
+
# slow listener without ever touching the byte ceiling.
|
|
161
|
+
# @param method [String] notification method
|
|
162
|
+
# @param params [Hash, nil] notification params
|
|
163
|
+
# @return [Integer] bytes
|
|
164
|
+
def payload_bytesize(method, params)
|
|
165
|
+
method.to_s.bytesize + params_bytesize(params)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @param params [Hash, nil] notification params
|
|
169
|
+
# @return [Integer] bytes
|
|
170
|
+
def params_bytesize(params)
|
|
171
|
+
return 0 if params.nil?
|
|
172
|
+
|
|
173
|
+
JSON.generate(params).bytesize
|
|
174
|
+
rescue StandardError
|
|
175
|
+
# Params always come from a parsed JSON message; if one somehow cannot
|
|
176
|
+
# be re-encoded, charge for it rather than letting it slip the budget.
|
|
177
|
+
params.to_s.bytesize
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @return [Integer] the ceiling, read at each delivery so a host can
|
|
181
|
+
# change it for a transport it knows is chatty
|
|
182
|
+
def capacity
|
|
183
|
+
MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @return [Integer] the byte ceiling, read at each delivery for the same
|
|
187
|
+
# reason as {#capacity}
|
|
188
|
+
def byte_capacity
|
|
189
|
+
MCPClient::Subscription::MAX_PENDING_NOTIFICATION_BYTES
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Add one entry, charging exactly what it retains. The queue and its
|
|
193
|
+
# totals only change here and in {#discard}, which is what makes
|
|
194
|
+
# {#pending_bytes} the sum of the queue rather than an estimate of it.
|
|
195
|
+
# Called with the lock held.
|
|
196
|
+
# @param entry [Queued]
|
|
197
|
+
# @return [void]
|
|
198
|
+
def push(entry)
|
|
199
|
+
@buffer << entry
|
|
200
|
+
@bytes += entry.bytes
|
|
201
|
+
@oversized_bytes += entry.bytes if entry.oversized
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Remove the entry at `index`, releasing exactly what it was charged.
|
|
205
|
+
# Called with the lock held.
|
|
206
|
+
# @param index [Integer]
|
|
207
|
+
# @return [Queued] the entry that was removed
|
|
208
|
+
def discard(index)
|
|
209
|
+
entry = @buffer.delete_at(index)
|
|
210
|
+
@bytes -= entry.bytes
|
|
211
|
+
@oversized_bytes -= entry.bytes if entry.oversized
|
|
212
|
+
entry
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Integer] the bytes charged against the budget: everything
|
|
216
|
+
# queued except the payload holding the oversized slot
|
|
217
|
+
def budgeted_bytes
|
|
218
|
+
@bytes - @oversized_bytes
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @return [Integer, nil] the position of the oversized payload, nil when
|
|
222
|
+
# the slot is free. There is at most one by construction: an arriving
|
|
223
|
+
# oversized payload takes the slot from its occupant first.
|
|
224
|
+
def oversized_index
|
|
225
|
+
@buffer.index(&:oversized)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Make room for one more notification. Called with the lock held.
|
|
229
|
+
# @param entry [Queued] the arriving notification
|
|
230
|
+
# @return [void]
|
|
231
|
+
def make_room(entry)
|
|
232
|
+
dropped = 0
|
|
233
|
+
while (index = crowded_out(entry))
|
|
234
|
+
discard(index)
|
|
235
|
+
dropped += 1
|
|
236
|
+
end
|
|
237
|
+
return if dropped.zero?
|
|
238
|
+
|
|
239
|
+
@dropped += dropped
|
|
240
|
+
report_dropped(dropped)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The position of the entry that has to go before `entry` can be queued,
|
|
244
|
+
# or nil once it fits. Each pressure admits only the entries whose
|
|
245
|
+
# removal relieves *it*, so every eviction makes progress and the loop in
|
|
246
|
+
# {#make_room} always ends:
|
|
247
|
+
#
|
|
248
|
+
# * the oversized slot: only its occupant will do, and an arriving
|
|
249
|
+
# payload that needs the slot always frees it in one step;
|
|
250
|
+
# * the byte budget: only the entries charged against it, and with none
|
|
251
|
+
# of them left the budget holds anything that is not oversized;
|
|
252
|
+
# * the count ceiling: every queued entry is one of the count.
|
|
253
|
+
#
|
|
254
|
+
# Which of the candidates goes is then the identity question (see the
|
|
255
|
+
# class comment). Called with the lock held.
|
|
256
|
+
# @param entry [Queued] the arriving notification
|
|
257
|
+
# @return [Integer, nil]
|
|
258
|
+
def crowded_out(entry)
|
|
259
|
+
return oversized_index if entry.oversized && oversized_index
|
|
260
|
+
return evictable_index(entry.key, budgeted_indices) if budgeted_bytes + budgeted_cost(entry) > byte_capacity
|
|
261
|
+
return evictable_index(entry.key, (0...@buffer.size).to_a) if @buffer.size >= capacity
|
|
262
|
+
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @param entry [Queued] the arriving notification
|
|
267
|
+
# @return [Integer] what queuing it would add to the budget: nothing when
|
|
268
|
+
# it is bound for the slot of its own
|
|
269
|
+
def budgeted_cost(entry)
|
|
270
|
+
entry.oversized ? 0 : entry.bytes
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @return [Array<Integer>] the positions of the entries the budget is
|
|
274
|
+
# charged for
|
|
275
|
+
def budgeted_indices
|
|
276
|
+
(0...@buffer.size).reject { |index| @buffer[index].oversized }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The candidate overflow should discard (see the class comment). Called
|
|
280
|
+
# with the lock held.
|
|
281
|
+
# @param key [Array] the arriving notification's identity
|
|
282
|
+
# @param candidates [Array<Integer>] positions that may be discarded
|
|
283
|
+
# @return [Integer, nil] its position, nil when there is no candidate
|
|
284
|
+
def evictable_index(key, candidates)
|
|
285
|
+
return nil if candidates.empty?
|
|
286
|
+
|
|
287
|
+
same = candidates.find { |index| @buffer[index].key == key }
|
|
288
|
+
return same if same
|
|
289
|
+
|
|
290
|
+
redundant = most_queued_identity(candidates)
|
|
291
|
+
redundant ? candidates.find { |index| @buffer[index].key == redundant } : candidates.first
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @param candidates [Array<Integer>] positions that may be discarded
|
|
295
|
+
# @return [Array, nil] the identity with more than one candidate queued,
|
|
296
|
+
# nil when every candidate names its own thing
|
|
297
|
+
def most_queued_identity(candidates)
|
|
298
|
+
counts = candidates.each_with_object(Hash.new(0)) { |index, tally| tally[@buffer[index].key] += 1 }
|
|
299
|
+
identity, count = counts.max_by { |_key, queued| queued }
|
|
300
|
+
count > 1 ? identity : nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @param dropped [Integer] how many were discarded just now
|
|
304
|
+
# @return [void]
|
|
305
|
+
def report_dropped(dropped)
|
|
306
|
+
logger = @owner.server.respond_to?(:logger) ? @owner.server.logger : nil
|
|
307
|
+
return unless logger
|
|
308
|
+
|
|
309
|
+
# The peer controls how often this happens, so it is said once per
|
|
310
|
+
# subscription at warn level and counted after that.
|
|
311
|
+
if @warned_about_drops
|
|
312
|
+
logger.debug("Subscription #{@owner.id} dropped #{dropped} more queued notification(s)")
|
|
313
|
+
else
|
|
314
|
+
@warned_about_drops = true
|
|
315
|
+
logger.warn("Subscription #{@owner.id} is receiving notifications faster than its listeners handle " \
|
|
316
|
+
"them; dropping repeats of what is already queued (at most #{capacity} notifications " \
|
|
317
|
+
"or #{byte_capacity} bytes, see MCPClient::Subscription#dropped_notifications)")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @return [Thread] the thread that runs the listeners
|
|
322
|
+
def start_thread
|
|
323
|
+
Thread.new do
|
|
324
|
+
Thread.current.name = 'MCP-subscription'
|
|
325
|
+
Thread.current.report_on_exception = false
|
|
326
|
+
while (entry = next_entry)
|
|
327
|
+
call_listeners(entry.listeners, entry.method_name, entry.params)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @return [Queued, nil] the next notification to deliver, nil once the
|
|
333
|
+
# subscription has ended and everything queued has been delivered
|
|
334
|
+
def next_entry
|
|
335
|
+
@mutex.synchronize do
|
|
336
|
+
@ready.wait(@mutex) while @buffer.empty? && !@stopped
|
|
337
|
+
discard(0) unless @buffer.empty?
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @param listeners [Array<Proc>] listeners to run
|
|
342
|
+
# @param method [String] notification method
|
|
343
|
+
# @param params [Hash, nil] notification params
|
|
344
|
+
# @return [void]
|
|
345
|
+
def call_listeners(listeners, method, params)
|
|
346
|
+
listeners.each do |listener|
|
|
347
|
+
listener.call(method, params)
|
|
348
|
+
rescue StandardError => e
|
|
349
|
+
@owner.server.logger.warn("Subscription listener error: #{e.message}") if @owner.server.respond_to?(:logger)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|