connectrpc-async 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +542 -0
- data/lib/connectrpc/async/application.rb +173 -0
- data/lib/connectrpc/async/completion_join.rb +249 -0
- data/lib/connectrpc/async/connection.rb +711 -0
- data/lib/connectrpc/async/connection_manager.rb +358 -0
- data/lib/connectrpc/async/connection_policy.rb +60 -0
- data/lib/connectrpc/async/host.rb +171 -0
- data/lib/connectrpc/async/http1_connection.rb +144 -0
- data/lib/connectrpc/async/http1_exchange.rb +27 -0
- data/lib/connectrpc/async/http1_stream.rb +76 -0
- data/lib/connectrpc/async/http1_transport.rb +642 -0
- data/lib/connectrpc/async/ingress.rb +100 -0
- data/lib/connectrpc/async/internal.rb +270 -0
- data/lib/connectrpc/async/server_session.rb +457 -0
- data/lib/connectrpc/async/stream_exchange.rb +324 -0
- data/lib/connectrpc/async/tls.rb +124 -0
- data/lib/connectrpc/async/tls_configuration.rb +309 -0
- data/lib/connectrpc/async/transport.rb +488 -0
- data/lib/connectrpc/async/version.rb +7 -0
- data/lib/connectrpc/async.rb +39 -0
- data/sig/connectrpc-async.rbs +165 -0
- data/sig/manifest.yaml +4 -0
- metadata +202 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "http1_connection"
|
|
5
|
+
require_relative "http1_exchange"
|
|
6
|
+
|
|
7
|
+
module ConnectRPC
|
|
8
|
+
module Async
|
|
9
|
+
# Explicit Connect HTTP/1.1 execution on the application's Async reactor.
|
|
10
|
+
class HTTP1Transport
|
|
11
|
+
include Internal::Affinity
|
|
12
|
+
|
|
13
|
+
def initialize(origin:, ca_file: nil, certificate_chain: nil, private_key: nil, connection_limit: 4,
|
|
14
|
+
max_requests: 128, connect_timeout: 10)
|
|
15
|
+
initialize_affinity
|
|
16
|
+
@origin = parse_origin(origin)
|
|
17
|
+
@connection_limit = Internal.positive_integer(connection_limit, "connection_limit")
|
|
18
|
+
@max_requests = Internal.positive_integer(max_requests, "max_requests")
|
|
19
|
+
@connect_timeout = Internal.seconds(connect_timeout, "connect_timeout")
|
|
20
|
+
if @origin.scheme == "http" && (ca_file || certificate_chain || private_key)
|
|
21
|
+
raise ArgumentError, "TLS options require an HTTPS origin"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if @origin.scheme == "https"
|
|
25
|
+
@snapshot = Internal::TLS.client(ca_file: ca_file, certificate_chain: certificate_chain,
|
|
26
|
+
private_key: private_key, alpn_protocols: ["http/1.1"])
|
|
27
|
+
end
|
|
28
|
+
@calls = {}.compare_by_identity
|
|
29
|
+
@leases = []
|
|
30
|
+
@idle = []
|
|
31
|
+
@state = :running
|
|
32
|
+
install_owner_guard
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inspect = "#<#{self.class} state=#{@state} origin=#{@origin}>"
|
|
36
|
+
def streaming_capabilities = %i[server_stream client_stream].freeze
|
|
37
|
+
|
|
38
|
+
def call(request)
|
|
39
|
+
check_affinity!
|
|
40
|
+
CallCancellation.with_request(request) do |outgoing|
|
|
41
|
+
with_operation(outgoing) do |operation|
|
|
42
|
+
operate(operation) { dispatch(operation) }
|
|
43
|
+
head = response_head(operation)
|
|
44
|
+
bytes = operate(operation) { read_body(operation) }
|
|
45
|
+
if head.status == 200
|
|
46
|
+
finish(operation)
|
|
47
|
+
else
|
|
48
|
+
# A complete HTTP error is useful to core even if upload is
|
|
49
|
+
# blocked. Abort before any yield can emit a normal chunk EOF.
|
|
50
|
+
cleanup(operation)
|
|
51
|
+
end
|
|
52
|
+
TransportResponse.new(status: head.status, headers: head.headers, body: bytes)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def open_stream(request)
|
|
58
|
+
raise ArgumentError, "A streaming block is required" unless block_given?
|
|
59
|
+
|
|
60
|
+
check_affinity!
|
|
61
|
+
CallCancellation.with_request(request) do |outgoing|
|
|
62
|
+
with_operation(outgoing) do |operation|
|
|
63
|
+
operate(operation) { dispatch(operation, streaming: true) }
|
|
64
|
+
yield Internal::HTTP1Exchange.new(self, operation)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def with_operation(request)
|
|
72
|
+
raise ArgumentError, "Request URL must match the transport origin" unless same_origin?(request.url)
|
|
73
|
+
|
|
74
|
+
check_request!(request)
|
|
75
|
+
unless @state == :running && @calls.size < @max_requests
|
|
76
|
+
raise Error.new(:unavailable, "HTTP/1 transport admission is closed or full"), cause: nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Registry values record cancellation delivery, not task completion.
|
|
80
|
+
operation = { request: request, tasks: {}, events: {}, writer_state: :pending, writer_reported: false }
|
|
81
|
+
@calls[operation] = true
|
|
82
|
+
begin
|
|
83
|
+
result = yield operation
|
|
84
|
+
cleanup(operation)
|
|
85
|
+
check_cleanup!(operation)
|
|
86
|
+
result
|
|
87
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- preserve the caller's original interruption through finalization
|
|
88
|
+
interruption = e unless e.is_a?(StandardError)
|
|
89
|
+
raise
|
|
90
|
+
ensure
|
|
91
|
+
begin
|
|
92
|
+
cleanup(operation, interruption: interruption)
|
|
93
|
+
ensure
|
|
94
|
+
@calls.delete(operation)
|
|
95
|
+
@progress.signal
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Each wait owns a private activated task. Foreign-thread callbacks only
|
|
101
|
+
# resolve the event, never touch a socket or a later lease.
|
|
102
|
+
def operate(operation, timeout: nil, &)
|
|
103
|
+
check_affinity!
|
|
104
|
+
request = operation.fetch(:request)
|
|
105
|
+
check_operation!(operation)
|
|
106
|
+
|
|
107
|
+
check_request!(request)
|
|
108
|
+
event = ::Async::Promise.new
|
|
109
|
+
operation.fetch(:events)[event] = true
|
|
110
|
+
registration = request.cancellation&.on_cancel { event.resolve([:cancellation, nil]) }
|
|
111
|
+
check_operation!(operation)
|
|
112
|
+
child = @lifetime.async(finished: false) do |owned|
|
|
113
|
+
child = owned
|
|
114
|
+
operation.fetch(:tasks)[owned] = false
|
|
115
|
+
value = activate(request) do
|
|
116
|
+
check_operation!(operation)
|
|
117
|
+
yield
|
|
118
|
+
end
|
|
119
|
+
event.resolve([:result, value])
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
request.lifecycle&.record_failure(e)
|
|
122
|
+
event.resolve([:error, e])
|
|
123
|
+
ensure
|
|
124
|
+
event.resolve([:stopped, nil])
|
|
125
|
+
end
|
|
126
|
+
expires = Internal.now + [timeout, Deadline::MAX_NATIVE_WAIT].min if timeout
|
|
127
|
+
expires = [expires || Float::INFINITY, Internal.now + request.deadline.remaining].min if request.deadline
|
|
128
|
+
expires = [expires || Float::INFINITY, operation[:completion_expires]].min if operation[:completion_expires]
|
|
129
|
+
check_operation!(operation)
|
|
130
|
+
tag, result = wait_event(event, expires)
|
|
131
|
+
check_request!(request)
|
|
132
|
+
check_operation!(operation)
|
|
133
|
+
raise result if tag == :error
|
|
134
|
+
raise IOError, "HTTP/1 operation stopped" unless tag == :result
|
|
135
|
+
|
|
136
|
+
result
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
request.lifecycle&.record_failure(e)
|
|
139
|
+
check_request!(request)
|
|
140
|
+
raise if e.is_a?(Error)
|
|
141
|
+
|
|
142
|
+
raise Error.new(:unavailable, "HTTP/1 transport failed"), cause: nil
|
|
143
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- an ensure must not replace this interruption with a later one
|
|
144
|
+
interruption = e
|
|
145
|
+
raise
|
|
146
|
+
ensure
|
|
147
|
+
begin
|
|
148
|
+
if child && !child.finished?
|
|
149
|
+
cleanup(operation, interruption: interruption)
|
|
150
|
+
else
|
|
151
|
+
operation[:tasks]&.delete(child)
|
|
152
|
+
end
|
|
153
|
+
ensure
|
|
154
|
+
registration&.unregister
|
|
155
|
+
operation[:events]&.delete(event)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def wait_event(event, expires)
|
|
160
|
+
loop do
|
|
161
|
+
return event.wait unless expires
|
|
162
|
+
|
|
163
|
+
remaining = expires - Internal.now
|
|
164
|
+
raise ::Async::TimeoutError if remaining <= 0
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
return event.wait(timeout: [remaining, Internal::MAX_WAIT].min)
|
|
168
|
+
rescue ::Async::TimeoutError
|
|
169
|
+
raise if Internal.now >= expires
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def acquire(operation)
|
|
175
|
+
loop do
|
|
176
|
+
check_operation!(operation)
|
|
177
|
+
check_request!(operation.fetch(:request))
|
|
178
|
+
check_operation!(operation)
|
|
179
|
+
raise IOError, "HTTP/1 transport closed" if @forced
|
|
180
|
+
|
|
181
|
+
if (resource = @idle.pop)
|
|
182
|
+
if resource.connection.viable?
|
|
183
|
+
operation[:resource] = resource
|
|
184
|
+
return resource
|
|
185
|
+
end
|
|
186
|
+
retire_idle(resource)
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
if @leases.size < @connection_limit
|
|
190
|
+
# Reserve before TCP/TLS can yield. A stalled setup consumes only
|
|
191
|
+
# its own slot and never serializes other reserved dials.
|
|
192
|
+
resource = Internal::HTTP1Resource.new
|
|
193
|
+
@leases << resource
|
|
194
|
+
@resources.reject!(&:finished?)
|
|
195
|
+
@resources << resource
|
|
196
|
+
operation[:resource] = resource
|
|
197
|
+
setup(operation, resource)
|
|
198
|
+
return resource
|
|
199
|
+
end
|
|
200
|
+
@progress.wait
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def setup(operation, resource)
|
|
205
|
+
check_operation!(operation)
|
|
206
|
+
request = operation.fetch(:request)
|
|
207
|
+
expires = Internal.now + @connect_timeout
|
|
208
|
+
expires = [expires, Internal.now + request.deadline.remaining].min if request.deadline
|
|
209
|
+
check_operation!(operation)
|
|
210
|
+
resource.setup = @lifetime.async(finished: false) do |owned|
|
|
211
|
+
resource.setup = owned
|
|
212
|
+
operation.fetch(:tasks)[owned] = false
|
|
213
|
+
activate(request) do
|
|
214
|
+
check_request!(request)
|
|
215
|
+
check_operation!(operation)
|
|
216
|
+
resource.raw = ::IO::Endpoint.tcp(@origin.hostname, @origin.port).connect
|
|
217
|
+
check_operation!(operation)
|
|
218
|
+
socket = resource.raw
|
|
219
|
+
if @snapshot
|
|
220
|
+
socket = resource.socket = @snapshot.socket(resource.raw)
|
|
221
|
+
socket.hostname = @origin.hostname
|
|
222
|
+
socket.connect
|
|
223
|
+
socket.post_connection_check(@origin.hostname)
|
|
224
|
+
unless [nil, "http/1.1"].include?(socket.alpn_protocol)
|
|
225
|
+
raise OpenSSL::SSL::SSLError, "HTTP/1.1 ALPN is required"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
resource.socket = socket
|
|
229
|
+
check_request!(request)
|
|
230
|
+
check_operation!(operation)
|
|
231
|
+
resource.connection = Internal::HTTP1Connection.new(Internal::HTTP1Stream.new(socket))
|
|
232
|
+
end
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
request.lifecycle&.record_failure(e)
|
|
235
|
+
raise
|
|
236
|
+
end
|
|
237
|
+
Internal.wait(resource.setup, expires)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def dispatch(operation, streaming: false)
|
|
241
|
+
request = operation.fetch(:request)
|
|
242
|
+
resource = acquire(operation)
|
|
243
|
+
connection = resource.connection
|
|
244
|
+
operation[:connection] = connection
|
|
245
|
+
check_request!(request)
|
|
246
|
+
check_operation!(operation)
|
|
247
|
+
headers = request.headers.dup
|
|
248
|
+
headers["connect-timeout-ms"] = [request.deadline.timeout_ms] if request.deadline
|
|
249
|
+
check_operation!(operation)
|
|
250
|
+
dispatched = ::Async::Promise.new
|
|
251
|
+
body = if streaming
|
|
252
|
+
Internal::HTTP1Output.new(dispatched)
|
|
253
|
+
elsif request.http_method != "GET"
|
|
254
|
+
::Protocol::HTTP::Body::Buffered.wrap([request.body])
|
|
255
|
+
end
|
|
256
|
+
operation[:output] = body
|
|
257
|
+
check_operation!(operation)
|
|
258
|
+
operation[:writer] = @lifetime.async(finished: false) do |owned|
|
|
259
|
+
operation[:writer] = owned
|
|
260
|
+
operation.fetch(:tasks)[owned] = false
|
|
261
|
+
activate(request) do
|
|
262
|
+
check_operation!(operation)
|
|
263
|
+
connection.write_request(@origin.authority, streaming ? "POST" : request.http_method,
|
|
264
|
+
request.url.request_uri, "HTTP/1.1", Internal.fields(headers))
|
|
265
|
+
if !streaming && request.http_method == "GET"
|
|
266
|
+
connection.stream.write("\r\n")
|
|
267
|
+
connection.stream.flush
|
|
268
|
+
connection.send_end_stream!
|
|
269
|
+
else
|
|
270
|
+
connection.write_body("HTTP/1.1", body)
|
|
271
|
+
end
|
|
272
|
+
operation[:writer_state] = :ok
|
|
273
|
+
end
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
operation.update(writer_error: e, writer_state: :failed)
|
|
276
|
+
request.lifecycle&.record_failure(e)
|
|
277
|
+
ensure
|
|
278
|
+
# Native ensure may call send_end_stream! even after cancellation.
|
|
279
|
+
# Only our explicit :ok means the whole request was normally written.
|
|
280
|
+
unless operation[:writer_state] == :ok
|
|
281
|
+
operation[:writer_state] = :failed
|
|
282
|
+
operation[:writer_error] ||= IOError.new("HTTP/1 request writer stopped")
|
|
283
|
+
body&.close(operation[:writer_error])
|
|
284
|
+
end
|
|
285
|
+
dispatched.resolve(nil)
|
|
286
|
+
end
|
|
287
|
+
dispatched.wait if streaming
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def response_head(operation, timeout: nil)
|
|
291
|
+
check_affinity!
|
|
292
|
+
return operation[:head] if operation[:head]
|
|
293
|
+
|
|
294
|
+
operate(operation, timeout: timeout) do
|
|
295
|
+
head, body = operation.fetch(:connection).read_head(operation.fetch(:request).http_method || "POST")
|
|
296
|
+
operation[:head] = head
|
|
297
|
+
operation[:input_body] = body
|
|
298
|
+
operation[:input] = Internal::Input.new(body)
|
|
299
|
+
head
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def read(operation, length, timeout: nil)
|
|
304
|
+
response_head(operation, timeout: timeout)
|
|
305
|
+
operate(operation, timeout: timeout) do
|
|
306
|
+
bytes = operation.fetch(:input).read(length)
|
|
307
|
+
operation[:eof] = true if bytes.nil?
|
|
308
|
+
bytes
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def read_body(operation)
|
|
313
|
+
request = operation.fetch(:request)
|
|
314
|
+
length = operation.fetch(:head).headers["content-length"]&.first&.to_i
|
|
315
|
+
limit = request.response_limit
|
|
316
|
+
raise Error.new(:resource_exhausted, "Response exceeds byte limit") if length && length > limit
|
|
317
|
+
|
|
318
|
+
bytes = +"".b
|
|
319
|
+
while (chunk = operation.fetch(:input).read([16_384, limit + 1 - bytes.bytesize].min))
|
|
320
|
+
check_request!(request)
|
|
321
|
+
check_operation!(operation)
|
|
322
|
+
bytes << chunk
|
|
323
|
+
raise Error.new(:resource_exhausted, "Response exceeds byte limit") if bytes.bytesize > limit
|
|
324
|
+
end
|
|
325
|
+
operation[:eof] = true
|
|
326
|
+
raise Error.new(:internal, "Content-Length does not match response") if length && bytes.bytesize != length
|
|
327
|
+
|
|
328
|
+
bytes
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def write(operation, bytes)
|
|
332
|
+
operate(operation) do
|
|
333
|
+
raise operation[:writer_error] if operation[:writer_state] == :failed
|
|
334
|
+
|
|
335
|
+
operation.fetch(:output).write(bytes)
|
|
336
|
+
end
|
|
337
|
+
nil
|
|
338
|
+
rescue Error
|
|
339
|
+
operation[:writer_reported] = true if operation[:writer_state] == :failed
|
|
340
|
+
raise
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def close_send(operation)
|
|
344
|
+
check_affinity!
|
|
345
|
+
return if operation[:cleaned]
|
|
346
|
+
|
|
347
|
+
operation.fetch(:output).close_write
|
|
348
|
+
nil
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def finish(operation, timeout: nil)
|
|
352
|
+
check_affinity!
|
|
353
|
+
return if operation[:finished]
|
|
354
|
+
|
|
355
|
+
operation[:completion_expires] = Internal.now + timeout if timeout
|
|
356
|
+
operate(operation, timeout: timeout) do
|
|
357
|
+
raise IOError, "HTTP response EOF is required" unless operation[:eof]
|
|
358
|
+
|
|
359
|
+
operation.fetch(:writer).wait
|
|
360
|
+
unless operation[:writer_state] == :ok || operation[:writer_reported]
|
|
361
|
+
raise(operation[:writer_error] || IOError.new("HTTP/1 request writer incomplete"))
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
cleanup(operation, reusable: operation[:writer_state] == :ok)
|
|
365
|
+
check_cleanup!(operation)
|
|
366
|
+
check_request!(operation.fetch(:request))
|
|
367
|
+
operation[:finished] = true
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def abort(operation)
|
|
372
|
+
check_affinity!
|
|
373
|
+
cleanup(operation) unless operation[:finished]
|
|
374
|
+
nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def cleanup(operation, reusable: false, interruption: nil)
|
|
378
|
+
return if operation[:cleanup_complete]
|
|
379
|
+
|
|
380
|
+
current = ::Async::Task.current
|
|
381
|
+
if operation.fetch(:tasks).key?(current)
|
|
382
|
+
# A joined task may request retirement, but cannot wait for its owner.
|
|
383
|
+
pending = retire_cleanup(operation)
|
|
384
|
+
raise pending if pending
|
|
385
|
+
|
|
386
|
+
return
|
|
387
|
+
end
|
|
388
|
+
if operation[:cleaned]
|
|
389
|
+
pending = retire_cleanup(operation)
|
|
390
|
+
interruption ||= pending
|
|
391
|
+
return if operation.fetch(:cleanup_owner).current?
|
|
392
|
+
|
|
393
|
+
loop do
|
|
394
|
+
operation.fetch(:cleanup_done).wait
|
|
395
|
+
break
|
|
396
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- external waiters retain their own interruption until disposition
|
|
397
|
+
interruption ||= e
|
|
398
|
+
end
|
|
399
|
+
raise interruption if interruption
|
|
400
|
+
|
|
401
|
+
return
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
operation[:cleaned] = true
|
|
405
|
+
operation[:cleanup_owner] = current
|
|
406
|
+
operation[:cleanup_done] = ::Async::Promise.new
|
|
407
|
+
resource = operation[:resource]
|
|
408
|
+
# Terminal entry closes all launch gates. Each child registered before
|
|
409
|
+
# activation could yield, so this set cannot gain another worker.
|
|
410
|
+
tasks = operation.fetch(:tasks).keys
|
|
411
|
+
accepted = false
|
|
412
|
+
begin
|
|
413
|
+
unless reusable && !operation[:retiring]
|
|
414
|
+
pending = retire_cleanup(operation)
|
|
415
|
+
interruption ||= pending
|
|
416
|
+
end
|
|
417
|
+
expires = Internal.now + Internal::CLEANUP_SECONDS
|
|
418
|
+
expires = [expires, operation[:completion_expires]].min if operation[:completion_expires]
|
|
419
|
+
if (deadline = operation.fetch(:request).deadline)
|
|
420
|
+
expires = [expires, Internal.now + deadline.remaining].min
|
|
421
|
+
end
|
|
422
|
+
events = operation.fetch(:events).keys
|
|
423
|
+
events.each { |event| event.resolve([:stopped, nil]) }
|
|
424
|
+
operation[:cleanup_task] = current.async(finished: false) do |owned|
|
|
425
|
+
operation[:cleanup_task] = owned
|
|
426
|
+
operation.fetch(:tasks)[owned] = false
|
|
427
|
+
activate(operation.fetch(:request)) do
|
|
428
|
+
# Normal cleanup waits for natural completion. Destructive paths
|
|
429
|
+
# already raw-aborted and sent each worker its one cancellation.
|
|
430
|
+
tasks.reverse_each(&:wait)
|
|
431
|
+
operation[:input_body]&.close
|
|
432
|
+
unless reusable && !operation[:retiring] && resource&.connection&.viable?
|
|
433
|
+
operation[:retiring] = true
|
|
434
|
+
resource&.abort
|
|
435
|
+
# Final descriptor release belongs to the owner after this
|
|
436
|
+
# child's lifecycle detachment and all worker joins.
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
Internal.wait(operation[:cleanup_task], expires)
|
|
441
|
+
# Task completion includes lifecycle detachment, not just body close.
|
|
442
|
+
raise IOError, "HTTP/1 cleanup stopped" unless operation[:cleanup_task].completed?
|
|
443
|
+
|
|
444
|
+
accepted = true
|
|
445
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- no exit may bypass final joins and disposition
|
|
446
|
+
if e.is_a?(StandardError)
|
|
447
|
+
operation[:cleanup_error] ||= e
|
|
448
|
+
operation.fetch(:request).lifecycle&.record_failure(e)
|
|
449
|
+
else
|
|
450
|
+
interruption ||= e
|
|
451
|
+
end
|
|
452
|
+
ensure
|
|
453
|
+
unless accepted && !operation[:retiring]
|
|
454
|
+
pending = retire_cleanup(operation)
|
|
455
|
+
interruption ||= pending
|
|
456
|
+
[operation[:cleanup_task], *tasks.reverse].compact.each do |task|
|
|
457
|
+
pending = join_cleanup_task(operation, task)
|
|
458
|
+
interruption ||= pending
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
if resource
|
|
462
|
+
if accepted && !operation[:retiring]
|
|
463
|
+
@idle << resource
|
|
464
|
+
else
|
|
465
|
+
pending = abort_resource(resource)
|
|
466
|
+
interruption ||= pending
|
|
467
|
+
begin
|
|
468
|
+
resource.close
|
|
469
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- raw disposition and joins are complete; preserve the owner's first interruption
|
|
470
|
+
interruption ||= e
|
|
471
|
+
end
|
|
472
|
+
resource.finish!
|
|
473
|
+
@leases.delete(resource)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
operation[:cleanup_complete] = true
|
|
477
|
+
operation.fetch(:cleanup_done).resolve(nil)
|
|
478
|
+
@progress.signal
|
|
479
|
+
end
|
|
480
|
+
raise interruption if interruption
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def retire_cleanup(operation)
|
|
484
|
+
operation[:retiring] = true
|
|
485
|
+
interruption = abort_resource(operation[:resource])
|
|
486
|
+
begin
|
|
487
|
+
operation[:output]&.close(IOError.new("HTTP/1 exchange retired"))
|
|
488
|
+
rescue StandardError => e
|
|
489
|
+
operation[:cleanup_error] ||= e
|
|
490
|
+
operation.fetch(:request).lifecycle&.record_failure(e)
|
|
491
|
+
end
|
|
492
|
+
events = operation.fetch(:events).keys
|
|
493
|
+
events.each { |event| event.resolve([:stopped, nil]) }
|
|
494
|
+
tasks = operation.fetch(:tasks).keys
|
|
495
|
+
tasks.each do |task|
|
|
496
|
+
next if task.current? || task.finished? || operation[:tasks][task]
|
|
497
|
+
|
|
498
|
+
# Mark before delivery: cancel can immediately run a reentrant ensure.
|
|
499
|
+
operation[:tasks][task] = true
|
|
500
|
+
begin
|
|
501
|
+
task.cancel
|
|
502
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- finish retirement even if delivery interrupts the owner
|
|
503
|
+
interruption ||= e
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
interruption
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def join_cleanup_task(operation, task)
|
|
510
|
+
interruption = nil
|
|
511
|
+
loop do
|
|
512
|
+
task.wait
|
|
513
|
+
break
|
|
514
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- a live owner interruption must not release a live task's reservation
|
|
515
|
+
if task.failed? && task.result.equal?(e)
|
|
516
|
+
operation[:cleanup_error] ||= e
|
|
517
|
+
break
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
interruption ||= e
|
|
521
|
+
end
|
|
522
|
+
interruption
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def check_operation!(operation)
|
|
526
|
+
return unless @forced || operation[:cleaned] || operation[:retiring]
|
|
527
|
+
|
|
528
|
+
raise Error.new(:unavailable, "HTTP/1 exchange is retired"), cause: nil
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def check_cleanup!(operation)
|
|
532
|
+
return unless operation[:cleanup_error]
|
|
533
|
+
|
|
534
|
+
raise Error.new(:unavailable, "HTTP/1 transport cleanup failed"), cause: nil
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# IO#close can suspend, and its caller may be interrupted repeatedly.
|
|
538
|
+
# Retry only this ownership boundary, retaining the first escape for the
|
|
539
|
+
# caller to propagate after the remaining disposition work.
|
|
540
|
+
def abort_resource(resource)
|
|
541
|
+
interruption = nil
|
|
542
|
+
begin
|
|
543
|
+
resource&.abort
|
|
544
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- native raw disposition must finish before cancellation or slot release
|
|
545
|
+
interruption ||= e
|
|
546
|
+
retry
|
|
547
|
+
end
|
|
548
|
+
interruption
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def retire_idle(resource)
|
|
552
|
+
interruption = abort_resource(resource)
|
|
553
|
+
begin
|
|
554
|
+
resource.close
|
|
555
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- finish idle accounting after raw disposal, retaining the first interruption
|
|
556
|
+
interruption ||= e
|
|
557
|
+
end
|
|
558
|
+
resource.finish!
|
|
559
|
+
@idle.delete(resource)
|
|
560
|
+
@leases.delete(resource)
|
|
561
|
+
@progress.signal
|
|
562
|
+
raise interruption if interruption
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def close_owned(expires)
|
|
566
|
+
waiter = @lifetime.async(finished: false) { @progress.wait until @calls.empty? }
|
|
567
|
+
begin
|
|
568
|
+
Internal.wait(waiter, expires)
|
|
569
|
+
@idle.dup.each { |resource| retire_idle(resource) }
|
|
570
|
+
@idle.clear
|
|
571
|
+
finish_closed(true)
|
|
572
|
+
rescue ::Async::TimeoutError
|
|
573
|
+
force_operations
|
|
574
|
+
finish_closed(false)
|
|
575
|
+
ensure
|
|
576
|
+
Internal.cancel(waiter)
|
|
577
|
+
Internal.join(waiter)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def force_operations
|
|
582
|
+
@forced = true
|
|
583
|
+
interruption = nil
|
|
584
|
+
@leases.dup.each do |resource|
|
|
585
|
+
pending = abort_resource(resource)
|
|
586
|
+
interruption ||= pending
|
|
587
|
+
end
|
|
588
|
+
operations = @calls.keys
|
|
589
|
+
operations.each do |operation|
|
|
590
|
+
cleanup(operation)
|
|
591
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- force every admitted operation before propagating the closer's interruption
|
|
592
|
+
interruption ||= e
|
|
593
|
+
end
|
|
594
|
+
@idle.dup.each do |resource|
|
|
595
|
+
retire_idle(resource)
|
|
596
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- an interrupted idle close cannot bypass remaining resources
|
|
597
|
+
interruption ||= e
|
|
598
|
+
end
|
|
599
|
+
@progress.signal
|
|
600
|
+
raise interruption if interruption
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def abort_owned
|
|
604
|
+
return if @aborting
|
|
605
|
+
|
|
606
|
+
@aborting = true
|
|
607
|
+
@state = :closing unless @state == :closed
|
|
608
|
+
begin
|
|
609
|
+
force_operations
|
|
610
|
+
ensure
|
|
611
|
+
# force_operations retains interruptions until all disposition ends.
|
|
612
|
+
if @state != :closed && @leases.empty? && @calls.keys.all? { |operation| operation[:cleanup_complete] }
|
|
613
|
+
finish_closed(false)
|
|
614
|
+
end
|
|
615
|
+
Internal.cancel(@lifetime) if @state == :closed
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def check_request!(request)
|
|
620
|
+
request.cancellation ? request.cancellation.check! : request.deadline&.check!
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def activate(request, &work)
|
|
624
|
+
request.lifecycle ? request.lifecycle.activate(&work) : work.call
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def parse_origin(value)
|
|
628
|
+
uri = URI(value)
|
|
629
|
+
unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment &&
|
|
630
|
+
uri.path.empty?
|
|
631
|
+
raise ArgumentError, "Origin must contain only an HTTP(S) scheme, host and port"
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
uri.freeze
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def same_origin?(uri)
|
|
638
|
+
[uri.scheme, uri.hostname&.downcase, uri.port] == [@origin.scheme, @origin.hostname.downcase, @origin.port]
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|