jrpc 2.0.0 → 2.2.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: 84a616f81dad776fc7efea4d4e8fd778a3e2d5b42568118f0206f44f9b4e1b10
4
+ data.tar.gz: b8e8a2d8468d5e713e9015faccea22370775b99e476f17b4962cc0ebdc504d35
5
5
  SHA512:
6
- metadata.gz: 365d4ab7d191d4853b48f9c05875afb11e4619bf4a8a733b66bf52f8dd79bcb702c4d91bdff561088bee4086dcda01af58c3ee4ce0e89d838c8490019c827aaa
7
- data.tar.gz: cb74295dd00108aa5e2de6bf93584204e903a36aa523cb6ea44cc5f18bf787c3273969a8cb18d89efbed89db8757b65ad24a45ba5ba66cd49aa2518413f989b8
6
+ metadata.gz: a58ecaa54dcaafae9871d5c7264e03adc57946859b75b72112a79bc7595df81495e5a4910fe69da28b66bf1b82c0cdb3026a5ccc0ea4f6786e81c50fea030413
7
+ data.tar.gz: abc7911d9cc8ffac7481decc11e01fc7524b1ce800c65c5bf7652c3567adad66ceedae7c63353f6f160a123957528254434807e58911dbaa61c221e3ee30727f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,45 @@
1
1
  # Changelog
2
2
 
3
- ### Unreleased
3
+ ### 2.2.0
4
+
5
+ **New**
6
+
7
+ * `JRPC::Errors::ServerError#data` — the JSON-RPC `error.data` member is now carried
8
+ onto the raised exception instead of being dropped. `Message.error_to_exception`
9
+ reads `error['data']` and passes it to every `ServerError` subclass built from a peer
10
+ error object, verbatim and untyped (String, Hash, Array, … — whatever the peer sent),
11
+ `nil` when omitted. It is the only machine-readable detail an error object carries
12
+ beyond `code`, and servers use it to say *which* param was invalid or *which* id
13
+ conflicted. `MalformedResponseError` is raised locally rather than mapped from a peer
14
+ error object, so its `data` is always `nil`.
15
+ `JRPC::Transport::Test` emits a `data` member when a handler raises an error that
16
+ carries one, so the round trip is testable. Backwards compatible: on those classes
17
+ `data:` is a keyword with a `nil` default, and the wire frame is unchanged when there
18
+ is no data — the member is omitted, never emitted as `null`.
19
+
20
+ ### 2.1.0
21
+
22
+ **New**
23
+
24
+ * Debug-level wire-payload logging. When a `logger:` is configured, both
25
+ `SimpleClient` and `SharedClient` emit every request/response payload (the raw
26
+ JSON frame, exactly as written/read) at `DEBUG`, tagged `[JRPC::SimpleClient]`
27
+ / `[JRPC::SharedClient]` with `>>` (sent) / `<<` (received) markers. No logger,
28
+ no logging.
29
+ * `JRPC::Transport::Test` — an in-process transport double for testing code that
30
+ uses JRPC, without a real server. Not required by default: `require
31
+ 'jrpc/transport/test'`, then inject via `transport:` on either client. Stub
32
+ methods with `on('method') { |params| ... }` (return value becomes the result;
33
+ raise a `JRPC::Errors::ServerError` for an error response, or a transport error
34
+ to simulate a socket failure); records `requests`/`notifications`/`sent` for
35
+ assertions. A raw escape hatch (`push_response`/`push_raise`, `strict: false`)
36
+ covers malformed-response, id-mismatch, and orphan-frame cases. Works with both
37
+ `SimpleClient` and `SharedClient`. (Closes #10.)
38
+ * Optional TCP MD5 Signature (RFC2385) support. Pass `tcp_md5_pass:` to
39
+ `SimpleClient`/`SharedClient` (or the transport directly) to authenticate the
40
+ connection with a per-peer MD5 key. Linux-only (`TCP_MD5SIG`); the key is
41
+ installed on the socket before connect, and a connect on a kernel/platform
42
+ without `TCP_MD5SIG` raises `ConnectionError`.
4
43
 
5
44
  ### 2.0.0
6
45
 
data/Gemfile CHANGED
@@ -5,16 +5,15 @@ source 'https://rubygems.org'
5
5
  # Specify your gem's dependencies in jrpc.gemspec
6
6
  gemspec
7
7
 
8
- gem 'bundler'
9
8
  gem 'rake', '~> 13.0'
10
9
  gem 'rspec', '~> 3.0'
11
10
 
12
11
  # Provides a real Fiber.scheduler for the fiber-caller specs (SharedClient §9.8).
13
12
  gem 'async', '~> 2.0'
14
13
 
15
- gem 'rubocop', '~> 1.21'
16
- gem 'rubocop-performance'
17
- gem 'rubocop-rspec'
14
+ gem 'rubocop', '~> 1.90.0'
15
+ gem 'rubocop-performance', '~> 1.27.0'
16
+ gem 'rubocop-rspec', '~> 3.10.2'
18
17
  gem 'rubocop-rake', '~> 0.7.1'
19
18
 
20
19
  gem 'simplecov', '~> 0.22', require: false
data/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # JRPC
2
2
 
3
- A JSON-RPC 2.0 client for Ruby, over TCP, with netstring framing.
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
+
6
+ A JSON-RPC v2 client for Ruby, over TCP, with netstring framing.
4
7
 
5
8
  JRPC ships two clients with sharp, separate responsibilities:
6
9
 
@@ -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])
@@ -128,6 +133,7 @@ Errors::Error (RuntimeError)
128
133
  ├── Errors::Timeout # message TTL elapsed, or SimpleClient read/write/connect timeout
