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,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Async
5
+ class ServerSession
6
+ include Internal::Affinity
7
+
8
+ def initialize(application:, tls: nil, tls_provider: nil, max_connections: 128, max_requests: 128,
9
+ handshake_timeout: 10, idle_timeout: 60, refresh_interval: 60, refresh_jitter: 0.1,
10
+ refresh_timeout: 10, on_tls_error: nil, alpn_protocols: ["h2"], http1: nil, stop_accepting: nil,
11
+ on_request_rejected: nil, lifecycle_factory: nil)
12
+ initialize_affinity
13
+ raise ArgumentError, "application must be callable" unless application.respond_to?(:call)
14
+ raise ArgumentError, "stop_accepting must be callable" if stop_accepting && !stop_accepting.respond_to?(:call)
15
+ if on_request_rejected && !on_request_rejected.respond_to?(:call)
16
+ raise ArgumentError, "on_request_rejected must be callable"
17
+ end
18
+
19
+ @lifecycle_factory = lifecycle_factory
20
+ @application = application
21
+ @http1 = http1
22
+ @stop_accepting = stop_accepting
23
+ @on_request_rejected = on_request_rejected
24
+ @selector = ::Async::HTTP::Protocol::HTTP.new if http1
25
+ @max_connections = Internal.positive_integer(max_connections, "max_connections")
26
+ @max_requests = Internal.positive_integer(max_requests, "max_requests")
27
+ @handshake_timeout = Internal.seconds(handshake_timeout, "handshake_timeout")
28
+ @idle_timeout = Internal.seconds(idle_timeout, "idle_timeout") unless idle_timeout.nil?
29
+ @tls = TLSConfiguration.new(tls: tls, tls_provider: tls_provider, refresh_interval: refresh_interval,
30
+ refresh_jitter: refresh_jitter, refresh_timeout: refresh_timeout,
31
+ on_error: on_tls_error, alpn_protocols: alpn_protocols)
32
+ @retirement_floor = 0
33
+ @admitted = {}
34
+ @h1_wire = {}
35
+ @identities = {}
36
+ install_owner_guard
37
+ end
38
+
39
+ def inspect = "#<#{self.class} state=#{@state} tls_generation=#{tls_generation.inspect}>"
40
+
41
+ def start
42
+ preflight_start
43
+ return self if @state == :running
44
+
45
+ @tls.start
46
+ @state = :running
47
+ self
48
+ end
49
+
50
+ def reload_tls(certificate_chain:, private_key:, client_ca:)
51
+ check_running_tls!
52
+ @tls.reload(certificate_chain: certificate_chain, private_key: private_key, client_ca: client_ca)
53
+ end
54
+
55
+ def tls_generation = @tls.generation
56
+ def tls_status = @tls.status
57
+ def refresh_tls = @tls.refresh
58
+
59
+ def close(grace: 5)
60
+ check_affinity!
61
+ @tls.send(:check_recursion!)
62
+ Internal.seconds(grace, "grace", zero: true)
63
+ return @close_outcome if @state == :closed
64
+
65
+ return nil if @cleanup&.current? || @closer&.current?
66
+
67
+ owned = resource_owned_task?
68
+ launch = @state != :closing
69
+ if launch
70
+ @state = :closing
71
+ @closer = ::Async::Task.new(@owner, finished: false) do
72
+ begin
73
+ close_owned(Internal.now + grace)
74
+ ensure
75
+ abort_owned
76
+ end
77
+ @close_outcome
78
+ end
79
+ end
80
+ if owned
81
+ @closer.run if launch
82
+ return @state == :closed ? @close_outcome : nil
83
+ end
84
+
85
+ waiter = @closer || @cleanup
86
+ Internal.join_complete(waiter, failure: true, on_interrupt: method(:abort_owned)) { @closer.run if launch }
87
+ @close_outcome
88
+ end
89
+
90
+ def abort
91
+ check_affinity!
92
+ @tls.send(:check_recursion!)
93
+ abort_owned
94
+ self
95
+ end
96
+
97
+ def drain_tls(grace: 5)
98
+ check_running_tls!
99
+ Internal.seconds(grace, "grace", zero: true)
100
+ cutoff = tls_generation
101
+ @retirement_floor = [@retirement_floor, cutoff].max
102
+ targets = @resources.reject { |resource| resource.send(:retirement_completed?) }
103
+ .select { |resource| resource.generation < cutoff }
104
+ targets.each { |resource| resource.draining = true }
105
+ drain_resources(targets, Internal.now + grace)
106
+ end
107
+
108
+ def peer_certificate
109
+ return unless Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@reactor)
110
+
111
+ der = @identities[::Async::Task.current?]
112
+ OpenSSL::X509::Certificate.new(der.dup) if der
113
+ end
114
+
115
+ # Accepted work must outlive cancellation of a listener's accept loop.
116
+ def schedule(&)
117
+ check_affinity!
118
+ @lifetime.async(finished: false, &)
119
+ end
120
+
121
+ # Hosting SPI. Capture must happen in the accepting task before it
122
+ # schedules any handshake work. The returned handle owns this one peer.
123
+ def accept(raw)
124
+ check_affinity!
125
+ @resources.reject! { |resource| resource.send(:retirement_completed?) }
126
+ unless @state == :running && @resources.size < @max_connections
127
+ Internal.raw_close(raw)
128
+ return
129
+ end
130
+ capture = @tls.send(:capture, raw)
131
+ resource = Internal::Resource.new(progress: @progress, generation: capture.generation, snapshot: capture)
132
+ resource.send(:retirement_parent=, @lifetime)
133
+ resource.raw = raw
134
+ @resources << resource
135
+ Accepted.new(self, resource, @progress)
136
+ rescue LifecycleError
137
+ raise
138
+ rescue StandardError
139
+ Internal.raw_close(raw)
140
+ nil
141
+ end
142
+
143
+ class Accepted
144
+ def initialize(session, resource, progress)
145
+ @session = session
146
+ @resource = resource
147
+ @progress = progress
148
+ end
149
+
150
+ def serve
151
+ @session.send(:serve, @resource) { |resource| @resource = resource }
152
+ end
153
+
154
+ def close
155
+ @resource.abort unless @resource.finishing?
156
+ end
157
+
158
+ def wait_closed
159
+ @progress.wait until @resource.send(:retirement_completed?)
160
+ end
161
+
162
+ def timeout=(value)
163
+ @resource.raw.timeout = value if @resource.raw.respond_to?(:timeout=)
164
+ end
165
+
166
+ def remote_address = @resource.raw.remote_address
167
+ def inspect = "#<#{self.class} accepted connection>"
168
+ end
169
+ private_constant :Accepted
170
+
171
+ private
172
+
173
+ # Nonyielding calling-task validation, shared with Host before startup
174
+ # cleanup can retire anything. A running session stays idempotent.
175
+ def preflight_start
176
+ check_affinity!
177
+ return if @state == :running
178
+ raise LifecycleError, "Server session cannot start after close" unless @state == :new
179
+
180
+ @tls.send(:check_recursion!)
181
+ end
182
+
183
+ def check_running_tls!
184
+ check_affinity!
185
+ raise LifecycleError, "A running TLS host is required" unless @state == :running && tls_generation
186
+ end
187
+
188
+ def serve(resource)
189
+ return if resource.finished?
190
+
191
+ resource.task = ::Async::Task.current
192
+ expires = Internal.now + @handshake_timeout
193
+ setup = resource.track(::Async::Task.new(::Async::Task.current, finished: false) { setup_connection(resource) })
194
+ setup.run
195
+ resource = Internal.wait(setup, expires)
196
+ yield resource if block_given?
197
+ return if resource.draining || (tls_generation && resource.generation < @retirement_floor)
198
+
199
+ resource.ready = true
200
+ resource.changed
201
+ resource.connection.start_connection if resource.connection.respond_to?(:start_connection)
202
+ idle = @lifetime.async(finished: false) { watch_idle(resource) } if @idle_timeout
203
+ resource.connection.each { |request| dispatch(resource, request) }
204
+ rescue StandardError
205
+ # Socket/setup/protocol failures terminate only this owned connection.
206
+ nil
207
+ ensure
208
+ Internal.cancel(idle)
209
+ resource.abort unless resource.finishing?
210
+ end
211
+
212
+ def setup_connection(resource)
213
+ socket = resource.snapshot.handshake { |socket| resource.socket = socket }
214
+ resource.socket = socket
215
+ stream = ::IO::Stream(socket)
216
+ if @http1 && ((!resource.snapshot.tls? &&
217
+ @selector.protocol_for(stream) == ::Async::HTTP::Protocol::HTTP1) ||
218
+ (resource.snapshot.tls? && socket.alpn_protocol != "h2"))
219
+ return resource if resource.draining || (tls_generation && resource.generation < @retirement_floor)
220
+
221
+ replacement = @http1.call(stream, progress: @progress, generation: resource.generation)
222
+ replacement.send(:retirement_parent=, @lifetime)
223
+ replacement.raw = resource.raw
224
+ replacement.socket = socket
225
+ replacement.snapshot = resource.snapshot
226
+ replacement.task = resource.task
227
+ @resources[@resources.index(resource)] = replacement
228
+ return replacement
229
+ end
230
+ if resource.snapshot.tls? && socket.alpn_protocol != "h2"
231
+ raise OpenSSL::SSL::SSLError, "HTTP/2 ALPN is required"
232
+ end
233
+
234
+ connection = Internal::ServerConnection.new(stream)
235
+ resource.connection = connection
236
+ connection.resource = resource
237
+ connection.read_connection_preface(Internal.settings(@max_requests))
238
+ resource
239
+ end
240
+
241
+ def watch_idle(resource)
242
+ loop do
243
+ if resource.empty?
244
+ remaining = @idle_timeout - (Internal.now - resource.activity_at)
245
+ if remaining <= 0
246
+ resource.abort
247
+ break
248
+ end
249
+ begin
250
+ ::Async::Task.current.with_timeout([remaining, Internal::MAX_WAIT].min) { resource.progress.wait }
251
+ rescue ::Async::TimeoutError
252
+ next
253
+ end
254
+ else
255
+ resource.progress.wait
256
+ end
257
+ break if resource.finished?
258
+ end
259
+ end
260
+
261
+ def dispatch(resource, request)
262
+ if resource.respond_to?(:prepare_stream_input) && @application.respond_to?(:http1_streaming_route?, true) &&
263
+ @application.send(:http1_streaming_route?, request)
264
+ retirement = resource.prepare_stream_input(request.body)
265
+ end
266
+ lifecycle = @lifecycle_factory&.call(request)
267
+ if lifecycle
268
+ exchange = Internal::Exchange.new(lifecycle)
269
+ if request.respond_to?(:stream)
270
+ request.stream.exchange = exchange
271
+ else
272
+ resource.exchange = exchange
273
+ end
274
+ end
275
+ @admitted.delete_if { |stream, completed| completed && stream.respond_to?(:closed?) && stream.closed? }
276
+ key = request.respond_to?(:stream) ? request.stream : request
277
+ if @state != :running || resource.draining || @admitted.size >= @max_requests
278
+ if exchange
279
+ exchange.decision = :admission_rejected
280
+ exchange.http_status = 503
281
+ end
282
+ @on_request_rejected&.call
283
+ retirement&.processor_finished
284
+ retirement&.head_resolved(503)
285
+ return @application.admission_response(request) if @application.respond_to?(:admission_response)
286
+
287
+ return ::Protocol::HTTP::Response[503, {}, nil]
288
+ end
289
+
290
+ @admitted[key] = false
291
+ begin
292
+ retirement&.on_close do
293
+ complete_admission(key, wire: true)
294
+ resource.changed
295
+ end
296
+ publishes = request.respond_to?(:stream) ||
297
+ (@application.respond_to?(:publication?, true) && @application.send(:publication?, request))
298
+ publication = Internal::Publication.new if publishes
299
+ handler = ::Async::Task.new(@lifetime, finished: false) do |task|
300
+ @identities[task] = resource.snapshot.authenticated_leaf
301
+ work = lambda do
302
+ if publication && @application.respond_to?(:call_with_publication)
303
+ @application.call_with_publication(request, lifecycle: lifecycle, publication: publication,
304
+ resource: resource)
305
+ elsif lifecycle
306
+ @application.call(request, lifecycle: lifecycle)
307
+ else
308
+ @application.call(request)
309
+ end
310
+ end
311
+ application_work = work
312
+ work = -> { lifecycle.send(:with_processing_completion, &application_work) } if lifecycle && publication
313
+ result = lifecycle ? lifecycle.activate(&work) : work.call
314
+ publication.publish(result) if publication && !publication.published?
315
+ result
316
+ rescue StandardError => e
317
+ raise unless publication
318
+
319
+ lifecycle&.record_failure(e)
320
+ publication.complete(e)
321
+ nil
322
+ ensure
323
+ complete_processor(publication, task, key, resource)
324
+ end
325
+ resource.track(handler)
326
+ handler.run
327
+ response = publication ? publication.response : handler.wait
328
+ retirement&.head_resolved(response&.status)
329
+ exchange.http_status = response&.status if exchange
330
+ if !request.respond_to?(:stream) && response
331
+ response.body = Internal::AdmissionBody.new(response.body) do
332
+ # Connect retirement owns completion through post-delivery hygiene.
333
+ complete_admission(key, wire: true) unless retirement
334
+ resource.changed
335
+ end
336
+ deferred = true
337
+ # An early Web head can stop an HTTP/1 client's unfinished upload.
338
+ # Keep input cleanup in the original processor before serialization;
339
+ # consumed unary input still permits incremental server streams.
340
+ # Connect retirement owns input hygiene after response delivery.
341
+ if publication && !retirement && GRPCWeb.intent?(Internal.headers(request)) &&
342
+ request.body && !request.body.empty?
343
+ begin
344
+ publication.wait
345
+ rescue Exception => e # rubocop:disable Lint/RescueException -- cancellation must close deferred ownership too
346
+ begin
347
+ response.body.close(e)
348
+ ensure
349
+ raise e
350
+ end
351
+ end
352
+ end
353
+ end
354
+ response
355
+ ensure
356
+ complete_admission(key, processor: true) unless handler
357
+ complete_admission(key, wire: true) unless deferred || retirement
358
+ resource.changed
359
+ end
360
+ end
361
+
362
+ def complete_processor(publication, task, key, resource)
363
+ @identities.delete(task)
364
+ complete_admission(key, processor: true)
365
+ resource.changed
366
+ publication&.complete($ERROR_INFO)
367
+ end
368
+
369
+ def complete_admission(key, processor: false, wire: false)
370
+ # An absent admission is terminal. Late or duplicate events cannot
371
+ # recreate either side of the processor/wire join.
372
+ return unless @admitted.key?(key)
373
+
374
+ @admitted[key] = true if processor
375
+ if key.respond_to?(:closed?)
376
+ @admitted.delete(key) if @admitted[key] && (@aborting || key.closed?)
377
+ else
378
+ @h1_wire[key] = true if wire
379
+ if @admitted[key] && @h1_wire[key]
380
+ @admitted.delete(key)
381
+ @h1_wire.delete(key)
382
+ end
383
+ end
384
+ end
385
+
386
+ def drain_resources(resources, expires)
387
+ tasks = resources.map do |resource|
388
+ @lifetime.async(finished: false) { resource.drain(@lifetime, expires) }
389
+ end
390
+ tasks.map(&:wait).all?
391
+ end
392
+
393
+ def stop_accepting
394
+ @stop_accepting&.call
395
+ end
396
+
397
+ def close_owned(expires)
398
+ stop_accepting
399
+ @tls.close
400
+ @resources.each { |resource| resource.draining = true }
401
+ drained = drain_resources(@resources, expires)
402
+ @admitted.clear
403
+ @h1_wire.clear
404
+ decide_close_outcome(drained)
405
+ end
406
+
407
+ def install_owner_guard
408
+ @lifetime = @owner.async do
409
+ ::Async::Condition.new.wait
410
+ ensure
411
+ abort_owned unless @state == :closed
412
+ end
413
+ end
414
+
415
+ def resource_owned_task?
416
+ @resources.any? { |resource| resource.send(:owns_task?, ::Async::Task.current) }
417
+ end
418
+
419
+ def decide_close_outcome(outcome)
420
+ return if @close_decided
421
+
422
+ @close_decided = true
423
+ @close_outcome = outcome
424
+ end
425
+
426
+ def abort_owned
427
+ return if @cleanup&.current?
428
+
429
+ owned = resource_owned_task?
430
+ launch = !@cleanup
431
+ if launch
432
+ @aborting = true
433
+ @state = :closing unless @state == :closed
434
+ decide_close_outcome(false)
435
+ @cleanup = ::Async::Task.new(@lifetime, transient: true, finished: false) { retire_session }
436
+ end
437
+ if owned
438
+ @cleanup.run if launch
439
+ else
440
+ Internal.join_complete(@cleanup, failure: true) { @cleanup.run if launch }
441
+ end
442
+ end
443
+
444
+ def retire_session
445
+ # Listener cleanup can yield. Fail every peer before crossing it.
446
+ @resources.each { |resource| Internal.raw_close(resource.raw) }
447
+ stop_accepting
448
+ @resources.each(&:abort)
449
+ @admitted.clear
450
+ @h1_wire.clear
451
+ @tls.close
452
+ finish_closed(@close_outcome)
453
+ Internal.cancel(@lifetime)
454
+ end
455
+ end
456
+ end
457
+ end