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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +79 -2
- data/lib/jrpc/payload_logging.rb +19 -0
- data/lib/jrpc/shared_client/transport_loop.rb +9 -1
- data/lib/jrpc/simple_client.rb +9 -0
- data/lib/jrpc/transport/base.rb +3 -0
- data/lib/jrpc/transport/tcp.rb +49 -0
- data/lib/jrpc/transport/test.rb +333 -0
- data/lib/jrpc/version.rb +1 -1
- data/lib/jrpc.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c257edbf0909e62cffe47dc7b8a79e928b4b4c24f7166bbbbec3b7141d01270f
|
|
4
|
+
data.tar.gz: 671298a50f68328de11586e0e80eeba0ae18814ce79827b57dcd54699605c20c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://rubygems.org/gems/jrpc)
|
|
4
|
+
[](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
|
-
|
|
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
|
-
|
|
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("[
|
|
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
|
data/lib/jrpc/simple_client.rb
CHANGED
|
@@ -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
|
data/lib/jrpc/transport/base.rb
CHANGED
|
@@ -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
|
data/lib/jrpc/transport/tcp.rb
CHANGED
|
@@ -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
data/lib/jrpc.rb
CHANGED
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.
|
|
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:
|