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.
@@ -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