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,711 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module ConnectRPC
6
+ module Async
7
+ module Internal
8
+ def self.settings(max_requests)
9
+ {
10
+ ::Protocol::HTTP2::Settings::ENABLE_PUSH => 0,
11
+ ::Protocol::HTTP2::Settings::MAXIMUM_CONCURRENT_STREAMS => max_requests,
12
+ ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE => 16_384,
13
+ ::Protocol::HTTP2::Settings::INITIAL_WINDOW_SIZE => 65_536
14
+ }
15
+ end
16
+
17
+ # Raised before a writer enters the guard of a connection that can no
18
+ # longer start streams. Nothing has been written for that writer.
19
+ class UnusableConnection < IOError; end
20
+
21
+ # Version-coupled wrappers around the selected stack, not a frame codec.
22
+ module Connection
23
+ attr_accessor :resource, :defer_close
24
+ attr_reader :writers, :reader_task
25
+
26
+ def initialize(...)
27
+ super
28
+ @writers = {}
29
+ @writer = nil
30
+ end
31
+
32
+ # A dispatcher has allocated and written nothing while it waits, so it
33
+ # is not a retirement writer until it holds the guard. It then rechecks
34
+ # before registering, without yielding, and returns to acquisition.
35
+ def synchronize(dispatch: false)
36
+ task = ::Async::Task.current
37
+ return yield if @writer.equal?(task)
38
+
39
+ raise UnusableConnection, "HTTP/2 connection closed" if @resource&.finished?
40
+
41
+ @writers[task] = true unless dispatch
42
+ begin
43
+ @write_frame_guard.acquire do
44
+ if dispatch
45
+ raise UnusableConnection, "HTTP/2 connection closed" if @resource&.finished?
46
+
47
+ @writers[task] = true
48
+ end
49
+ @writer = task
50
+ begin
51
+ yield
52
+ ensure
53
+ @writer = nil
54
+ end
55
+ end
56
+ ensure
57
+ @writers.delete(task)
58
+ end
59
+ end
60
+
61
+ # Acquire while cancellable. Once a frame starts, encode/flush/state
62
+ # must finish together; a second cancellation is reserved for raw abort.
63
+ def write_frame(frame)
64
+ synchronize { ::Async::Task.current.defer_cancel { super } }
65
+ end
66
+
67
+ def write_frames(&)
68
+ synchronize { ::Async::Task.current.defer_cancel { super(&) } }
69
+ end
70
+
71
+ # Mirror async-http 0.105.0 and Async 2.46's eager Task#async, publishing
72
+ # the actual transient reader before EOF/error can reenter retirement.
73
+ def read_in_background(parent: ::Async::Task.current)
74
+ raise "Connection is closed!" if closed?
75
+ raise ::Async::Task::FinishedError if parent.finished?
76
+
77
+ reader = @reader_task = ::Async::Task.new(parent, transient: true) do |task|
78
+ @reader = task
79
+ task.annotate("#{version} reading data for #{self.class}.")
80
+ begin
81
+ until closed?
82
+ consume_window
83
+ read_frame
84
+ end
85
+ rescue StandardError => e
86
+ # Upstream forwards the original reader failure through close.
87
+ ensure
88
+ if @reader
89
+ @reader = nil
90
+ close(e)
91
+ end
92
+ end
93
+ end
94
+ reader.run
95
+ reader
96
+ end
97
+
98
+ def consume_local_window(frame)
99
+ if frame.length > @local_window.available
100
+ raise ::Protocol::HTTP2::FlowControlError, "DATA exceeds connection receive window"
101
+ end
102
+
103
+ super
104
+ end
105
+
106
+ def delete(id)
107
+ super
108
+ ensure
109
+ @resource&.changed
110
+ end
111
+
112
+ def close(error = nil)
113
+ @requests&.close
114
+ if @defer_close
115
+ @resource&.changed
116
+ elsif @resource
117
+ @resource.abort(error)
118
+ else
119
+ super
120
+ end
121
+ end
122
+
123
+ def finish_close(error = nil)
124
+ @requests&.close
125
+ # Skip our override while retaining upstream queue/reader/framer cleanup.
126
+ super_method = method(:close).super_method
127
+ super_method.call(error)
128
+ end
129
+ end
130
+
131
+ class GRPCHeaderError < ::Protocol::HTTP2::HeaderError
132
+ def initialize(message, code)
133
+ super(message)
134
+ @code = code
135
+ end
136
+ end
137
+
138
+ # Publish ownership before the pinned output's eager first body read.
139
+ class Output < ::Async::HTTP::Protocol::HTTP2::Output
140
+ def start(parent: ::Async::Task.current)
141
+ raise "Task already started!" if @task
142
+
143
+ work = method(@body.stream? ? :stream : :passthrough)
144
+ task = @task = ::Async::Task.new(parent, &work)
145
+ yield task
146
+ task.run
147
+ task
148
+ end
149
+ end
150
+
151
+ module Stream # rubocop:disable Metrics/ModuleLength
152
+ attr_reader :grpc_initial_headers, :grpc_initial_end_stream, :grpc_trailers, :grpc_error, :grpc_reset,
153
+ :declared_length
154
+
155
+ def grpc_mark! = @grpc = true
156
+ def grpc? = !!@grpc
157
+
158
+ def grpc_fields(headers)
159
+ size = headers.sum { |key, value| key.bytesize + value.bytesize + 32 }
160
+ if size > GRPCProtocol::FIELD_LIMIT
161
+ @grpc_error = Error.new(:resource_exhausted, "gRPC field block exceeds byte limit")
162
+ raise GRPCHeaderError.new("gRPC field block exceeds byte limit", ::Protocol::HTTP2::Error::ENHANCE_YOUR_CALM)
163
+ end
164
+ values = {}
165
+ headers.each { |key, value| (values[key] ||= []) << value unless key.start_with?(":") }
166
+ GRPCProtocol.snapshot(values)
167
+ end
168
+
169
+ def receive_initial_headers(headers, end_stream)
170
+ @grpc_web ||= headers.any? do |key, value|
171
+ key.casecmp?("content-type") && value.downcase.start_with?("application/grpc-web")
172
+ end
173
+ @grpc ||= !@grpc_web && headers.any? do |key, value|
174
+ key.casecmp?("content-type") && value.downcase.start_with?("application/grpc")
175
+ end
176
+ grpc_fields(headers) if @grpc_web
177
+ if @grpc
178
+ fields = grpc_fields(headers)
179
+ status = headers.find { |key, _value| key == ":status" }&.last.to_i
180
+ informational = status.between?(100, 199)
181
+ if informational && end_stream
182
+ @grpc_error = Error.new(:internal, "Informational gRPC HEADERS ended the stream")
183
+ raise GRPCHeaderError.new("Informational END_STREAM", ::Protocol::HTTP2::Error::PROTOCOL_ERROR)
184
+ end
185
+ if @grpc_initial_headers
186
+ @grpc_error = Error.new(:internal, "Nonfinal second gRPC HEADERS")
187
+ raise GRPCHeaderError.new("Nonfinal second gRPC HEADERS", ::Protocol::HTTP2::Error::PROTOCOL_ERROR)
188
+ end
189
+ unless informational
190
+ @grpc_initial_headers = fields
191
+ @grpc_initial_end_stream = end_stream
192
+ end
193
+ end
194
+ super
195
+ ensure
196
+ @declared_length = @length
197
+ end
198
+
199
+ def receive_trailing_headers(headers, end_stream)
200
+ grpc_fields(headers) if @grpc_web
201
+ if @grpc
202
+ @grpc_trailers = grpc_fields(headers)
203
+ unless end_stream
204
+ @grpc_error = Error.new(:internal, "Nonfinal gRPC trailing HEADERS")
205
+ raise GRPCHeaderError.new("Nonfinal gRPC trailing HEADERS", ::Protocol::HTTP2::Error::PROTOCOL_ERROR)
206
+ end
207
+ end
208
+ @headers.trailer!
209
+ super
210
+ end
211
+
212
+ def prepare_input(length)
213
+ raise ArgumentError, "Input body already prepared!" if @input
214
+
215
+ @reclaimed_input = 0
216
+ @input = Ingress.new(self, length)
217
+ end
218
+
219
+ def receive_data(frame)
220
+ if %i[open half_closed_local].include?(@state) && frame.length > @local_window.available
221
+ @input&.retire(::Protocol::HTTP2::StreamError.for(::Protocol::HTTP2::Error::FLOW_CONTROL_ERROR))
222
+ send_reset_stream(::Protocol::HTTP2::Error::FLOW_CONTROL_ERROR)
223
+ return
224
+ end
225
+
226
+ super
227
+ end
228
+
229
+ def receive_reset_stream(frame)
230
+ if @grpc
231
+ @grpc_reset = frame.unpack
232
+ if @grpc_reset == ::Protocol::HTTP2::Error::NO_ERROR
233
+ fields = @grpc_initial_end_stream ? @grpc_initial_headers : @grpc_trailers
234
+ statuses = fields && fields["grpc-status"]
235
+ unless statuses && statuses.size == 1 && !GRPCProtocol.status_number(statuses.first).nil?
236
+ @grpc_error ||= Error.new(:internal, "HTTP/2 NO_ERROR reset without gRPC status")
237
+ end
238
+ else
239
+ code = { 3 => :resource_exhausted, 7 => :unavailable, 8 => :canceled,
240
+ 11 => :resource_exhausted, 12 => :permission_denied }.fetch(@grpc_reset, :internal)
241
+ @grpc_error ||= Error.new(code, "HTTP/2 stream reset")
242
+ end
243
+ end
244
+ unless @state == :idle || frame.unpack == ::Protocol::HTTP2::Error::NO_ERROR
245
+ @input&.retire(nil, discard: true)
246
+ end
247
+ super
248
+ end
249
+
250
+ def process_data(frame)
251
+ @grpc_data_end_stream = true if @grpc && frame.end_stream?
252
+ data = frame.unpack
253
+ if (input = @input)
254
+ input.write(data) unless data.empty?
255
+ input.close_write if frame.end_stream?
256
+ discarded = frame.length - data.bytesize
257
+ else
258
+ discarded = frame.length
259
+ end
260
+ reclaim_input(discarded)
261
+ data
262
+ rescue ::Protocol::HTTP2::ProtocolError
263
+ raise
264
+ rescue StandardError
265
+ send_reset_stream(::Protocol::HTTP2::Error::INTERNAL_ERROR)
266
+ end
267
+
268
+ def reclaim_input(amount)
269
+ return if closed? || @state == :half_closed_remote || @connection.closed?
270
+
271
+ @reclaimed_input = (@reclaimed_input || 0) + amount
272
+ request_window_update
273
+ end
274
+
275
+ def request_window_update
276
+ return unless @reclaimed_input&.positive? && @local_window.limited?
277
+
278
+ @connection.synchronize do
279
+ next if closed? || @state == :half_closed_remote || @connection.closed?
280
+ next unless @reclaimed_input.positive? && @local_window.limited?
281
+
282
+ ::Async::Task.current.defer_cancel do
283
+ amount = @reclaimed_input
284
+ @reclaimed_input = 0
285
+ # Reserve credit before flush can yield to the reader. A peer
286
+ # may spend the update as soon as it reaches the socket.
287
+ @local_window.expand(amount)
288
+ frame = ::Protocol::HTTP2::WindowUpdateFrame.new(@id)
289
+ frame.pack(amount)
290
+ write_frame(frame)
291
+ end
292
+ end
293
+ end
294
+
295
+ def closed(error)
296
+ @reclaimed_input = 0
297
+ @input&.retire(error)
298
+ super
299
+ end
300
+
301
+ def finish_output(error = nil)
302
+ if (@grpc || @grpc_web) && error.is_a?(ConnectRPC::Error) && error.code == "resource_exhausted"
303
+ @output = nil
304
+ send_reset_stream(::Protocol::HTTP2::Error::ENHANCE_YOUR_CALM) unless closed?
305
+ return
306
+ end
307
+ super
308
+ end
309
+
310
+ def send_headers(headers, flags = 0)
311
+ if @grpc || @grpc_web
312
+ values = {}
313
+ headers.each { |key, value| (values[key] ||= []) << value.to_s }
314
+ begin
315
+ GRPCProtocol.check_fields!(values)
316
+ rescue Error => e
317
+ @grpc_error = e
318
+ raise
319
+ end
320
+ end
321
+ super
322
+ end
323
+
324
+ def send_body(body, trailer = nil, &)
325
+ # A peer reset can close the stream while deferred headers flush.
326
+ # Upstream otherwise starts an output task on the closed stream.
327
+ return body.close if closed?
328
+
329
+ @output = Output.new(self, body, trailer)
330
+ @output.start(&)
331
+ end
332
+ end
333
+
334
+ class RequestStream < ::Async::HTTP::Protocol::HTTP2::Request::Stream
335
+ include Stream
336
+
337
+ attr_accessor :exchange
338
+ attr_reader :response_task
339
+
340
+ def initialize(...)
341
+ @cancellation_source = CancellationSource.new
342
+ super
343
+ end
344
+
345
+ def processing_cancellation = @cancellation_source&.token
346
+
347
+ def detach_cancellation
348
+ @cancellation_source = nil
349
+ end
350
+
351
+ def response_written? = @response_written && !@response_reset && (!@grpc || @grpc_terminal_written)
352
+
353
+ def send_body(...)
354
+ super { |task| @response_task = task }
355
+ end
356
+
357
+ def receive_reset_stream(frame)
358
+ @cancellation_source&.cancel if frame.unpack == ::Protocol::HTTP2::Error::CANCEL
359
+ @response_reset = true
360
+ super
361
+ end
362
+
363
+ def send_headers(headers, flags = 0)
364
+ super
365
+ @grpc_terminal_written = true if @grpc && flags.anybits?(::Protocol::HTTP2::END_STREAM)
366
+ @response_written = true if flags.anybits?(::Protocol::HTTP2::END_STREAM) && !@response_reset
367
+ end
368
+
369
+ def finish_output(error = nil)
370
+ # Raw abort has already retired the connection. Its owned tasks must
371
+ # finish locally rather than attempting another reset on that socket.
372
+ return if @connection.closed?
373
+
374
+ writable = !closed? && !@response_reset && error.nil?
375
+ super
376
+ @response_written = true if writable && !@response_reset
377
+ end
378
+ end
379
+
380
+ class ResponseStream < ::Async::HTTP::Protocol::HTTP2::Response::Stream
381
+ include Stream
382
+
383
+ attr_reader :output_task
384
+
385
+ # A raw-first destructive abort has already retired this connection.
386
+ # Output unwinding must not attempt another reset on its closed socket.
387
+ def finish_output(error = nil)
388
+ return if @connection.closed?
389
+
390
+ super
391
+ end
392
+
393
+ def send_body(...)
394
+ super { |task| @output_task = task }
395
+ end
396
+ end
397
+
398
+ class ClientConnection < ::Async::HTTP::Protocol::HTTP2::Client
399
+ include Connection
400
+
401
+ def create_response
402
+ ResponseStream.create(self, next_stream_id).response
403
+ end
404
+
405
+ # Peer SETTINGS can change stream capacity for waiting acquisitions.
406
+ def update_remote_settings(changes)
407
+ super
408
+ ensure
409
+ @resource&.changed
410
+ end
411
+
412
+ def receive_goaway(frame)
413
+ super
414
+ ensure
415
+ @resource&.unusable
416
+ end
417
+ end
418
+
419
+ class ServerConnection < ::Async::HTTP::Protocol::HTTP2::Server
420
+ include Connection
421
+
422
+ def accept_stream(stream_id)
423
+ ::Protocol::HTTP2::Connection.instance_method(:accept_stream).bind_call(self, stream_id) do
424
+ RequestStream.create(self, stream_id)
425
+ end
426
+ end
427
+
428
+ # No connection-wide barrier: reset and response failures are local to
429
+ # this stream. Application work is a separately stoppable host task.
430
+ def each(task: ::Async::Task.current)
431
+ while (request = @requests.dequeue)
432
+ next if request.stream.closed?
433
+
434
+ worker = ::Async::Task.new(task, finished: false) do |_worker, incoming|
435
+ response = error = written = nil
436
+ begin
437
+ response = yield(incoming)
438
+ incoming.stream.exchange.http_status = response&.status if incoming.stream.exchange
439
+ unless incoming.stream.closed?
440
+ synchronize do
441
+ ::Async::Task.current.defer_cancel { incoming.send_response(response) }
442
+ end
443
+ response = nil
444
+ # send_body schedules output; wait outside the connection guard.
445
+ incoming.stream.response_task&.wait
446
+ written = incoming.stream.response_written?
447
+ end
448
+ rescue StandardError => e
449
+ error = e
450
+ reset(incoming.stream, e)
451
+ ensure
452
+ error ||= $ERROR_INFO
453
+ begin
454
+ cleaned = close_request(response, incoming) { |failure| error ||= failure }
455
+ ensure
456
+ incoming.stream.exchange&.finish(written && cleaned && incoming.stream.response_written? &&
457
+ !@resource.finished?, error || $ERROR_INFO)
458
+ @resource.changed
459
+ end
460
+ end
461
+ end
462
+ @resource.track(worker)
463
+ worker.run(request)
464
+ end
465
+ end
466
+
467
+ private
468
+
469
+ def close_request(response, request)
470
+ response&.close
471
+ request.body&.close
472
+ true
473
+ rescue ::Protocol::HTTP2::ProtocolError, IOError, SystemCallError => e
474
+ yield e if block_given?
475
+ nil
476
+ end
477
+
478
+ def reset(stream, error)
479
+ code = if stream.grpc? && error.is_a?(ConnectRPC::Error) && error.code == "resource_exhausted"
480
+ ::Protocol::HTTP2::Error::ENHANCE_YOUR_CALM
481
+ else
482
+ ::Protocol::HTTP2::Error::INTERNAL_ERROR
483
+ end
484
+ stream.send_reset_stream(code) unless stream.closed?
485
+ rescue ::Protocol::HTTP2::ProtocolError, IOError, SystemCallError
486
+ nil
487
+ end
488
+ end
489
+
490
+ class Resource
491
+ attr_accessor :raw, :socket, :connection, :task, :generation, :snapshot, :ready, :draining
492
+ attr_reader :tasks, :progress, :activity_at
493
+ # Called when the connection stops accepting new streams, before any
494
+ # retirement join, so its owner can update accounting promptly.
495
+ attr_writer :on_unusable
496
+
497
+ def initialize(progress:, generation: nil, snapshot: nil)
498
+ @shared_progress = progress
499
+ @progress = ::Async::Condition.new
500
+ @generation = generation
501
+ @snapshot = snapshot
502
+ @tasks = []
503
+ @activity_at = Internal.now
504
+ @ready = false
505
+ @draining = false
506
+ @aborted = false
507
+ @finishing = false
508
+ @drain_task = nil
509
+ @retirement_members = {}
510
+ @cancelled_roots = {}
511
+ @forced_roots = {}
512
+ end
513
+
514
+ def changed
515
+ @activity_at = Internal.now
516
+ @progress.signal
517
+ @shared_progress.signal
518
+ end
519
+
520
+ def track(task)
521
+ raise IOError, "Connection is retiring" if @aborted
522
+
523
+ @tasks.reject!(&:finished?)
524
+ @tasks << task
525
+ task
526
+ end
527
+
528
+ def empty?
529
+ (!@connection || @connection.streams.empty?) && @tasks.all?(&:finished?)
530
+ end
531
+
532
+ def finished? = @aborted
533
+ def finishing? = @finishing
534
+ def unusable = @on_unusable&.call(self)
535
+
536
+ def abort(error = IOError.new("HTTP/2 connection closed"))
537
+ return if @cleanup&.current?
538
+
539
+ capture_retirement
540
+ owned = owns_task?(::Async::Task.current)
541
+ launch = !@cleanup
542
+ if launch
543
+ @aborted = true
544
+ @abort_during_drain = true if @drain_task && !@drained
545
+ @cleanup = ::Async::Task.new(@lifetime, transient: true, finished: false) { retire(error) }
546
+ end
547
+ if owned
548
+ start_cleanup if launch
549
+ else
550
+ Internal.join_complete(@cleanup, failure: true) { start_cleanup if launch }
551
+ end
552
+ nil
553
+ end
554
+
555
+ def drain(parent, expires)
556
+ return Internal.wait(@drain_task, expires) if @drain_task
557
+
558
+ if @aborted
559
+ abort
560
+ return true
561
+ end
562
+
563
+ @draining = true
564
+ unless @ready
565
+ abort
566
+ return true
567
+ end
568
+ @drain_task = ::Async::Task.new(parent, finished: false) do
569
+ @progress.wait until empty?
570
+ next !@abort_during_drain if @aborted
571
+
572
+ @finishing = true
573
+ @connection.defer_close = true
574
+ @connection.send_goaway
575
+ @raw.shutdown(Socket::SHUT_WR)
576
+ reader = @connection.reader_task
577
+ cancel_root(reader)
578
+ Internal.join_complete(reader) if reader
579
+ # Discard unread inbound control bytes before closing TCP, so
580
+ # the peer receives the complete response/GOAWAY rather than RST.
581
+ loop { @raw.readpartial(16_384) }
582
+ rescue EOFError
583
+ @drained = true
584
+ true
585
+ rescue StandardError
586
+ false
587
+ ensure
588
+ abort
589
+ end
590
+ @drain_task.run
591
+ Internal.wait(@drain_task, expires)
592
+ rescue ::Async::TimeoutError
593
+ abort
594
+ false
595
+ end
596
+
597
+ private
598
+
599
+ def retirement_parent=(parent)
600
+ @lifetime = parent
601
+ end
602
+
603
+ def retirement_completed? = !!@retirement_completed
604
+
605
+ # Retirement starts, closing raw TCP, before the owner notification
606
+ # can yield; a caller cancelled in that notification cannot leave it
607
+ # unstarted. The notification still precedes any retirement join.
608
+ def start_cleanup
609
+ @cleanup.run
610
+ ensure
611
+ unusable
612
+ end
613
+
614
+ def retirement_roots
615
+ outputs = (@connection ? @connection.streams.values : []).flat_map do |stream|
616
+ [stream.respond_to?(:response_task) ? stream.response_task : nil,
617
+ stream.respond_to?(:output_task) ? stream.output_task : nil]
618
+ end
619
+ [*@connection&.writers&.keys, @connection&.reader_task, *@tasks, @task, *outputs].compact.uniq
620
+ end
621
+
622
+ def capture_retirement
623
+ retirement_roots.each { |task| capture_task(task) }
624
+ members = @retirement_members.keys
625
+ members.each { |task| capture_task(task) }
626
+ end
627
+
628
+ def capture_task(task)
629
+ @retirement_members[task] = true
630
+ task.children&.to_a&.each { |child| capture_task(child) unless child.transient? }
631
+ end
632
+
633
+ def owns_task?(task)
634
+ capture_retirement
635
+ while task
636
+ return true if @retirement_members.key?(task)
637
+ return false if task.transient?
638
+
639
+ task = task.parent
640
+ end
641
+ false
642
+ end
643
+
644
+ def cancel_root(task)
645
+ return if !task || @cancelled_roots.key?(task) || task.finished?
646
+
647
+ # Record cascade coverage before cancellation can reenter or reparent.
648
+ mark_cancelled(task)
649
+ task.cancel
650
+ # The first request can remain deferred by the pinned frame/response
651
+ # guard. Deliver it now, once, rather than waiting on that guard.
652
+ force_deferred(task)
653
+ end
654
+
655
+ def force_deferred(task)
656
+ return if @forced_roots.key?(task) || task.finished? || !task.cancel_deferred?
657
+
658
+ @forced_roots[task] = true
659
+ task.cancel
660
+ end
661
+
662
+ def mark_cancelled(task)
663
+ @cancelled_roots[task] = true
664
+ task.children&.to_a&.each { |child| mark_cancelled(child) unless child.transient? }
665
+ end
666
+
667
+ def retire(error)
668
+ # Raw TCP precedes any protocol, buffered stream or TLS disposal.
669
+ Internal.raw_close(@raw)
670
+ # Best-effort scheduling hint only. A return proves no unwind or
671
+ # completion; the retained-task joins below establish retirement.
672
+ ::Async::Task.current.yield
673
+ @connection&.close!
674
+ loop do
675
+ capture_retirement
676
+ writers = @connection&.writers&.keys || []
677
+ roots = retirement_roots
678
+ roots.reject! do |task|
679
+ ancestor = task
680
+ covered = false
681
+ until ancestor.transient? || !(ancestor = ancestor.parent)
682
+ covered ||= roots.include?(ancestor)
683
+ end
684
+ covered
685
+ end
686
+ roots.each { |task| cancel_root(task) }
687
+ # An ancestor's cascade can leave the registered frame writer with
688
+ # only a deferred request. Logical coverage is not forced delivery.
689
+ writers.each { |writer| force_deferred(writer) }
690
+ @retirement_members.keys.reverse_each { |task| Internal.join_complete(task) }
691
+ capture_retirement
692
+ break if @retirement_members.keys.all?(&:finished?)
693
+ end
694
+ begin
695
+ @connection&.finish_close(error)
696
+ rescue StandardError
697
+ nil
698
+ ensure
699
+ begin
700
+ @socket&.close
701
+ rescue StandardError
702
+ nil
703
+ end
704
+ end
705
+ @retirement_completed = true
706
+ changed
707
+ end
708
+ end
709
+ end
710
+ end
711
+ end