jrpc 1.1.7 → 2.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.gitignore +1 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +228 -0
  6. data/CHANGELOG.md +84 -0
  7. data/Gemfile +17 -0
  8. data/README.md +163 -13
  9. data/Rakefile +3 -1
  10. data/bin/console +15 -0
  11. data/bin/jrpc +111 -0
  12. data/bin/jrpc-shell +109 -0
  13. data/bin/setup +8 -0
  14. data/jrpc.gemspec +9 -8
  15. data/lib/jrpc/errors.rb +65 -0
  16. data/lib/jrpc/id_generator.rb +22 -0
  17. data/lib/jrpc/message.rb +78 -0
  18. data/lib/jrpc/shared_client/outbound_queue.rb +71 -0
  19. data/lib/jrpc/shared_client/registry.rb +46 -0
  20. data/lib/jrpc/shared_client/ticket.rb +84 -0
  21. data/lib/jrpc/shared_client/transport_loop.rb +290 -0
  22. data/lib/jrpc/shared_client.rb +194 -0
  23. data/lib/jrpc/simple_client.rb +89 -0
  24. data/lib/jrpc/transport/base.rb +60 -0
  25. data/lib/jrpc/transport/tcp.rb +243 -0
  26. data/lib/jrpc/transport.rb +12 -0
  27. data/lib/jrpc/version.rb +3 -1
  28. data/lib/jrpc.rb +15 -16
  29. metadata +35 -76
  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 -98
  46. data/lib/jrpc/utils.rb +0 -9
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ class SharedClient
5
+ class TransportLoop
6
+ SELECT_FLOOR = 60.0
7
+
8
+ def initialize(
9
+ transport:, registry:, outbound_queue:, wake_pipe_reader:,
10
+ write_timeout:, reap_timeout:, logger:,
11
+ shutdown_check:,
12
+ clock: nil
13
+ )
14
+ @transport = transport
15
+ @registry = registry
16
+ @outbound_queue = outbound_queue
17
+ @wake_pipe_reader = wake_pipe_reader
18
+ @write_timeout = write_timeout
19
+ @reap_timeout = reap_timeout
20
+ @logger = logger
21
+ @shutdown_check = shutdown_check
22
+ @clock = clock || method(:default_clock)
23
+ @last_rx_at = nil
24
+ end
25
+
26
+ # Runs the transport loop. Calls on_crash.(err) on unexpected exception, then returns.
27
+ def run(&)
28
+ main_loop
29
+ rescue StandardError => e
30
+ log_error("transport thread crashed: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
31
+ err = Errors::ConnectionError.new("transport thread crashed: #{e.class}: #{e.message}")
32
+ yield(err)
33
+ end
34
+
35
+ private
36
+
37
+ def clock_now
38
+ @clock.call
39
+ end
40
+
41
+ def default_clock
42
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ end
44
+
45
+ def shutting_down?
46
+ @shutdown_check.call
47
+ end
48
+
49
+ def main_loop
50
+ loop do
51
+ break if shutting_down? && @outbound_queue.empty? && @registry.empty?
52
+
53
+ ensure_connected
54
+
55
+ ios_read = [@wake_pipe_reader]
56
+ ios_read << @transport.socket unless @transport.closed?
57
+ ios_write = []
58
+ ios_write << @transport.socket if !@transport.closed? && !@outbound_queue.empty?
59
+
60
+ timeout = compute_select_timeout
61
+ readable, writable, = IO.select(ios_read, ios_write, [], timeout)
62
+
63
+ drain_wake_pipe if readable&.include?(@wake_pipe_reader)
64
+ flush_one_outbound if writable&.include?(@transport.socket)
65
+ consume_inbound if readable&.include?(@transport.socket)
66
+
67
+ expire_due_tickets
68
+ sweep_dead_threads
69
+ reap_if_idle
70
+ end
71
+ end
72
+
73
+ def ensure_connected
74
+ return unless @transport.closed?
75
+ return if @outbound_queue.empty? && @registry.empty?
76
+
77
+ begin
78
+ @transport.connect
79
+ @last_rx_at = clock_now
80
+ rescue Transport::Base::ConnectionError, Transport::Base::Timeout => e
81
+ drain_connection_error("connect failed: #{e.message}")
82
+ end
83
+ end
84
+
85
+ def compute_select_timeout
86
+ now = clock_now
87
+ candidates = [SELECT_FLOOR]
88
+
89
+ registry_min = nil
90
+ @registry.each_ticket { |t| registry_min = [registry_min || t.expires_at, t.expires_at].min if t.expires_at }
91
+ queue_min = @outbound_queue.earliest_deadline
92
+
93
+ [registry_min, queue_min].each do |deadline|
94
+ candidates << [deadline - now, 0].max if deadline
95
+ end
96
+
97
+ if @reap_timeout && !@transport.closed? && @outbound_queue.empty? && @registry.empty? && @last_rx_at
98
+ reap_remaining = (@last_rx_at + @reap_timeout) - now
99
+ candidates << [reap_remaining, 0].max
100
+ end
101
+
102
+ candidates.min
103
+ end
104
+
105
+ def drain_wake_pipe
106
+ loop do
107
+ @wake_pipe_reader.read_nonblock(1024)
108
+ end
109
+ rescue IO::WaitReadable
110
+ # drained
111
+ end
112
+
113
+ def flush_one_outbound
114
+ ticket = @outbound_queue.pop_nonblock
115
+ return unless ticket
116
+
117
+ return if ticket.cancelled?
118
+
119
+ now = clock_now
120
+ if ticket.expired?(now)
121
+ @registry.delete(ticket) if ticket.id
122
+ if ticket.thread.nil?
123
+ log_error('fire_and_forget notification expired before send')
124
+ elsif ticket.alive?
125
+ ticket.reject(Errors::Timeout.new('ttl expired before send'))
126
+ else
127
+ log_error("ticket expired before send; owner gone: #{ticket.id.inspect}")
128
+ end
129
+ return
130
+ end
131
+
132
+ begin
133
+ @transport.write_frame(ticket.payload, timeout: @write_timeout)
134
+ rescue Transport::Base::Timeout => e
135
+ err = Errors::Timeout.new("write timeout: #{e.message}")
136
+ signal_or_log(ticket, err)
137
+ drain_connection_error('write timeout; partial frame may have been sent')
138
+ return
139
+ rescue Transport::Base::ConnectionError, Transport::Base::Error => e
140
+ err = Errors::ConnectionError.new(e.message)
141
+ signal_or_log(ticket, err)
142
+ drain_connection_error(e.message)
143
+ return
144
+ end
145
+
146
+ # Fire-and-forget notification: nothing more to do.
147
+ return if ticket.thread.nil?
148
+
149
+ # Blocking notification (no id): the caller only waits for the send.
150
+ # Request (has id): leave it registered; the response will resolve it.
151
+ ticket.fulfill(nil) if ticket.id.nil?
152
+ end
153
+
154
+ def read_next_frame
155
+ @transport.try_read_frame
156
+ rescue Transport::Base::MalformedFrame => e
157
+ log_error("framing error: #{e.message}")
158
+ drain_connection_error('framing corruption; stream resynchronized')
159
+ nil
160
+ rescue Transport::Base::ConnectionError, Transport::Base::Error => e
161
+ drain_connection_error(e.message)
162
+ nil
163
+ end
164
+
165
+ def consume_inbound
166
+ loop do
167
+ frame = read_next_frame
168
+ return unless frame
169
+
170
+ break if frame == :wait
171
+
172
+ @last_rx_at = clock_now
173
+
174
+ begin
175
+ parsed = Message.parse(frame)
176
+ rescue Errors::MalformedResponseError => e
177
+ log_error("JSON parse error on inbound frame: #{e.message}")
178
+ drain_connection_error('framing corruption; stream resynchronized')
179
+ return
180
+ end
181
+
182
+ id = parsed['id']
183
+ if id.nil?
184
+ log_error('server-initiated message received (no id), dropping')
185
+ next
186
+ end
187
+
188
+ ticket = @registry.fetch_and_delete(id)
189
+ if ticket.nil?
190
+ log_error("orphan response: id=#{id.inspect}")
191
+ next
192
+ end
193
+
194
+ if ticket.cancelled? || !ticket.alive?
195
+ log_error("orphan response: ticket #{id.inspect} cancelled or owner dead")
196
+ next
197
+ end
198
+
199
+ begin
200
+ Message.validate_response!(parsed, id)
201
+ rescue Errors::MalformedResponseError => e
202
+ ticket.reject(e)
203
+ next
204
+ end
205
+
206
+ if parsed.key?('error')
207
+ ticket.reject(Message.error_to_exception(parsed['error']))
208
+ else
209
+ ticket.fulfill(parsed['result'])
210
+ end
211
+ end
212
+ end
213
+
214
+ def expire_due_tickets
215
+ now = clock_now
216
+ expired_ids = []
217
+ @registry.each_ticket { |t| expired_ids << t.id if t.expired?(now) }
218
+ expired_ids.each do |id|
219
+ ticket = @registry.fetch_and_delete(id)
220
+ next unless ticket
221
+
222
+ if ticket.alive?
223
+ ticket.reject(Errors::Timeout.new('ttl expired'))
224
+ else
225
+ log_error("expired ticket #{id.inspect} owner thread dead; dropping")
226
+ end
227
+ end
228
+
229
+ @outbound_queue.each_snapshot do |ticket|
230
+ next unless ticket.expired?(now)
231
+
232
+ @outbound_queue.delete(ticket)
233
+ if ticket.thread.nil?
234
+ log_error('fire_and_forget notification expired in queue')
235
+ elsif ticket.alive?
236
+ ticket.reject(Errors::Timeout.new('ttl expired'))
237
+ else
238
+ log_error("expired queued ticket #{ticket.id.inspect} owner thread dead; dropping")
239
+ end
240
+ end
241
+ end
242
+
243
+ def sweep_dead_threads
244
+ dead = []
245
+ @registry.each_ticket { |t| dead << t unless t.alive? }
246
+ dead.each do |ticket|
247
+ @registry.delete(ticket)
248
+ log_error("owner thread dead; dropping in-flight ticket #{ticket.id.inspect}")
249
+ end
250
+ end
251
+
252
+ def reap_if_idle
253
+ return unless @reap_timeout
254
+ return if @transport.closed?
255
+ return unless @outbound_queue.empty? && @registry.empty?
256
+ return unless @last_rx_at && (clock_now - @last_rx_at) >= @reap_timeout
257
+
258
+ @transport.close
259
+ @last_rx_at = nil
260
+ end
261
+
262
+ def drain_connection_error(message)
263
+ begin
264
+ @transport.close
265
+ rescue StandardError
266
+ nil
267
+ end
268
+ err = Errors::ConnectionError.new(message)
269
+ @registry.drain_all_with(err)
270
+ @outbound_queue.each_snapshot do |ticket|
271
+ @outbound_queue.delete(ticket)
272
+ signal_or_log(ticket, err)
273
+ end
274
+ @last_rx_at = nil
275
+ end
276
+
277
+ def signal_or_log(ticket, err)
278
+ if ticket.thread
279
+ ticket.reject(err)
280
+ else
281
+ log_error("fire_and_forget send failed: #{err.message}")
282
+ end
283
+ end
284
+
285
+ def log_error(msg)
286
+ @logger&.error("[JRPC::SharedClient] #{msg}")
287
+ end
288
+ end
289
+ end
290
+ 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,89 @@
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
+ attr_reader :server
10
+
11
+ def initialize(server, **options)
12
+ @closed = false
13
+ @server = server
14
+ @read_timeout = options.fetch(:read_timeout, 60)
15
+ @write_timeout = options.fetch(:write_timeout, 60)
16
+ @autoclose = options.fetch(:autoclose, false)
17
+ @logger = options[:logger]
18
+ @transport = options.fetch(:transport) do
19
+ Transport.build(server, **options)
20
+ end
21
+ @id_gen = options.fetch(:id_gen) do
22
+ IdGenerator.new(prefix: options[:id_prefix], thread_safe: false)
23
+ end
24
+ end
25
+
26
+ def request(method, params = nil, read_timeout: @read_timeout, write_timeout: @write_timeout)
27
+ raise Errors::ClientError, 'client closed' if @closed
28
+
29
+ id = @id_gen.next
30
+ json = Message.dump(Message.build_request(method, params, id))
31
+
32
+ with_transport_error_handling do
33
+ connect_if_needed!
34
+ @transport.write_frame(json, timeout: write_timeout)
35
+ raw = @transport.read_frame(timeout: read_timeout)
36
+ response = Message.parse(raw)
37
+ Message.validate_response!(response, id)
38
+ raise Message.error_to_exception(response['error']) if response.key?('error')
39
+
40
+ response['result']
41
+ end
42
+ end
43
+
44
+ def notification(method, params = nil, write_timeout: @write_timeout)
45
+ raise Errors::ClientError, 'client closed' if @closed
46
+
47
+ json = Message.dump(Message.build_notification(method, params))
48
+
49
+ with_transport_error_handling do
50
+ connect_if_needed!
51
+ @transport.write_frame(json, timeout: write_timeout)
52
+ nil
53
+ end
54
+ end
55
+
56
+ def close
57
+ return true if @closed
58
+
59
+ @transport.close
60
+ @closed = true
61
+ true
62
+ end
63
+
64
+ def closed?
65
+ @closed
66
+ end
67
+
68
+ private
69
+
70
+ def connect_if_needed!
71
+ @transport.connect if @transport.closed?
72
+ end
73
+
74
+ # Translate transport-level errors into the client's public Errors:: hierarchy and
75
+ # apply the autoclose policy. Shared by request and notification so the mapping and
76
+ # the close-after-each-call rule live in exactly one place.
77
+ def with_transport_error_handling
78
+ yield
79
+ rescue Transport::Base::Timeout => e
80
+ raise Errors::Timeout, e.message
81
+ rescue Transport::Base::ConnectionError => e
82
+ raise Errors::ConnectionError, e.message
83
+ rescue Transport::Base::MalformedFrame => e
84
+ raise Errors::MalformedResponseError, e.message
85
+ ensure
86
+ @transport.close if @autoclose
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,60 @@
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
+ end
25
+
26
+ # Abstract interface. Subclasses must implement every method below; the bodies
27
+ # only exist to fail loudly if one is forgotten, so they carry no logic worth
28
+ # covering and are excluded from coverage via :nocov:.
29
+ # :nocov:
30
+ def connect
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def write_frame(bytes, timeout:)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ def read_frame(timeout:)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def try_read_frame
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def close
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def closed?
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def socket
55
+ raise NotImplementedError
56
+ end
57
+ # :nocov:
58
+ end
59
+ end
60
+ end