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