jrpc 1.1.8 → 2.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +228 -0
  5. data/CHANGELOG.md +67 -0
  6. data/Gemfile +17 -0
  7. data/README.md +240 -13
  8. data/Rakefile +3 -1
  9. data/bin/console +1 -0
  10. data/bin/jrpc +37 -26
  11. data/bin/jrpc-shell +34 -24
  12. data/jrpc.gemspec +6 -8
  13. data/lib/jrpc/errors.rb +65 -0
  14. data/lib/jrpc/id_generator.rb +22 -0
  15. data/lib/jrpc/message.rb +78 -0
  16. data/lib/jrpc/payload_logging.rb +19 -0
  17. data/lib/jrpc/shared_client/outbound_queue.rb +71 -0
  18. data/lib/jrpc/shared_client/registry.rb +46 -0
  19. data/lib/jrpc/shared_client/ticket.rb +84 -0
  20. data/lib/jrpc/shared_client/transport_loop.rb +298 -0
  21. data/lib/jrpc/shared_client.rb +194 -0
  22. data/lib/jrpc/simple_client.rb +98 -0
  23. data/lib/jrpc/transport/base.rb +63 -0
  24. data/lib/jrpc/transport/tcp.rb +292 -0
  25. data/lib/jrpc/transport/test.rb +333 -0
  26. data/lib/jrpc/transport.rb +12 -0
  27. data/lib/jrpc/version.rb +3 -1
  28. data/lib/jrpc.rb +14 -16
  29. metadata +25 -71
  30. data/.travis.yml +0 -4
  31. data/lib/jrpc/base_client.rb +0 -123
  32. data/lib/jrpc/error/client_error.rb +0 -5
  33. data/lib/jrpc/error/connection_error.rb +0 -11
  34. data/lib/jrpc/error/error.rb +0 -5
  35. data/lib/jrpc/error/internal_error.rb +0 -9
  36. data/lib/jrpc/error/internal_server_error.rb +0 -5
  37. data/lib/jrpc/error/invalid_params.rb +0 -9
  38. data/lib/jrpc/error/invalid_request.rb +0 -9
  39. data/lib/jrpc/error/method_not_found.rb +0 -9
  40. data/lib/jrpc/error/parse_error.rb +0 -9
  41. data/lib/jrpc/error/server_error.rb +0 -11
  42. data/lib/jrpc/error/unknown_error.rb +0 -5
  43. data/lib/jrpc/tcp_client.rb +0 -112
  44. data/lib/jrpc/transport/socket_base.rb +0 -88
  45. data/lib/jrpc/transport/socket_tcp.rb +0 -132
  46. data/lib/jrpc/utils.rb +0 -9
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ class SharedClient
5
+ class TransportLoop
6
+ include PayloadLogging
7
+
8
+ SELECT_FLOOR = 60.0
9
+
10
+ def initialize(
11
+ transport:, registry:, outbound_queue:, wake_pipe_reader:,
12
+ write_timeout:, reap_timeout:, logger:,
13
+ shutdown_check:,
14
+ clock: nil
15
+ )
16
+ @transport = transport
17
+ @registry = registry
18
+ @outbound_queue = outbound_queue
19
+ @wake_pipe_reader = wake_pipe_reader
20
+ @write_timeout = write_timeout
21
+ @reap_timeout = reap_timeout
22
+ @logger = logger
23
+ @shutdown_check = shutdown_check
24
+ @clock = clock || method(:default_clock)
25
+ @last_rx_at = nil
26
+ end
27
+
28
+ # Runs the transport loop. Calls on_crash.(err) on unexpected exception, then returns.
29
+ def run(&)
30
+ main_loop
31
+ rescue StandardError => e
32
+ log_error("transport thread crashed: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
33
+ err = Errors::ConnectionError.new("transport thread crashed: #{e.class}: #{e.message}")
34
+ yield(err)
35
+ end
36
+
37
+ private
38
+
39
+ def clock_now
40
+ @clock.call
41
+ end
42
+
43
+ def default_clock
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ end
46
+
47
+ def shutting_down?
48
+ @shutdown_check.call
49
+ end
50
+
51
+ def main_loop
52
+ loop do
53
+ break if shutting_down? && @outbound_queue.empty? && @registry.empty?
54
+
55
+ ensure_connected
56
+
57
+ ios_read = [@wake_pipe_reader]
58
+ ios_read << @transport.socket unless @transport.closed?
59
+ ios_write = []
60
+ ios_write << @transport.socket if !@transport.closed? && !@outbound_queue.empty?
61
+
62
+ timeout = compute_select_timeout
63
+ readable, writable, = IO.select(ios_read, ios_write, [], timeout)
64
+
65
+ drain_wake_pipe if readable&.include?(@wake_pipe_reader)
66
+ flush_one_outbound if writable&.include?(@transport.socket)
67
+ consume_inbound if readable&.include?(@transport.socket)
68
+
69
+ expire_due_tickets
70
+ sweep_dead_threads
71
+ reap_if_idle
72
+ end
73
+ end
74
+
75
+ def ensure_connected
76
+ return unless @transport.closed?
77
+ return if @outbound_queue.empty? && @registry.empty?
78
+
79
+ begin
80
+ @transport.connect
81
+ @last_rx_at = clock_now
82
+ rescue Transport::Base::ConnectionError, Transport::Base::Timeout => e
83
+ drain_connection_error("connect failed: #{e.message}")
84
+ end
85
+ end
86
+
87
+ def compute_select_timeout
88
+ now = clock_now
89
+ candidates = [SELECT_FLOOR]
90
+
91
+ registry_min = nil
92
+ @registry.each_ticket { |t| registry_min = [registry_min || t.expires_at, t.expires_at].min if t.expires_at }
93
+ queue_min = @outbound_queue.earliest_deadline
94
+
95
+ [registry_min, queue_min].each do |deadline|
96
+ candidates << [deadline - now, 0].max if deadline
97
+ end
98
+
99
+ if @reap_timeout && !@transport.closed? && @outbound_queue.empty? && @registry.empty? && @last_rx_at
100
+ reap_remaining = (@last_rx_at + @reap_timeout) - now
101
+ candidates << [reap_remaining, 0].max
102
+ end
103
+
104
+ candidates.min
105
+ end
106
+
107
+ def drain_wake_pipe
108
+ loop do
109
+ @wake_pipe_reader.read_nonblock(1024)
110
+ end
111
+ rescue IO::WaitReadable
112
+ # drained
113
+ end
114
+
115
+ def flush_one_outbound
116
+ ticket = @outbound_queue.pop_nonblock
117
+ return unless ticket
118
+
119
+ return if ticket.cancelled?
120
+
121
+ now = clock_now
122
+ if ticket.expired?(now)
123
+ @registry.delete(ticket) if ticket.id
124
+ if ticket.thread.nil?
125
+ log_error('fire_and_forget notification expired before send')
126
+ elsif ticket.alive?
127
+ ticket.reject(Errors::Timeout.new('ttl expired before send'))
128
+ else
129
+ log_error("ticket expired before send; owner gone: #{ticket.id.inspect}")
130
+ end
131
+ return
132
+ end
133
+
134
+ begin
135
+ log_sent(ticket.payload)
136
+ @transport.write_frame(ticket.payload, timeout: @write_timeout)
137
+ rescue Transport::Base::Timeout => e
138
+ err = Errors::Timeout.new("write timeout: #{e.message}")
139
+ signal_or_log(ticket, err)
140
+ drain_connection_error('write timeout; partial frame may have been sent')
141
+ return
142
+ rescue Transport::Base::ConnectionError, Transport::Base::Error => e
143
+ err = Errors::ConnectionError.new(e.message)
144
+ signal_or_log(ticket, err)
145
+ drain_connection_error(e.message)
146
+ return
147
+ end
148
+
149
+ # Fire-and-forget notification: nothing more to do.
150
+ return if ticket.thread.nil?
151
+
152
+ # Blocking notification (no id): the caller only waits for the send.
153
+ # Request (has id): leave it registered; the response will resolve it.
154
+ ticket.fulfill(nil) if ticket.id.nil?
155
+ end
156
+
157
+ def read_next_frame
158
+ @transport.try_read_frame
159
+ rescue Transport::Base::MalformedFrame => e
160
+ log_error("framing error: #{e.message}")
161
+ drain_connection_error('framing corruption; stream resynchronized')
162
+ nil
163
+ rescue Transport::Base::ConnectionError, Transport::Base::Error => e
164
+ drain_connection_error(e.message)
165
+ nil
166
+ end
167
+
168
+ def consume_inbound
169
+ loop do
170
+ frame = read_next_frame
171
+ return unless frame
172
+
173
+ break if frame == :wait
174
+
175
+ @last_rx_at = clock_now
176
+ log_received(frame)
177
+
178
+ begin
179
+ parsed = Message.parse(frame)
180
+ rescue Errors::MalformedResponseError => e
181
+ log_error("JSON parse error on inbound frame: #{e.message}")
182
+ drain_connection_error('framing corruption; stream resynchronized')
183
+ return
184
+ end
185
+
186
+ id = parsed['id']
187
+ if id.nil?
188
+ log_error('server-initiated message received (no id), dropping')
189
+ next
190
+ end
191
+
192
+ ticket = @registry.fetch_and_delete(id)
193
+ if ticket.nil?
194
+ log_error("orphan response: id=#{id.inspect}")
195
+ next
196
+ end
197
+
198
+ if ticket.cancelled? || !ticket.alive?
199
+ log_error("orphan response: ticket #{id.inspect} cancelled or owner dead")
200
+ next
201
+ end
202
+
203
+ begin
204
+ Message.validate_response!(parsed, id)
205
+ rescue Errors::MalformedResponseError => e
206
+ ticket.reject(e)
207
+ next
208
+ end
209
+
210
+ if parsed.key?('error')
211
+ ticket.reject(Message.error_to_exception(parsed['error']))
212
+ else
213
+ ticket.fulfill(parsed['result'])
214
+ end
215
+ end
216
+ end
217
+
218
+ def expire_due_tickets
219
+ now = clock_now
220
+ expired_ids = []
221
+ @registry.each_ticket { |t| expired_ids << t.id if t.expired?(now) }
222
+ expired_ids.each do |id|
223
+ ticket = @registry.fetch_and_delete(id)
224
+ next unless ticket
225
+
226
+ if ticket.alive?
227
+ ticket.reject(Errors::Timeout.new('ttl expired'))
228
+ else
229
+ log_error("expired ticket #{id.inspect} owner thread dead; dropping")
230
+ end
231
+ end
232
+
233
+ @outbound_queue.each_snapshot do |ticket|
234
+ next unless ticket.expired?(now)
235
+
236
+ @outbound_queue.delete(ticket)
237
+ if ticket.thread.nil?
238
+ log_error('fire_and_forget notification expired in queue')
239
+ elsif ticket.alive?
240
+ ticket.reject(Errors::Timeout.new('ttl expired'))
241
+ else
242
+ log_error("expired queued ticket #{ticket.id.inspect} owner thread dead; dropping")
243
+ end
244
+ end
245
+ end
246
+
247
+ def sweep_dead_threads
248
+ dead = []
249
+ @registry.each_ticket { |t| dead << t unless t.alive? }
250
+ dead.each do |ticket|
251
+ @registry.delete(ticket)
252
+ log_error("owner thread dead; dropping in-flight ticket #{ticket.id.inspect}")
253
+ end
254
+ end
255
+
256
+ def reap_if_idle
257
+ return unless @reap_timeout
258
+ return if @transport.closed?
259
+ return unless @outbound_queue.empty? && @registry.empty?
260
+ return unless @last_rx_at && (clock_now - @last_rx_at) >= @reap_timeout
261
+
262
+ @transport.close
263
+ @last_rx_at = nil
264
+ end
265
+
266
+ def drain_connection_error(message)
267
+ begin
268
+ @transport.close
269
+ rescue StandardError
270
+ nil
271
+ end
272
+ err = Errors::ConnectionError.new(message)
273
+ @registry.drain_all_with(err)
274
+ @outbound_queue.each_snapshot do |ticket|
275
+ @outbound_queue.delete(ticket)
276
+ signal_or_log(ticket, err)
277
+ end
278
+ @last_rx_at = nil
279
+ end
280
+
281
+ def signal_or_log(ticket, err)
282
+ if ticket.thread
283
+ ticket.reject(err)
284
+ else
285
+ log_error("fire_and_forget send failed: #{err.message}")
286
+ end
287
+ end
288
+
289
+ def log_error(msg)
290
+ @logger&.error("[#{log_tag}] #{msg}")
291
+ end
292
+
293
+ def log_tag
294
+ 'JRPC::SharedClient'
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ class SharedClient
5
+ attr_reader :server
6
+
7
+ def initialize(server, **options)
8
+ @server = server
9
+ @write_timeout = options.fetch(:write_timeout, 5)
10
+ @default_ttl = options.fetch(:default_ttl, 30)
11
+ @reap_timeout = options.fetch(:reap_timeout, nil)
12
+ @max_queue_size = options.fetch(:max_queue_size, 10_000)
13
+ @logger = options.fetch(:logger, nil)
14
+ # Single monotonic clock source shared with the transport loop. The
15
+ # :clock option is an internal test seam (deterministic TTL specs);
16
+ # callers should not need it.
17
+ @clock = options.fetch(:clock, nil) || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
18
+
19
+ if @write_timeout && @default_ttl && @write_timeout >= @default_ttl
20
+ raise ArgumentError, "write_timeout (#{@write_timeout}) must be < default_ttl (#{@default_ttl})"
21
+ end
22
+
23
+ @transport = options.fetch(:transport) do
24
+ Transport.build(server, **options)
25
+ end
26
+
27
+ @id_gen = options.fetch(:id_gen) do
28
+ IdGenerator.new(prefix: options.fetch(:id_prefix, nil), thread_safe: true)
29
+ end
30
+
31
+ @registry = SharedClient::Registry.new
32
+ @outbound_queue = SharedClient::OutboundQueue.new(capacity: @max_queue_size)
33
+ @wake_pipe_reader, @wake_pipe_writer = IO.pipe
34
+
35
+ @lifecycle_mutex = Mutex.new
36
+ # :running -> :closing -> :closed (user-initiated close)
37
+ # :running -> :dead (transport thread crashed)
38
+ @status = :running
39
+
40
+ shutdown_check = -> { @lifecycle_mutex.synchronize { @status == :closing } }
41
+
42
+ @transport_loop = SharedClient::TransportLoop.new(
43
+ transport: @transport,
44
+ registry: @registry,
45
+ outbound_queue: @outbound_queue,
46
+ wake_pipe_reader: @wake_pipe_reader,
47
+ write_timeout: @write_timeout,
48
+ reap_timeout: @reap_timeout,
49
+ logger: @logger,
50
+ shutdown_check: shutdown_check,
51
+ clock: @clock
52
+ )
53
+
54
+ @transport_thread = Thread.new do
55
+ @transport_loop.run do |err|
56
+ @lifecycle_mutex.synchronize { @status = :dead }
57
+ drain_all(err)
58
+ end
59
+ end
60
+ @transport_thread.abort_on_exception = false
61
+ end
62
+
63
+ def request(method, params = nil, ttl: @default_ttl)
64
+ id = @id_gen.next
65
+ ticket = Ticket.new(
66
+ id: id,
67
+ payload: Message.dump(Message.build_request(method, params, id)),
68
+ thread: Thread.current,
69
+ expires_at: ttl ? clock_now + ttl : nil
70
+ )
71
+
72
+ enqueue!(ticket)
73
+ await(ticket, ttl)
74
+ end
75
+
76
+ def notification(method, params = nil, ttl: @default_ttl, fire_and_forget: false)
77
+ ticket = Ticket.new(
78
+ id: nil,
79
+ payload: Message.dump(Message.build_notification(method, params)),
80
+ thread: fire_and_forget ? nil : Thread.current,
81
+ expires_at: ttl ? clock_now + ttl : nil
82
+ )
83
+
84
+ enqueue!(ticket)
85
+ return nil if fire_and_forget
86
+
87
+ await(ticket, ttl)
88
+ nil
89
+ end
90
+
91
+ def close(timeout: 5)
92
+ @lifecycle_mutex.synchronize do
93
+ return true if @status == :closed
94
+
95
+ @status = :closing
96
+ end
97
+
98
+ wake_transport
99
+
100
+ joined = @transport_thread.join(timeout)
101
+
102
+ unless joined
103
+ begin
104
+ @transport.close
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ @transport_thread.join(1.0)
109
+ @transport_thread.kill if @transport_thread.alive?
110
+ @transport_thread.join
111
+ # Forced kill: the loop never reached its graceful drain, so fail
112
+ # whatever was still in flight. (A graceful exit drained itself; a
113
+ # crash drained via on_crash. reject is idempotent in every case.)
114
+ drain_all(Errors::ConnectionError.new('client force-closed'))
115
+ end
116
+
117
+ # Safe now that the transport thread is guaranteed dead.
118
+ begin
119
+ @wake_pipe_writer.close
120
+ rescue StandardError
121
+ nil
122
+ end
123
+ begin
124
+ @wake_pipe_reader.close
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ @lifecycle_mutex.synchronize { @status = :closed }
130
+ !joined.nil?
131
+ end
132
+
133
+ def closed?
134
+ @lifecycle_mutex.synchronize { @status == :closed }
135
+ end
136
+
137
+ WAIT_GRACE = 1.0 # caller-side backstop beyond the loop-enforced ttl
138
+
139
+ private
140
+
141
+ def clock_now
142
+ @clock.call
143
+ end
144
+
145
+ def enqueue!(ticket)
146
+ @lifecycle_mutex.synchronize do
147
+ case @status
148
+ when :closing, :closed
149
+ raise Errors::ClientError, 'client closed'
150
+ when :dead
151
+ raise Errors::ClientError, 'client unusable: transport thread exited'
152
+ end
153
+ @outbound_queue.push_nonblock(ticket)
154
+ @registry.register(ticket) if ticket.id
155
+ end
156
+ wake_transport
157
+ end
158
+
159
+ # Caller side. Blocks thread- and fiber-cooperatively until the loop
160
+ # resolves the ticket, with a real-time backstop in case the loop dies.
161
+ def await(ticket, ttl)
162
+ ticket.wait(ttl ? ttl + WAIT_GRACE : nil)
163
+
164
+ if ticket.fulfilled?
165
+ ticket.result
166
+ elsif ticket.rejected?
167
+ raise ticket.error
168
+ else
169
+ raise Errors::Timeout, "request timed out after #{ttl}s"
170
+ end
171
+ ensure
172
+ unless ticket.resolved?
173
+ ticket.cancel
174
+ @registry.delete(ticket)
175
+ @outbound_queue.delete(ticket)
176
+ end
177
+ end
178
+
179
+ def drain_all(err)
180
+ @registry.drain_all_with(err)
181
+ @outbound_queue.close_and_drain.each do |ticket|
182
+ next if ticket.thread.nil?
183
+
184
+ ticket.reject(err)
185
+ end
186
+ end
187
+
188
+ def wake_transport
189
+ @wake_pipe_writer.write_nonblock('.')
190
+ rescue StandardError
191
+ nil
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ # NOT thread-safe: a SimpleClient instance must not be shared across threads or
5
+ # fibers. It owns a single transport/socket plus an unsynchronized id counter, so
6
+ # concurrent calls would interleave socket reads/writes and corrupt the framing
7
+ # buffer. Use one instance per thread/fiber (or a pool of instances).
8
+ class SimpleClient
9
+ include PayloadLogging
10
+
11
+ attr_reader :server
12
+
13
+ def initialize(server, **options)
14
+ @closed = false
15
+ @server = server
16
+ @read_timeout = options.fetch(:read_timeout, 60)
17
+ @write_timeout = options.fetch(:write_timeout, 60)
18
+ @autoclose = options.fetch(:autoclose, false)
19
+ @logger = options[:logger]
20
+ @transport = options.fetch(:transport) do
21
+ Transport.build(server, **options)
22
+ end
23
+ @id_gen = options.fetch(:id_gen) do
24
+ IdGenerator.new(prefix: options[:id_prefix], thread_safe: false)
25
+ end
26
+ end
27
+
28
+ def request(method, params = nil, read_timeout: @read_timeout, write_timeout: @write_timeout)
29
+ raise Errors::ClientError, 'client closed' if @closed
30
+
31
+ id = @id_gen.next
32
+ json = Message.dump(Message.build_request(method, params, id))
33
+
34
+ with_transport_error_handling do
35
+ connect_if_needed!
36
+ log_sent(json)
37
+ @transport.write_frame(json, timeout: write_timeout)
38
+ raw = @transport.read_frame(timeout: read_timeout)
39
+ log_received(raw)
40
+ response = Message.parse(raw)
41
+ Message.validate_response!(response, id)
42
+ raise Message.error_to_exception(response['error']) if response.key?('error')
43
+
44
+ response['result']
45
+ end
46
+ end
47
+
48
+ def notification(method, params = nil, write_timeout: @write_timeout)
49
+ raise Errors::ClientError, 'client closed' if @closed
50
+
51
+ json = Message.dump(Message.build_notification(method, params))
52
+
53
+ with_transport_error_handling do
54
+ connect_if_needed!
55
+ log_sent(json)
56
+ @transport.write_frame(json, timeout: write_timeout)
57
+ nil
58
+ end
59
+ end
60
+
61
+ def close
62
+ return true if @closed
63
+
64
+ @transport.close
65
+ @closed = true
66
+ true
67
+ end
68
+
69
+ def closed?
70
+ @closed
71
+ end
72
+
73
+ private
74
+
75
+ def log_tag
76
+ 'JRPC::SimpleClient'
77
+ end
78
+
79
+ def connect_if_needed!
80
+ @transport.connect if @transport.closed?
81
+ end
82
+
83
+ # Translate transport-level errors into the client's public Errors:: hierarchy and
84
+ # apply the autoclose policy. Shared by request and notification so the mapping and
85
+ # the close-after-each-call rule live in exactly one place.
86
+ def with_transport_error_handling
87
+ yield
88
+ rescue Transport::Base::Timeout => e
89
+ raise Errors::Timeout, e.message
90
+ rescue Transport::Base::ConnectionError => e
91
+ raise Errors::ConnectionError, e.message
92
+ rescue Transport::Base::MalformedFrame => e
93
+ raise Errors::MalformedResponseError, e.message
94
+ ensure
95
+ @transport.close if @autoclose
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ module Transport
5
+ class Base
6
+ class Error < StandardError; end
7
+
8
+ class ConnectionError < Error; end
9
+
10
+ class Timeout < Error; end
11
+
12
+ class MalformedFrame < Error; end
13
+
14
+ def initialize(server, **options)
15
+ @server = server
16
+ # connect_timeout: total wall-clock budget for the whole connect (across retries).
17
+ # connect_attempt_timeout: cap on a single connect attempt; nil means a single
18
+ # attempt is bounded only by whatever is left of connect_timeout.
19
+ @connect_timeout = options.fetch(:connect_timeout, 60)
20
+ @connect_attempt_timeout = options.fetch(:connect_attempt_timeout, nil)
21
+ @connect_retry_count = options.fetch(:connect_retry_count, 0)
22
+ @connect_retry_interval = options.fetch(:connect_retry_interval, 0.5)
23
+ @write_timeout = options.fetch(:write_timeout, nil)
24
+ # Optional RFC2385 TCP MD5 Signature key. nil disables it. When set, the
25
+ # transport installs it on the socket before connect (Linux-only). See Tcp.
26
+ @tcp_md5_pass = options.fetch(:tcp_md5_pass, nil)
27
+ end
28
+
29
+ # Abstract interface. Subclasses must implement every method below; the bodies
30
+ # only exist to fail loudly if one is forgotten, so they carry no logic worth
31
+ # covering and are excluded from coverage via :nocov:.
32
+ # :nocov:
33
+ def connect
34
+ raise NotImplementedError
35
+ end
36
+
37
+ def write_frame(bytes, timeout:)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def read_frame(timeout:)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def try_read_frame
46
+ raise NotImplementedError
47
+ end
48
+
49
+ def close
50
+ raise NotImplementedError
51
+ end
52
+
53
+ def closed?
54
+ raise NotImplementedError
55
+ end
56
+
57
+ def socket
58
+ raise NotImplementedError
59
+ end
60
+ # :nocov:
61
+ end
62
+ end
63
+ end