volucer-worker 0.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.
@@ -0,0 +1,622 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The call plane for an application that has no worker channel.
4
+ #
5
+ # A `Volucer::Worker` reaches `kv`, `lock` and `jobs` on the stream that carries its requests. A
6
+ # FastCGI application has no such stream -- it exists for the duration of one request and can only
7
+ # answer the frame it was handed -- so volucer serves the same namespaces on a socket of its own,
8
+ # `[rpc]`, which this class dials:
9
+ #
10
+ # require 'volucer'
11
+ #
12
+ # rpc = Volucer::Rpc.connect(env: env) # `env` is the Rack environment
13
+ #
14
+ # rpc.kv('cache').set('greeting', 'hello')
15
+ # holder = rpc.lock.acquire('pdf:create')
16
+ # rpc.jobs('mail').push('SendEmail', payload: '{"to":"a@b"}')
17
+ #
18
+ # Nothing here is configured, and that is the point. The address and the backend's identity are
19
+ # **in the request's environment**, put there by volucer -- `VOLUCER_RPC` and `VOLUCER_RPC_TOKEN`
20
+ # as CGI parameters, or `X-Volucer-Rpc` and `X-Volucer-Rpc-Token` as headers for an `upstream`
21
+ # origin, which Rack presents as `HTTP_X_VOLUCER_RPC`. One deployment states the address once, in
22
+ # one file, and both sides read it; an application never has a second copy to keep in step.
23
+ #
24
+ # A backend whose `services` list is empty is handed neither, which is why {.connect} does not raise
25
+ # when they are missing: the application gets a plane that refuses every call with a message naming
26
+ # the missing variable. A request that never touches a store therefore still works, and the one that
27
+ # does gets an error pointing at the deployment rather than at this class.
28
+ #
29
+ # ## The wire is the worker protocol's, minus the handshake
30
+ #
31
+ # The same {Header}, the same `CALL`/`RETURN`, the same CRC-32C. What a worker establishes once in
32
+ # `HELLO`/`HELLO_ACK` -- version, checksum policy, process identity -- would be paid for on every
33
+ # request by a CGI process, so instead **each call carries the token**. That also means a grant
34
+ # withdrawn by a reload takes effect on the next call rather than at the next reconnect.
35
+ #
36
+ # One consequence worth knowing: there is no `HELLO`, so there is no offer to skip the checksum, so
37
+ # every frame this class writes is checksummed.
38
+ #
39
+ # ## The deadline is here, unlike a worker's channel
40
+ #
41
+ # `Worker#call` blocks with no timeout of its own, because the protocol guarantees an answer. This
42
+ # class reads a **socket**, so a deadline is both possible and necessary: a call whose answer was
43
+ # lost, or a server that stopped reading, would otherwise hang until the request timed out -- and
44
+ # that failure names the wrong thing.
45
+ #
46
+ # The default matches `[rpc].call_timeout_seconds` in the default deployment, and **a call that
47
+ # legitimately takes longer needs a longer deadline**: {Lock#acquire} may wait, so a caller asking
48
+ # for a long wait must say so -- `Rpc.connect(timeout: 30)`.
49
+ #
50
+ # ## A failure raises, and says whether it may have happened
51
+ #
52
+ # These methods return values and raise {ProtocolError} for a failure, which is what a Ruby
53
+ # application expects and what `Volucer::Worker#call` already does. The distinction that has to
54
+ # survive is {ProtocolError#unknown?}: true when the call was **written** and the answer never
55
+ # arrived, so retrying may duplicate a side effect; false when nothing was sent, or when the server
56
+ # answered a refusal.
57
+
58
+ require 'socket'
59
+ # `IO#wait_readable` is what gives a read a deadline, and it is not in core on every supported Ruby
60
+ # -- `io/wait` has to be required explicitly on 3.1, which CI tests. Without it the deadline check
61
+ # below is false for a real socket, and the client would silently fall back to a blocking read with
62
+ # no timeout at all: the precise failure the deadline exists to prevent.
63
+ require 'io/wait'
64
+
65
+ require_relative 'protocol'
66
+ # For `Volucer.unix_path`, so the `unix:` prefix is spelled once in this gem rather than twice. The
67
+ # dependency is one direction -- `worker` knows nothing about this file -- so the require is safe in
68
+ # either order.
69
+ require_relative 'worker'
70
+
71
+ module Volucer
72
+ # The `[rpc]` call plane, as a client.
73
+ #
74
+ # Built by {.connect} from the request's environment, or constructed directly with a connected
75
+ # stream -- which is what the tests do, because every interesting case here is about what the
76
+ # client does with a particular answer.
77
+ class Rpc
78
+ # The CGI parameter (or upstream header) carrying the call plane's address.
79
+ ENDPOINT_PARAM = 'VOLUCER_RPC'
80
+
81
+ # The CGI parameter (or upstream header) carrying this backend's token.
82
+ TOKEN_PARAM = 'VOLUCER_RPC_TOKEN'
83
+
84
+ # The header form an `upstream` origin is given, as Rack spells it in its environment.
85
+ ENDPOINT_HEADER = 'HTTP_X_VOLUCER_RPC'
86
+
87
+ # The header form of the token.
88
+ TOKEN_HEADER = 'HTTP_X_VOLUCER_RPC_TOKEN'
89
+
90
+ # The default deadline for one call, in seconds.
91
+ #
92
+ # The same number `[rpc].call_timeout_seconds` defaults to, so a deployment that configured
93
+ # nothing behaves the same on both sides.
94
+ DEFAULT_TIMEOUT = 10.0
95
+
96
+ # How long to wait for a connection, in seconds.
97
+ #
98
+ # A **cap**, not a second budget: {#call} gives the whole round trip one deadline, and this bounds
99
+ # the dial's share of it. Without a cap, a dial to an address that is up but unresponsive would
100
+ # consume the entire call deadline and leave the read none -- so the failure would name the read
101
+ # path rather than the connection. The default deployment's `[rpc].call_timeout_seconds` is 10, so
102
+ # half of it is a dial nobody can honestly call slow, and a legitimate remote listener answers in
103
+ # milliseconds.
104
+ DIAL_SHARE = 0.5
105
+
106
+ # The first call id. Ids descend from the top of the range, matching the SDKs and the server.
107
+ FIRST_CALL_ID = 0xFFFFFFFF
108
+
109
+ # The last failure's message, if some call did not return a result.
110
+ attr_reader :last_call_error
111
+
112
+ # The address this plane dials, or nil when the deployment granted this backend nothing.
113
+ #
114
+ # For a diagnostic -- "which call plane is this application talking to?" is the first question
115
+ # when a store works in one backend and not another. The address is in the request's environment
116
+ # either way, so this publishes nothing new.
117
+ attr_reader :address
118
+
119
+ # Build a plane from the request's environment, or explicitly.
120
+ #
121
+ # Reads `VOLUCER_RPC`/`VOLUCER_RPC_TOKEN`, falling back to the `X-Volucer-Rpc*` header form an
122
+ # `upstream` backend is handed. An `address:` or `token:` passed here wins, which is what a test
123
+ # or a script needs.
124
+ #
125
+ # **`env:` is the Rack request's environment, and a Rack application should pass it.** volucer
126
+ # puts the address in the *request*, which is a different thing from the process's own
127
+ # environment: under FastCGI the values arrive as CGI parameters, and an `upstream` origin gets
128
+ # them as headers. Reading `ENV` would therefore work for a script a deployment started by hand
129
+ # and fail for every real request, with a message blaming the deployment. A CLI script or a queue
130
+ # consumer omits it and gets `ENV`, which is correct for both -- and `ENV` answers the same `[]`
131
+ # a Rack environment does, which is why this needs no type test.
132
+ #
133
+ # Returns a plane even when the deployment granted nothing: the *call* is what fails, with the
134
+ # missing variable named. A request that never touches a store must still work, and the one that
135
+ # does deserves a message about the deployment rather than an exception from a constructor it
136
+ # did not know it was calling.
137
+ #
138
+ # **Nothing is dialled here.** The connection is made by the first call, so a request that never
139
+ # touches a store pays for neither a socket nor a failed connect -- and a deployment whose call
140
+ # plane is briefly unavailable does not fail a request that would not have used it.
141
+ def self.connect(address: nil, token: nil, timeout: DEFAULT_TIMEOUT, env: nil)
142
+ source = env.nil? ? ENV : env
143
+ resolved = address.nil? ? first_value(source, ENDPOINT_PARAM, ENDPOINT_HEADER) : address
144
+ resolved_token = token.nil? ? (first_value(source, TOKEN_PARAM, TOKEN_HEADER) || '') : token
145
+
146
+ new(
147
+ nil, resolved_token, resolved, timeout,
148
+ unavailable: resolved.nil? ? unavailable_message(env) : nil
149
+ )
150
+ end
151
+
152
+ # Why a plane with no address exists, in the words that name the variable.
153
+ #
154
+ # The message distinguishes "the process's environment" from "the request's", because those are
155
+ # different mistakes: a CLI script reading `ENV` versus a backend whose `services` list is empty.
156
+ def self.unavailable_message(env)
157
+ where = env.nil? ? 'its environment' : 'the Rack request environment'
158
+ "this application was handed no call plane: #{ENDPOINT_PARAM} is not in #{where}, which " \
159
+ "means the backend's `services` list is empty or the deployment has no [rpc] listener"
160
+ end
161
+
162
+ # The first of `names` that holds a non-blank value, or nil.
163
+ #
164
+ # Blank rather than merely absent, because `VOLUCER_RPC=` is what a shell produces while somebody
165
+ # is debugging, and an empty value is not an address to dial. The same rule
166
+ # {Volucer.socket_address} applies to `VOLUCER_WORKER_SOCKET`.
167
+ def self.first_value(source, *names)
168
+ names.each do |name|
169
+ value = source[name]
170
+ next unless value.is_a?(String)
171
+
172
+ value = value.strip
173
+ return value unless value.empty?
174
+ end
175
+ nil
176
+ end
177
+
178
+ # Split an address into a unix path or a `[host, port]` pair.
179
+ #
180
+ # One method so the two forms are decided in one place, and so a malformed address is an error
181
+ # at connect rather than an `Integer()` failure a frame later.
182
+ def self.address_of(address)
183
+ path = Volucer.unix_path(address)
184
+ return [path, nil] unless path.nil?
185
+
186
+ host, _, port = address.rpartition(':')
187
+ unless port.match?(/\A\d+\z/)
188
+ raise ArgumentError, "#{address.inspect} is neither a unix: path nor a host:port address"
189
+ end
190
+
191
+ [nil, [host.empty? ? '127.0.0.1' : host, Integer(port, 10)]]
192
+ end
193
+
194
+ # @param stream [IO, nil] the connection to speak over, or nil before the first call
195
+ # @param token [String] the backend's identity, sent with every call
196
+ # @param address [String, nil] where to dial, when a stream is not given
197
+ # @param timeout [Float] how long one call may take, in seconds
198
+ # @param unavailable [String, nil] why there is no address, for a plane the deployment granted
199
+ # nothing. Passed to the constructor rather than set afterwards so there is no moment in which a
200
+ # plane exists with neither an address nor an explanation.
201
+ def initialize(stream, token = '', address = nil, timeout = DEFAULT_TIMEOUT, unavailable: nil)
202
+ if timeout <= 0
203
+ raise ArgumentError,
204
+ 'a call deadline has to be positive: zero would abandon every call the moment it was sent'
205
+ end
206
+
207
+ @stream = stream
208
+ @token = token || ''
209
+ @address = address
210
+ @timeout = timeout
211
+ # Why there is no stream **at construction time**, kept apart from `last_call_error` because
212
+ # that one describes the last *call*. A plane built without an address would otherwise report
213
+ # "the call plane could not be reached" -- or nothing at all -- instead of naming the variable
214
+ # the deployment did not set.
215
+ @unavailable = unavailable
216
+ @last_call_error = unavailable
217
+ @last_call_was_unknown = false
218
+ @next_call_id = FIRST_CALL_ID
219
+ @deadline = nil
220
+ end
221
+
222
+ # Whether the last failed call may have taken effect.
223
+ #
224
+ # True for a connection that broke or timed out **after the call was written** -- the server may
225
+ # have served it and lost the answer. False for a call that was never sent, which is the whole
226
+ # reason {.connect} records a missing address instead of raising: "the deployment granted this
227
+ # backend nothing" is a definite non-event.
228
+ def last_call_was_unknown?
229
+ @last_call_was_unknown
230
+ end
231
+
232
+ # Whether there is a live connection to reuse.
233
+ def connected?
234
+ !@stream.nil?
235
+ end
236
+
237
+ # Drop the socket. The next call reconnects.
238
+ #
239
+ # Not needed before the process exits -- a CGI child's descriptors go with it -- but a caller that
240
+ # keeps an `Rpc` in a constant, or a long-running CLI script, may want the descriptor back.
241
+ def close
242
+ stream = @stream
243
+ @stream = nil
244
+ return nil if stream.nil?
245
+
246
+ stream.close if stream.respond_to?(:close)
247
+ nil
248
+ rescue IOError, SystemCallError
249
+ nil
250
+ end
251
+
252
+ # ------------------------------------------------------------------ calling
253
+
254
+ # One round trip. Returns the result, or raises {ProtocolError}.
255
+ #
256
+ # A `name` is `"<namespace>.<verb>"`, with the one exception the protocol documents: a **`kv`
257
+ # call is three-part**, `"kv.<storage>.<verb>"`, because the deployment chooses the storage
258
+ # names. {#kv}, {#lock} and {#jobs} build those names for you and are what an application should
259
+ # normally use; this is the escape hatch for a namespace added to the server later.
260
+ #
261
+ # @raise [ProtocolError] if the call was refused, the connection failed, or the deadline expired.
262
+ # {ProtocolError#unknown?} says whether it may nevertheless have taken effect.
263
+ def call(name, params = {})
264
+ @last_call_error = nil
265
+ @last_call_was_unknown = false
266
+ # **One deadline for the whole call, dial included.** Two additive budgets -- a connect timeout
267
+ # plus a read timeout -- make the worst case their sum, which is not what "one call may take
268
+ # `call_timeout_seconds`" means to anyone reading the deployment's configuration.
269
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
270
+
271
+ # Recorded first, then raised: `ensure_stream` writes the message into `last_call_error`, and a
272
+ # caller that rescues the exception may still ask this object why.
273
+ raise failed(@last_call_error, unknown: false) unless ensure_stream
274
+
275
+ call_id = take_call_id
276
+ body = { 'name' => name, 'params' => params }
277
+ # Omitted rather than sent empty when there is no token. The server tells the two apart on
278
+ # purpose: "carried no token" is a deployment that granted this backend nothing, and "carried a
279
+ # token no backend was issued" is an application reading the wrong variable.
280
+ body['token'] = @token unless @token.empty?
281
+
282
+ begin
283
+ write_frame(Frame.new(MessageType::CALL, call_id, Volucer.json_bytes(body)))
284
+ rescue IOError, SystemCallError => e
285
+ # The bytes may or may not have reached the server: a write that failed part-way is exactly
286
+ # the undecided case this flag exists for.
287
+ close
288
+ raise failed("call #{name.inspect} could not be written: #{e.message}", unknown: true)
289
+ end
290
+
291
+ await_return(call_id, name)
292
+ end
293
+
294
+ # ------------------------------------------------------------------ handles
295
+
296
+ # A handle to one of the server's key-value storages.
297
+ #
298
+ # The storage must be one this backend was granted in `services`; a call naming another is
299
+ # refused by the server with the storages that *are* reachable.
300
+ def kv(storage = 'default')
301
+ Kv.new(self, storage)
302
+ end
303
+
304
+ # A handle to the server's resource locks.
305
+ #
306
+ # Read {Lock}'s documentation before relying on one: a lease is not a fencing token, and that is
307
+ # a property of leases rather than of this transport.
308
+ def lock
309
+ Lock.new(self)
310
+ end
311
+
312
+ # A handle to one of the server's queues, from the producer's side.
313
+ #
314
+ # @param pipeline [String] the name from the `[jobs.pipelines.<name>]` table.
315
+ def jobs(pipeline)
316
+ Jobs.new(self, pipeline)
317
+ end
318
+
319
+ # ----------------------------------------------------------------- transport
320
+
321
+ private
322
+
323
+ # Record what went wrong and build the exception to raise.
324
+ #
325
+ # One place, so a new failure path cannot set one and forget the other -- and `unknown` is a
326
+ # required keyword, because choosing it is the decision that matters.
327
+ def failed(message, unknown:)
328
+ @last_call_was_unknown = unknown
329
+ @last_call_error = message
330
+ ProtocolError.new(message, fatal: false, unknown: unknown)
331
+ end
332
+
333
+ # The next call id, counting down and wrapping.
334
+ #
335
+ # Descending from the top of the range, like every other SDK: request ids and call ids are
336
+ # deliberately from opposite ends, so a call mistaken for a request is a visible correlation
337
+ # failure rather than a plausible answer.
338
+ def take_call_id
339
+ call_id = @next_call_id
340
+ @next_call_id = call_id <= 1 ? FIRST_CALL_ID : call_id - 1
341
+ call_id
342
+ end
343
+
344
+ # The stream, dialling if there is not one yet.
345
+ #
346
+ # One path for both cases: a plane that has never connected, and a plane whose connection a
347
+ # previous call dropped. What must not be retried is a deployment that handed over no address at
348
+ # all -- that is `@unavailable`, decided once, and re-dialling nothing would only replace its
349
+ # message with a vaguer one.
350
+ #
351
+ # A failed *dial*, by contrast, is retried on the next call: it is the server being briefly
352
+ # absent, and a plane that gave up for the life of the process would turn a two-second gap into a
353
+ # permanently broken application.
354
+ def ensure_stream
355
+ return true unless @stream.nil?
356
+
357
+ unless @unavailable.nil?
358
+ @last_call_error = @unavailable
359
+ return false
360
+ end
361
+
362
+ if @address.nil?
363
+ @last_call_error = 'this call plane has no stream and no address to dial'
364
+ return false
365
+ end
366
+
367
+ stream = dial(@address)
368
+ if stream.nil?
369
+ @last_call_error = "the call plane at #{@address} could not be reached"
370
+ return false
371
+ end
372
+
373
+ @stream = stream
374
+ true
375
+ end
376
+
377
+ # Connect, or nil -- a connection failure is a call's failure, not a constructor's.
378
+ #
379
+ # Bounded by what is left of the call's deadline, so a dial cannot consume the time the read
380
+ # needs. {DIAL_SHARE} caps its share at half.
381
+ def dial(address)
382
+ path, tcp = Rpc.address_of(address)
383
+ budget = dial_budget
384
+ return nil if budget <= 0
385
+
386
+ if path.nil?
387
+ # `Socket.tcp` rather than `TCPSocket.new`: only the former takes a timeout.
388
+ Socket.tcp(tcp[0], tcp[1], connect_timeout: budget)
389
+ else
390
+ UNIXSocket.new(path)
391
+ end
392
+ rescue IOError, SystemCallError, SocketError, ArgumentError
393
+ nil
394
+ end
395
+
396
+ # How long the dial may take: half the plane's timeout, or what is left of this call if less.
397
+ def dial_budget
398
+ share = @timeout * DIAL_SHARE
399
+ return share if @deadline.nil?
400
+
401
+ [share, @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)].min
402
+ end
403
+
404
+ # Write one frame, checksummed.
405
+ #
406
+ # No handshake happened, so nothing negotiated the checksum away and it is never optional here.
407
+ def write_frame(frame)
408
+ @stream.write(frame.encode(checksum: true))
409
+ # A no-op for a socket, and correct for a `StringIO` in a test.
410
+ @stream.flush if @stream.respond_to?(:flush)
411
+ nil
412
+ end
413
+
414
+ # Read until a RETURN for `call_id` arrives.
415
+ #
416
+ # Every exit that leaves the call undecided -- the deadline, a broken connection, an answer that
417
+ # belongs to another exchange -- closes the socket, because the stream can no longer be trusted
418
+ # to be where this client thinks it is. A refusal does not: the server answered, so the stream is
419
+ # aligned and the next call reuses it.
420
+ def await_return(call_id, name)
421
+ # Re-armed per call rather than per plane, so a plane reused across two calls gets two full
422
+ # deadlines rather than one that expired during the first. `call` has already set it before the
423
+ # dial, which is what makes the dial and the read share one budget; this only extends it when
424
+ # `await_return` is reached without going through `call`.
425
+ @deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
426
+
427
+ loop do
428
+ header, payload = read_frame
429
+ if header.nil?
430
+ # The server closed the connection after the call was written, so whether it was served is
431
+ # not knowable from here.
432
+ close
433
+ raise failed(
434
+ "call #{name.inspect} was written but the server closed the connection before answering",
435
+ unknown: true
436
+ )
437
+ end
438
+
439
+ unless header.message_type == MessageType::RETURN
440
+ # Anything else is not part of this exchange. A worker absorbs telemetry here; this
441
+ # transport never sees unsolicited frames, so a correlated frame is a protocol error rather
442
+ # than something to skip.
443
+ close
444
+ raise failed(
445
+ "while waiting for a RETURN the server sent a #{header.type_name}",
446
+ unknown: true
447
+ )
448
+ end
449
+
450
+ unless header.request_id == call_id
451
+ close
452
+ raise failed(
453
+ "a RETURN for call #{header.request_id} arrived while waiting for call #{call_id}",
454
+ unknown: true
455
+ )
456
+ end
457
+
458
+ answer = begin
459
+ Volucer.decode_json(payload, 'a RETURN')
460
+ rescue ProtocolError => e
461
+ # An answer this client cannot read is an answer it cannot act on, and the call may well
462
+ # have been served: undecided, not refused.
463
+ close
464
+ raise failed("call #{name.inspect} was answered with a payload that is not a RETURN: #{e.message}",
465
+ unknown: true)
466
+ end
467
+
468
+ unless answer['ok']
469
+ # A refusal carrying `unknown: true` says the effect may have happened; one without it says
470
+ # nothing happened. The server decides which, and this passes it through rather than
471
+ # collapsing the two.
472
+ raise failed(
473
+ answer.fetch('error', 'the call was refused'),
474
+ unknown: answer['unknown'] == true
475
+ )
476
+ end
477
+
478
+ return answer['result']
479
+ end
480
+ rescue Framing => e
481
+ # A truncated frame, a checksum mismatch, an impossible payload length: the stream's position is
482
+ # no longer knowable, so the connection is discarded rather than reused.
483
+ close
484
+ raise failed("call #{name.inspect} failed: #{e.message}", unknown: true)
485
+ rescue Deadline
486
+ close
487
+ raise failed(
488
+ "call #{name.inspect} did not answer within #{@timeout}s; it may have been served, so " \
489
+ 'retrying is not necessarily safe (a longer deadline: Rpc.connect(timeout: ...))',
490
+ unknown: true
491
+ )
492
+ rescue IOError, SystemCallError => e
493
+ close
494
+ raise failed("call #{name.inspect} failed: #{e.message}", unknown: true)
495
+ end
496
+
497
+ # One frame, or nil at a clean end of stream between calls.
498
+ #
499
+ # The framing failures are wrapped in {Framing} so that {#await_return} can tell them -- which
500
+ # must close the connection -- from the failures it has already converted into a {ProtocolError}
501
+ # with its `unknown` flag chosen.
502
+ def read_frame
503
+ header = read_header
504
+ return [nil, nil] if header.nil?
505
+
506
+ [header, read_payload(header)]
507
+ rescue ProtocolError => e
508
+ raise Framing, e.message
509
+ end
510
+
511
+ # The next frame header, or nil at a clean end of stream.
512
+ #
513
+ # A stream that ends before the first byte is a clean close -- that is what a server does when its
514
+ # idle timeout expires between calls. A stream that ends *part-way* through a header is an error,
515
+ # because treating it as one would hide a truncated frame.
516
+ def read_header
517
+ raw = read_exactly(HEADER_LEN, allow_eof: true)
518
+ return nil if raw.nil?
519
+
520
+ Header.decode(raw)
521
+ end
522
+
523
+ # Read a frame's payload and verify its checksum.
524
+ #
525
+ # A mismatch means framing has already desynchronised, so it is fatal by construction.
526
+ def read_payload(header)
527
+ return ''.b if header.payload_len.zero?
528
+
529
+ # Refused *before* reading, which is the point of the limit: a corrupt `payload_len` must not
530
+ # make the reader allocate 4 GiB.
531
+ if header.payload_len > MAX_PAYLOAD_LEN
532
+ raise ProtocolError,
533
+ "a frame declares #{header.payload_len} payload bytes, above the " \
534
+ "#{MAX_PAYLOAD_LEN} limit; refusing it before allocating"
535
+ end
536
+
537
+ payload = read_exactly(header.payload_len)
538
+ return payload if header.declares_no_checksum?
539
+
540
+ computed = Crc32c.digest(payload)
541
+ return payload if computed == header.checksum
542
+
543
+ raise ProtocolError,
544
+ 'a RETURN arrived with a checksum that does not match ' \
545
+ "(expected #{format('0x%08x', header.checksum)}, computed #{format('0x%08x', computed)})"
546
+ end
547
+
548
+ # Exactly `length` bytes, or nil at a clean end of stream when `allow_eof` and nothing was read.
549
+ #
550
+ # The deadline is applied per read rather than once, so a peer that dribbles a frame out cannot
551
+ # hold a call open past the timeout by sending one byte at a time.
552
+ def read_exactly(length, allow_eof: false)
553
+ buffer = +''.b
554
+ while buffer.bytesize < length
555
+ chunk = read_some(length - buffer.bytesize, allow_eof && buffer.empty?)
556
+ if chunk.nil?
557
+ return nil if allow_eof && buffer.empty?
558
+
559
+ raise ProtocolError,
560
+ "the stream ended after #{buffer.bytesize} of #{length} bytes"
561
+ end
562
+ buffer << chunk
563
+ end
564
+ buffer
565
+ end
566
+
567
+ # Up to `length` bytes from the stream, or nil at end of stream.
568
+ def read_some(length, allow_eof)
569
+ return read_blocking(length, allow_eof) unless deadline_capable?
570
+
571
+ loop do
572
+ chunk = @stream.read_nonblock(length, exception: false)
573
+ case chunk
574
+ when :wait_readable
575
+ remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
576
+ raise Deadline if remaining <= 0
577
+
578
+ # nil from `wait_readable` is the timeout expiring; the loop recomputes and raises.
579
+ @stream.wait_readable(remaining)
580
+ when :wait_writable
581
+ # Unreachable for a read, and handled rather than left to `NoMethodError` on a Symbol.
582
+ raise ProtocolError, 'the stream asked to wait for writability during a read'
583
+ when nil
584
+ return nil
585
+ else
586
+ return chunk
587
+ end
588
+ end
589
+ end
590
+
591
+ # The fallback for a stream with no non-blocking read -- a `StringIO` in a test.
592
+ #
593
+ # No deadline, and that is honest: the deadline is a property of the transport. What must not
594
+ # happen is a *socket* taking this path, which is what {#deadline_capable?} is for.
595
+ def read_blocking(length, allow_eof)
596
+ chunk = @stream.read(length)
597
+ return nil if chunk.nil? || chunk.empty?
598
+
599
+ chunk
600
+ rescue EOFError
601
+ return nil if allow_eof
602
+
603
+ raise
604
+ end
605
+
606
+ # Whether the stream can be given a deadline.
607
+ #
608
+ # A socket can: `read_nonblock` plus `wait_readable` is a bounded read. A `StringIO` cannot, and
609
+ # an earlier version of this client assumed every stream could -- so a deadline applied to
610
+ # something that silently ignored it left the read blocking for ever. The test is on the
611
+ # *capability*, not on the class, so a test double that implements both gets the real path.
612
+ def deadline_capable?
613
+ @stream.respond_to?(:read_nonblock) && @stream.respond_to?(:wait_readable)
614
+ end
615
+
616
+ # Internal: the deadline expired. Never escapes this file.
617
+ class Deadline < StandardError; end
618
+
619
+ # Internal: the stream's framing is no longer trustworthy. Never escapes this file.
620
+ class Framing < StandardError; end
621
+ end
622
+ end