tether 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +5 -0
- data/Rakefile +17 -0
- data/lib/tether/client.rb +298 -0
- data/lib/tether/deadline.rb +127 -0
- data/lib/tether/dispatcher.rb +109 -0
- data/lib/tether/errors.rb +204 -0
- data/lib/tether/headers.rb +256 -0
- data/lib/tether/inbox_mux.rb +113 -0
- data/lib/tether/msg.rb +90 -0
- data/lib/tether/nuid.rb +68 -0
- data/lib/tether/protocol/encoder.rb +125 -0
- data/lib/tether/protocol/parser.rb +336 -0
- data/lib/tether/protocol.rb +18 -0
- data/lib/tether/subscription.rb +123 -0
- data/lib/tether/transport.rb +114 -0
- data/lib/tether/version.rb +5 -0
- data/lib/tether.rb +28 -0
- data/sig/tether/client.rbs +45 -0
- data/sig/tether/deadline.rbs +21 -0
- data/sig/tether/dispatcher.rbs +19 -0
- data/sig/tether/errors.rbs +110 -0
- data/sig/tether/headers.rbs +50 -0
- data/sig/tether/inbox_mux.rbs +20 -0
- data/sig/tether/msg.rbs +16 -0
- data/sig/tether/nuid.rbs +21 -0
- data/sig/tether/protocol/encoder.rbs +13 -0
- data/sig/tether/protocol/parser.rbs +43 -0
- data/sig/tether/protocol.rbs +22 -0
- data/sig/tether/subscription.rbs +24 -0
- data/sig/tether/transport.rbs +22 -0
- data/sig/tether/version.rbs +3 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f4bb93cf37cafd3c8d5c716e840583e7e393533606648c59ec8b4c001ac9eecf
|
|
4
|
+
data.tar.gz: 59be9816c6773a4404207b8bccba9bff98eb764911432118e7fada6e7db5cc3e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6d3d29e1be6c242d9ca6f19c8b59be5154c8fbe5d29910349463f17663c3f77e1d82be4ba9e402f7104d122e559bd4b26d1779ad4d2a07e5e9b61d794cb771bc
|
|
7
|
+
data.tar.gz: fe088501f0b7a9e6df145034c118529efe7803cc52395e92c4135006873a64231b00790753a1b28c539e78352a5e2241f3731bf4a7dc93791bb7c89f9f1d7087
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry Vorotilin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
data/Rakefile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new
|
|
11
|
+
|
|
12
|
+
desc "Validate RBS signatures"
|
|
13
|
+
task :rbs do
|
|
14
|
+
sh "rbs -I sig -r uri validate"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
task default: %i[spec rubocop rbs]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tether
|
|
6
|
+
# A connection to a NATS server.
|
|
7
|
+
#
|
|
8
|
+
# One reader thread parses the socket and enqueues to subscriptions; each
|
|
9
|
+
# subscription runs its callback on its own worker. Writes take a lock that
|
|
10
|
+
# spans a whole frame. Nothing here uses `Timeout.timeout` or `Thread#raise`.
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_URI = "nats://127.0.0.1:4222"
|
|
13
|
+
DEFAULT_CONNECT_TIMEOUT = 2
|
|
14
|
+
DEFAULT_FLUSH_TIMEOUT = 10
|
|
15
|
+
DEFAULT_REQUEST_TIMEOUT = 2
|
|
16
|
+
|
|
17
|
+
# @return [Hash, nil] the INFO the server sent on connect
|
|
18
|
+
attr_reader :server_info
|
|
19
|
+
|
|
20
|
+
# @return [Symbol] :disconnected, :connected, or :closed
|
|
21
|
+
attr_reader :status
|
|
22
|
+
|
|
23
|
+
# @return [Exception, nil] the last asynchronous error seen
|
|
24
|
+
attr_reader :last_error
|
|
25
|
+
|
|
26
|
+
# Connects and returns a client, yielding it and closing after if a block is given.
|
|
27
|
+
#
|
|
28
|
+
# Connects, and when a block is given yields the client and closes it after.
|
|
29
|
+
#
|
|
30
|
+
# @param uri [String, nil]
|
|
31
|
+
# @param options [Hash{Symbol => Object}] CONNECT overrides, plus :connect_timeout and :max_pending
|
|
32
|
+
# @return [Client, Object] the client, or the block's value
|
|
33
|
+
# @raise [ConnectionError] if the server cannot be reached
|
|
34
|
+
def self.connect(uri = nil, **options)
|
|
35
|
+
client = new(uri, **options)
|
|
36
|
+
client.connect
|
|
37
|
+
return client unless block_given?
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
yield client
|
|
41
|
+
ensure
|
|
42
|
+
client.close
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds a client without opening a socket. Call {#connect}, or use
|
|
47
|
+
# {.connect} to do both.
|
|
48
|
+
#
|
|
49
|
+
# @param uri [String, nil]
|
|
50
|
+
# @param options [Hash{Symbol => Object}]
|
|
51
|
+
def initialize(uri = nil, **options)
|
|
52
|
+
@uri = uri || ENV.fetch("NATS_URL", DEFAULT_URI)
|
|
53
|
+
@options = options
|
|
54
|
+
@status = :disconnected
|
|
55
|
+
@sid_counter = 0
|
|
56
|
+
@lock = Mutex.new
|
|
57
|
+
@pongs = Thread::Queue.new
|
|
58
|
+
@error_handlers = []
|
|
59
|
+
@dispatcher = build_dispatcher
|
|
60
|
+
@parser = Protocol::Parser.new(@dispatcher)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Opens the socket, exchanges INFO and CONNECT, and starts the reader.
|
|
64
|
+
#
|
|
65
|
+
# @param timeout [Numeric, nil]
|
|
66
|
+
# @return [self]
|
|
67
|
+
def connect(timeout: @options.fetch(:connect_timeout, DEFAULT_CONNECT_TIMEOUT))
|
|
68
|
+
deadline = Deadline.after(timeout)
|
|
69
|
+
@transport = Transport.connect(@uri, deadline: deadline)
|
|
70
|
+
read_server_info(deadline)
|
|
71
|
+
@transport.write(Protocol::Encoder.connect(connect_options))
|
|
72
|
+
@status = :connected
|
|
73
|
+
start_reader
|
|
74
|
+
flush(timeout: deadline.remaining)
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Publishes a message.
|
|
79
|
+
#
|
|
80
|
+
# Fire and forget: the server sends no acknowledgement, so a successful
|
|
81
|
+
# return means the frame reached the socket, not that the server processed
|
|
82
|
+
# it. Follow with {#flush} when you need that guarantee.
|
|
83
|
+
#
|
|
84
|
+
# @param subject [String]
|
|
85
|
+
# @param payload [String, nil]
|
|
86
|
+
# @param reply_to [String, nil]
|
|
87
|
+
# @param headers [Headers, nil]
|
|
88
|
+
# @return [void]
|
|
89
|
+
# @raise [MaxPayloadError] if the frame exceeds what the server advertised
|
|
90
|
+
def publish(subject, payload = nil, reply_to: nil, headers: nil)
|
|
91
|
+
ensure_connected!
|
|
92
|
+
frame = if headers
|
|
93
|
+
Protocol::Encoder.hpub(subject, payload, headers, reply_to)
|
|
94
|
+
else
|
|
95
|
+
Protocol::Encoder.pub(subject, payload, reply_to)
|
|
96
|
+
end
|
|
97
|
+
check_payload_size!(subject, frame)
|
|
98
|
+
@transport.write(frame)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Subscribes, delivering to the block on a worker thread when one is given.
|
|
103
|
+
#
|
|
104
|
+
# @param subject [String]
|
|
105
|
+
# @param queue [String, nil] queue group
|
|
106
|
+
# @param max_pending [Integer]
|
|
107
|
+
# @return [Subscription]
|
|
108
|
+
def subscribe(subject, queue: nil, max_pending: Subscription::DEFAULT_MAX_PENDING, &callback)
|
|
109
|
+
ensure_connected!
|
|
110
|
+
subscription = Subscription.new(
|
|
111
|
+
sid: next_sid, subject: subject, queue_group: queue, max_pending: max_pending,
|
|
112
|
+
on_slow_consumer: method(:report_slow_consumer), responder: self, &callback
|
|
113
|
+
)
|
|
114
|
+
@dispatcher.add(subscription)
|
|
115
|
+
@transport.write(Protocol::Encoder.sub(subject, subscription.sid, queue: queue))
|
|
116
|
+
subscription
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Cancels a subscription.
|
|
120
|
+
#
|
|
121
|
+
# With max_msgs the server keeps delivering until that many more messages
|
|
122
|
+
# have arrived, so the subscription stays registered and its worker keeps
|
|
123
|
+
# running until then.
|
|
124
|
+
#
|
|
125
|
+
# @param subscription [Subscription]
|
|
126
|
+
# @param max_msgs [Integer, nil] unsubscribe only after this many more messages
|
|
127
|
+
# @return [void]
|
|
128
|
+
def unsubscribe(subscription, max_msgs = nil)
|
|
129
|
+
@transport.write(Protocol::Encoder.unsub(subscription.sid, max_msgs)) unless @transport.closed?
|
|
130
|
+
return if max_msgs
|
|
131
|
+
|
|
132
|
+
@dispatcher.remove(subscription.sid)
|
|
133
|
+
subscription.close
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Publishes a request and waits for the first reply.
|
|
138
|
+
#
|
|
139
|
+
# Every request on a connection shares one inbox subscription; replies are
|
|
140
|
+
# demultiplexed in memory, so N concurrent requests cost one subscription.
|
|
141
|
+
#
|
|
142
|
+
# @param subject [String]
|
|
143
|
+
# @param payload [String, nil]
|
|
144
|
+
# @param timeout [Numeric, nil]
|
|
145
|
+
# @param headers [Headers, nil]
|
|
146
|
+
# @return [Msg]
|
|
147
|
+
# @raise [NoRespondersError] if nobody is subscribed to the subject
|
|
148
|
+
# @raise [TimeoutError] if no reply arrives in time
|
|
149
|
+
def request(subject, payload = nil, timeout: DEFAULT_REQUEST_TIMEOUT, headers: nil)
|
|
150
|
+
ensure_connected!
|
|
151
|
+
inbox_mux.request(subject, payload, deadline: Deadline.after(timeout), headers: headers)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Builds a reply subject for a subscribe-then-publish exchange, which is
|
|
155
|
+
# what you want when a single request may draw several replies.
|
|
156
|
+
#
|
|
157
|
+
# @return [String] a fresh inbox subject, unrelated to the shared request inbox
|
|
158
|
+
def new_inbox
|
|
159
|
+
"#{InboxMux::INBOX_PREFIX}.#{NUID.next}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Round trips a PING, which the server answers only after everything sent
|
|
163
|
+
# before it has been processed.
|
|
164
|
+
#
|
|
165
|
+
# @param timeout [Numeric, nil]
|
|
166
|
+
# @return [true]
|
|
167
|
+
# @raise [TimeoutError]
|
|
168
|
+
def flush(timeout: DEFAULT_FLUSH_TIMEOUT)
|
|
169
|
+
ensure_connected!
|
|
170
|
+
@transport.write(Protocol::PING)
|
|
171
|
+
raise TimeoutError, "flush timed out after #{timeout}s" if @pongs.pop(timeout: timeout).nil?
|
|
172
|
+
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Closes the socket, which unblocks the reader's pending read so it can
|
|
177
|
+
# exit on its own. Nothing is killed: a thread stopped mid-parse would
|
|
178
|
+
# leave shared state in whatever shape the interrupt found it.
|
|
179
|
+
#
|
|
180
|
+
# @return [void]
|
|
181
|
+
def close
|
|
182
|
+
return if @status == :closed
|
|
183
|
+
|
|
184
|
+
@status = :closed
|
|
185
|
+
@inbox_mux&.close
|
|
186
|
+
@transport&.close
|
|
187
|
+
@reader.join(1) if @reader && @reader != Thread.current
|
|
188
|
+
@dispatcher.clear
|
|
189
|
+
@pongs.close
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @return [Boolean] whether the handshake completed and the socket is live
|
|
194
|
+
def connected? = @status == :connected
|
|
195
|
+
|
|
196
|
+
# @return [Boolean] whether {#close} has run; a closed client cannot be reopened
|
|
197
|
+
def closed? = @status == :closed
|
|
198
|
+
|
|
199
|
+
# Registers a handler for errors that arrive outside any call.
|
|
200
|
+
#
|
|
201
|
+
# Errors are never raised into a caller's thread: they are recorded and
|
|
202
|
+
# passed here, on the reader thread.
|
|
203
|
+
#
|
|
204
|
+
# @yieldparam error [Exception]
|
|
205
|
+
# @return [void]
|
|
206
|
+
def on_error(&handler)
|
|
207
|
+
@error_handlers << handler
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Counts live subscriptions. Request/reply adds exactly one, however many
|
|
212
|
+
# requests are in flight.
|
|
213
|
+
#
|
|
214
|
+
# @return [Integer] subscriptions currently registered, including the shared request inbox
|
|
215
|
+
def subscription_count = @dispatcher.size
|
|
216
|
+
|
|
217
|
+
# The ceiling the server advertised in INFO. {#publish} refuses anything
|
|
218
|
+
# larger rather than letting the server close the connection over it.
|
|
219
|
+
#
|
|
220
|
+
# @return [Integer] largest payload the server accepts
|
|
221
|
+
def max_payload = @server_info&.fetch("max_payload", nil)
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def inbox_mux
|
|
226
|
+
@lock.synchronize { @inbox_mux ||= InboxMux.new(self, max_pending: @options.fetch(:max_pending, Subscription::DEFAULT_MAX_PENDING)) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_dispatcher
|
|
230
|
+
Dispatcher.new(
|
|
231
|
+
ping: -> { @transport.write(Protocol::PONG) },
|
|
232
|
+
pong: -> { @pongs.push(true) unless @pongs.closed? },
|
|
233
|
+
info: ->(json) { @server_info = JSON.parse(json) },
|
|
234
|
+
error: ->(description) { record_error(ServerError.for(description)) },
|
|
235
|
+
protocol_error: ->(message) { record_error(ProtocolError.new(message)) }
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def read_server_info(deadline)
|
|
240
|
+
@parser << @transport.read(deadline) until @server_info
|
|
241
|
+
@parser.max_payload = @server_info["max_payload"]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def connect_options
|
|
245
|
+
allowed = @options.except(:connect_timeout, :max_pending)
|
|
246
|
+
allowed.merge(name: @options[:name] || "tether")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def start_reader
|
|
250
|
+
@reader = Thread.new do
|
|
251
|
+
Thread.current.name = "tether:reader"
|
|
252
|
+
read_loop
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def read_loop
|
|
257
|
+
until @transport.closed? || @parser.broken?
|
|
258
|
+
data = @transport.read
|
|
259
|
+
break if data.nil?
|
|
260
|
+
|
|
261
|
+
@parser << data
|
|
262
|
+
end
|
|
263
|
+
rescue ConnectionClosedError, TimeoutError => e
|
|
264
|
+
record_error(e) unless @status == :closed
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
record_error(e)
|
|
267
|
+
ensure
|
|
268
|
+
@pongs.close unless @pongs.closed?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def next_sid
|
|
272
|
+
@lock.synchronize { (@sid_counter += 1).to_s }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def check_payload_size!(subject, frame)
|
|
276
|
+
limit = max_payload
|
|
277
|
+
return unless limit && frame.bytesize > limit
|
|
278
|
+
|
|
279
|
+
raise MaxPayloadError, "payload for #{subject} exceeds the server's max_payload of #{limit}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def report_slow_consumer(subscription)
|
|
283
|
+
record_error(SlowConsumerError.new("dropped a message on #{subscription.subject}"))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def record_error(error)
|
|
287
|
+
@last_error = error
|
|
288
|
+
@error_handlers.each { |handler| handler.call(error) }
|
|
289
|
+
rescue StandardError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def ensure_connected!
|
|
294
|
+
raise ConnectionClosedError, "connection is closed" if @status == :closed
|
|
295
|
+
raise ConnectionError, "not connected" unless @status == :connected
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tether
|
|
4
|
+
# An absolute point in time on the monotonic clock, threaded through every
|
|
5
|
+
# blocking call in tether instead of a bare number of seconds.
|
|
6
|
+
#
|
|
7
|
+
# A Deadline is only ever consumed by primitives that interrupt themselves
|
|
8
|
+
# (`Queue#pop(timeout:)`, `IO#timeout=`, `ConditionVariable#wait`). It is never
|
|
9
|
+
# handed to `Timeout.timeout`, which raises at an arbitrary bytecode boundary
|
|
10
|
+
# and can leave a half-written frame on the wire or a mutex locked forever.
|
|
11
|
+
class Deadline
|
|
12
|
+
# A deadline that never expires.
|
|
13
|
+
NONE = nil
|
|
14
|
+
|
|
15
|
+
# @return [Float, nil] the monotonic instant this deadline expires at, or nil if it never does
|
|
16
|
+
attr_reader :at
|
|
17
|
+
|
|
18
|
+
# Builds a deadline that expires after a duration from now.
|
|
19
|
+
#
|
|
20
|
+
# @param seconds [Numeric, nil] duration in seconds, or nil for a deadline that never expires
|
|
21
|
+
# @return [Deadline]
|
|
22
|
+
def self.after(seconds)
|
|
23
|
+
new(seconds.nil? ? nil : now + seconds)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Builds a deadline that never expires.
|
|
27
|
+
#
|
|
28
|
+
# @return [Deadline]
|
|
29
|
+
def self.infinite
|
|
30
|
+
new(nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Accepts whatever a caller passed for a timeout and returns a Deadline.
|
|
34
|
+
#
|
|
35
|
+
# @param value [Deadline, Numeric, nil]
|
|
36
|
+
# @return [Deadline]
|
|
37
|
+
def self.coerce(value)
|
|
38
|
+
return value if value.is_a?(self)
|
|
39
|
+
|
|
40
|
+
after(value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reads the monotonic clock, which never jumps backwards when the system
|
|
44
|
+
# clock is adjusted.
|
|
45
|
+
#
|
|
46
|
+
# @return [Float] the current monotonic clock reading
|
|
47
|
+
def self.now
|
|
48
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Wraps an absolute monotonic instant. Prefer {.after}, which takes a
|
|
52
|
+
# duration.
|
|
53
|
+
#
|
|
54
|
+
# @param at [Float, nil] a monotonic instant, or nil for a deadline that never expires
|
|
55
|
+
def initialize(at)
|
|
56
|
+
@at = at
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An infinite deadline reports nil remaining, which every blocking
|
|
61
|
+
# primitive in Ruby already reads as "wait forever".
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] whether this deadline can never expire
|
|
64
|
+
def infinite?
|
|
65
|
+
@at.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Seconds left before expiry, clamped at zero.
|
|
69
|
+
#
|
|
70
|
+
# Returns nil for an infinite deadline, which is what every blocking
|
|
71
|
+
# primitive in Ruby already interprets as "wait forever".
|
|
72
|
+
#
|
|
73
|
+
# @return [Float, nil]
|
|
74
|
+
def remaining
|
|
75
|
+
return nil if infinite?
|
|
76
|
+
|
|
77
|
+
left = @at - self.class.now
|
|
78
|
+
left.negative? ? 0.0 : left
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean] whether the instant has passed; always false when infinite
|
|
82
|
+
def expired?
|
|
83
|
+
return false if infinite?
|
|
84
|
+
|
|
85
|
+
@at <= self.class.now
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Raises if this deadline has already expired.
|
|
89
|
+
#
|
|
90
|
+
# @param subject [String, nil] included in the error message for context
|
|
91
|
+
# @raise [TimeoutError]
|
|
92
|
+
# @return [void]
|
|
93
|
+
def check!(subject = nil)
|
|
94
|
+
return unless expired?
|
|
95
|
+
|
|
96
|
+
raise TimeoutError, subject ? "deadline expired: #{subject}" : "deadline expired"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Narrows this deadline, never widens it.
|
|
100
|
+
#
|
|
101
|
+
# @param seconds [Numeric, nil]
|
|
102
|
+
# @return [Deadline] the earlier of this deadline and now + seconds
|
|
103
|
+
def with_at_most(seconds)
|
|
104
|
+
return self if seconds.nil?
|
|
105
|
+
|
|
106
|
+
candidate = self.class.now + seconds
|
|
107
|
+
infinite? || candidate < @at ? self.class.new(candidate) : self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns the earlier of two deadlines.
|
|
111
|
+
#
|
|
112
|
+
# @param other [Deadline]
|
|
113
|
+
# @return [Deadline]
|
|
114
|
+
def earliest(other)
|
|
115
|
+
return other if infinite?
|
|
116
|
+
return self if other.infinite?
|
|
117
|
+
|
|
118
|
+
other.at < @at ? other : self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [String] shows time remaining rather than the absolute instant,
|
|
122
|
+
# which is meaningless out of context
|
|
123
|
+
def inspect
|
|
124
|
+
infinite? ? "#<Tether::Deadline infinite>" : format("#<Tether::Deadline remaining=%.3fs>", remaining)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tether
|
|
4
|
+
# Routes parsed frames to subscriptions and to connection level handlers.
|
|
5
|
+
#
|
|
6
|
+
# This is the parser's sink. It knows nothing about sockets or reconnection,
|
|
7
|
+
# so the parser stays testable without a client.
|
|
8
|
+
class Dispatcher
|
|
9
|
+
# Wires up the connection level events the parser will report.
|
|
10
|
+
#
|
|
11
|
+
# @param handlers [Hash{Symbol => Proc}] :ping, :pong, :info, :error, :protocol_error
|
|
12
|
+
def initialize(**handlers)
|
|
13
|
+
@handlers = handlers
|
|
14
|
+
@subscriptions = {}
|
|
15
|
+
@lock = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Registers a subscription so messages carrying its sid reach it.
|
|
19
|
+
#
|
|
20
|
+
# @param subscription [Subscription]
|
|
21
|
+
# @return [Subscription]
|
|
22
|
+
def add(subscription)
|
|
23
|
+
@lock.synchronize { @subscriptions[subscription.sid] = subscription }
|
|
24
|
+
subscription
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Stops routing to a subscription. Messages already in flight for it are
|
|
28
|
+
# dropped when they arrive.
|
|
29
|
+
#
|
|
30
|
+
# @param sid [String]
|
|
31
|
+
# @return [Subscription, nil] the subscription that was registered, if any
|
|
32
|
+
def remove(sid)
|
|
33
|
+
@lock.synchronize { @subscriptions.delete(sid) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Looks up a subscription by the token the server echoed back.
|
|
37
|
+
#
|
|
38
|
+
# @param sid [String]
|
|
39
|
+
# @return [Subscription, nil] nil once the subscription has been removed
|
|
40
|
+
def [](sid)
|
|
41
|
+
@lock.synchronize { @subscriptions[sid] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Subscription>] a snapshot, safe to iterate while others subscribe
|
|
45
|
+
def subscriptions
|
|
46
|
+
@lock.synchronize { @subscriptions.values }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] how many subscriptions are registered
|
|
50
|
+
def size
|
|
51
|
+
@lock.synchronize { @subscriptions.size }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Closes every subscription and empties the registry.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def clear
|
|
58
|
+
@lock.synchronize { @subscriptions.values.tap { @subscriptions.clear } }.each(&:close)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Routes a message to its subscription, on the reader thread.
|
|
62
|
+
#
|
|
63
|
+
# A message whose sid is unknown, because the subscription was cancelled
|
|
64
|
+
# while it was in flight, is dropped rather than raised over.
|
|
65
|
+
#
|
|
66
|
+
# @return [void]
|
|
67
|
+
def on_msg(subject, sid, reply_to, header_bytes, payload)
|
|
68
|
+
subscription = self[sid] or return
|
|
69
|
+
|
|
70
|
+
subscription.deliver(
|
|
71
|
+
Msg.new(subject: subject, data: payload, reply_to: reply_to,
|
|
72
|
+
header_bytes: header_bytes, subscription: subscription)
|
|
73
|
+
)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The connection must answer promptly or the server drops it as stale.
|
|
78
|
+
#
|
|
79
|
+
# @return [void]
|
|
80
|
+
def on_ping = @handlers[:ping]&.call
|
|
81
|
+
|
|
82
|
+
# Completes a {Client#flush}.
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
def on_pong = @handlers[:pong]&.call
|
|
86
|
+
|
|
87
|
+
# Ignored: tether turns off verbose mode, so the server sends these only if
|
|
88
|
+
# a caller asked for them explicitly.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def on_ok = nil
|
|
92
|
+
|
|
93
|
+
# Arrives once at connect and again whenever cluster topology changes.
|
|
94
|
+
#
|
|
95
|
+
# @return [void]
|
|
96
|
+
def on_info(json) = @handlers[:info]&.call(json)
|
|
97
|
+
|
|
98
|
+
# An `-ERR` line, which may or may not be followed by the server closing
|
|
99
|
+
# the connection. See {ServerError.fatal?}.
|
|
100
|
+
#
|
|
101
|
+
# @return [void]
|
|
102
|
+
def on_error(description) = @handlers[:error]&.call(description)
|
|
103
|
+
|
|
104
|
+
# The parser gave up on the stream; the connection is no longer usable.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def on_protocol_error(message) = @handlers[:protocol_error]&.call(message)
|
|
108
|
+
end
|
|
109
|
+
end
|