jrpc 1.1.7 → 2.0.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/.gitignore +1 -0
- data/.rspec +1 -0
- data/.rubocop.yml +228 -0
- data/CHANGELOG.md +84 -0
- data/Gemfile +17 -0
- data/README.md +163 -13
- data/Rakefile +3 -1
- data/bin/console +15 -0
- data/bin/jrpc +111 -0
- data/bin/jrpc-shell +109 -0
- data/bin/setup +8 -0
- data/jrpc.gemspec +9 -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/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 +290 -0
- data/lib/jrpc/shared_client.rb +194 -0
- data/lib/jrpc/simple_client.rb +89 -0
- data/lib/jrpc/transport/base.rb +60 -0
- data/lib/jrpc/transport/tcp.rb +243 -0
- data/lib/jrpc/transport.rb +12 -0
- data/lib/jrpc/version.rb +3 -1
- data/lib/jrpc.rb +15 -16
- metadata +35 -76
- 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 -98
- data/lib/jrpc/utils.rb +0 -9
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
class SharedClient
|
|
5
|
+
class TransportLoop
|
|
6
|
+
SELECT_FLOOR = 60.0
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
transport:, registry:, outbound_queue:, wake_pipe_reader:,
|
|
10
|
+
write_timeout:, reap_timeout:, logger:,
|
|
11
|
+
shutdown_check:,
|
|
12
|
+
clock: nil
|
|
13
|
+
)
|
|
14
|
+
@transport = transport
|
|
15
|
+
@registry = registry
|
|
16
|
+
@outbound_queue = outbound_queue
|
|
17
|
+
@wake_pipe_reader = wake_pipe_reader
|
|
18
|
+
@write_timeout = write_timeout
|
|
19
|
+
@reap_timeout = reap_timeout
|
|
20
|
+
@logger = logger
|
|
21
|
+
@shutdown_check = shutdown_check
|
|
22
|
+
@clock = clock || method(:default_clock)
|
|
23
|
+
@last_rx_at = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs the transport loop. Calls on_crash.(err) on unexpected exception, then returns.
|
|
27
|
+
def run(&)
|
|
28
|
+
main_loop
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
log_error("transport thread crashed: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
31
|
+
err = Errors::ConnectionError.new("transport thread crashed: #{e.class}: #{e.message}")
|
|
32
|
+
yield(err)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def clock_now
|
|
38
|
+
@clock.call
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_clock
|
|
42
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def shutting_down?
|
|
46
|
+
@shutdown_check.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def main_loop
|
|
50
|
+
loop do
|
|
51
|
+
break if shutting_down? && @outbound_queue.empty? && @registry.empty?
|
|
52
|
+
|
|
53
|
+
ensure_connected
|
|
54
|
+
|
|
55
|
+
ios_read = [@wake_pipe_reader]
|
|
56
|
+
ios_read << @transport.socket unless @transport.closed?
|
|
57
|
+
ios_write = []
|
|
58
|
+
ios_write << @transport.socket if !@transport.closed? && !@outbound_queue.empty?
|
|
59
|
+
|
|
60
|
+
timeout = compute_select_timeout
|
|
61
|
+
readable, writable, = IO.select(ios_read, ios_write, [], timeout)
|
|
62
|
+
|
|
63
|
+
drain_wake_pipe if readable&.include?(@wake_pipe_reader)
|
|
64
|
+
flush_one_outbound if writable&.include?(@transport.socket)
|
|
65
|
+
consume_inbound if readable&.include?(@transport.socket)
|
|
66
|
+
|
|
67
|
+
expire_due_tickets
|
|
68
|
+
sweep_dead_threads
|
|
69
|
+
reap_if_idle
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ensure_connected
|
|
74
|
+
return unless @transport.closed?
|
|
75
|
+
return if @outbound_queue.empty? && @registry.empty?
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
@transport.connect
|
|
79
|
+
@last_rx_at = clock_now
|
|
80
|
+
rescue Transport::Base::ConnectionError, Transport::Base::Timeout => e
|
|
81
|
+
drain_connection_error("connect failed: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def compute_select_timeout
|
|
86
|
+
now = clock_now
|
|
87
|
+
candidates = [SELECT_FLOOR]
|
|
88
|
+
|
|
89
|
+
registry_min = nil
|
|
90
|
+
@registry.each_ticket { |t| registry_min = [registry_min || t.expires_at, t.expires_at].min if t.expires_at }
|
|
91
|
+
queue_min = @outbound_queue.earliest_deadline
|
|
92
|
+
|
|
93
|
+
[registry_min, queue_min].each do |deadline|
|
|
94
|
+
candidates << [deadline - now, 0].max if deadline
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if @reap_timeout && !@transport.closed? && @outbound_queue.empty? && @registry.empty? && @last_rx_at
|
|
98
|
+
reap_remaining = (@last_rx_at + @reap_timeout) - now
|
|
99
|
+
candidates << [reap_remaining, 0].max
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
candidates.min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def drain_wake_pipe
|
|
106
|
+
loop do
|
|
107
|
+
@wake_pipe_reader.read_nonblock(1024)
|
|
108
|
+
end
|
|
109
|
+
rescue IO::WaitReadable
|
|
110
|
+
# drained
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flush_one_outbound
|
|
114
|
+
ticket = @outbound_queue.pop_nonblock
|
|
115
|
+
return unless ticket
|
|
116
|
+
|
|
117
|
+
return if ticket.cancelled?
|
|
118
|
+
|
|
119
|
+
now = clock_now
|
|
120
|
+
if ticket.expired?(now)
|
|
121
|
+
@registry.delete(ticket) if ticket.id
|
|
122
|
+
if ticket.thread.nil?
|
|
123
|
+
log_error('fire_and_forget notification expired before send')
|
|
124
|
+
elsif ticket.alive?
|
|
125
|
+
ticket.reject(Errors::Timeout.new('ttl expired before send'))
|
|
126
|
+
else
|
|
127
|
+
log_error("ticket expired before send; owner gone: #{ticket.id.inspect}")
|
|
128
|
+
end
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
@transport.write_frame(ticket.payload, timeout: @write_timeout)
|
|
134
|
+
rescue Transport::Base::Timeout => e
|
|
135
|
+
err = Errors::Timeout.new("write timeout: #{e.message}")
|
|
136
|
+
signal_or_log(ticket, err)
|
|
137
|
+
drain_connection_error('write timeout; partial frame may have been sent')
|
|
138
|
+
return
|
|
139
|
+
rescue Transport::Base::ConnectionError, Transport::Base::Error => e
|
|
140
|
+
err = Errors::ConnectionError.new(e.message)
|
|
141
|
+
signal_or_log(ticket, err)
|
|
142
|
+
drain_connection_error(e.message)
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Fire-and-forget notification: nothing more to do.
|
|
147
|
+
return if ticket.thread.nil?
|
|
148
|
+
|
|
149
|
+
# Blocking notification (no id): the caller only waits for the send.
|
|
150
|
+
# Request (has id): leave it registered; the response will resolve it.
|
|
151
|
+
ticket.fulfill(nil) if ticket.id.nil?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read_next_frame
|
|
155
|
+
@transport.try_read_frame
|
|
156
|
+
rescue Transport::Base::MalformedFrame => e
|
|
157
|
+
log_error("framing error: #{e.message}")
|
|
158
|
+
drain_connection_error('framing corruption; stream resynchronized')
|
|
159
|
+
nil
|
|
160
|
+
rescue Transport::Base::ConnectionError, Transport::Base::Error => e
|
|
161
|
+
drain_connection_error(e.message)
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def consume_inbound
|
|
166
|
+
loop do
|
|
167
|
+
frame = read_next_frame
|
|
168
|
+
return unless frame
|
|
169
|
+
|
|
170
|
+
break if frame == :wait
|
|
171
|
+
|
|
172
|
+
@last_rx_at = clock_now
|
|
173
|
+
|
|
174
|
+
begin
|
|
175
|
+
parsed = Message.parse(frame)
|
|
176
|
+
rescue Errors::MalformedResponseError => e
|
|
177
|
+
log_error("JSON parse error on inbound frame: #{e.message}")
|
|
178
|
+
drain_connection_error('framing corruption; stream resynchronized')
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
id = parsed['id']
|
|
183
|
+
if id.nil?
|
|
184
|
+
log_error('server-initiated message received (no id), dropping')
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
ticket = @registry.fetch_and_delete(id)
|
|
189
|
+
if ticket.nil?
|
|
190
|
+
log_error("orphan response: id=#{id.inspect}")
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if ticket.cancelled? || !ticket.alive?
|
|
195
|
+
log_error("orphan response: ticket #{id.inspect} cancelled or owner dead")
|
|
196
|
+
next
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
begin
|
|
200
|
+
Message.validate_response!(parsed, id)
|
|
201
|
+
rescue Errors::MalformedResponseError => e
|
|
202
|
+
ticket.reject(e)
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if parsed.key?('error')
|
|
207
|
+
ticket.reject(Message.error_to_exception(parsed['error']))
|
|
208
|
+
else
|
|
209
|
+
ticket.fulfill(parsed['result'])
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def expire_due_tickets
|
|
215
|
+
now = clock_now
|
|
216
|
+
expired_ids = []
|
|
217
|
+
@registry.each_ticket { |t| expired_ids << t.id if t.expired?(now) }
|
|
218
|
+
expired_ids.each do |id|
|
|
219
|
+
ticket = @registry.fetch_and_delete(id)
|
|
220
|
+
next unless ticket
|
|
221
|
+
|
|
222
|
+
if ticket.alive?
|
|
223
|
+
ticket.reject(Errors::Timeout.new('ttl expired'))
|
|
224
|
+
else
|
|
225
|
+
log_error("expired ticket #{id.inspect} owner thread dead; dropping")
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
@outbound_queue.each_snapshot do |ticket|
|
|
230
|
+
next unless ticket.expired?(now)
|
|
231
|
+
|
|
232
|
+
@outbound_queue.delete(ticket)
|
|
233
|
+
if ticket.thread.nil?
|
|
234
|
+
log_error('fire_and_forget notification expired in queue')
|
|
235
|
+
elsif ticket.alive?
|
|
236
|
+
ticket.reject(Errors::Timeout.new('ttl expired'))
|
|
237
|
+
else
|
|
238
|
+
log_error("expired queued ticket #{ticket.id.inspect} owner thread dead; dropping")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def sweep_dead_threads
|
|
244
|
+
dead = []
|
|
245
|
+
@registry.each_ticket { |t| dead << t unless t.alive? }
|
|
246
|
+
dead.each do |ticket|
|
|
247
|
+
@registry.delete(ticket)
|
|
248
|
+
log_error("owner thread dead; dropping in-flight ticket #{ticket.id.inspect}")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def reap_if_idle
|
|
253
|
+
return unless @reap_timeout
|
|
254
|
+
return if @transport.closed?
|
|
255
|
+
return unless @outbound_queue.empty? && @registry.empty?
|
|
256
|
+
return unless @last_rx_at && (clock_now - @last_rx_at) >= @reap_timeout
|
|
257
|
+
|
|
258
|
+
@transport.close
|
|
259
|
+
@last_rx_at = nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def drain_connection_error(message)
|
|
263
|
+
begin
|
|
264
|
+
@transport.close
|
|
265
|
+
rescue StandardError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
err = Errors::ConnectionError.new(message)
|
|
269
|
+
@registry.drain_all_with(err)
|
|
270
|
+
@outbound_queue.each_snapshot do |ticket|
|
|
271
|
+
@outbound_queue.delete(ticket)
|
|
272
|
+
signal_or_log(ticket, err)
|
|
273
|
+
end
|
|
274
|
+
@last_rx_at = nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def signal_or_log(ticket, err)
|
|
278
|
+
if ticket.thread
|
|
279
|
+
ticket.reject(err)
|
|
280
|
+
else
|
|
281
|
+
log_error("fire_and_forget send failed: #{err.message}")
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def log_error(msg)
|
|
286
|
+
@logger&.error("[JRPC::SharedClient] #{msg}")
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
class SharedClient
|
|
5
|
+
attr_reader :server
|
|
6
|
+
|
|
7
|
+
def initialize(server, **options)
|
|
8
|
+
@server = server
|
|
9
|
+
@write_timeout = options.fetch(:write_timeout, 5)
|
|
10
|
+
@default_ttl = options.fetch(:default_ttl, 30)
|
|
11
|
+
@reap_timeout = options.fetch(:reap_timeout, nil)
|
|
12
|
+
@max_queue_size = options.fetch(:max_queue_size, 10_000)
|
|
13
|
+
@logger = options.fetch(:logger, nil)
|
|
14
|
+
# Single monotonic clock source shared with the transport loop. The
|
|
15
|
+
# :clock option is an internal test seam (deterministic TTL specs);
|
|
16
|
+
# callers should not need it.
|
|
17
|
+
@clock = options.fetch(:clock, nil) || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
18
|
+
|
|
19
|
+
if @write_timeout && @default_ttl && @write_timeout >= @default_ttl
|
|
20
|
+
raise ArgumentError, "write_timeout (#{@write_timeout}) must be < default_ttl (#{@default_ttl})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@transport = options.fetch(:transport) do
|
|
24
|
+
Transport.build(server, **options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@id_gen = options.fetch(:id_gen) do
|
|
28
|
+
IdGenerator.new(prefix: options.fetch(:id_prefix, nil), thread_safe: true)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@registry = SharedClient::Registry.new
|
|
32
|
+
@outbound_queue = SharedClient::OutboundQueue.new(capacity: @max_queue_size)
|
|
33
|
+
@wake_pipe_reader, @wake_pipe_writer = IO.pipe
|
|
34
|
+
|
|
35
|
+
@lifecycle_mutex = Mutex.new
|
|
36
|
+
# :running -> :closing -> :closed (user-initiated close)
|
|
37
|
+
# :running -> :dead (transport thread crashed)
|
|
38
|
+
@status = :running
|
|
39
|
+
|
|
40
|
+
shutdown_check = -> { @lifecycle_mutex.synchronize { @status == :closing } }
|
|
41
|
+
|
|
42
|
+
@transport_loop = SharedClient::TransportLoop.new(
|
|
43
|
+
transport: @transport,
|
|
44
|
+
registry: @registry,
|
|
45
|
+
outbound_queue: @outbound_queue,
|
|
46
|
+
wake_pipe_reader: @wake_pipe_reader,
|
|
47
|
+
write_timeout: @write_timeout,
|
|
48
|
+
reap_timeout: @reap_timeout,
|
|
49
|
+
logger: @logger,
|
|
50
|
+
shutdown_check: shutdown_check,
|
|
51
|
+
clock: @clock
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@transport_thread = Thread.new do
|
|
55
|
+
@transport_loop.run do |err|
|
|
56
|
+
@lifecycle_mutex.synchronize { @status = :dead }
|
|
57
|
+
drain_all(err)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
@transport_thread.abort_on_exception = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request(method, params = nil, ttl: @default_ttl)
|
|
64
|
+
id = @id_gen.next
|
|
65
|
+
ticket = Ticket.new(
|
|
66
|
+
id: id,
|
|
67
|
+
payload: Message.dump(Message.build_request(method, params, id)),
|
|
68
|
+
thread: Thread.current,
|
|
69
|
+
expires_at: ttl ? clock_now + ttl : nil
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
enqueue!(ticket)
|
|
73
|
+
await(ticket, ttl)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def notification(method, params = nil, ttl: @default_ttl, fire_and_forget: false)
|
|
77
|
+
ticket = Ticket.new(
|
|
78
|
+
id: nil,
|
|
79
|
+
payload: Message.dump(Message.build_notification(method, params)),
|
|
80
|
+
thread: fire_and_forget ? nil : Thread.current,
|
|
81
|
+
expires_at: ttl ? clock_now + ttl : nil
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
enqueue!(ticket)
|
|
85
|
+
return nil if fire_and_forget
|
|
86
|
+
|
|
87
|
+
await(ticket, ttl)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def close(timeout: 5)
|
|
92
|
+
@lifecycle_mutex.synchronize do
|
|
93
|
+
return true if @status == :closed
|
|
94
|
+
|
|
95
|
+
@status = :closing
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
wake_transport
|
|
99
|
+
|
|
100
|
+
joined = @transport_thread.join(timeout)
|
|
101
|
+
|
|
102
|
+
unless joined
|
|
103
|
+
begin
|
|
104
|
+
@transport.close
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
@transport_thread.join(1.0)
|
|
109
|
+
@transport_thread.kill if @transport_thread.alive?
|
|
110
|
+
@transport_thread.join
|
|
111
|
+
# Forced kill: the loop never reached its graceful drain, so fail
|
|
112
|
+
# whatever was still in flight. (A graceful exit drained itself; a
|
|
113
|
+
# crash drained via on_crash. reject is idempotent in every case.)
|
|
114
|
+
drain_all(Errors::ConnectionError.new('client force-closed'))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Safe now that the transport thread is guaranteed dead.
|
|
118
|
+
begin
|
|
119
|
+
@wake_pipe_writer.close
|
|
120
|
+
rescue StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
begin
|
|
124
|
+
@wake_pipe_reader.close
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@lifecycle_mutex.synchronize { @status = :closed }
|
|
130
|
+
!joined.nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def closed?
|
|
134
|
+
@lifecycle_mutex.synchronize { @status == :closed }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
WAIT_GRACE = 1.0 # caller-side backstop beyond the loop-enforced ttl
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def clock_now
|
|
142
|
+
@clock.call
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def enqueue!(ticket)
|
|
146
|
+
@lifecycle_mutex.synchronize do
|
|
147
|
+
case @status
|
|
148
|
+
when :closing, :closed
|
|
149
|
+
raise Errors::ClientError, 'client closed'
|
|
150
|
+
when :dead
|
|
151
|
+
raise Errors::ClientError, 'client unusable: transport thread exited'
|
|
152
|
+
end
|
|
153
|
+
@outbound_queue.push_nonblock(ticket)
|
|
154
|
+
@registry.register(ticket) if ticket.id
|
|
155
|
+
end
|
|
156
|
+
wake_transport
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Caller side. Blocks thread- and fiber-cooperatively until the loop
|
|
160
|
+
# resolves the ticket, with a real-time backstop in case the loop dies.
|
|
161
|
+
def await(ticket, ttl)
|
|
162
|
+
ticket.wait(ttl ? ttl + WAIT_GRACE : nil)
|
|
163
|
+
|
|
164
|
+
if ticket.fulfilled?
|
|
165
|
+
ticket.result
|
|
166
|
+
elsif ticket.rejected?
|
|
167
|
+
raise ticket.error
|
|
168
|
+
else
|
|
169
|
+
raise Errors::Timeout, "request timed out after #{ttl}s"
|
|
170
|
+
end
|
|
171
|
+
ensure
|
|
172
|
+
unless ticket.resolved?
|
|
173
|
+
ticket.cancel
|
|
174
|
+
@registry.delete(ticket)
|
|
175
|
+
@outbound_queue.delete(ticket)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def drain_all(err)
|
|
180
|
+
@registry.drain_all_with(err)
|
|
181
|
+
@outbound_queue.close_and_drain.each do |ticket|
|
|
182
|
+
next if ticket.thread.nil?
|
|
183
|
+
|
|
184
|
+
ticket.reject(err)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def wake_transport
|
|
189
|
+
@wake_pipe_writer.write_nonblock('.')
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
# NOT thread-safe: a SimpleClient instance must not be shared across threads or
|
|
5
|
+
# fibers. It owns a single transport/socket plus an unsynchronized id counter, so
|
|
6
|
+
# concurrent calls would interleave socket reads/writes and corrupt the framing
|
|
7
|
+
# buffer. Use one instance per thread/fiber (or a pool of instances).
|
|
8
|
+
class SimpleClient
|
|
9
|
+
attr_reader :server
|
|
10
|
+
|
|
11
|
+
def initialize(server, **options)
|
|
12
|
+
@closed = false
|
|
13
|
+
@server = server
|
|
14
|
+
@read_timeout = options.fetch(:read_timeout, 60)
|
|
15
|
+
@write_timeout = options.fetch(:write_timeout, 60)
|
|
16
|
+
@autoclose = options.fetch(:autoclose, false)
|
|
17
|
+
@logger = options[:logger]
|
|
18
|
+
@transport = options.fetch(:transport) do
|
|
19
|
+
Transport.build(server, **options)
|
|
20
|
+
end
|
|
21
|
+
@id_gen = options.fetch(:id_gen) do
|
|
22
|
+
IdGenerator.new(prefix: options[:id_prefix], thread_safe: false)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def request(method, params = nil, read_timeout: @read_timeout, write_timeout: @write_timeout)
|
|
27
|
+
raise Errors::ClientError, 'client closed' if @closed
|
|
28
|
+
|
|
29
|
+
id = @id_gen.next
|
|
30
|
+
json = Message.dump(Message.build_request(method, params, id))
|
|
31
|
+
|
|
32
|
+
with_transport_error_handling do
|
|
33
|
+
connect_if_needed!
|
|
34
|
+
@transport.write_frame(json, timeout: write_timeout)
|
|
35
|
+
raw = @transport.read_frame(timeout: read_timeout)
|
|
36
|
+
response = Message.parse(raw)
|
|
37
|
+
Message.validate_response!(response, id)
|
|
38
|
+
raise Message.error_to_exception(response['error']) if response.key?('error')
|
|
39
|
+
|
|
40
|
+
response['result']
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notification(method, params = nil, write_timeout: @write_timeout)
|
|
45
|
+
raise Errors::ClientError, 'client closed' if @closed
|
|
46
|
+
|
|
47
|
+
json = Message.dump(Message.build_notification(method, params))
|
|
48
|
+
|
|
49
|
+
with_transport_error_handling do
|
|
50
|
+
connect_if_needed!
|
|
51
|
+
@transport.write_frame(json, timeout: write_timeout)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close
|
|
57
|
+
return true if @closed
|
|
58
|
+
|
|
59
|
+
@transport.close
|
|
60
|
+
@closed = true
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def closed?
|
|
65
|
+
@closed
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def connect_if_needed!
|
|
71
|
+
@transport.connect if @transport.closed?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Translate transport-level errors into the client's public Errors:: hierarchy and
|
|
75
|
+
# apply the autoclose policy. Shared by request and notification so the mapping and
|
|
76
|
+
# the close-after-each-call rule live in exactly one place.
|
|
77
|
+
def with_transport_error_handling
|
|
78
|
+
yield
|
|
79
|
+
rescue Transport::Base::Timeout => e
|
|
80
|
+
raise Errors::Timeout, e.message
|
|
81
|
+
rescue Transport::Base::ConnectionError => e
|
|
82
|
+
raise Errors::ConnectionError, e.message
|
|
83
|
+
rescue Transport::Base::MalformedFrame => e
|
|
84
|
+
raise Errors::MalformedResponseError, e.message
|
|
85
|
+
ensure
|
|
86
|
+
@transport.close if @autoclose
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
module Transport
|
|
5
|
+
class Base
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
class ConnectionError < Error; end
|
|
9
|
+
|
|
10
|
+
class Timeout < Error; end
|
|
11
|
+
|
|
12
|
+
class MalformedFrame < Error; end
|
|
13
|
+
|
|
14
|
+
def initialize(server, **options)
|
|
15
|
+
@server = server
|
|
16
|
+
# connect_timeout: total wall-clock budget for the whole connect (across retries).
|
|
17
|
+
# connect_attempt_timeout: cap on a single connect attempt; nil means a single
|
|
18
|
+
# attempt is bounded only by whatever is left of connect_timeout.
|
|
19
|
+
@connect_timeout = options.fetch(:connect_timeout, 60)
|
|
20
|
+
@connect_attempt_timeout = options.fetch(:connect_attempt_timeout, nil)
|
|
21
|
+
@connect_retry_count = options.fetch(:connect_retry_count, 0)
|
|
22
|
+
@connect_retry_interval = options.fetch(:connect_retry_interval, 0.5)
|
|
23
|
+
@write_timeout = options.fetch(:write_timeout, nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Abstract interface. Subclasses must implement every method below; the bodies
|
|
27
|
+
# only exist to fail loudly if one is forgotten, so they carry no logic worth
|
|
28
|
+
# covering and are excluded from coverage via :nocov:.
|
|
29
|
+
# :nocov:
|
|
30
|
+
def connect
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write_frame(bytes, timeout:)
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read_frame(timeout:)
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def try_read_frame
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def closed?
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def socket
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
end
|
|
57
|
+
# :nocov:
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|