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 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
@@ -0,0 +1,5 @@
1
+ # Tether
2
+
3
+ ## License
4
+
5
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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