129
134
  └── Errors::ServerError # peer returned an error, or the response was unusable
130
135
  attr_reader :code # nil for malformed responses
136
+ attr_reader :data # the error object's `data` member, nil when absent
131
137
  ├── Errors::ParseError # -32700
132
138
  ├── Errors::InvalidRequest # -32600
133
139
  ├── Errors::MethodNotFound # -32601
@@ -140,11 +146,17 @@ Errors::Error (RuntimeError)
140
146
 
141
147
  `MalformedResponseError` is a `ServerError`, not a `ClientError`: a malformed response is the peer's fault.
142
148
 
149
+ `#data` carries the error object's optional `data` member through untouched — a String,
150
+ Hash, Array or number, whatever the peer sent, and `nil` when it sent none. It is where a
151
+ server says *which* param was invalid or *which* id conflicted, so log it alongside the
152
+ code. `MalformedResponseError` is raised locally rather than built from a peer error
153
+ object, so its `data` is always `nil`.
154
+
143
155
  ```ruby
144
156
  begin
145
157
  client.request(:do_thing, [1, 2])
146
158
  rescue JRPC::Errors::ServerError => e
147
- warn "rpc error #{e.code}: #{e.message}"
159
+ warn "rpc error #{e.code}: #{e.message} #{e.data.inspect}"
148
160
  rescue JRPC::Errors::Timeout
149
161
  warn "timed out"
150
162
  rescue JRPC::Errors::ConnectionError => e
@@ -161,6 +173,78 @@ require 'oj'
161
173
  Oj.mimic_JSON
162
174
  ```
163
175
 
