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,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module JRPC
6
+ module Transport
7
+ class Tcp < Base
8
+ # Pull error classes into this scope so `raise ConnectionError` resolves correctly.
9
+ # Without this, Ruby's constant lookup would find JRPC::ConnectionError (v1) instead.
10
+ ConnectionError = Base::ConnectionError
11
+ Timeout = Base::Timeout
12
+ MalformedFrame = Base::MalformedFrame
13
+
14
+ # RFC2385 TCP MD5 Signature. TCP_MD5SIG is a Linux socket option (IPPROTO_TCP
15
+ # level); it is absent on platforms that don't support it, in which case the
16
+ # transport raises ConnectionError when a key is requested. The kernel caps the
17
+ # key at 80 bytes (TCP_MD5SIG_MAXKEYLEN, not exported as a Ruby constant).
18
+ TCP_MD5SIG = defined?(::Socket::TCP_MD5SIG) ? ::Socket::TCP_MD5SIG : nil
19
+ TCP_MD5SIG_MAXKEYLEN = 80
20
+
21
+ def initialize(server, **options)
22
+ super
23
+ @socket = nil
24
+ @read_buffer = ''.b
25
+ end
26
+
27
+ def connect
28
+ deadline = @connect_timeout ? monotonic_now + @connect_timeout : nil
29
+ attempts_remaining = @connect_retry_count + 1
30
+ begin
31
+ connect_once(deadline)
32
+ rescue ConnectionError, Timeout
33
+ attempts_remaining -= 1
34
+ raise if attempts_remaining <= 0
35
+ raise if deadline && remaining_time(deadline) <= 0 # total budget spent
36
+
37
+ sleep @connect_retry_interval
38
+ retry
39
+ end
40
+ end
41
+
42
+ def write_frame(bytes, timeout:)
43
+ raise ConnectionError, 'transport closed' if @socket.nil?
44
+
45
+ frame = "#{bytes.bytesize}:#{bytes},"
46
+ written = 0
47
+ deadline = timeout ? monotonic_now + timeout : nil
48
+
49
+ while written < frame.bytesize
50
+ remaining = remaining_time(deadline)
51
+ close_and_raise_timeout!('write') if remaining && remaining <= 0
52
+
53
+ writable = @socket.wait_writable(remaining)
54
+ close_and_raise_timeout!('write') unless writable
55
+
56
+ begin
57
+ n = @socket.write_nonblock(frame.byteslice(written..))
58
+ written += n
59
+ rescue IO::WaitWritable
60
+ # socket not ready yet; loop back to IO.select. Rescue the module (not the
61
+ # concrete IO::EAGAINWaitWritable) so IO::EWOULDBLOCKWaitWritable is also
62
+ # caught on platforms where EAGAIN != EWOULDBLOCK (macOS/BSD).
63
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
64
+ close
65
+ raise ConnectionError, "write failed: #{e.class}: #{e.message}"
66
+ end
67
+ end
68
+ end
69
+
70
+ def read_frame(timeout:)
71
+ raise ConnectionError, 'transport closed' if @socket.nil?
72
+
73
+ deadline = timeout ? monotonic_now + timeout : nil
74
+
75
+ loop do
76
+ result = try_parse_frame
77
+ return result unless result == :wait
78
+
79
+ remaining = remaining_time(deadline)
80
+ close_and_raise_timeout!('read') if remaining && remaining <= 0
81
+
82
+ readable = @socket.wait_readable(remaining)
83
+ close_and_raise_timeout!('read') unless readable
84
+
85
+ fill_buffer
86
+ end
87
+ end
88
+
89
+ def try_read_frame
90
+ raise ConnectionError, 'transport closed' if @socket.nil?
91
+
92
+ # Parse first so already-buffered frames survive an EOF on the next read.
93
+ result = try_parse_frame
94
+ return result unless result == :wait
95
+
96
+ # fill_buffer swallows EAGAIN (no data yet); the re-parse then returns :wait.
97
+ fill_buffer
98
+ try_parse_frame
99
+ end
100
+
101
+ def close
102
+ begin
103
+ @socket&.close
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ @socket = nil
108
+ @read_buffer = ''.b
109
+ end
110
+
111
+ def closed?
112
+ @socket.nil? || @socket.closed?
113
+ end
114
+
115
+ attr_reader :socket
116
+
117
+ private
118
+
119
+ # Attempt a single TCP connect. All errors are normalised to ConnectionError or Timeout.
120
+ def connect_once(deadline)
121
+ # Close any existing socket first so connecting on an already-connected
122
+ # transport replaces it cleanly instead of orphaning the old file descriptor.
123
+ begin
124
+ @socket&.close
125
+ rescue StandardError
126
+ nil
127
+ end
128
+ @socket = nil
129
+
130
+ sock, sockaddr = build_socket
131
+ apply_tcp_md5sig!(sock, sockaddr) if @tcp_md5_pass
132
+
133
+ loop do
134
+ break if try_connect_nonblock(sock, sockaddr, deadline)
135
+ end
136
+
137
+ @socket = sock
138
+ @read_buffer = ''.b
139
+ end
140
+
141
+ def fill_buffer
142
+ chunk = @socket.read_nonblock(65_536)
143
+ @read_buffer << chunk.b
144
+ rescue IO::WaitReadable
145
+ # no data right now; caller already confirmed readable via IO.select
146
+ rescue Errno::ECONNRESET, Errno::EPIPE, IOError => e
147
+ close
148
+ raise ConnectionError, "read failed: #{e.class}: #{e.message}"
149
+ end
150
+
151
+ def build_socket
152
+ host, port_str = @server.split(':', 2)
153
+ addr_info = ::Socket.getaddrinfo(host, nil, nil, ::Socket::SOCK_STREAM)
154
+ family = ::Socket.const_get(addr_info[0][0])
155
+ sockaddr = ::Socket.pack_sockaddr_in(port_str.to_i, addr_info[0][3])
156
+ sock = ::Socket.new(family, ::Socket::SOCK_STREAM, 0)
157
+ [sock, sockaddr]
158
+ rescue StandardError => e
159
+ raise ConnectionError, "#{e.class}: #{e.message}"
160
+ end
161
+
162
+ # Install the RFC2385 TCP MD5 Signature key on the socket *before* connect, keyed
163
+ # to the peer at +peer_sockaddr+. The kernel then signs and verifies every segment
164
+ # of the connection; a peer with a mismatched (or absent) key has its segments
165
+ # silently dropped, so the handshake never completes. Linux-only. Any failure
166
+ # — unsupported platform, oversized key, or a setsockopt error — closes the
167
+ # just-built socket (no fd leak) and raises ConnectionError, since a security
168
+ # option that silently fails to apply is worse than a refused connection.
169
+ def apply_tcp_md5sig!(sock, peer_sockaddr)
170
+ raise ConnectionError, 'tcp_md5_pass set but TCP_MD5SIG is unsupported on this platform' if TCP_MD5SIG.nil?
171
+
172
+ key = @tcp_md5_pass.b
173
+ if key.bytesize > TCP_MD5SIG_MAXKEYLEN
174
+ raise ConnectionError, "tcp_md5_pass is #{key.bytesize} bytes; max is #{TCP_MD5SIG_MAXKEYLEN}"
175
+ end
176
+
177
+ # struct tcp_md5sig: sockaddr_storage(128) + flags(u8) + prefixlen(u8) +
178
+ # keylen(u16) + ifindex(u32) + key[80]. Basic per-peer mode leaves
179
+ # flags/prefixlen/ifindex zero; keylen/ifindex are native byte order.
180
+ addr = peer_sockaddr.b.ljust(128, "\x00".b)
181
+ meta = [0, 0, key.bytesize, 0].pack('CCSL')
182
+ keybuf = key.ljust(TCP_MD5SIG_MAXKEYLEN, "\x00".b)
183
+ sock.setsockopt(::Socket::IPPROTO_TCP, TCP_MD5SIG, addr + meta + keybuf)
184
+ rescue ConnectionError
185
+ close_socket(sock)
186
+ raise
187
+ rescue SystemCallError => e
188
+ close_socket(sock)
189
+ raise ConnectionError, "failed to set TCP MD5 signature (RFC2385): #{e.class}: #{e.message}"
190
+ rescue StandardError => e
191
+ # Any other failure (e.g. a non-String tcp_md5_pass that doesn't respond
192
+ # to #b) still closes the socket and normalises to ConnectionError.
193
+ close_socket(sock)
194
+ raise ConnectionError, "invalid tcp_md5_pass: #{e.class}: #{e.message}"
195
+ end
196
+
197
+ def close_socket(sock)
198
+ sock&.close
199
+ rescue StandardError
200
+ nil
201
+ end
202
+
203
+ def try_connect_nonblock(sock, sockaddr, deadline)
204
+ sock.connect_nonblock(sockaddr)
205
+ true # connected
206
+ rescue Errno::EISCONN
207
+ true # already connected
208
+ rescue IO::WaitWritable
209
+ writable = sock.wait_writable(connect_wait_timeout(deadline))
210
+ unless writable
211
+ begin
212
+ sock.close
213
+ rescue StandardError
214
+ nil
215
+ end
216
+ raise Timeout, "connect timed out to #{@server}"
217
+ end
218
+ false
219
+ # loop again → next connect_nonblock call will return EISCONN or error
220
+ rescue StandardError => e
221
+ begin
222
+ sock.close
223
+ rescue StandardError
224
+ nil
225
+ end
226
+ raise ConnectionError, "#{e.class}: #{e.message}"
227
+ end
228
+
229
+ # Wait passed to a single IO.select while connecting: the smaller of the per-attempt
230
+ # cap and the time left in the overall connect deadline. nil (block forever) only when
231
+ # neither bound is set.
232
+ def connect_wait_timeout(deadline)
233
+ bounds = [@connect_attempt_timeout, remaining_time(deadline)].compact
234
+ return nil if bounds.empty?
235
+
236
+ wait = bounds.min
237
+ wait.negative? ? 0 : wait
238
+ end
239
+
240
+ def try_parse_frame
241
+ return :wait if @read_buffer.empty?
242
+
243
+ colon_idx = @read_buffer.index(':'.b)
244
+
245
+ if colon_idx.nil?
246
+ unless @read_buffer.match?(/\A\d+\z/)
247
+ raise MalformedFrame, "non-digit in length prefix: #{@read_buffer.byteslice(0, 32).inspect}"
248
+ end
249
+
250
+ return :wait
251
+ end
252
+
253
+ raise MalformedFrame, 'empty length prefix' if colon_idx.zero?
254
+
255
+ length_str = @read_buffer.byteslice(0, colon_idx)
256
+ raise MalformedFrame, "non-digit in length prefix: #{length_str.inspect}" unless length_str.match?(/\A\d+\z/)
257
+ if length_str.bytesize > 1 && length_str.getbyte(0) == 48 # ord('0'): leading zero
258
+ raise MalformedFrame, "leading zero in length prefix: #{length_str.inspect}"
259
+ end
260
+
261
+ length = Integer(length_str, 10)
262
+ frame_end = colon_idx + 1 + length # index of the expected comma
263
+
264
+ return :wait if @read_buffer.bytesize <= frame_end
265
+
266
+ unless @read_buffer.getbyte(frame_end) == 44 # ord(',')
267
+ raise MalformedFrame, "missing comma terminator at position #{frame_end}"
268
+ end
269
+
270
+ data = @read_buffer.byteslice(colon_idx + 1, length).force_encoding(Encoding::UTF_8)
271
+ # The line-194 guard guarantees bytesize > frame_end, so this byteslice is never nil.
272
+ @read_buffer = @read_buffer.byteslice((frame_end + 1)..)
273
+ data
274
+ end
275
+
276
+ def monotonic_now
277
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
278
+ end
279
+
280
+ def remaining_time(deadline)
281
+ return nil unless deadline
282
+
283
+ deadline - monotonic_now
284
+ end
285
+
286
+ def close_and_raise_timeout!(operation)
287
+ close
288
+ raise Timeout, "#{operation} timed out on #{@server}"
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An in-process transport double for testing code that uses JRPC, without a real
4
+ # TCP server. NOT required by default — require it explicitly from your test setup:
5
+ #
6
+ # require 'jrpc/transport/test'
7
+ #
8
+ # Then inject it via the `transport:` option of either client:
9
+ #
10
+ # transport = JRPC::Transport::Test.new
11
+ # transport.on('sum') { |params| params['a'] + params['b'] }
12
+ # client = JRPC::SimpleClient.new('test', transport: transport)
13
+ # client.request('sum', { 'a' => 1, 'b' => 2 }) # => 3
14
+ # transport.last_request # => { 'jsonrpc' => '2.0', 'method' => 'sum', ... }
15
+ #
16
+ # Two scripting mechanisms, both feeding a single FIFO inbound queue:
17
+ #
18
+ # * Handlers (`on`): the primary, high-level API. When the client writes a
19
+ # request, the matching handler runs and its return value is encoded as a
20
+ # JSON-RPC result response echoing the request id. A handler may instead raise:
21
+ # - a JRPC::Errors::ServerError (or subclass) -> encoded as an error response
22
+ # (its #code, or -32000 if nil/absent);
23
+ # - a transport error (ConnectionError / Timeout / MalformedFrame) -> raised
24
+ # when the client reads the response, simulating a socket-level failure.
25
+ # * Raw frames (`push_response` / `push_raise`): the low-level escape hatch for
26
+ # testing malformed responses, id mismatches, and orphan/unsolicited frames,
27
+ # where you control the literal bytes the client reads.
28
+ #
29
+ # In `strict` mode (the default) a request whose method has no handler raises
30
+ # UnexpectedRequest at write time, so a missing stub fails loudly instead of
31
+ # hanging. Set `strict: false` to drive reads purely via push_response/push_raise.
32
+ require 'jrpc'
33
+ require 'socket'
34
+ require 'monitor'
35
+ require 'json'
36
+
37
+ module JRPC
38
+ module Transport
39
+ class Test < Base
40
+ # Resolve `raise ConnectionError`-style names to the transport hierarchy the
41
+ # clients rescue, mirroring Tcp (otherwise constant lookup finds JRPC::* v1).
42
+ ConnectionError = Base::ConnectionError
43
+ Timeout = Base::Timeout
44
+ MalformedFrame = Base::MalformedFrame
45
+
46
+ # Raised at write time when a request arrives for a method with no registered
47
+ # handler and strict mode is on. A test-harness assertion, not a transport
48
+ # condition, so it is deliberately outside the Base::Error hierarchy: it
49
+ # propagates raw to your test (via SimpleClient) instead of being swallowed
50
+ # and remapped to a generic ConnectionError.
51
+ class UnexpectedRequest < StandardError; end
52
+
53
+ # GC backstop: release the socketpair FDs if a transport is dropped without an
54
+ # explicit #shutdown. Returns a proc that captures only the two IOs, never self.
55
+ def self.finalizer(io, signal)
56
+ proc do
57
+ [io, signal].each do |sock|
58
+ sock.close
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+ end
64
+
65
+ def initialize(server = 'test', **options)
66
+ super
67
+ @strict = options.fetch(:strict, true)
68
+ @mon = Monitor.new
69
+ @handlers = {}
70
+ @inbound = [] # FIFO of [:frame, String] | [:raise, Exception]
71
+ @sent = [] # raw payload strings exactly as the client wrote them
72
+ @requests = [] # parsed request envelopes (Hash) in write order
73
+ @notifications = [] # parsed notification envelopes (Hash) in write order
74
+ @open = false
75
+ @io = nil
76
+ @signal = nil
77
+ @fail_connect = nil
78
+ end
79
+
80
+ # --- Scripting API (called from your test thread) ---------------------------
81
+
82
+ # Register a handler for +method+. The block receives the request params
83
+ # (Array, Hash, or nil) and its return value becomes the JSON-RPC result.
84
+ def on(method, &block)
85
+ raise ArgumentError, 'on requires a block' unless block
86
+
87
+ @mon.synchronize { @handlers[method.to_s] = block }
88
+ self
89
+ end
90
+
91
+ # Enqueue a literal inbound frame. Accepts a JSON String (used verbatim, so it
92
+ # may be intentionally malformed) or a Hash (serialized with JSON.generate).
93
+ def push_response(frame)
94
+ payload = frame.is_a?(String) ? frame : JSON.generate(frame)
95
+ enqueue([:frame, payload])
96
+ self
97
+ end
98
+
99
+ # Enqueue an error to be raised on the client's next read, simulating a
100
+ # socket-level failure mid-stream. Pass a transport error for realistic
101
+ # behavior (e.g. JRPC::Transport::Base::ConnectionError.new('reset')).
102
+ def push_raise(error)
103
+ enqueue([:raise, error])
104
+ self
105
+ end
106
+
107
+ # Arm #connect to raise +error+ on every attempt until cleared by #reset.
108
+ # Defaults to a ConnectionError so SharedClient's loop treats it as a normal
109
+ # connect failure (drained), rather than a crash.
110
+ def fail_connect(error = ConnectionError.new('connect failed'))
111
+ @mon.synchronize { @fail_connect = error }
112
+ self
113
+ end
114
+
115
+ # Clear recordings, the inbound queue, and any armed connect failure. Keeps
116
+ # registered handlers so a transport can be reused across examples.
117
+ def reset
118
+ @mon.synchronize do
119
+ @inbound.clear
120
+ @sent.clear
121
+ @requests.clear
122
+ @notifications.clear
123
+ @fail_connect = nil
124
+ drain_signal
125
+ end
126
+ self
127
+ end
128
+
129
+ def sent = @mon.synchronize { @sent.dup }
130
+ def requests = @mon.synchronize { @requests.dup }
131
+ def notifications = @mon.synchronize { @notifications.dup }
132
+ def last_request = @mon.synchronize { @requests.last }
133
+
134
+ # Close the socketpair FDs. Idempotent. Call from an after-hook for
135
+ # deterministic FD cleanup; otherwise the GC finalizer reclaims them.
136
+ def shutdown
137
+ @mon.synchronize do
138
+ @open = false
139
+ [@io, @signal].each do |sock|
140
+ sock&.close
141
+ rescue StandardError
142
+ nil
143
+ end
144
+ @io = nil
145
+ @signal = nil
146
+ end
147
+ end
148
+
149
+ # --- Transport interface (called from the client / SharedClient loop) -------
150
+
151
+ def connect
152
+ @mon.synchronize do
153
+ raise @fail_connect if @fail_connect
154
+
155
+ open_socketpair if @io.nil?
156
+ @open = true
157
+ end
158
+ end
159
+
160
+ # closed? tracks the logical open flag, not the FD: #close keeps the socketpair
161
+ # alive (so a concurrent IO.select in SharedClient's loop is never yanked) and
162
+ # only flips the flag, mirroring the proven spec helper.
163
+ def closed?
164
+ @mon.synchronize { !@open }
165
+ end
166
+
167
+ def socket
168
+ @mon.synchronize { @open ? @io : nil }
169
+ end
170
+
171
+ def write_frame(bytes, **)
172
+ @mon.synchronize do
173
+ raise ConnectionError, 'transport closed' if closed_unlocked?
174
+
175
+ @sent << bytes
176
+ envelope = JSON.parse(bytes)
177
+ if envelope.key?('id')
178
+ handle_request(envelope)
179
+ else
180
+ handle_notification(envelope)
181
+ end
182
+ end
183
+ end
184
+
185
+ def read_frame(**)
186
+ @mon.synchronize do
187
+ raise ConnectionError, 'transport closed' if closed_unlocked?
188
+
189
+ entry = pop_inbound
190
+ raise Timeout, 'read_frame: no scripted response available' if entry.nil?
191
+
192
+ deliver(entry)
193
+ end
194
+ end
195
+
196
+ def try_read_frame
197
+ @mon.synchronize do
198
+ raise ConnectionError, 'transport closed' if closed_unlocked?
199
+
200
+ entry = pop_inbound
201
+ if entry.nil?
202
+ drain_signal
203
+ return :wait
204
+ end
205
+
206
+ deliver(entry)
207
+ end
208
+ end
209
+
210
+ def close
211
+ @mon.synchronize do
212
+ @open = false
213
+ @inbound.clear
214
+ # Wake a loop blocked in IO.select so it re-checks closed? promptly.
215
+ signal_readable
216
+ true
217
+ end
218
+ end
219
+
220
+ private
221
+
222
+ def closed_unlocked?
223
+ !@open
224
+ end
225
+
226
+ def open_socketpair
227
+ # A bidirectional UNIX socketpair: @io (returned by #socket) stays writable
228
+ # while its send buffer has room, and becomes readable when we write a wake
229
+ # byte to @signal. SharedClient's loop selects on it for both directions;
230
+ # IO.pipe would not work (its read end is never writable, so the loop would
231
+ # never flush). Linux/macOS only.
232
+ @io, @signal = ::Socket.socketpair(:UNIX, :STREAM, 0)
233
+ # Drop any prior finalizer (from an earlier connect/shutdown cycle) before
234
+ # registering the new one, so they don't accumulate on a reused instance.
235
+ ObjectSpace.undefine_finalizer(self)
236
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@io, @signal))
237
+ end
238
+
239
+ def handle_request(envelope)
240
+ @requests << envelope
241
+ method = envelope['method']
242
+ handler = @handlers[method]
243
+ if handler
244
+ run_request_handler(envelope['id'], envelope['params'], handler)
245
+ elsif @strict
246
+ raise UnexpectedRequest,
247
+ "JRPC::Transport::Test: no handler for request #{method.inspect}; " \
248
+ "register one with `transport.on(#{method.inspect}) { ... }`, " \
249
+ 'queue a frame with push_response, or set strict: false'
250
+ end
251
+ # non-strict + no handler: reads are driven entirely by push_response/push_raise.
252
+ end
253
+
254
+ def run_request_handler(id, params, handler)
255
+ result = handler.call(params)
256
+ enqueue([:frame, result_response(id, result)])
257
+ rescue Errors::ServerError => e
258
+ enqueue([:frame, error_response(id, e)])
259
+ rescue Base::Error => e
260
+ # Transport-level failure (ConnectionError/Timeout/MalformedFrame): surface it
261
+ # when the client reads the response, simulating a mid-stream socket error.
262
+ enqueue([:raise, e])
263
+ end
264
+
265
+ def handle_notification(envelope)
266
+ @notifications << envelope
267
+ handler = @handlers[envelope['method']]
268
+ return unless handler
269
+
270
+ begin
271
+ handler.call(envelope['params'])
272
+ rescue Base::Error
273
+ # Simulate a send-time socket failure; surfaces through write_frame.
274
+ raise
275
+ rescue Errors::ServerError
276
+ # Notifications have no response channel, so a server error is meaningless.
277
+ nil
278
+ end
279
+ end
280
+
281
+ def result_response(id, result)
282
+ JSON.generate({ 'jsonrpc' => JRPC::JSON_RPC_VERSION, 'id' => id, 'result' => result })
283
+ end
284
+
285
+ def error_response(id, error)
286
+ code = error.respond_to?(:code) && error.code.is_a?(Integer) ? error.code : -32_000
287
+ JSON.generate(
288
+ { 'jsonrpc' => JRPC::JSON_RPC_VERSION, 'id' => id,
289
+ 'error' => { 'code' => code, 'message' => error.message } }
290
+ )
291
+ end
292
+
293
+ def enqueue(entry)
294
+ @mon.synchronize do
295
+ @inbound << entry
296
+ signal_readable
297
+ end
298
+ end
299
+
300
+ def pop_inbound
301
+ @inbound.shift
302
+ end
303
+
304
+ def deliver(entry)
305
+ type, value = entry
306
+ case type
307
+ when :raise then raise value
308
+ when :frame then value
309
+ end
310
+ end
311
+
312
+ # Make @io readable so a loop blocked in IO.select wakes. Best-effort: a full
313
+ # buffer just means the socket is already readable, so swallow any error.
314
+ def signal_readable
315
+ return if @signal.nil?
316
+
317
+ @signal.write_nonblock('.')
318
+ rescue StandardError
319
+ nil
320
+ end
321
+
322
+ # Drain accumulated wake bytes so @io stops selecting readable once the inbound
323
+ # queue is empty, preventing the loop from spinning.
324
+ def drain_signal
325
+ return if @io.nil?
326
+
327
+ loop { @io.read_nonblock(4096) }
328
+ rescue IO::WaitReadable, IOError
329
+ nil
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jrpc/transport/base'
4
+ require 'jrpc/transport/tcp'
5
+
6
+ module JRPC
7
+ module Transport
8
+ def self.build(server, **)
9
+ JRPC::Transport::Tcp.new(server, **)
10
+ end
11
+ end
12
+ end
data/lib/jrpc/version.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JRPC
2
- VERSION = '1.1.8'
4
+ VERSION = '2.1.0'
3
5
  end
