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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6b19635dd523a8e74030ff16895e26bb0e48425980c371ff738f9c5c7dfdda82
4
+ data.tar.gz: 5e6af3b34ebb3a0b0aa27d0101897fffeb8b77aede76c7b6da8a7ae5ef5d11f9
5
+ SHA512:
6
+ metadata.gz: 57a7800ba24453582815b87d79bb748b332bbc3fa03c595dccb01656df48d84be01d1d917f19afe7510eac619bf33898489201182ab077cf8d335bd659a0c36b
7
+ data.tar.gz: b66a1c98fa1039136f71bd12177bfb59cf9dfbc2a7a1b0f6f84ccc9d38b7c3719d8533f5a41e84d589004715f87b05755524fad6c93794836a4da1419b4b28c7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jim Myhrberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # connectrpc-falcon
2
+
3
+ Optional Falcon process-worker hosting for Connect unary and streaming RPC, with native
4
+ Protocol::HTTP dispatch and an explicit ordinary Rack/Rails fallback.
5
+ Require `connectrpc/falcon`; core and Rails bundles do not load this package.
6
+ The API is experimental. This package is MIT licensed and released with the
7
+ whole [release train](../../docs/releasing.md).
8
+
9
+ See the [supported 0.x contract](../../docs/support.md) for the target support
10
+ profile, matched package versions, compatibility policy and adoption gates.
11
+
12
+ ## Run a service
13
+
14
+ The [runnable configuration](examples/falcon.rb) uses Falcon's normal service
15
+ DSL and Google protobuf's standard `StringValue` message. In an application
16
+ bundle containing `connectrpc-falcon` and `google-protobuf`, run:
17
+
18
+ ```sh
19
+ bundle exec falcon host examples/falcon.rb
20
+ ```
21
+
22
+ The example binds localhost, starts two workers and serves
23
+ `/demo.EchoService/Echo`. `mise run falcon:example` runs an ephemeral version,
24
+ checks both codecs and the Rack fallback, then stops all workers.
25
+
26
+ Include `ConnectRPC::Falcon::Environment` in each service. Define
27
+ `connectrpc_server` to construct a `ConnectRPC::Server` in the worker. Optional
28
+ `rack_app`, `tls`, `tls_provider` and `server_options` methods supply the fallback,
29
+ TLS source and shared limits. `url` describes the listener. Application
30
+ `prepare!` runs after fork, before static TLS compilation or the first provider
31
+ call. Initialize application-owned database clients there; the library never
32
+ opens a database connection. Falcon binds raw TCP in the parent and owns
33
+ worker count, readiness, health checks and its default restart policy.
34
+
35
+ For direct use inside an application-owned Async task:
36
+
37
+ ```ruby
38
+ endpoint = ConnectRPC::Falcon::Endpoint.parse("https://127.0.0.1:9292")
39
+ host = ConnectRPC::Falcon::Server.new(
40
+ server: connect_server,
41
+ endpoint: endpoint,
42
+ rack_app: ordinary_rack_app,
43
+ tls_provider: -> { application_tls_snapshot }
44
+ )
45
+ begin
46
+ host.run
47
+ # Run the rest of the application in this reactor.
48
+ ensure
49
+ host.close(grace: 5)
50
+ end
51
+ ```
52
+
53
+ Rack fallback caching is disabled by default (`cache: false`). Falcon's fallback
54
+ content encoding remains active, and native RPC bypasses both middleware layers.
55
+ To enable Falcon's existing shared in-memory cache within each worker, pass
56
+ `cache: true` to `ConnectRPC::Falcon::Server.new`. In a service configuration,
57
+ use the supported `server_options` path:
58
+
59
+ ```ruby
60
+ server_options { { cache: true } }
61
+ ```
62
+
63
+ The inherited Falcon `cache` DSL setting does not configure this host. `cache:`
64
+ must be `true` or `false`; with no `rack_app`, either value has no effect.
65
+
66
+ Opt-in retains the pinned Falcon 0.57.0 / async-http-cache 0.4.6 semantics. Cache
67
+ keys use authority, method and path, plus response `Vary` headers. Custom
68
+ authorization headers are not automatically cache keys. GET/HEAD requests
69
+ without a body, `Authorization` or `Cookie` can have 200, 203, 300, 301, 302, 404
70
+ and 410 responses cached without an explicit `Cache-Control` header. Entries
71
+ without `max-age` can persist for the worker's lifetime.
72
+ Applications must set `Cache-Control` and `Vary` so responses are safe to share.
73
+ Use `private` or `no-store` for unshared content; response `no-cache` alone is
74
+ insufficient. Use appropriate `Vary` headers for deliberately shared content
75
+ that depends on request headers. These are upstream cache semantics, not a
76
+ promise of full RFC cache policy support.
77
+
78
+ A TLS provider returns a complete Hash with exactly these symbol keys:
79
+
80
+ ```ruby
81
+ {
82
+ version: "application-owned-opaque-version",
83
+ certificate_chain: [leaf_pem, intermediate_pem],
84
+ private_key: private_key_pem,
85
+ client_ca: [explicit_client_ca_pem]
86
+ }
87
+ ```
88
+
89
+ Use explicit `client_ca: nil` for server-only TLS. A missing/nil provider result,
90
+ missing field, invalid key/chain/trust or source exception fails closed; it never
91
+ selects plaintext. Listener scheme and TLS settings must agree. The same
92
+ complete configuration without `version` can be supplied as static `tls:`.
93
+
94
+ ## TLS lifecycle
95
+
96
+ The worker caches immutable compiled snapshots. Handshakes perform no provider
97
+ or database work. Every raw accept captures one generation before scheduling,
98
+ including no-SNI/IP clients and sockets waiting to send their ClientHello.
99
+ Pending handshakes keep that captured identity even if a refresh completes.
100
+ TLS negotiates `h2` or `http/1.1`; no ALPN selects HTTP/1.1. Plaintext supports
101
+ HTTP/2 prior knowledge and HTTP/1. The standalone Async Host remains strict h2.
102
+
103
+ Provider defaults are `refresh_interval: 60`, `refresh_jitter: 0.1` and
104
+ `refresh_timeout: 10`. Scheduling uses monotonic time after completed source
105
+ checks, with uniform positive/negative jitter. Concurrent `refresh_tls` callers
106
+ join one worker-local attempt. Cancelling one waiter does not cancel that
107
+ attempt. Recursive provider refresh/close calls fail safely. These cooperative
108
+ timeouts and shutdown bounds cannot preempt arbitrary native or CPU-bound code.
109
+ There is no fixed database-outage propagation guarantee or fleet-atomic update.
110
+
111
+ Every result is validated. Equal versions with equal canonical identity/trust
112
+ content preserve generation and context; PEM whitespace and trust-anchor order
113
+ do not cause churn. Equal versions with changed content fail. A different
114
+ version can deliberately restore previously used material with a new generation.
115
+ Failed refreshes retain the last good snapshot and source-check timestamp.
116
+ There is no maximum-staleness policy. Actual wall-clock expiry of the earliest
117
+ served identity-chain certificate rejects new accepts and pending handshake
118
+ starts. Established connections retain their negotiated identity/trust.
119
+
120
+ `refresh_tls` requests an immediate provider check. `reload_tls` accepts the
121
+ three static TLS fields and conflicts with provider ownership. `tls_generation`
122
+ is initially 1 for TLS. `tls_status` returns safe state, generation, category,
123
+ `source_check_age` and `publication_age` in monotonic seconds; it never exposes
124
+ the source version, certificates, keys or fingerprints. `on_tls_error:` receives
125
+ an immutable safe category/status event. Default diagnostics are fixed safe
126
+ text; reporter exceptions are isolated. Initial failure prevents successful
127
+ TLS/application traffic before Falcon reports startup failure. TCP backlog can
128
+ still queue connections on the parent-bound listener.
129
+
130
+ `drain_tls(grace: 5)` captures a generation cutoff and retires older connections.
131
+ Pending older handshakes close immediately. Already admitted responses can
132
+ finish; later requests/streams are refused. `peer_certificate` returns a detached
133
+ authenticated client leaf only in the exact session handler task. Child tasks,
134
+ other servers, plaintext and server-only TLS have no client identity. It does
135
+ not claim that a peer-presented chain is a verified chain.
136
+
137
+ TLS keeps the shared Async policy: explicit-only trust anchors, scoped partial
138
+ chains, TLS 1.2+, fresh session context per generation, disabled session cache
139
+ and `OP_NO_TICKET`. This does not claim that OpenSSL emits zero TLS 1.3 tickets.
140
+
141
+ ## Routing and shutdown
142
+
143
+ The native application owns each registered service prefix, including unknown
144
+ methods. It separates the raw query from the path for core dispatch. Safe unary GET
145
+ uses the same application over HTTP/1 and HTTP/2; the app must route intended
146
+ GET calls and own cache/security policy. Native RPC retains the core
147
+ opaque identity/gzip bytes, wire/inflated caps, deadlines, metadata and errors.
148
+ Falcon's generic content encoding/cache middleware wraps only `rack_app`.
149
+ Native handlers do not inherit Rails callbacks/authentication. Ordinary Rails
150
+ fallback preserves its controller, CSRF and session policy.
151
+
152
+ This listener rejects upgrades, CONNECT tunnels, full/partial Rack hijack and
153
+ protocol upgrades before successful upgrade bytes or socket transfer. WebSockets
154
+ and ActionCable are unsupported here. Ordinary enumerable, chunked, streamed
155
+ and callable HTTP bodies are supported. Callable bodies must close their supplied
156
+ stream according to the Protocol::Rack contract. Plain Falcon and the existing
157
+ Rack/Rails integration APIs are unchanged.
158
+
159
+ Native H2 uses the shared Async private bounded ingress for both RPC requests and
160
+ ordinary Rack fallback input. Slow or nonreading Rack consumers withhold stream
161
+ credit while healthy same-connection streams can advance. Connect HTTP/1.1 early responses use the bounded input retirement policy below.
162
+ See the [Async ingress bounds](../connectrpc-async/README.md#backend-boundaries-and-verification)
163
+ for padding, cleanup, allocation layers and the limits of this guarantee.
164
+
165
+ `close(grace: 5)` and normal managed worker stop stop accepts/refresh, then
166
+ perform bounded connection drain. HTTP/1 completion includes fixed/chunked wire
167
+ bytes and response-finished callbacks. On grace expiry, teardown closes raw TCP
168
+ before cancelling owned work and buffered/TLS cleanup. Callback close is guarded
169
+ against reentry. Fatal teardown or owner cancellation outside the managed stop
170
+ boundary uses raw-first abort and offers no response-delivery guarantee.
171
+ HTTP response completion is separate from TLS `close_notify`; orderly TLS
172
+ shutdown is not promised.
173
+
174
+ External abort and close callers wait for actual connection retirement, including
175
+ owned processors, output tasks, cleanup descendants and final disposal. Owned
176
+ tasks initiating or reentering abort request retirement without waiting on
177
+ cleanup that must join them. A session accepted handle's existing `wait_closed`
178
+ operation uses this same boundary, so the active connection metric and server
179
+ connection slot remain retained until cleanup completes. Each external abort/close
180
+ joiner retains its own first interruption through the join.
181
+ An external close caller interrupted during a nonzero graceful drain requests
182
+ forced session retirement, joins actual cleanup, then raises its identical first
183
+ exception. Competitors keep their own result or exception, and an already-decided
184
+ outcome remains unchanged.
185
+
186
+ `Server#close` returns `bool?`: a resource-owned handler initiating positive-grace
187
+ close starts the ordinary asynchronous drain and returns nil while pending.
188
+ Admitted siblings keep their grace and can continue processing. Owned repeated
189
+ requests and cleanup/close coordinator reentry also return nil while pending,
190
+ without joining cleanup that must join them. Nil neither decides the outcome nor
191
+ publishes completion. Repeated requests retain the first closer and expiry even
192
+ when passed a different grace. External callers join actual cleanup and receive
193
+ the final boolean, true for drained or false for forced teardown. Once closed,
194
+ all calls return that same boolean, including coordinator reentry. Zero grace
195
+ still forces teardown; explicit abort and already-decided outcomes are preserved.
196
+
197
+ Private task joins distinguish a resolved result from actual finalization. They
198
+ block on required children and task-local progress/finalization notifications,
199
+ retaining captured child identities through reparenting or transient changes.
200
+ A scheduling opportunity after raw close establishes no completion boundary;
201
+ all retained task joins still precede disposal and retirement publication.
202
+
203
+ The grace period bounds cooperative drain before forced teardown; it is not a
204
+ hard cleanup timer. Non-yielding application/native work can delay retirement
205
+ indefinitely. Ordinary owner cancellation leaves independent cleanup running
206
+ while an external waiter or lifetime guard keeps the tree alive; terminating the
207
+ reactor cannot guarantee completion. H2 client pool cleanup can also outlast an
208
+ RPC deadline, as described in the [Async guide](../connectrpc-async/README.md).
209
+
210
+ Falcon's local HTTP/1 resource and the shared Async session use source-coupled
211
+ private completion and lifetime-anchor operations. Use matched Async/Falcon
212
+ package versions. These operations add no public API or SPI and change neither
213
+ HTTP/1 response timing nor the input retirement limits below.
214
+
215
+ ## Development
216
+
217
+ The independent checksummed bundle uses `vendor/falcon` and reports full optional
218
+ library coverage under `coverage/falcon/<Ruby version>`:
219
+
220
+ ```sh
221
+ mise run falcon:setup
222
+ mise run falcon:test
223
+ mise run falcon:check
224
+ mise run falcon:verify
225
+ ```
226
+
227
+ `falcon:tools`, `falcon:build`, `falcon:package`, `falcon:example` and
228
+ `falcon:interop` are separate discoverable leaves. Both Ruby lanes exercise real
229
+ localhost protocols and workers. The Go peer uses the repository's independently
230
+ pinned Connect/Go graph. This is unary subset evidence, not full conformance.
231
+
232
+ ## RPC observation
233
+
234
+ Configure `instrumentation:` on the core Client/Server to enable the fixed
235
+ neutral per-call lifecycle or the optional [OpenTelemetry adapter](../connectrpc-otel/README.md).
236
+ Native hosts create explicit handles before request admission, activate the
237
+ actual handler tasks, and finish after full-write/reset/cleanup evidence.
238
+ Connection-level setup/admission before an RPC exists is outside this lifecycle.
239
+
240
+ ## Static signatures
241
+
242
+ This package owns `sig/` and its discovery manifest. Add
243
+ `library "connectrpc-falcon"` to a strict Steep target with this gem installed.
244
+ See the [RBS guide](../../docs/rbs.md) for supported APIs, extension contracts,
245
+ partial records, runtime validation and framework-signature limits.
246
+ `mise run rbs:check` checks application callers for this package; strict core
247
+ source checking is a separate target from backend implementation checking.
248
+
249
+ The finite upstream compatibility signatures in `rbs/async_service_0_25` are explicit
250
+ opt-ins, separate from normal gem discovery. See the [RBS guide](../../docs/rbs.md#opt-in-framework-compatibility)
251
+ for tested versions, supported framework forms and how to avoid duplicate
252
+ application-owned declarations.
253
+
254
+ ## Explicit unary cancellation
255
+
256
+ Pass `cancellation: source.token` to core or generated client calls, where
257
+ `source = ConnectRPC::CancellationSource.new`. The source is permanent and
258
+ shareable; only `source.cancel` initiates cancellation. `timeout: nil` keeps no
259
+ RPC deadline or wire timeout. Context offers cooperative `cancelled?`,
260
+ `cancellation_reason` and `check!`. See the [core guide](../../README.md) for
261
+ listener, arbitration and registration lifetime rules.
262
+
263
+ The Async transport accepts foreign-thread cancellation through a Promise;
264
+ only the owner reactor stops private SDK tasks and performs stream cleanup.
265
+ Healthy multiplexed siblings survive. A stuck frame retains the one 100 ms
266
+ raw-first whole-connection cleanup fallback. Native HTTP/2 Context observes
267
+ only an actual peer RST_STREAM(CANCEL), before input teardown. Other resets,
268
+ normal half-close, connection loss and host shutdown do not fabricate explicit
269
+ cancellation. Processing completion detaches the source independently of delivery.
270
+
271
+ Falcon HTTP/1 and portable Rack/Rails do not add peer detection or application
272
+ token env injection. Application handlers remain cooperative.
273
+
274
+ ## Native streaming
275
+
276
+ The core [streaming guide](../../docs/streaming.md) documents all three scoped
277
+ Connect streaming shapes, typed generated clients/handlers, early headers,
278
+ per-message limits and bounded ownership. Native Async/Falcon HTTP/2 supports
279
+ both codecs and identity/gzip over h2c/TLS/mTLS. Falcon additionally serves Connect server and client
280
+ streaming over HTTP/1.1,
281
+ with protobuf/ProtoJSON and identity/gzip over TCP/TLS/mTLS. The standalone
282
+ Async Host and original Async Transport remain H2-only. The explicit
283
+ `ConnectRPC::Async::HTTP1Transport` supports Connect unary, safe GET, server
284
+ streaming and client streaming in an application-owned reactor.
285
+
286
+ The streaming guide defines Falcon's cooperative 500 ms total final-close budget
287
+ and unchanged 100 ms / 256 KiB discard limits. Native Connect streaming clients
288
+ have a separate one-second absolute HTTP tail policy. It applies even with a nil
289
+ RPC deadline and can reject otherwise valid late EOF or slow intermediaries.
290
+
291
+ Incomplete H1 uploads disable reuse at response-head resolution. Falcon delivers
292
+ the full response and HTTP EOF after original processor cleanup, then half-closes
293
+ raw TCP writes. One absolute final-close expiry starts at the actual final HTTP
294
+ flush plus 500 ms, before body callbacks or lifecycle observation. Original-input
295
+ discard retains one absolute 100 ms allowance and 256 KiB cap, including adapter
296
+ remainders and full 64 KiB read reservations. After discard stops, Falcon waits
297
+ only the remaining part of the original 500 ms budget, without further reads.
298
+ Following pipelined requests are never parsed or dispatched. Admission/socket
299
+ ownership lasts until retirement and original processing join. Earlier owner
300
+ shutdown can shorten either phase; failed delivery receives no success grace.
301
+
302
+ Both timers are cooperative and may overshoot under scheduler delay. Holding
303
+ admission during remaining grace increases resource retention. This is best
304
+ effort reset avoidance, with no arbitrary network-delivery guarantee. The separate
305
+ `falcon:conformance:http1` production profiles pass 174/174 identity and 160/160
306
+ gzip cases on Ruby 3.4.12 and 4.0.9 with unchanged assertions and inventories.
307
+ Web rejection-head ordering is repaired separately, with its gate excluded from
308
+ Connect response-first delivery. Historical Web failures and the baseline
309
+ repair remain recorded in the
310
+ [conformance status](../../conformance/README.md#connect-http11-server-profiles).
311
+
312
+ `falcon:conformance` runs actual server profiles. `falcon:interop:streaming`
313
+ exercises real workers with the pinned Go streaming client. Both belong to
314
+ `falcon:verify`; duplicated native-client runs are not Falcon server evidence.
315
+
316
+ ## Native gRPC
317
+
318
+ Native HTTP/2 supports unary, server streaming, client streaming and bidi gRPC
319
+ with protobuf, identity/gzip, h2c, verified TLS and mTLS. Configure the core
320
+ Client with `protocol: :grpc` and the Server with `:grpc` or `:auto`. Generated
321
+ clients and handlers reuse the existing declarations. Portable HTTP/1 execution
322
+ retains its early rejection guard. See the [gRPC guide](../../docs/grpc.md) for
323
+ status codecs, actual final HEADERS, independent status bounds and native field
324
+ retention limits. Connect's one-second terminal tail policy remains Connect-only.
325
+
326
+ Use `falcon:conformance:grpc` for exact official inventories and
327
+ `falcon:interop:grpc` for generated official grpc-go peer checks. Both belong
328
+ to the owning verify task. `grpc:generation:check` reproduces official Go and
329
+ Ruby peer outputs; `grpc:status:check` reproduces private Any/Status descriptors.
330
+
331
+ ## gRPC-Web
332
+
333
+ Native H1 and H2 support explicitly enabled binary/text unary and server-stream gRPC-Web. H1 retains original processor identity and admission through wire/callback completion. Browser incremental delivery uses text.
334
+ See the [Web guide](../../docs/grpc-web.md) for protocol, CORS, bounds and completion rules.
335
+
336
+ Rejected native Web HTTP/1 input now has an explicit resource-policy allowance:
337
+ one absolute monotonic 100 ms budget to finish the original fixed/chunked body
338
+ framing. Progress never resets it. The valid grpc-timeout captured at original
339
+ application entry can shorten this allowance; cancellation, host drain and
340
+ owner abort retain control. Reads use the pinned HTTP/1 body's at-most-64 KiB
341
+ chunks, without aggregating the upload or consuming the next request. An existing
342
+ input-adapter remainder can coexist with one discard read, each at most 64 KiB;
343
+ this is not a total allocation or RSS bound. Successful
344
+ calls already consume their input and do not enter this tail policy.
345
+
346
+ On expiry or malformed framing, cleanup retires only the affected connection,
347
+ closing raw TCP before body/TLS cleanup. The original RPC processing outcome
348
+ remains separate from aborted delivery and cleanup failure. Terminal bytes stay
349
+ staged until the original processor and application cleanup succeed. This is a
350
+ best effort within bounded cleanup, not a delivery promise for arbitrarily slow
351
+ uploads. The tail budget runs once and does not restart or stack; existing
352
+ backend input-cancellation and teardown allowances remain separate.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run with: bundle exec falcon host examples/falcon.rb
4
+ require "connectrpc/falcon"
5
+ require "connectrpc/bindings/google_protobuf"
6
+ require "google/protobuf/wrappers_pb"
7
+
8
+ service "connectrpc-echo" do
9
+ include ConnectRPC::Falcon::Environment
10
+
11
+ url { ENV.fetch("CONNECTRPC_URL", "http://127.0.0.1:9292") }
12
+ count 2
13
+
14
+ # Application initialization runs in each Falcon worker before TLS loads.
15
+ prepare! do |_instance|
16
+ # Initialize application-owned dependencies here, after fork.
17
+ end
18
+
19
+ connectrpc_server do
20
+ method = ConnectRPC::Method.new(service: "demo.EchoService", name: "Echo",
21
+ request_type: Google::Protobuf::StringValue,
22
+ response_type: Google::Protobuf::StringValue, idempotency: :no_side_effects)
23
+ service = ConnectRPC::Service.new(name: "demo.EchoService", methods: [method])
24
+ ConnectRPC::Server.new(services: [service], binding: ConnectRPC::Bindings::GoogleProtobuf.new,
25
+ handlers: { method.path => ->(message, _context) { message } })
26
+ end
27
+
28
+ rack_app do
29
+ ->(_env) { [200, { "content-type" => "text/plain" }, ["Ordinary Rack fallback\n"]] }
30
+ end
31
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Falcon
5
+ # A result is distinct from task finalization and retained child ownership.
6
+ class CompletionJoin
7
+ # Installed only on a selected instance, with independent state per gem.
8
+ module Consume
9
+ def consume
10
+ result = super
11
+ @connectrpc_falcon_completion&.consumed
12
+ result
13
+ end
14
+ end
15
+ private_constant :Consume
16
+
17
+ class Observation
18
+ attr_reader :required, :generation, :promise, :violation
19
+
20
+ def initialize(target)
21
+ @target = target
22
+ @fiber = target.fiber
23
+ @scheduler = Fiber.scheduler
24
+ @required = {}.compare_by_identity
25
+ @generation = 0
26
+ @promise = ::Async::Promise.new
27
+ @active = true
28
+ @terminal = false
29
+ @queued = nil
30
+ end
31
+
32
+ def terminal? = @terminal
33
+ def fiber_alive? = @fiber&.alive?
34
+
35
+ def retain(children)
36
+ added = false
37
+ children.each do |child|
38
+ next if @required.key?(child)
39
+
40
+ @required[child] = true
41
+ added = true
42
+ end
43
+ advance if added
44
+ end
45
+
46
+ # Node#consume can expose work before Task#finished? becomes true.
47
+ # Capture identities here, before any deferred check or tree mutation.
48
+ def consumed
49
+ return unless @active
50
+
51
+ retain(CompletionJoin.children(@target))
52
+ return if @terminal || @queued
53
+
54
+ no_work = @required.each_key.none? { |child| CompletionJoin.pending?(child) }
55
+ return unless @target.finished? || (@target.fiber.nil? && no_work)
56
+
57
+ raise "Completion scheduler closed before finalization" if @scheduler.closed?
58
+
59
+ @queued = Check.new(self)
60
+ @scheduler.push(@queued)
61
+ rescue StandardError => e
62
+ violate(e)
63
+ end
64
+
65
+ def check
66
+ @queued = nil
67
+ retain(CompletionJoin.children(@target))
68
+ unless @target.finished?
69
+ if !fiber_alive? && @required.each_key.none? { |child| CompletionJoin.pending?(child) }
70
+ raise CompletionJoin.residual_violation(@target)
71
+ end
72
+
73
+ return
74
+ end
75
+
76
+ raise "Finished task still has its captured live fiber" if fiber_alive?
77
+
78
+ @terminal = true
79
+ # The original fiber is gone, but retained joins can still expose
80
+ # new membership at consume. Keep that hook active until joined.
81
+ @fiber = @scheduler = nil
82
+ advance
83
+ end
84
+
85
+ def violate(error)
86
+ return if @violation
87
+
88
+ @violation = error
89
+ release
90
+ advance
91
+ rescue StandardError
92
+ # Keep the violation inspectable even if shutdown prevents waking.
93
+ nil
94
+ end
95
+
96
+ def release
97
+ @active = false
98
+ @fiber = @scheduler = nil
99
+ end
100
+
101
+ private
102
+
103
+ def advance
104
+ previous = @promise
105
+ @generation += 1
106
+ @promise = ::Async::Promise.new
107
+ previous.resolve(nil)
108
+ end
109
+ end
110
+ private_constant :Observation
111
+
112
+ # Scheduler callbacks run after finish!'s non-yielding tail. They are
113
+ # neither Tasks nor children, and never execute application work.
114
+ class Check
115
+ def initialize(observation)
116
+ @observation = observation
117
+ end
118
+
119
+ def alive? = !@observation.nil?
120
+
121
+ def transfer
122
+ observation = @observation
123
+ return unless observation
124
+
125
+ @observation = nil
126
+ observation.check
127
+ rescue StandardError => e
128
+ observation.violate(e)
129
+ end
130
+ end
131
+ private_constant :Check
132
+
133
+ def self.observation(target)
134
+ target.instance_variable_get(:@connectrpc_falcon_completion)
135
+ end
136
+
137
+ def self.children(target)
138
+ target.children&.to_a&.reject { |child| child.transient? || child.finished? } || []
139
+ end
140
+
141
+ def self.pending?(target, seen = {}.compare_by_identity)
142
+ return false if seen.key?(target)
143
+ return true unless target.finished?
144
+
145
+ seen[target] = true
146
+ state = observation(target)
147
+ return false unless state
148
+
149
+ !state.terminal? || state.violation || state.required.each_key.any? { |child| pending?(child, seen) }
150
+ end
151
+
152
+ def self.scan(target, retained)
153
+ actual = children(target)
154
+ state = observation(target)
155
+ state&.retain(actual)
156
+ actual.each { |child| retained[child] = true }
157
+ state&.required&.each_key { |child| retained[child] = true }
158
+ retained.keys.select { |child| pending?(child) }
159
+ end
160
+
161
+ def self.residual_violation(target)
162
+ attached = target.children&.to_a&.map do |child|
163
+ { status: child.status, finished: child.finished?, transient: child.transient? }
164
+ end
165
+ RuntimeError.new("Completion observation violation: unfinished task without a live fiber or required work; " \
166
+ "status=#{target.status}; attached_children=#{attached.inspect}")
167
+ end
168
+
169
+ def self.call(task, failure: false, on_interrupt: nil)
170
+ interruption = stored_failure = nil
171
+ pending = [[task, false, {}.compare_by_identity]]
172
+ begin
173
+ yield if block_given?
174
+ rescue Exception => e # rubocop:disable Lint/RescueException -- finish the join before propagating caller interruption
175
+ interruption = e
176
+ end
177
+ while (entry = pending.last)
178
+ target, known, retained = entry
179
+ raise ArgumentError, "Cannot join the current task" if target.current?
180
+
181
+ begin
182
+ if interruption && on_interrupt
183
+ request = on_interrupt
184
+ on_interrupt = nil
185
+ request.call
186
+ end
187
+ target.wait unless known
188
+ entry[1] = true
189
+ rescue Exception => e # rubocop:disable Lint/RescueException -- repeated interruption cannot abandon retirement
190
+ if target.failed? && target.result.equal?(e)
191
+ stored_failure ||= e if (failure && target.equal?(task)) || !e.is_a?(StandardError)
192
+ entry[1] = true
193
+ else
194
+ interruption ||= e
195
+ end
196
+ next
197
+ end
198
+
199
+ work = scan(target, retained)
200
+ state = observation(target)
201
+ state.consumed if state && target.finished? && !state.terminal?
202
+ raise state.violation if state&.violation
203
+
204
+ unless work.empty?
205
+ pending.concat(work.map { |child| [child, false, {}.compare_by_identity] })
206
+ next
207
+ end
208
+ if target.finished? && (!state || state.terminal?)
209
+ state&.release
210
+ pending.pop
211
+ next
212
+ end
213
+
214
+ unless state
215
+ raise residual_violation(target) unless target.fiber&.alive?
216
+
217
+ state = Observation.new(target)
218
+ target.instance_variable_set(:@connectrpc_falcon_completion, state)
219
+ target.extend(Consume)
220
+ # Installation, capture and the first rescan have no yielding gap.
221
+ state.consumed
222
+ next
223
+ end
224
+
225
+ generation = state.generation
226
+ signal = state.promise
227
+ next unless scan(target, retained).empty?
228
+ raise state.violation if state.violation
229
+ next if target.finished? && state.terminal?
230
+ raise residual_violation(target) if !target.finished? && !state.fiber_alive?
231
+ next unless generation == state.generation && signal.equal?(state.promise)
232
+
233
+ begin
234
+ signal.wait
235
+ rescue Exception => e # rubocop:disable Lint/RescueException -- latch interruption owns no completion credit
236
+ interruption ||= e
237
+ end
238
+ end
239
+ raise interruption if interruption
240
+ raise stored_failure if stored_failure
241
+
242
+ task.result
243
+ end
244
+ end
245
+ private_constant :CompletionJoin
246
+ end
247
+ end