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.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Tether
6
+ # Generator for NATS unique identifiers: a 12 character random prefix followed
7
+ # by a 10 character base62 sequence, incremented by a random step so that
8
+ # identifiers are neither guessable nor collision prone across processes.
9
+ #
10
+ # Instances are not thread safe. Use {NUID.next} for the process wide,
11
+ # mutex guarded generator.
12
+ class NUID
13
+ DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
14
+ BASE = 62
15
+ PREFIX_LENGTH = 12
16
+ SEQUENCE_LENGTH = 10
17
+ TOTAL_LENGTH = PREFIX_LENGTH + SEQUENCE_LENGTH
18
+ MAX_SEQUENCE = BASE**SEQUENCE_LENGTH
19
+ MIN_INCREMENT = 33
20
+ MAX_INCREMENT = 333
21
+ PADDING = ("0" * SEQUENCE_LENGTH).freeze
22
+ private_constant :PADDING
23
+
24
+ @mutex = Mutex.new
25
+ @generator = nil
26
+
27
+ class << self
28
+ # Generates an identifier from the process wide generator.
29
+ #
30
+ # @return [String] a 22 character identifier
31
+ def next
32
+ @mutex.synchronize { (@generator ||= new).next }
33
+ end
34
+ end
35
+
36
+ # Seeds a generator with its own random prefix, so two instances in the
37
+ # same process never collide.
38
+ def initialize
39
+ reseed!
40
+ end
41
+
42
+ # Generates the next identifier.
43
+ #
44
+ # @return [String] a 22 character identifier
45
+ def next
46
+ @sequence += @increment
47
+ reseed! if @sequence >= MAX_SEQUENCE
48
+
49
+ buffer = @prefix + PADDING
50
+ value = @sequence
51
+ (TOTAL_LENGTH - 1).downto(PREFIX_LENGTH) do |index|
52
+ buffer.setbyte(index, DIGITS.getbyte(value % BASE))
53
+ value /= BASE
54
+ end
55
+ buffer
56
+ end
57
+
58
+ private
59
+
60
+ def reseed!
61
+ @prefix = SecureRandom.random_bytes(PREFIX_LENGTH).each_byte.with_object(+"") do |byte, out|
62
+ out << DIGITS.getbyte(byte % BASE)
63
+ end
64
+ @sequence = SecureRandom.random_number(MAX_SEQUENCE)
65
+ @increment = MIN_INCREMENT + SecureRandom.random_number(MAX_INCREMENT - MIN_INCREMENT)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tether
6
+ module Protocol
7
+ # Builds complete protocol frames as single binary strings.
8
+ #
9
+ # Every method returns one whole frame. The writer takes its lock around the
10
+ # write of that string, never around the pieces of it: frames from different
11
+ # threads must not interleave on the wire.
12
+ module Encoder
13
+ # The server seeds every connection with `verbose:true, pedantic:true,
14
+ # echo:true` and merges CONNECT into that struct, so an absent key keeps
15
+ # the server's default rather than clearing it. Omitting `verbose` alone
16
+ # makes the server acknowledge every PUB and SUB with `+OK`; omitting
17
+ # `headers` makes it rewrite HMSG as MSG with the header bytes cut off,
18
+ # which silently strips all JetStream metadata.
19
+ DEFAULT_CONNECT = {
20
+ verbose: false,
21
+ pedantic: false,
22
+ tls_required: false,
23
+ lang: "ruby",
24
+ version: Tether::VERSION,
25
+ protocol: 1,
26
+ echo: true,
27
+ headers: true,
28
+ no_responders: true
29
+ }.freeze
30
+
31
+ class << self
32
+ # Encodes a `PUB` frame.
33
+ #
34
+ # @param subject [String]
35
+ # @param payload [String, nil]
36
+ # @param reply_to [String, nil]
37
+ # @return [String] binary encoded frame
38
+ def pub(subject, payload = nil, reply_to = nil)
39
+ body = binary(payload)
40
+ frame = buffer(subject.bytesize + body.bytesize + 64)
41
+ write_head(frame, "PUB ", subject, reply_to)
42
+ frame << " " << body.bytesize.to_s << CRLF
43
+ frame << body << CRLF
44
+ end
45
+
46
+ # Encodes an `HPUB` frame.
47
+ #
48
+ # @param subject [String]
49
+ # @param payload [String, nil]
50
+ # @param headers [Headers, String] headers, or a pre-encoded header block
51
+ # @param reply_to [String, nil]
52
+ # @return [String] binary encoded frame
53
+ def hpub(subject, payload, headers, reply_to = nil)
54
+ block = headers.is_a?(Headers) ? headers.to_wire : binary(headers)
55
+ body = binary(payload)
56
+ frame = buffer(subject.bytesize + block.bytesize + body.bytesize + 64)
57
+ write_head(frame, "HPUB ", subject, reply_to)
58
+ frame << " " << block.bytesize.to_s
59
+ frame << " " << (block.bytesize + body.bytesize).to_s << CRLF
60
+ frame << block << body << CRLF
61
+ end
62
+
63
+ # Encodes a `SUB` frame.
64
+ #
65
+ # @param subject [String]
66
+ # @param sid [Integer, String]
67
+ # @param queue [String, nil] queue group
68
+ # @return [String] binary encoded frame
69
+ def sub(subject, sid, queue: nil)
70
+ frame = buffer(subject.bytesize + 32)
71
+ frame << "SUB " << binary(subject)
72
+ frame << " " << binary(queue) if queue
73
+ frame << " " << sid.to_s << CRLF
74
+ end
75
+
76
+ # Encodes an `UNSUB` frame.
77
+ #
78
+ # @param sid [Integer, String]
79
+ # @param max_msgs [Integer, nil] unsubscribe after this many messages
80
+ # @return [String] binary encoded frame
81
+ def unsub(sid, max_msgs = nil)
82
+ frame = buffer(32)
83
+ frame << "UNSUB " << sid.to_s
84
+ frame << " " << max_msgs.to_s if max_msgs
85
+ frame << CRLF
86
+ end
87
+
88
+ # Encodes a `CONNECT` frame over {DEFAULT_CONNECT}.
89
+ #
90
+ # @param options [Hash] overrides for the defaults
91
+ # @return [String] binary encoded frame
92
+ # @raise [ArgumentError] if no_responders is requested without headers
93
+ def connect(options = {})
94
+ payload = DEFAULT_CONNECT.merge(options)
95
+ if payload[:no_responders] && !payload[:headers]
96
+ raise ArgumentError, "no_responders requires headers; the server closes the connection otherwise"
97
+ end
98
+
99
+ json = JSON.generate(payload)
100
+ frame = buffer(json.bytesize + 16)
101
+ frame << "CONNECT " << json << CRLF
102
+ end
103
+
104
+ private
105
+
106
+ def write_head(frame, verb, subject, reply_to)
107
+ frame << verb << binary(subject)
108
+ frame << " " << binary(reply_to) if reply_to
109
+ frame
110
+ end
111
+
112
+ def buffer(capacity)
113
+ String.new(capacity: capacity, encoding: BINARY)
114
+ end
115
+
116
+ def binary(value)
117
+ return EMPTY_PAYLOAD if value.nil?
118
+ return value if value.encoding == BINARY || value.ascii_only?
119
+
120
+ value.b
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ module Protocol
5
+ # Incremental parser for the server side of the NATS protocol.
6
+ #
7
+ # Bytes are appended to one buffer and consumed by advancing an index.
8
+ # Control lines are read in place: subjects, reply subjects, header blocks
9
+ # and payloads are the only substrings allocated, and integers are scanned
10
+ # straight out of the buffer. The buffer is reset in place once drained and
11
+ # compacted only when the consumed prefix passes {COMPACT_THRESHOLD}.
12
+ #
13
+ # The parser never raises into its caller. The reader thread is the only
14
+ # demultiplexer for the whole connection, so its death would stall every
15
+ # subscription at once; malformed input is reported through
16
+ # `sink.on_protocol_error` and the parser marks itself {#broken?}.
17
+ #
18
+ # The sink must respond to:
19
+ #
20
+ # - `on_msg(subject, sid, reply_to, header_bytes, payload)` where `sid` is
21
+ # the opaque token this client chose at SUB time, echoed back verbatim,
22
+ # and `header_bytes` is nil for `MSG` or the raw header block for `HMSG`
23
+ # - `on_ping`, `on_pong`, `on_ok`
24
+ # - `on_info(json)` with the still unparsed JSON text
25
+ # - `on_error(description)` for an `-ERR` line
26
+ # - `on_protocol_error(message)`
27
+ class Parser
28
+ COMPACT_THRESHOLD = 64 * 1024
29
+
30
+ # A leftover at or below this is copied out rather than left in place, so
31
+ # the buffer does not grow across reads and make every later append
32
+ # target a large string.
33
+ SHORT_REMAINDER = 1024
34
+ SNIPPET_LIMIT = 64
35
+
36
+ AWAITING_CONTROL_LINE = :control
37
+ AWAITING_PAYLOAD = :payload
38
+
39
+ MSG_LINE = /\GMSG[ \t]+(\S+)[ \t]+(\S+)[ \t]+(?:(\S+)[ \t]+)?(\d{1,9})[ \t]*\r\n/i
40
+ HMSG_LINE = /\GHMSG[ \t]+(\S+)[ \t]+(\S+)[ \t]+(?:(\S+)[ \t]+)?(\d{1,9})[ \t]+(\d{1,9})[ \t]*\r\n/i
41
+
42
+ CR = 13
43
+ LF = 10
44
+ UPPER_M = 77
45
+ LOWER_M = 109
46
+ UPPER_H = 72
47
+ LOWER_H = 104
48
+ UPPER_P = 80
49
+ LOWER_P = 112
50
+ UPPER_I = 73
51
+ LOWER_I = 105
52
+ PLUS = 43
53
+ MINUS = 45
54
+ UPPER_A = 65
55
+ UPPER_Z = 90
56
+ CASE_SHIFT = 32
57
+ private_constant :MSG_LINE, :HMSG_LINE, :CR, :LF, :UPPER_A, :UPPER_Z, :CASE_SHIFT,
58
+ :UPPER_M, :LOWER_M, :UPPER_H, :LOWER_H, :UPPER_P, :LOWER_P,
59
+ :UPPER_I, :LOWER_I, :PLUS, :MINUS
60
+
61
+ # @return [Symbol] :control or :payload
62
+ def state = @awaiting_payload ? AWAITING_PAYLOAD : AWAITING_CONTROL_LINE
63
+
64
+ # @return [Integer, nil] largest frame accepted, from the server's INFO
65
+ attr_accessor :max_payload
66
+
67
+ # @param sink [Object] receives the parsed frames
68
+ # @param max_control_line [Integer] sanity bound on a single control line
69
+ # @param max_payload [Integer, nil] reject a frame larger than the server
70
+ # advertised in INFO, rather than buffering toward it
71
+ def initialize(sink, max_control_line: DEFAULT_MAX_CONTROL_LINE, max_payload: nil)
72
+ @sink = sink
73
+ @max_control_line = max_control_line
74
+ @max_payload = max_payload
75
+ @buffer = String.new(capacity: COMPACT_THRESHOLD, encoding: BINARY)
76
+ reset!
77
+ end
78
+
79
+ # Appends bytes and consumes every complete frame in the buffer.
80
+ #
81
+ # @param data [String] bytes as read from the socket
82
+ # @return [self]
83
+ def <<(data)
84
+ return self if @broken || data.nil? || data.empty?
85
+
86
+ @buffer << (data.encoding == BINARY ? data : data.b)
87
+ run
88
+ self
89
+ end
90
+ alias parse <<
91
+
92
+ # Discards buffered bytes and returns to the initial state.
93
+ #
94
+ # @return [void]
95
+ def reset!
96
+ @buffer.clear
97
+ @position = 0
98
+ @awaiting_payload = false
99
+ @broken = false
100
+ clear_frame
101
+ end
102
+
103
+ # A broken parser consumes nothing further; the client is expected to
104
+ # tear the connection down.
105
+ #
106
+ # @return [Boolean] whether a protocol violation stopped this parser
107
+ def broken?
108
+ @broken
109
+ end
110
+
111
+ # @return [Integer] bytes buffered but not yet consumed, which is the tail
112
+ # of a frame still arriving
113
+ def pending_bytes
114
+ @buffer.bytesize - @position
115
+ end
116
+
117
+ private
118
+
119
+ def run
120
+ buffer = @buffer
121
+ sink = @sink
122
+ pos = @position
123
+ size = buffer.bytesize
124
+
125
+ loop do
126
+ if @awaiting_payload
127
+ needed = @needed
128
+ break if size - pos < needed + CRLF_SIZE
129
+
130
+ unless buffer.getbyte(pos + needed) == CR && buffer.getbyte(pos + needed + 1) == LF
131
+ @position = pos
132
+ return fail_protocol!("payload not terminated by CRLF on #{@subject}")
133
+ end
134
+
135
+ if (header_size = @header_size)
136
+ sink.on_msg(@subject, @sid, @reply_to, buffer.byteslice(pos, header_size),
137
+ buffer.byteslice(pos + header_size, needed - header_size))
138
+ else
139
+ sink.on_msg(@subject, @sid, @reply_to, nil, buffer.byteslice(pos, needed))
140
+ end
141
+ pos += needed + CRLF_SIZE
142
+ @awaiting_payload = false
143
+ next
144
+ end
145
+
146
+ line_end = buffer.index(CRLF, pos)
147
+ unless line_end
148
+ if size - pos > @max_control_line
149
+ @position = pos
150
+ return fail_protocol!("control line exceeds #{@max_control_line} bytes")
151
+ end
152
+ break
153
+ end
154
+
155
+ byte = buffer.getbyte(pos)
156
+ if [UPPER_M, LOWER_M].include?(byte)
157
+ unless buffer.index(MSG_LINE, pos)
158
+ @position = pos
159
+ return malformed(pos, line_end)
160
+ end
161
+
162
+ subject = ::Regexp.last_match(1)
163
+ sid = ::Regexp.last_match(2)
164
+ reply_to = ::Regexp.last_match(3)
165
+ header_size = nil
166
+ needed = ::Regexp.last_match(4).to_i
167
+ elsif [UPPER_H, LOWER_H].include?(byte)
168
+ unless buffer.index(HMSG_LINE, pos)
169
+ @position = pos
170
+ return malformed(pos, line_end)
171
+ end
172
+
173
+ subject = ::Regexp.last_match(1)
174
+ sid = ::Regexp.last_match(2)
175
+ reply_to = ::Regexp.last_match(3)
176
+ header_size = ::Regexp.last_match(4).to_i
177
+ needed = ::Regexp.last_match(5).to_i
178
+ else
179
+ @position = pos
180
+ return unless dispatch(pos, line_end)
181
+
182
+ pos = line_end + CRLF_SIZE
183
+ next
184
+ end
185
+
186
+ if (header_size && header_size > needed) || (@max_payload && needed > @max_payload)
187
+ @position = pos
188
+ return reject_sizes(subject, header_size, needed)
189
+ end
190
+
191
+ pos = line_end + CRLF_SIZE
192
+
193
+ # The payload is usually already buffered, in which case the frame is
194
+ # emitted straight from locals and never touches the object at all.
195
+ if size - pos >= needed + CRLF_SIZE
196
+ unless buffer.getbyte(pos + needed) == CR && buffer.getbyte(pos + needed + 1) == LF
197
+ @position = pos
198
+ return fail_protocol!("payload not terminated by CRLF on #{subject}")
199
+ end
200
+
201
+ if header_size
202
+ sink.on_msg(subject, sid, reply_to, buffer.byteslice(pos, header_size),
203
+ buffer.byteslice(pos + header_size, needed - header_size))
204
+ else
205
+ sink.on_msg(subject, sid, reply_to, nil, buffer.byteslice(pos, needed))
206
+ end
207
+ pos += needed + CRLF_SIZE
208
+ next
209
+ end
210
+
211
+ @subject = subject
212
+ @sid = sid
213
+ @reply_to = reply_to
214
+ @header_size = header_size
215
+ @needed = needed
216
+ @awaiting_payload = true
217
+ end
218
+
219
+ @position = pos
220
+ compact
221
+ end
222
+
223
+ def reject_sizes(subject, header_size, needed)
224
+ return fail_protocol!("header size exceeds total size on #{subject}") if header_size && header_size > needed
225
+
226
+ fail_protocol!("frame of #{needed} bytes exceeds max_payload #{@max_payload} on #{subject}")
227
+ end
228
+
229
+ def dispatch(start, line_end)
230
+ case @buffer.getbyte(start)
231
+ when UPPER_P, LOWER_P then dispatch_ping_pong(start, line_end)
232
+ when PLUS then dispatch_ok(start, line_end)
233
+ when MINUS then dispatch_err(start, line_end)
234
+ when UPPER_I, LOWER_I then dispatch_info(start, line_end)
235
+ else unknown_operation(start, line_end)
236
+ end
237
+ end
238
+
239
+ def dispatch_ping_pong(start, line_end)
240
+ if verb?(start, line_end, "PING")
241
+ @sink.on_ping
242
+ true
243
+ elsif verb?(start, line_end, "PONG")
244
+ @sink.on_pong
245
+ true
246
+ else
247
+ unknown_operation(start, line_end)
248
+ end
249
+ end
250
+
251
+ def dispatch_ok(start, line_end)
252
+ return unknown_operation(start, line_end) unless verb?(start, line_end, "+OK")
253
+
254
+ @sink.on_ok
255
+ true
256
+ end
257
+
258
+ def dispatch_err(start, line_end)
259
+ return unknown_operation(start, line_end) unless verb?(start, line_end, "-ERR")
260
+
261
+ description = trimmed(start + 4, line_end)
262
+ @sink.on_error(description.delete_prefix("'").delete_suffix("'"))
263
+ true
264
+ end
265
+
266
+ def dispatch_info(start, line_end)
267
+ return unknown_operation(start, line_end) unless verb?(start, line_end, "INFO")
268
+
269
+ json = trimmed(start + 4, line_end)
270
+ return fail_protocol!("malformed INFO: #{snippet(start, line_end)}") if json.empty?
271
+
272
+ @sink.on_info(json)
273
+ true
274
+ end
275
+
276
+ def verb?(start, limit, verb)
277
+ return false if limit - start < verb.bytesize
278
+
279
+ verb.bytesize.times do |offset|
280
+ expected = verb.getbyte(offset)
281
+ actual = @buffer.getbyte(start + offset)
282
+ next if actual == expected
283
+ return false unless expected.between?(UPPER_A, UPPER_Z) && actual == expected + CASE_SHIFT
284
+ end
285
+ true
286
+ end
287
+
288
+ def trimmed(from, to)
289
+ @buffer.byteslice(from, to - from).strip
290
+ end
291
+
292
+ def snippet(start, line_end)
293
+ @buffer.byteslice(start, [line_end - start, SNIPPET_LIMIT].min).inspect
294
+ end
295
+
296
+ def clear_frame
297
+ @subject = nil
298
+ @sid = nil
299
+ @reply_to = nil
300
+ @needed = nil
301
+ @header_size = nil
302
+ end
303
+
304
+ # Draining the buffer in place is the common case. Otherwise the leftover
305
+ # is copied out whenever it is short, which keeps the append target small
306
+ # instead of letting it grow toward COMPACT_THRESHOLD across many reads;
307
+ # copying a few hundred bytes costs far less than appending into a buffer
308
+ # that has grown to tens of kilobytes. A long remainder, meaning a large
309
+ # frame still arriving, is left alone until the threshold.
310
+ def compact
311
+ remaining = @buffer.bytesize - @position
312
+ if remaining.zero?
313
+ @buffer.clear
314
+ @position = 0
315
+ elsif @position >= COMPACT_THRESHOLD || (@position.positive? && remaining <= SHORT_REMAINDER)
316
+ @buffer = @buffer.byteslice(@position..) || String.new(encoding: BINARY)
317
+ @position = 0
318
+ end
319
+ end
320
+
321
+ def malformed(start, line_end)
322
+ fail_protocol!("malformed control line: #{snippet(start, line_end)}")
323
+ end
324
+
325
+ def unknown_operation(start, line_end)
326
+ fail_protocol!("unknown protocol operation: #{snippet(start, line_end)}")
327
+ end
328
+
329
+ def fail_protocol!(message)
330
+ @broken = true
331
+ @sink.on_protocol_error(message)
332
+ false
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ # The NATS client protocol: a line based text protocol over TCP where every
5
+ # control line ends in CRLF and payloads are length prefixed.
6
+ module Protocol
7
+ CRLF = "\r\n"
8
+ CRLF_SIZE = 2
9
+ EMPTY_PAYLOAD = ""
10
+
11
+ PING = "PING#{CRLF}".freeze
12
+ PONG = "PONG#{CRLF}".freeze
13
+
14
+ DEFAULT_MAX_CONTROL_LINE = 1024 * 1024
15
+
16
+ BINARY = Encoding::BINARY
17
+ end
18
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ # One subscription, with its own bounded queue and worker thread.
5
+ #
6
+ # The reader thread only ever enqueues here, never runs the callback: a slow
7
+ # handler must not delay delivery to other subscriptions, and must not delay
8
+ # the PONG that keeps the connection from being dropped as stale.
9
+ class Subscription
10
+ DEFAULT_MAX_PENDING = 65_536
11
+
12
+ # @return [String] the token sent to the server and echoed back on every message
13
+ attr_reader :sid
14
+
15
+ # @return [String]
16
+ attr_reader :subject
17
+
18
+ # @return [String, nil]
19
+ attr_reader :queue_group
20
+
21
+ # @return [Integer] messages delivered to this subscription
22
+ attr_reader :delivered
23
+
24
+ # @return [Integer] messages dropped because the queue was full
25
+ attr_reader :dropped
26
+
27
+ # @return [Client, nil] used by {Msg#respond}
28
+ attr_reader :responder
29
+
30
+ # Starts a worker thread when a callback is given. Without one, messages
31
+ # queue up until {#next_message} takes them on the caller's thread.
32
+ #
33
+ # @param sid [String]
34
+ # @param subject [String]
35
+ # @param queue_group [String, nil]
36
+ # @param max_pending [Integer] queue depth before this is a slow consumer
37
+ # @param on_slow_consumer [Proc, nil] called with self when a message is dropped
38
+ # @param responder [Client, nil] lets {Msg#respond} publish a reply
39
+ # @param callback [Proc, nil] when given, messages are delivered to it on a worker thread
40
+ def initialize(sid:, subject:, queue_group: nil, max_pending: DEFAULT_MAX_PENDING,
41
+ on_slow_consumer: nil, responder: nil, &callback)
42
+ @responder = responder
43
+ @sid = sid
44
+ @subject = subject
45
+ @queue_group = queue_group
46
+ @queue = Thread::SizedQueue.new(max_pending)
47
+ @on_slow_consumer = on_slow_consumer
48
+ @callback = callback
49
+ @delivered = 0
50
+ @dropped = 0
51
+ @lock = Mutex.new
52
+ @worker = start_worker if callback
53
+ end
54
+
55
+ # Enqueues a message. Called on the reader thread, so it never blocks.
56
+ #
57
+ # @param msg [Msg]
58
+ # @return [Boolean] false when the message was dropped
59
+ def deliver(msg)
60
+ @queue.push(msg, true)
61
+ @lock.synchronize { @delivered += 1 }
62
+ true
63
+ rescue ThreadError
64
+ @lock.synchronize { @dropped += 1 }
65
+ @on_slow_consumer&.call(self)
66
+ false
67
+ rescue ClosedQueueError
68
+ false
69
+ end
70
+
71
+ # Takes the next message, for a subscription with no callback.
72
+ #
73
+ # @param deadline [Deadline]
74
+ # @return [Msg]
75
+ # @raise [TimeoutError] if the deadline passes first
76
+ def next_message(deadline = Deadline.infinite)
77
+ msg = @queue.pop(timeout: deadline.remaining)
78
+ raise TimeoutError, "no message on #{@subject} within the deadline" if msg.nil? && !@queue.closed?
79
+ raise ConnectionClosedError, "subscription on #{@subject} was closed" if msg.nil?
80
+
81
+ msg
82
+ end
83
+
84
+ # Depth of the queue between the reader thread and this subscription's
85
+ # worker. Growth here means the handler is not keeping up.
86
+ #
87
+ # @return [Integer] messages waiting to be handled
88
+ def pending
89
+ @queue.size
90
+ end
91
+
92
+ # @return [Boolean] whether delivery has stopped; a closed subscription never resumes
93
+ def closed?
94
+ @queue.closed?
95
+ end
96
+
97
+ # Stops delivery and lets the worker finish what it already took.
98
+ #
99
+ # @return [void]
100
+ def close
101
+ @queue.close
102
+ @worker&.join(1)
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ def start_worker
109
+ Thread.new do
110
+ Thread.current.name = "tether:sub:#{@subject}"
111
+ while (msg = @queue.pop)
112
+ invoke(msg)
113
+ end
114
+ end
115
+ end
116
+
117
+ def invoke(msg)
118
+ @callback.call(msg)
119
+ rescue StandardError => e
120
+ @on_slow_consumer.nil? ? warn("tether: subscription callback raised: #{e.class}: #{e.message}") : nil
121
+ end
122
+ end
123
+ end