176
+ ## TCP MD5 Signature (RFC2385)
177
+
178
+ Both clients accept `tcp_md5_pass:` to enable per-connection authentication via the
179
+ [TCP MD5 Signature option](https://www.rfc-editor.org/rfc/rfc2385). The kernel signs and
180
+ verifies every TCP segment with `MD5(key + segment + addresses/ports)`; a peer with a
181
+ mismatched or absent key has its segments silently dropped, so the handshake never
182
+ completes.
183
+
184
+ ```ruby
185
+ client = JRPC::SimpleClient.new("10.0.0.2:1234", tcp_md5_pass: "shared-secret")
186
+ ```
187
+
188
+ - **Linux-only.** It relies on the `TCP_MD5SIG` socket option (and a kernel built with
189
+ `CONFIG_TCP_MD5SIG`). When `tcp_md5_pass` is set on a platform/kernel without it, the
190
+ first connect raises `ConnectionError` — the option never silently no-ops.
191
+ - **The server must be configured with the same key for this client's address.** JRPC
192
+ only sets the client side; the peer (e.g. a router/BGP-style endpoint, or another
193
+ socket with a matching `TCP_MD5SIG`) must agree on the key.
194
+ - **Key length is capped at 80 bytes** (`TCP_MD5SIG_MAXKEYLEN`); a longer key raises
195
+ `ConnectionError`.
196
+ - The key is installed on the socket **before** connect, so it also protects the
197
+ handshake itself. It survives reconnects (reaping, connection drops) transparently.
198
+
199
+ ## Testing
200
+
201
+ `JRPC::Transport::Test` is an in-process transport double for testing code that
202
+ talks to a JSON-RPC server, without standing up a real one. It is **not** loaded
203
+ by default — require it explicitly from your test setup:
204
+
205
+ ```ruby
206
+ require 'jrpc/transport/test'
207
+
208
+ transport = JRPC::Transport::Test.new
209
+ transport.on('sum') { |params| params['a'] + params['b'] }
210
+
211
+ client = JRPC::SimpleClient.new('test', transport: transport)
212
+ client.request('sum', { 'a' => 1, 'b' => 2 }) # => 3
213
+
214
+ transport.last_request # => { "jsonrpc" => "2.0", "method" => "sum", "params" => {...}, "id" => "..." }
215
+ ```
216
+
217
+ Inject it through the `transport:` option of either `SimpleClient` or `SharedClient`.
218
+
219
+ **Handlers** are the high-level API. A handler's return value is encoded as a result
220
+ response echoing the request id. Raise to produce other outcomes:
221
+
222
+ ```ruby
223
+ # JSON-RPC error response (mapped back to the matching JRPC::Errors class on the caller):
224
+ transport.on('lookup') { raise JRPC::Errors::MethodNotFound, 'no such method' }
225
+
226
+ # Simulated socket-level failure, raised when the client reads the response:
227
+ transport.on('flaky') { raise JRPC::Transport::Base::ConnectionError, 'peer reset' }
228
+ ```
229
+
230
+ In **strict mode (the default)** a request for a method with no handler raises
231
+ `JRPC::Transport::Test::UnexpectedRequest` at write time, so a missing stub fails
232
+ loudly instead of hanging. Pass `strict: false` to drive reads entirely with the
233
+ raw escape hatch:
234
+
235
+ ```ruby
236
+ transport = JRPC::Transport::Test.new(strict: false)
237
+ # Feed literal response frames — for malformed responses, id mismatches, orphans:
238
+ transport.push_response({ 'jsonrpc' => '2.0', 'id' => 'abc', 'result' => 42 })
239
+ transport.push_raise(JRPC::Transport::Base::MalformedFrame.new('garbage'))
240
+ ```
241
+
242
+ Other helpers: `fail_connect(error)` arms `connect` to raise; `requests`,
243
+ `notifications`, and `sent` expose recordings for assertions; `reset` clears
244
+ recordings and queued frames (keeping handlers). The transport opens a Unix
245
+ socketpair so `SharedClient`'s `IO.select` loop works — call `shutdown` (e.g. in an
246
+ `after` hook) for deterministic FD cleanup, or let the GC finalizer reclaim it.
247
+
164
248
  ## CLI tools
165
249
 
166
250
  Two executables ship with the gem:
data/lib/jrpc/errors.rb CHANGED
@@ -11,15 +11,22 @@ module JRPC
11
11
 
12
12
  class Timeout < Error; end
13
13
 
14
+ # A JSON-RPC error object the peer sent back, carrying its `code` and its optional
15
+ # `data` member verbatim. Per the spec `data` is "a primitive or structured value"
16
+ # defined by the server, so it arrives as whatever JSON.parse produced — String,
17
+ # Hash, Array, Numeric — or nil when the peer omitted it.
14
18
  class ServerError < Error
15
- attr_reader :code
19
+ attr_reader :code, :data
16
20
 
17
- def initialize(message, code: nil)
21
+ def initialize(message, code: nil, data: nil)
18
22
  @code = code
23
+ @data = data
19
24
  super(message)
20
25
  end
21
26
  end
22
27
 
28
+ # Raised locally when a frame is unparseable or violates the envelope rules, so it
29
+ # never corresponds to a peer error object: no code, no data.
23
30
  class MalformedResponseError < ServerError
24
31
  def initialize(message)
25
32
  super(message, code: nil)
@@ -27,32 +34,32 @@ module JRPC
27
34
  end
28
35
 
29
36
  class ParseError < ServerError
30
- def initialize(message)
31
- super(message, code: -32_700)
37
+ def initialize(message, data: nil)
38
+ super(message, code: -32_700, data: data)
32
39
  end
33
40
  end
34
41
 
35
42
  class InvalidRequest < ServerError
