connectrpc-falcon 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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Falcon
5
+ # HTTPS describes the listener without compiling TLS in the parent process.
6
+ class Endpoint < ::Async::HTTP::Endpoint
7
+ def initialize(url, endpoint = nil, **options)
8
+ unless %w[http https].include?(url.scheme) && url.hostname && !url.userinfo && !url.query && !url.fragment &&
9
+ ["",
10
+ "/"].include?(url.path) && !options.keys.intersect?(%i[ssl_context tls_configuration scheme protocol])
11
+ raise ArgumentError, "Expected a raw HTTP(S) listener endpoint"
12
+ end
13
+
14
+ super
15
+ end
16
+
17
+ def build_endpoint(endpoint = nil)
18
+ endpoint || tcp_endpoint
19
+ end
20
+ end
21
+
22
+ # socket_accept runs before Wrapper schedules the accepted task, including
23
+ # connections with no ClientHello yet. Never select a generation via SNI.
24
+ class AcceptWrapper < ::IO::Endpoint::Wrapper
25
+ def initialize(session, on_reject:)
26
+ super()
27
+ @session = session
28
+ @on_reject = on_reject
29
+ @listeners = {}
30
+ end
31
+
32
+ def socket_accept(server)
33
+ @listeners[::Async::Task.current] = true
34
+ loop do
35
+ raw, address = super
36
+ accepted = @session.accept(raw)
37
+ return [accepted, address] if accepted
38
+
39
+ @on_reject.call
40
+ end
41
+ end
42
+
43
+ def schedule(&)
44
+ if @listeners.key?(::Async::Task.current)
45
+ @session.schedule(&)
46
+ else
47
+ super
48
+ end
49
+ end
50
+
51
+ def stop
52
+ @listeners.each_key(&:stop)
53
+ @listeners.clear
54
+ end
55
+ end
56
+ private_constant :AcceptWrapper
57
+ end
58
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module ConnectRPC
6
+ module Falcon
7
+ class Service < ::Falcon::Service::Server
8
+ def run(_instance, evaluator, listener)
9
+ # This enclosing task's ensure executes on Falcon's ordinary managed
10
+ # Interrupt route before its children are cancelled by the container.
11
+ ready = ::Async::Queue.new
12
+ ::Async::Task.current.async do |task|
13
+ server = evaluator.make_server(listener.endpoint)
14
+ server.run
15
+ ready.enqueue([server, nil])
16
+ announced = true
17
+ task.children&.to_a&.each(&:wait)
18
+ rescue StandardError => e
19
+ raise if announced
20
+
21
+ ready.enqueue([nil, e])
22
+ ensure
23
+ if $ERROR_INFO && !managed_stop?($ERROR_INFO)
24
+ server&.abort
25
+ else
26
+ server&.close
27
+ end
28
+ end
29
+ server, error = ready.dequeue
30
+ raise error if error
31
+
32
+ # Service::Server keeps this for its normal statistics/process title,
33
+ # and cannot declare readiness until the worker's initial load succeeds.
34
+ server
35
+ end
36
+
37
+ private
38
+
39
+ def managed_stop?(error)
40
+ while error
41
+ return true if error.is_a?(Interrupt)
42
+
43
+ error = error.cause
44
+ end
45
+ false
46
+ end
47
+ end
48
+
49
+ module Environment
50
+ include ::Falcon::Environment::Server
51
+
52
+ def service_class = Service
53
+ def endpoint = Endpoint.parse(url, **endpoint_options)
54
+ def rack_app = nil
55
+ def tls = nil
56
+ def tls_provider = nil
57
+ def server_options = {}
58
+
59
+ def connectrpc_server
60
+ raise NotImplementedError, "Define the worker-local connectrpc_server"
61
+ end
62
+
63
+ def make_server(bound)
64
+ Server.new(server: connectrpc_server, endpoint: bound, rack_app: rack_app, tls: tls,
65
+ tls_provider: tls_provider, utilization_registry: self[:utilization_registry], **server_options)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require_relative "completion_join"
5
+
6
+ require "async/http/protocol/http1/server"
7
+
8
+ module ConnectRPC
9
+ module Falcon
10
+ # The pinned HTTP/1 writer flushes framing after body EOF, before close
11
+ # callbacks. Track that state without inspecting application payload bytes.
12
+ class HTTP1ResponseBody < ::Protocol::HTTP::Body::Wrapper
13
+ attr_reader :eof
14
+
15
+ def read
16
+ chunk = super
17
+ @eof = true if chunk.nil?
18
+ chunk
19
+ end
20
+
21
+ def empty?
22
+ empty = super
23
+ @eof = true if empty
24
+ empty
25
+ end
26
+ end
27
+ private_constant :HTTP1ResponseBody
28
+
29
+ class HTTP1Connection < ::Async::HTTP::Protocol::HTTP1::Server
30
+ attr_accessor :resource
31
+ attr_writer :rejected_input, :retiring_raw
32
+
33
+ def close_read
34
+ # The framing parser can close input itself before raising EOFError.
35
+ @rejected_input&.abort_read
36
+ @retiring_raw.close if @retiring_raw && !@retiring_raw.closed?
37
+ super
38
+ end
39
+
40
+ def next_request
41
+ return if @resource.draining || @resource.finished?
42
+
43
+ request = super
44
+ if request
45
+ fields = request.headers.header
46
+ if fields.any? do |key, value|
47
+ key.casecmp?("content-type") && value.downcase.start_with?("application/grpc-web")
48
+ end &&
49
+ fields.sum { |key, value| key.bytesize + value.bytesize + 32 } > GRPCProtocol::FIELD_LIMIT
50
+ raise Error.new(:resource_exhausted, "Web field block exceeds byte limit"), cause: nil
51
+ end
52
+ end
53
+ # Retirement can happen while the upstream parser is blocked.
54
+ return if @resource.draining || @resource.finished?
55
+
56
+ @resource.active = true if request
57
+ @resource.changed
58
+ request
59
+ end
60
+
61
+ def each(...)
62
+ super
63
+ ensure
64
+ # Header failures and cancellation can bypass write_body entirely.
65
+ begin
66
+ @resource.exchange&.finish(false, $ERROR_INFO)
67
+ ensure
68
+ @resource.exchange = nil
69
+ begin
70
+ @resource.input_retirement&.close(false)
71
+ ensure
72
+ @resource.input_retirement = nil
73
+ @resource.failed! if @resource.active
74
+ @resource.active = false
75
+ @resource.changed
76
+ end
77
+ end
78
+ end
79
+
80
+ # Match the pinned writer's positional arguments.
81
+ # rubocop:disable-next Style/OptionalBooleanParameter
82
+ def write_body(version, body, head = false, trailer = nil)
83
+ if @resource.input_retirement
84
+ @response_eof = head || body.nil?
85
+ body = @response_body = HTTP1ResponseBody.new(body) if body
86
+ end
87
+ super
88
+ written = true
89
+ rescue StandardError
90
+ @resource.failed!
91
+ raise
92
+ ensure
93
+ @response_body = nil
94
+ @response_eof = false
95
+ # Upstream returns after final fixed/chunked bytes AND body callbacks.
96
+ begin
97
+ @resource.exchange&.finish(!!written, $ERROR_INFO)
98
+ ensure
99
+ begin
100
+ @resource.input_retirement&.close(!!written)
101
+ ensure
102
+ @resource.input_retirement = nil
103
+ @resource.exchange = nil
104
+ @resource.active = false
105
+ @resource.changed
106
+ end
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def flush_stream
113
+ result = super
114
+ if @response_eof || @response_body&.eof
115
+ @resource.input_retirement&.final_flushed
116
+ @response_eof = false
117
+ @response_body = nil
118
+ end
119
+ result
120
+ end
121
+ end
122
+ private_constant :HTTP1Connection
123
+
124
+ # Connect-only transfer from the original processor to the connection task.
125
+ # Framing completion never relies on empty?, which also becomes true on error.
126
+ class HTTP1StreamInput
127
+ attr_writer :input
128
+
129
+ def initialize(body, raw:, connection:)
130
+ @body = body
131
+ @framed = body&.body
132
+ @raw = raw
133
+ @connection = connection
134
+ end
135
+
136
+ def on_close(&completion)
137
+ @completion = completion
138
+ end
139
+
140
+ def framed_complete?
141
+ case @framed
142
+ when nil then true
143
+ when ::Protocol::HTTP1::Body::Fixed then @framed.remaining.zero?
144
+ when ::Protocol::HTTP1::Body::Chunked then !@framed.length.nil?
145
+ else false
146
+ end
147
+ end
148
+
149
+ def head_resolved(status)
150
+ @retiring = status == 505 || !framed_complete?
151
+ @connection.persistent = false if @retiring
152
+ end
153
+
154
+ def processor_finished
155
+ @discarded = @input&.release_remainder || 0
156
+ @input = nil
157
+ @body&.close if framed_complete?
158
+ @processed = true
159
+ end
160
+
161
+ def final_flushed
162
+ return if @close_expires
163
+
164
+ @close_expires = now + 0.5
165
+ end
166
+
167
+ def close(delivered)
168
+ return if @closed
169
+
170
+ @closed = true
171
+ begin
172
+ if delivered && @processed && @retiring
173
+ retire if @close_expires
174
+ elsif !delivered || !@processed
175
+ abort_read
176
+ end
177
+ rescue StandardError
178
+ # Hygiene cannot change an already observed RPC delivery outcome.
179
+ abort_read
180
+ ensure
181
+ begin
182
+ # A retired connection is never reused, even after complete framing.
183
+ abort_read if @retiring || !delivered || !@processed
184
+ ensure
185
+ begin
186
+ @body&.close
187
+ ensure
188
+ @completion&.call
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def retire
197
+ # HTTP completion is already observed. TLS cleanup must not precede
198
+ # this raw TCP half-close or extend the one final-flush budget.
199
+ @raw.shutdown(Socket::SHUT_WR)
200
+ begin
201
+ remaining = @close_expires - now
202
+ ::Async::Task.current.with_timeout(remaining) { discard } if remaining.positive?
203
+ rescue StandardError
204
+ # A late framing/timeout failure cannot revise completed delivery.
205
+ nil
206
+ end
207
+ remaining = @close_expires - now
208
+ ::Async::Task.current.sleep(remaining) if remaining.positive? && !@raw.closed?
209
+ end
210
+
211
+ def discard
212
+ # Chunked EOF closes the buffered/TLS read side before raising. Guard
213
+ # only post-delivery reads, without recursively closing the connection.
214
+ @connection.retiring_raw = @raw
215
+ expires = now + 0.1
216
+ until framed_complete?
217
+ return if 262_144 - @discarded < 65_536
218
+
219
+ remaining = expires - now
220
+ return if remaining <= 0
221
+
222
+ # Pinned framed reads have no length argument and return <=64 KiB.
223
+ # Reserve a whole block before each read, including adapter remainder.
224
+ chunk = ::Async::Task.current.with_timeout(remaining) { @body.read }
225
+ @discarded += chunk.bytesize if chunk
226
+ end
227
+ ensure
228
+ @connection.retiring_raw = nil
229
+ end
230
+
231
+ def abort_read
232
+ @raw.close unless @raw.closed?
233
+ # Finishable.close must still resolve after raw-first teardown. Closing
234
+ # connection state first prevents framed close_read on an already shut IO.
235
+ @connection.close
236
+ rescue IOError, SystemCallError
237
+ nil
238
+ end
239
+
240
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
241
+ end
242
+ private_constant :HTTP1StreamInput
243
+
244
+ # One request's original framing and deadline, owned only by native Web H1.
245
+ class HTTP1RejectedInput
246
+ def initialize(body, raw:, connection:, headers:)
247
+ @body = body
248
+ @raw = raw
249
+ @connection = connection
250
+ # Capture before processing. Invalid fields remain core's validation
251
+ # responsibility and must not change its validation order.
252
+ @deadline = GRPCProtocol.request_deadline(headers)
253
+ rescue Error
254
+ @deadline = nil
255
+ end
256
+
257
+ def close(error = $ERROR_INFO)
258
+ return if @closed
259
+
260
+ @closed = true
261
+ begin
262
+ @connection.rejected_input = self
263
+ raise error if error
264
+
265
+ if @body && !@body.empty?
266
+ @expires = now + 0.1
267
+ @expires = [@expires, now + @deadline.remaining].min if @deadline
268
+ until @body.empty?
269
+ remaining = @expires - now
270
+ raise ::Async::TimeoutError, "Rejected Web input did not finish" if remaining <= 0
271
+
272
+ # Pinned Fixed/Chunked reads are <=64 KiB and preserve framing,
273
+ # including a core input adapter's discarded readahead remainder.
274
+ ::Async::Task.current.with_timeout(remaining) { @body.read }
275
+ end
276
+ end
277
+ finished = true
278
+ ensure
279
+ # Cancellation and malformed/expired input must fail raw TCP before
280
+ # Finishable/body or TLS cleanup can yield. Never signal RPC cancellation.
281
+ begin
282
+ abort_read unless finished
283
+ ensure
284
+ begin
285
+ @body&.close
286
+ ensure
287
+ @connection.rejected_input = nil
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ def abort_read
294
+ @raw.close unless @raw.closed?
295
+ end
296
+
297
+ private
298
+
299
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
300
+ end
301
+ private_constant :HTTP1RejectedInput
302
+
303
+ # Serial HTTP/1 ownership. It deliberately has no HTTP/2 stream/GOAWAY API.
304
+ class HTTP1Resource
305
+ attr_accessor :raw, :socket, :task, :snapshot, :ready, :draining, :active, :exchange, :input_retirement
306
+ attr_reader :connection, :generation, :progress, :activity_at
307
+
308
+ def initialize(stream, progress:, generation:)
309
+ @connection = HTTP1Connection.new(stream, "HTTP/1.1")
310
+ @connection.resource = self
311
+ @shared_progress = progress
312
+ @progress = ::Async::Condition.new
313
+ @generation = generation
314
+ @tasks = []
315
+ @retirement_members = {}
316
+ @cancelled_roots = {}
317
+ @activity_at = now
318
+ @active = @ready = @draining = @aborted = @finishing = false
319
+ end
320
+
321
+ def changed
322
+ @activity_at = now
323
+ @progress.signal
324
+ @shared_progress.signal
325
+ end
326
+
327
+ def track(task)
328
+ raise IOError, "Connection is retiring" if @aborted
329
+
330
+ @tasks.reject!(&:finished?)
331
+ @tasks << task
332
+ task
333
+ end
334
+
335
+ def empty? = !@active && @tasks.all?(&:finished?)
336
+ def finished? = @aborted
337
+ def finishing? = @finishing || @draining
338
+
339
+ def failed!
340
+ @failed = true
341
+ end
342
+
343
+ def web_input(body, headers:)
344
+ HTTP1RejectedInput.new(body, raw: @raw, connection: @connection, headers: headers)
345
+ end
346
+
347
+ def prepare_stream_input(body)
348
+ @input_retirement = HTTP1StreamInput.new(body, raw: @raw, connection: @connection)
349
+ end
350
+
351
+ def abort
352
+ return if @cleanup&.current?
353
+
354
+ capture_retirement
355
+ owned = owns_task?(::Async::Task.current)
356
+ launch = !@cleanup
357
+ if launch
358
+ @aborted = true
359
+ @cleanup = ::Async::Task.new(@lifetime, transient: true, finished: false) { retire }
360
+ end
361
+ if owned
362
+ @cleanup.run if launch
363
+ else
364
+ join(@cleanup, failure: true) { @cleanup.run if launch }
365
+ end
366
+ nil
367
+ end
368
+
369
+ def drain(parent, expires)
370
+ return wait(@drain_task, expires) if @drain_task
371
+
372
+ if @aborted
373
+ abort
374
+ return true
375
+ end
376
+
377
+ @draining = true
378
+ unless @ready
379
+ abort
380
+ return true
381
+ end
382
+ @drain_task = parent.async(finished: false) do
383
+ @progress.wait until empty? || @aborted
384
+ next false if @aborted || @failed
385
+
386
+ @finishing = true
387
+ # Cancel an idle parser only after the admitted response and callback
388
+ # complete. Raw half-close preserves the HTTP wire-completion boundary.
389
+ @raw.shutdown(Socket::SHUT_WR)
390
+ cancel_root(@task)
391
+ join(@task)
392
+ loop { @raw.readpartial(16_384) }
393
+ rescue EOFError
394
+ true
395
+ rescue StandardError
396
+ false
397
+ ensure
398
+ abort
399
+ end
400
+ wait(@drain_task, expires)
401
+ rescue ::Async::TimeoutError
402
+ abort
403
+ false
404
+ end
405
+
406
+ private
407
+
408
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
409
+
410
+ def wait(task, expires)
411
+ remaining = expires - now
412
+ raise ::Async::TimeoutError if remaining <= 0
413
+
414
+ ::Async::Task.current.with_timeout(remaining) { task.wait }
415
+ end
416
+
417
+ def retirement_parent=(parent)
418
+ @lifetime = parent
419
+ end
420
+
421
+ def retirement_completed? = !!@retirement_completed
422
+
423
+ def retirement_roots = [*@tasks, @task].compact.uniq
424
+
425
+ def capture_retirement
426
+ retirement_roots.each { |task| capture_task(task) }
427
+ members = @retirement_members.keys
428
+ members.each { |task| capture_task(task) }
429
+ end
430
+
431
+ def capture_task(task)
432
+ @retirement_members[task] = true
433
+ task.children&.to_a&.each { |child| capture_task(child) unless child.transient? }
434
+ end
435
+
436
+ def owns_task?(task)
437
+ capture_retirement
438
+ while task
439
+ return true if @retirement_members.key?(task)
440
+ return false if task.transient?
441
+
442
+ task = task.parent
443
+ end
444
+ false
445
+ end
446
+
447
+ def cancel_root(task)
448
+ return if !task || @cancelled_roots.key?(task) || task.finished?
449
+
450
+ mark_cancelled(task)
451
+ task.cancel
452
+ # Pinned HTTP/1 defers cancellation through response/body callbacks.
453
+ # A deferred request still needs its one immediate forced delivery.
454
+ task.cancel if task.cancel_deferred?
455
+ end
456
+
457
+ def mark_cancelled(task)
458
+ @cancelled_roots[task] = true
459
+ task.children&.to_a&.each { |child| mark_cancelled(child) unless child.transient? }
460
+ end
461
+
462
+ def retire
463
+ @raw.close unless @raw.closed?
464
+ # Best-effort scheduling hint only. A return proves no unwind or
465
+ # completion; the retained-task joins below establish retirement.
466
+ ::Async::Task.current.yield
467
+ loop do
468
+ capture_retirement
469
+ roots = retirement_roots
470
+ roots.reject! do |task|
471
+ ancestor = task
472
+ covered = false
473
+ until ancestor.transient? || !(ancestor = ancestor.parent)
474
+ covered ||= roots.include?(ancestor)
475
+ end
476
+ covered
477
+ end
478
+ roots.each { |task| cancel_root(task) }
479
+ @retirement_members.keys.reverse_each { |task| join(task) }
480
+ capture_retirement
481
+ break if @retirement_members.keys.all?(&:finished?)
482
+ end
483
+ begin
484
+ @connection.close
485
+ rescue StandardError
486
+ nil
487
+ end
488
+ @retirement_completed = true
489
+ changed
490
+ end
491
+
492
+ # Local counterpart of the private Async helper; no shared public utility.
493
+ def join(task, failure: false)
494
+ return unless task
495
+
496
+ CompletionJoin.call(task, failure: failure) { yield if block_given? }
497
+ end
498
+ end
499
+ private_constant :HTTP1Resource
500
+ end
501
+ end