data/lib/jrpc.rb CHANGED
@@ -1,22 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'socket'
2
4
  require 'json'
5
+ require 'concurrent'
3
6
  require 'jrpc/version'
4
- require 'jrpc/error/error'
5
- require 'jrpc/utils'
6
- require 'jrpc/base_client'
7
- require 'jrpc/transport/socket_base'
8
- require 'jrpc/transport/socket_tcp'
9
- require 'jrpc/tcp_client'
10
- require 'jrpc/error/connection_error'
11
- require 'jrpc/error/client_error'
12
- require 'jrpc/error/server_error'
13
- require 'jrpc/error/internal_error'
14
- require 'jrpc/error/internal_server_error'
15
- require 'jrpc/error/invalid_params'
16
- require 'jrpc/error/invalid_request'
17
- require 'jrpc/error/method_not_found'
18
- require 'jrpc/error/parse_error'
19
- require 'jrpc/error/unknown_error'
7
+ require 'jrpc/errors'
8
+ require 'jrpc/id_generator'
9
+ require 'jrpc/message'
10
+ require 'jrpc/payload_logging'
11
+ require 'jrpc/transport'
12
+ require 'jrpc/simple_client'
13
+ require 'jrpc/shared_client/ticket'
14
+ require 'jrpc/shared_client/registry'
15
+ require 'jrpc/shared_client/outbound_queue'
16
+ require 'jrpc/shared_client/transport_loop'
17
+ require 'jrpc/shared_client'
20
18
 
21
19
  module JRPC
22
20
  JSON_RPC_VERSION = '2.0'