36
- def initialize(message)
37
- super(message, code: -32_600)
43
+ def initialize(message, data: nil)
44
+ super(message, code: -32_600, data: data)
38
45
  end
39
46
  end
40
47
 
41
48
  class MethodNotFound < ServerError
42
- def initialize(message)
43
- super(message, code: -32_601)
49
+ def initialize(message, data: nil)
50
+ super(message, code: -32_601, data: data)
44
51
  end
45
52
  end
46
53
 
47
54
  class InvalidParams < ServerError
48
- def initialize(message)
49
- super(message, code: -32_602)
55
+ def initialize(message, data: nil)
56
+ super(message, code: -32_602, data: data)
50
57
  end
51
58
  end
52
59
 
53
60
  class InternalError < ServerError
54
- def initialize(message)
55
- super(message, code: -32_603)
61
+ def initialize(message, data: nil)
62
+ super(message, code: -32_603, data: data)
56
63
  end
57
64
  end
58
65
 
data/lib/jrpc/message.rb CHANGED
@@ -49,17 +49,21 @@ module JRPC
49
49
  end
50
50
  end
51
51
 
52
+ # `error.data` is optional per the spec and is passed through untouched: servers use
53
+ # it to say *which* param was invalid, *which* id conflicted, and so on — the only
54
+ # machine-readable detail an error object carries beyond the code.
52
55
  def self.error_to_exception(error_hash)
53
56
  code = error_hash['code']
54
57
  message = error_hash['message']
58
+ data = error_hash['data']
55
59
  case code
56
- when -32_700 then Errors::ParseError.new(message)
57
- when -32_600 then Errors::InvalidRequest.new(message)
58
- when -32_601 then Errors::MethodNotFound.new(message)
59
- when -32_602 then Errors::InvalidParams.new(message)
60
- when -32_603 then Errors::InternalError.new(message)
61
- when -32_099..-32_000 then Errors::InternalServerError.new(message, code: code)
62
- else Errors::UnknownError.new(message, code: code)
60
+ when -32_700 then Errors::ParseError.new(message, data: data)
61
+ when -32_600 then Errors::InvalidRequest.new(message, data: data)
62
+ when -32_601 then Errors::MethodNotFound.new(message, data: data)
63
+ when -32_602 then Errors::InvalidParams.new(message, data: data)
64
+ when -32_603 then Errors::InternalError.new(message, data: data)
65
+ when -32_099..-32_000 then Errors::InternalServerError.new(message, code: code, data: data)
66
+ else Errors::UnknownError.new(message, code: code, data: data)
63
67
  end
64
68
  end
65
69
 
@@ -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,335 @@
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
+ # `data` is emitted only when the raised error carries one, so a handler that
286
+ # ignores it produces the same frame as before.
287
+ def error_response(id, error)
288
+ code = error.respond_to?(:code) && error.code.is_a?(Integer) ? error.code : -32_000
289
+ data = error.respond_to?(:data) ? error.data : nil
290
+ err = { 'code' => code, 'message' => error.message }
291
+ err['data'] = data unless data.nil?
292
+ JSON.generate({ 'jsonrpc' => JRPC::JSON_RPC_VERSION, 'id' => id, 'error' => err })
293
+ end
294
+
295
+ def enqueue(entry)
296
+ @mon.synchronize do
297
+ @inbound << entry
298
+ signal_readable
299
+ end
300
+ end
301
+
302
+ def pop_inbound
303
+ @inbound.shift
304
+ end
305
+
306
+ def deliver(entry)
307
+ type, value = entry
308
+ case type
309
+ when :raise then raise value
310
+ when :frame then value
311
+ end
312
+ end
313
+
314
+ # Make @io readable so a loop blocked in IO.select wakes. Best-effort: a full
315
+ # buffer just means the socket is already readable, so swallow any error.
316
+ def signal_readable
317
+ return if @signal.nil?
318
+
319
+ @signal.write_nonblock('.')
320
+ rescue StandardError
321
+ nil
322
+ end
323
+
324
+ # Drain accumulated wake bytes so @io stops selecting readable once the inbound
325
+ # queue is empty, preventing the loop from spinning.
326
+ def drain_signal
327
+ return if @io.nil?
328
+
329
+ loop { @io.read_nonblock(4096) }
330
+ rescue IO::WaitReadable, IOError
331
+ nil
332
+ end
333
+ end
334
+ end
335
+ 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.2.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.2.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: