jrpc 2.0.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9d76a4b0925e75829ba1a406594a0ef8f0c8045e38d6a06362c3eac2892affb
4
- data.tar.gz: f6f28cf60f79bd9108f82299b8cac6ada99ae3a0a80bcc85baa3fa1b51c86560
3
+ metadata.gz: c257edbf0909e62cffe47dc7b8a79e928b4b4c24f7166bbbbec3b7141d01270f
4
+ data.tar.gz: 671298a50f68328de11586e0e80eeba0ae18814ce79827b57dcd54699605c20c
5
5
  SHA512:
6
- metadata.gz: 365d4ab7d191d4853b48f9c05875afb11e4619bf4a8a733b66bf52f8dd79bcb702c4d91bdff561088bee4086dcda01af58c3ee4ce0e89d838c8490019c827aaa
7
- data.tar.gz: cb74295dd00108aa5e2de6bf93584204e903a36aa523cb6ea44cc5f18bf787c3273969a8cb18d89efbed89db8757b65ad24a45ba5ba66cd49aa2518413f989b8
6
+ metadata.gz: 807cca67ef7ae784989daa7cf89797d9e255f77087822d1c1cae607839d02b2aed75916e5b1e75c47a11bfba5526eccd8df257fd1e36cf38029ba02e137ded28
7
+ data.tar.gz: c987f2beba104d457137aeaa74821db2fb6835dee9161c39c50f1e409f254efc924c8c23178cc43e07867e083d7c11ebfd1dbf8f3a70d617c652380267abc224
data/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ### Unreleased
4
4
 
5
+ **New**
6
+
7
+ * Debug-level wire-payload logging. When a `logger:` is configured, both
8
+ `SimpleClient` and `SharedClient` emit every request/response payload (the raw
9
+ JSON frame, exactly as written/read) at `DEBUG`, tagged `[JRPC::SimpleClient]`
10
+ / `[JRPC::SharedClient]` with `>>` (sent) / `<<` (received) markers. No logger,
11
+ no logging.
12
+ * `JRPC::Transport::Test` — an in-process transport double for testing code that
13
+ uses JRPC, without a real server. Not required by default: `require
14
+ 'jrpc/transport/test'`, then inject via `transport:` on either client. Stub
15
+ methods with `on('method') { |params| ... }` (return value becomes the result;
16
+ raise a `JRPC::Errors::ServerError` for an error response, or a transport error
17
+ to simulate a socket failure); records `requests`/`notifications`/`sent` for
18
+ assertions. A raw escape hatch (`push_response`/`push_raise`, `strict: false`)
19
+ covers malformed-response, id-mismatch, and orphan-frame cases. Works with both
20
+ `SimpleClient` and `SharedClient`. (Closes #10.)
21
+ * Optional TCP MD5 Signature (RFC2385) support. Pass `tcp_md5_pass:` to
22
+ `SimpleClient`/`SharedClient` (or the transport directly) to authenticate the
23
+ connection with a per-peer MD5 key. Linux-only (`TCP_MD5SIG`); the key is
24
+ installed on the socket before connect, and a connect on a kernel/platform
25
+ without `TCP_MD5SIG` raises `ConnectionError`.
26
+
5
27
  ### 2.0.0
6
28
 
7
29
  Full rewrite. JRPC 2.0 is not API-compatible with 1.x.
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # JRPC
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/jrpc.svg)](https://rubygems.org/gems/jrpc)
4
+ [![CI](https://github.com/didww/jrpc/actions/workflows/ci.yml/badge.svg)](https://github.com/didww/jrpc/actions/workflows/ci.yml)
5
+
3
6
  A JSON-RPC 2.0 client for Ruby, over TCP, with netstring framing.
4
7
 
5
8
  JRPC ships two clients with sharp, separate responsibilities:
@@ -37,7 +40,8 @@ client = JRPC::SimpleClient.new(
37
40
  connect_retry_count: 0, # retries after the first failed connect
38
41
  autoclose: false, # close the socket after every call
39
42
  id_prefix: nil, # random per instance if nil
40
- logger: nil
43
+ tcp_md5_pass: nil, # RFC2385 TCP MD5 Signature key (Linux-only); nil disables
44
+ logger: nil # when set, logs every wire payload at DEBUG; nil disables
41
45
  )
42
46
 
43
47
  result = client.request(:sum, [1, 2])
@@ -72,7 +76,8 @@ client = JRPC::SharedClient.new(
72
76
  default_ttl: 30, # per-message lifetime, seconds
73
77
  max_queue_size: 10_000, # bounded; pass nil for unbounded (opt-in OOM risk)
74
78
  id_prefix: nil,
75
- logger: nil
79
+ tcp_md5_pass: nil, # RFC2385 TCP MD5 Signature key (Linux-only); nil disables
80
+ logger: nil # when set, logs every wire payload at DEBUG; nil disables
76
81
  )
77
82
 
78
83
  result = client.request(:sum, [1, 2])
@@ -161,6 +166,78 @@ require 'oj'
161
166
  Oj.mimic_JSON
162
167
  ```
163
168
 
169
+ ## TCP MD5 Signature (RFC2385)
170
+
171
+ Both clients accept `tcp_md5_pass:` to enable per-connection authentication via the
172
+ [TCP MD5 Signature option](https://www.rfc-editor.org/rfc/rfc2385). The kernel signs and
173
+ verifies every TCP segment with `MD5(key + segment + addresses/ports)`; a peer with a
174
+ mismatched or absent key has its segments silently dropped, so the handshake never
175
+ completes.
176
+
177
+ ```ruby
178
+ client = JRPC::SimpleClient.new("10.0.0.2:1234", tcp_md5_pass: "shared-secret")
179
+ ```
180
+
181
+ - **Linux-only.** It relies on the `TCP_MD5SIG` socket option (and a kernel built with
182
+ `CONFIG_TCP_MD5SIG`). When `tcp_md5_pass` is set on a platform/kernel without it, the
183
+ first connect raises `ConnectionError` — the option never silently no-ops.
184
+ - **The server must be configured with the same key for this client's address.** JRPC
185
+ only sets the client side; the peer (e.g. a router/BGP-style endpoint, or another
186
+ socket with a matching `TCP_MD5SIG`) must agree on the key.
187
+ - **Key length is capped at 80 bytes** (`TCP_MD5SIG_MAXKEYLEN`); a longer key raises
188
+ `ConnectionError`.
189
+ - The key is installed on the socket **before** connect, so it also protects the
190
+ handshake itself. It survives reconnects (reaping, connection drops) transparently.
191
+
192
+ ## Testing
193
+
194
+ `JRPC::Transport::Test` is an in-process transport double for testing code that
195
+ talks to a JSON-RPC server, without standing up a real one. It is **not** loaded
196
+ by default — require it explicitly from your test setup:
197
+
198
+ ```ruby
199
+ require 'jrpc/transport/test'
200
+
201
+ transport = JRPC::Transport::Test.new
202
+ transport.on('sum') { |params| params['a'] + params['b'] }
203
+
204
+ client = JRPC::SimpleClient.new('test', transport: transport)
205
+ client.request('sum', { 'a' => 1, 'b' => 2 }) # => 3
206
+
207
+ transport.last_request # => { "jsonrpc" => "2.0", "method" => "sum", "params" => {...}, "id" => "..." }
208
+ ```
209
+
210
+ Inject it through the `transport:` option of either `SimpleClient` or `SharedClient`.
211
+
212
+ **Handlers** are the high-level API. A handler's return value is encoded as a result
213
+ response echoing the request id. Raise to produce other outcomes:
214
+
215
+ ```ruby
216
+ # JSON-RPC error response (mapped back to the matching JRPC::Errors class on the caller):
217
+ transport.on('lookup') { raise JRPC::Errors::MethodNotFound, 'no such method' }
218
+
219
+ # Simulated socket-level failure, raised when the client reads the response:
220
+ transport.on('flaky') { raise JRPC::Transport::Base::ConnectionError, 'peer reset' }
221
+ ```
222
+
223
+ In **strict mode (the default)** a request for a method with no handler raises
224
+ `JRPC::Transport::Test::UnexpectedRequest` at write time, so a missing stub fails
225
+ loudly instead of hanging. Pass `strict: false` to drive reads entirely with the
226
+ raw escape hatch:
227
+
228
+ ```ruby
229
+ transport = JRPC::Transport::Test.new(strict: false)
230
+ # Feed literal response frames — for malformed responses, id mismatches, orphans:
231
+ transport.push_response({ 'jsonrpc' => '2.0', 'id' => 'abc', 'result' => 42 })
232
+ transport.push_raise(JRPC::Transport::Base::MalformedFrame.new('garbage'))
233
+ ```
234
+
235
+ Other helpers: `fail_connect(error)` arms `connect` to raise; `requests`,
236
+ `notifications`, and `sent` expose recordings for assertions; `reset` clears
237
+ recordings and queued frames (keeping handlers). The transport opens a Unix
238
+ socketpair so `SharedClient`'s `IO.select` loop works — call `shutdown` (e.g. in an
239
+ `after` hook) for deterministic FD cleanup, or let the GC finalizer reclaim it.
240
+
164
241
  ## CLI tools
165
242
 
166
243
  Two executables ship with the gem:
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ # Debug-level wire-payload logging shared by the clients. When a `logger` is
5
+ # configured, every request/response payload (the raw JSON netstring body,
6
+ # exactly as written/read) is emitted at DEBUG. Without a logger it is a no-op.
7
+ module PayloadLogging
8
+ SEND_MARK = '>>'
9
+ RECV_MARK = '<<'
10
+
11
+ def log_sent(payload)
12
+ @logger&.debug("[#{log_tag}] #{SEND_MARK} #{payload}")
13
+ end
14
+
15
+ def log_received(payload)
16
+ @logger&.debug("[#{log_tag}] #{RECV_MARK} #{payload}")
17
+ end
18
+ end
19
+ end
@@ -3,6 +3,8 @@
3
3
  module JRPC
4
4
  class SharedClient
5
5
  class TransportLoop
6
+ include PayloadLogging
7
+
6
8
  SELECT_FLOOR = 60.0
7
9
 
8
10
  def initialize(
@@ -130,6 +132,7 @@ module JRPC
130
132
  end
131
133
 
132
134
  begin
135
+ log_sent(ticket.payload)
133
136
  @transport.write_frame(ticket.payload, timeout: @write_timeout)
134
137
  rescue Transport::Base::Timeout => e
135
138
  err = Errors::Timeout.new("write timeout: #{e.message}")
@@ -170,6 +173,7 @@ module JRPC
170
173
  break if frame == :wait
171
174
 
172
175
  @last_rx_at = clock_now
176
+ log_received(frame)
173
177
 
174
178
  begin
175
179
  parsed = Message.parse(frame)
@@ -283,7 +287,11 @@ module JRPC
283
287
  end
284
288
 
285
289
  def log_error(msg)
286
- @logger&.error("[JRPC::SharedClient] #{msg}")
290
+ @logger&.error("[#{log_tag}] #{msg}")
291
+ end
292
+
293
+ def log_tag
294
+ 'JRPC::SharedClient'
287
295
  end
288
296
  end
289
297
  end
@@ -6,6 +6,8 @@ module JRPC
6
6
  # concurrent calls would interleave socket reads/writes and corrupt the framing
7
7
  # buffer. Use one instance per thread/fiber (or a pool of instances).
8
8
  class SimpleClient
9
+ include PayloadLogging
10
+
9
11
  attr_reader :server
10
12
 
11
13
  def initialize(server, **options)
@@ -31,8 +33,10 @@ module JRPC
31
33
 
32
34
  with_transport_error_handling do
33
35
  connect_if_needed!
36
+ log_sent(json)
34
37
  @transport.write_frame(json, timeout: write_timeout)
35
38
  raw = @transport.read_frame(timeout: read_timeout)
39
+ log_received(raw)
36
40
  response = Message.parse(raw)
37
41
  Message.validate_response!(response, id)
38
42
  raise Message.error_to_exception(response['error']) if response.key?('error')
@@ -48,6 +52,7 @@ module JRPC
48
52
 
49
53
  with_transport_error_handling do
50
54
  connect_if_needed!
55
+ log_sent(json)
51
56
  @transport.write_frame(json, timeout: write_timeout)
52
57
  nil
53
58
  end
@@ -67,6 +72,10 @@ module JRPC
67
72
 
68
73
  private
69
74
 
75
+ def log_tag
76
+ 'JRPC::SimpleClient'
77
+ end
78
+
70
79
  def connect_if_needed!
71
80
  @transport.connect if @transport.closed?
72
81
  end
@@ -21,6 +21,9 @@ module JRPC
21
21
  @connect_retry_count = options.fetch(:connect_retry_count, 0)
22
22
  @connect_retry_interval = options.fetch(:connect_retry_interval, 0.5)
23
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)
24
27
  end
25
28
 
26
29
  # Abstract interface. Subclasses must implement every method below; the bodies
@@ -11,6 +11,13 @@ module JRPC
11
11
  Timeout = Base::Timeout
12
12
  MalformedFrame = Base::MalformedFrame
13
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
+
14
21
  def initialize(server, **options)
15
22
  super
16
23
  @socket = nil
@@ -121,6 +128,7 @@ module JRPC
121
128
  @socket = nil
122
129
 
123
130
  sock, sockaddr = build_socket
131
+ apply_tcp_md5sig!(sock, sockaddr) if @tcp_md5_pass
124
132
 
125
133
  loop do
126
134
  break if try_connect_nonblock(sock, sockaddr, deadline)
@@ -151,6 +159,47 @@ module JRPC
151
159
  raise ConnectionError, "#{e.class}: #{e.message}"
152
160
  end
153
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
+
154
203
  def try_connect_nonblock(sock, sockaddr, deadline)
155
204
  sock.connect_nonblock(sockaddr)
156
205
  true # connected
@@ -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
data/lib/jrpc/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JRPC
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
data/lib/jrpc.rb CHANGED
@@ -7,6 +7,7 @@ require 'jrpc/version'
7
7
  require 'jrpc/errors'
8
8
  require 'jrpc/id_generator'
9
9
  require 'jrpc/message'
10
+ require 'jrpc/payload_logging'
10
11
  require 'jrpc/transport'
11
12
  require 'jrpc/simple_client'
12
13
  require 'jrpc/shared_client/ticket'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jrpc
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Talakevich
@@ -64,6 +64,7 @@ files:
64
64
  - lib/jrpc/errors.rb
65
65
  - lib/jrpc/id_generator.rb
66
66
  - lib/jrpc/message.rb
67
+ - lib/jrpc/payload_logging.rb
67
68
  - lib/jrpc/shared_client.rb
68
69
  - lib/jrpc/shared_client/outbound_queue.rb
69
70
  - lib/jrpc/shared_client/registry.rb
@@ -73,6 +74,7 @@ files:
73
74
  - lib/jrpc/transport.rb
74
75
  - lib/jrpc/transport/base.rb
75
76
  - lib/jrpc/transport/tcp.rb
77
+ - lib/jrpc/transport/test.rb
76
78
  - lib/jrpc/version.rb
77
79
  homepage: https://github.com/didww/jrpc
78
80
  licenses: