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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 843e0f0e2b818850e7d1a88d12aafb12394ee4c5282beeba57ec86cf1950e12f
4
+ data.tar.gz: 70feea3fd34c277e8adeac67d7587f352b907a694b724f023abd4a0b44904670
5
+ SHA512:
6
+ metadata.gz: b6b7452d5985c7780379bae1c2ccfa5e737dc88aefdf703b39aba88c4e6d078b41224bd6c6094975e5931688a9dab4765f9aea3c96c621f4a49264e599ce37f0
7
+ data.tar.gz: 60304a2a0136c2cb3ece3dd7583420f0353fbd56ebf5ddbb4d06b65264b6f9c41127668b3b485ab3945aa28c02a6837fb42cf1302afc8dfb371f52b554d495bd
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,542 @@
1
+ # ConnectRPC Async
2
+
3
+ Optional HTTP/2 hosting and explicit HTTP/1.1 and HTTP/2 transports. Require `connectrpc/async`
4
+ explicitly. Core, Rails and Rack remain independent. This experimental package
5
+ supports Connect unary and streaming, identity and opt-in core gzip, protobuf and ProtoJSON,
6
+ h2c prior knowledge and TLS with verified peers and ALPN `h2`.
7
+
8
+ The example assumes existing core `server`, `binding`, `method` and `message`
9
+ objects, as shown in the repository unary examples.
10
+
11
+ ```ruby
12
+ require "async"
13
+ require "connectrpc/async"
14
+
15
+ Async do
16
+ host = ConnectRPC::Async::Host.new(server: server).start
17
+ origin = "http://127.0.0.1:#{host.port}"
18
+ transport = ConnectRPC::Async::Transport.new(origin: origin)
19
+ client = ConnectRPC::Client.new(base_url: origin, binding: binding, transport: transport)
20
+ begin
21
+ client.call(method, message)
22
+ ensure
23
+ transport.close
24
+ host.close
25
+ end
26
+ end.wait
27
+ ```
28
+
29
+ The application owns a long-lived reactor task. Construction, networking and
30
+ lifecycle operations must use its reactor and thread. There is no hidden
31
+ synchronous reactor thread. Scheduler-compatible I/O yields; CPU, native codecs
32
+ and native DNS may block. Ensure graceful close before the owner exits. Owner
33
+ cancellation aborts only owned sockets/tasks and makes no delivery promise.
34
+
35
+ `Host.new(server:, host: "127.0.0.1", port: 0, tls: nil, max_connections: 128,
36
+ max_requests: 128, handshake_timeout: 10, idle_timeout: 60)` returns a host.
37
+ `start` binds/listens before returning self; `port` and `addresses` expose the
38
+ actual binding. States are new/running/closing/closed; hosts cannot restart.
39
+ Connection admission happens before TLS/preface. Request admission fails with
40
+ bare 503. Setup has one combined cooperative timeout. Idle expiry runs only
41
+ without streams or handlers and can be disabled with nil.
42
+
43
+ `Host#wait` supports application supervision. Before start it raises
44
+ `LifecycleError`, unless the Host was already closed. Ordinary shutdown returns
45
+ nil after all listener tasks stop; session retirement may still be running.
46
+ External `close(grace:)` callers await the session's boolean drain outcome;
47
+ resource-owned calls can return nil while shutdown is pending, as described
48
+ below. Terminal listener failure instead makes `wait` raise the identical
49
+ original exception after cooperative session abort, listener joins and actual
50
+ finalization of its stored private exit task.
51
+ Repeated/concurrent waits share that outcome; cancelling a waiter does not close
52
+ the Host. Like other lifecycle operations, `wait` requires the owning reactor
53
+ and thread.
54
+
55
+ ```ruby
56
+ Async do
57
+ host = ConnectRPC::Async::Host.new(server: server).start
58
+ begin
59
+ host.wait # Ordinary listener stop returns nil; terminal failure raises.
60
+ ensure
61
+ host.close # Await session drain separately from ordinary listener stop.
62
+ end
63
+ end.wait
64
+ ```
65
+
66
+ Each listener retries accept-only `EMFILE`, `ENFILE`, `ENOBUFS`, `ENOMEM`,
67
+ `ECONNABORTED` and `EINTR` with private cooperative delays of 0.05, 0.1, 0.2,
68
+ 0.4 and 0.5 seconds. The sixth consecutive selected failure is terminal.
69
+ The requested delay totals 1.25 seconds; scheduler/native stalls can extend it.
70
+ A successful raw accept resets only that listener's streak, even if admission
71
+ or TLS subsequently rejects the peer. Admitted RPCs and healthy listeners keep
72
+ running during backoff. Other accept errors and unexpected listener errors are
73
+ terminal. No RPC is replayed and no retry policy knobs are exposed.
74
+
75
+ The first terminal listener cause closes every listener and forces raw-first
76
+ session abort with zero drain time, then cooperative cancellation and joins.
77
+ Server abort has no cleanup deadline and cannot forcibly interrupt nonyielding
78
+ or cancellation-resistant application code. The stored exit task runs outside
79
+ the session lifetime under the application owner and survives ordinary owner
80
+ cancellation; `wait` joins its actual finalization before reporting the cause.
81
+ Ordinary owner cancellation stops accepting and resolves ordinary waits after listener
82
+ finalization, while session retirement may still be running. `close` retains
83
+ its drain result and does not raise the stored listener cause. A terminal cause
84
+ already observed during startup is raised by `start` after cleanup; bind/initial
85
+ setup errors remain synchronous and subsequent `wait` returns nil. The Host cannot restart
86
+ after close or failure. Caller-context validation, including the TLS provider
87
+ and `on_tls_error` recursion guard, runs in the calling task before startup
88
+ cleanup: a recursive initial `start` raises `LifecycleError` without creating
89
+ the exit task or aborting the outer startup. An overlapping `start` from another
90
+ task while startup is still in progress raises `LifecycleError` before binding,
91
+ so it cannot leak a second listener set, reset startup state or abort the first
92
+ start. Startup failure uses that same exit task and preserves
93
+ its first caller/setup error, or an already recorded terminal cause, through
94
+ repeated caller interruption. When an independent ordinary close already owns
95
+ shutdown, startup exit stops only listeners and late acquisitions. It preserves
96
+ that close's admitted RPCs, original grace and outcome without awaiting its peer
97
+ drain. Partial failure inside upstream multi-address binding is outside this
98
+ recovery policy.
99
+
100
+ `Transport.new(origin:, ca_file: nil, certificate_chain: nil, private_key: nil,
101
+ connection_limit: 1, max_requests: 128, connect_timeout: 10,
102
+ connection_policy: ConnectionPolicy.new, connection_observer: nil)` implements
103
+ core's transport seam. Origins contain only scheme/host/port. Request paths,
104
+ including core's base prefix, remain intact. One connection multiplexes requests.
105
+ Full admission fails with unavailable; acquisition waits honor finite RPC
106
+ deadlines. Nil RPC deadlines remain unbounded, while each connection attempt has
107
+ its own `connect_timeout`. No redirects, proxy environment routing or HTTP/1
108
+ fallback. The transport carries opaque encoded bytes; core owns compression and
109
+ negotiation. [Client connection management](#client-connection-management)
110
+ describes reconnection, backoff, admission and the single reroute exception to
111
+ "no retries".
112
+
113
+ Both objects provide `close(grace: 5)`: stop admission, await handlers/calls and
114
+ complete wire streams, send GOAWAY when empty, then half-close and await EOF.
115
+ External callers receive true when drained, false after forced teardown. Grace is finite and
116
+ nonnegative; zero aborts. Repeated/concurrent calls share the first outcome.
117
+ Finite deadlines begin in core before encoding and cover acquisition, headers and
118
+ body. A shared connection attempt is bounded by `connect_timeout` instead.
119
+ Timers use at most 86400-second waits without shortening logical deadlines.
120
+ Client cleanup uses one total internal 100ms allowance. Ordinary cancellation resets
121
+ its stream. A frame that cannot finish within cleanup deliberately fails the
122
+ whole connection and siblings. Forced teardown closes raw TCP before cancelling
123
+ and joining writers/readers, then performs buffered/TLS cleanup. These are
124
+ cooperative bounds, not arbitrary native/application interruption guarantees.
125
+
126
+ Forced teardown invalidates the connection immediately. External callers of
127
+ private connection abort and server session abort/close then wait for owned
128
+ tasks, their cleanup descendants and final buffered/TLS disposal. An owned task
129
+ initiating or reentering abort requests retirement without waiting on cleanup
130
+ that must join that same task. Each external abort/close joiner retains its own
131
+ first interruption until cleanup completes.
132
+ An external server close caller interrupted during a nonzero graceful drain
133
+ requests forced session retirement, joins actual cleanup, then raises its
134
+ identical first exception. Other callers retain their own result or exception,
135
+ and an already-decided close outcome remains unchanged.
136
+
137
+ Server `Host#close` and `ServerSession#close` return `bool?`. A resource-owned
138
+ handler initiating positive-grace close starts the ordinary asynchronous drain
139
+ and returns nil while shutdown is pending. Admitted siblings retain their grace
140
+ and can continue processing. Owned repeated requests and cleanup/close coordinator
141
+ reentry also return nil while pending, without joining cleanup that must join
142
+ them. Nil neither decides the outcome nor publishes completion. Repeated requests
143
+ share the original closer and expiry, even with a different grace argument.
144
+ External callers join actual cleanup and receive its final boolean. Once closed,
145
+ every call returns that same boolean, including coordinator reentry. Zero grace
146
+ still forces teardown; explicit abort and already-decided outcomes are preserved.
147
+ The client `Transport` and `HTTP1Transport` close return types remain `bool`.
148
+
149
+ H2 connection release, acquisition or close can therefore wait beyond an RPC deadline
150
+ for actual retirement. The 100 ms fallback allowance triggers destructive
151
+ cleanup; it is not a hard deadline for that cleanup.
152
+
153
+ Private task joins distinguish a resolved result from actual finalization. They
154
+ block on required children and task-local progress/finalization notifications,
155
+ retaining captured child identities through reparenting or transient changes.
156
+ A scheduling opportunity after raw close establishes no completion boundary;
157
+ all retained task joins still precede disposal and retirement publication.
158
+
159
+ These joins require cooperative tasks and a live application reactor. A
160
+ non-yielding handler or native operation can delay them indefinitely. Cleanup
161
+ survives ordinary owner cancellation while an external waiter or lifetime guard
162
+ keeps the task tree alive; reactor termination cannot guarantee completion.
163
+
164
+ See the [supported 0.x contract](../../docs/support.md) for the target support
165
+ profile, matched package versions, compatibility policy and adoption gates.
166
+
167
+ ## Client connection management
168
+
169
+ Each H2 `Transport` owns a private connection manager on its reactor. Its policy
170
+ is on by default; these are the defaults:
171
+
172
+ ```ruby
173
+ policy = ConnectRPC::Async::ConnectionPolicy.new(
174
+ backoff: ConnectRPC::Async::Backoff.new(base: 1.0, multiplier: 1.6, jitter: 0.2, max: 30.0),
175
+ wait_for_ready: false,
176
+ max_pending_bytes: 16 * 1024 * 1024
177
+ )
178
+ transport = ConnectRPC::Async::Transport.new(origin: origin, connection_policy: policy,
179
+ connection_observer: ->(event) { record(event) })
180
+ transport.connection_state # => :idle, :connecting, :ready, :transient_failure, :closing or :closed
181
+ ```
182
+
183
+ Invalid values raise `ArgumentError` at construction. `wait_for_ready: true`
184
+ requires a backoff, and `connection_policy: nil` is rejected.
185
+
186
+ - No socket opens before the first call. A connection is ready after TCP, TLS
187
+ with hostname and ALPN `h2` verification, the client preface and the server's
188
+ first SETTINGS frame. TCP connect alone is not readiness.
189
+ - Attempts are shared, one at a time, and run under the transport rather than
190
+ under any RPC task or lifecycle. A call whose deadline or cancellation fires
191
+ stops waiting without cancelling the attempt; the attempt continues until
192
+ `connect_timeout` or `close`, and a connection that becomes ready stays for
193
+ the next call. No attempt starts without a waiting call.
194
+ - After a failed attempt the manager waits
195
+ `min(base * multiplier ** (failures - 1), max) * (1 - rand * jitter)` seconds
196
+ before the next one. Jitter only shortens a delay, so `max` is a hard cap.
197
+ Readiness resets `failures`; bursts of calls and idle periods do not. A timer
198
+ that expires with no waiting call starts nothing and the state returns to
199
+ `idle`. GOAWAY is not a failure.
200
+ - Calls are fail-fast by default. A call fails with `unavailable` when an
201
+ attempt it joined fails and no usable connection exists; otherwise it keeps
202
+ waiting for capacity. A call whose attempt succeeded can still join a later
203
+ one, for example after another call took the new capacity. Calls arriving
204
+ while the timer runs fail at once unless a usable connection exists. With
205
+ `wait_for_ready: true`, calls keep waiting through backoff and later attempts
206
+ until their own deadline, cancellation or `close`.
207
+ - `backoff: nil` removes the timer and the `transient_failure` state. A failed
208
+ attempt fails every call that joined it, and the next call dials at once.
209
+ - `connection_limit` counts open connections, reusable or not, plus the attempt
210
+ in progress. A call reuses the first connection with spare stream capacity,
211
+ which is the peer's `SETTINGS_MAX_CONCURRENT_STREAMS`, and opens another only
212
+ when none has capacity and a slot is free. A peer raising that limit wakes
213
+ waiting calls; lowering it never revokes a stream already acquired. While any
214
+ connection is usable the state stays `ready`, even if an extra attempt failed
215
+ and is backing off. Extra connections add capacity, not load spreading.
216
+ - A connection that receives GOAWAY or dies takes no new streams. It counts as
217
+ draining and keeps its slot until its streams finish; replacing it while it
218
+ drains is not implemented yet. A connection that dies while idle republishes
219
+ the state immediately.
220
+ - Every call and stream takes one of `max_requests`. `Transport#call` (Connect
221
+ unary) also reserves its encoded request bytes from `max_pending_bytes`: the
222
+ POST body, or the request target for GET. Native gRPC unary and all streams
223
+ reserve no bytes. A full count or byte budget, or a single call larger than
224
+ the budget, fails immediately with `unavailable`. `max_pending_bytes: nil`
225
+ admits by count only. Reservations last until the call's terminal outcome.
226
+ - There are no retries, with one exception that is not a replay: a call whose
227
+ connection became unusable after acquisition but before any stream ID was
228
+ allocated or request byte written returns to acquisition with its existing
229
+ admission. Once request HEADERS start, a failed call is never resent; only a
230
+ separate later call reconnects.
231
+ - `connection_observer` receives frozen `ConnectionEvent` values with `type`
232
+ (`:state_changed`, `:attempt_finished` or `:admission_rejected`), `state`,
233
+ `previous_state`, `outcome` (`:ready`, `:refused`, `:timeout`, `:tls`,
234
+ `:protocol`, `:closed` or `:other`), `duration` in monotonic seconds, and the
235
+ counts `active`, `draining`, `pending_calls` and `pending_bytes`. Events carry
236
+ no addresses, certificates, payloads, URLs or exception text. The observer
237
+ runs synchronously on the owner reactor after the manager's bookkeeping and
238
+ must return quickly. Its `StandardError`s are ignored. Calling `call`,
239
+ `open_stream`, `open_grpc_stream` or `close` from the observer raises;
240
+ `connection_state` and `inspect` remain available.
241
+ - `connection_state` is a published observation, not a promise that the next
242
+ call succeeds. `close` additionally stops admission, fails waiting calls with
243
+ `unavailable`, and cancels the backoff timer and any attempt, raw socket
244
+ first.
245
+
246
+ ## TLS and client authentication
247
+
248
+ Applications load their own files. Host TLS is a complete hash:
249
+
250
+ ```ruby
251
+ tls = {
252
+ certificate_chain: [File.read("leaf.pem"), File.read("intermediate.pem")],
253
+ private_key: File.read("key.pem"),
254
+ client_ca: nil # Or a nonempty array of explicit trust-anchor PEM certificates.
255
+ }
256
+ ```
257
+
258
+ Each certificate element contains exactly one PEM certificate, leaf first.
259
+ Keys are unencrypted PEM private keys. Inputs are copied and parsed without
260
+ password prompts; key possession, leaf validity and adjacent chain signatures
261
+ are checked. Snapshots retain private parsed objects, redact inspection/errors,
262
+ and compile fresh contexts before publication. Explicit intermediates, including
263
+ an empty array, prevent auto-chaining through the client-auth trust store.
264
+ Both roles require TLS >=1.2 and post-check ALPN `h2`. Clients always verify
265
+ peer/hostname, including localhost, using system roots plus optional `ca_file`.
266
+ Client chain/key must appear together; TLS options are invalid for h2c.
267
+
268
+ A nonempty `client_ca` requires verified client certificates using only those
269
+ anchors, with partial-chain trust enabled deliberately. A scoped intermediate
270
+ can be the boundary; sibling issuers under its root are not trusted. Nil means
271
+ server-only TLS and no client authentication. There is no optional-client mode.
272
+ `host.peer_certificate` returns a detached authenticated client leaf only within
273
+ that host's exact handler task; outside/child/other-host/plain/server-only TLS
274
+ returns nil. It does not expose or claim a verified chain and does not change
275
+ core Context. Applications own authorization based on that identity.
276
+
277
+ `host.reload_tls(certificate_chain:, private_key:, client_ca:)` replaces the
278
+ complete snapshot for newly accepted sockets on the same listener. Generation
279
+ starts at 1 and increments only after successful validation; invalid candidates
280
+ leave the last good snapshot intact. Existing connections retain their prior
281
+ identity and authentication. Every generation has a fresh context/session ID,
282
+ OP_NO_TICKET and SESSION_CACHE_OFF. Reuse is disabled; this does not claim that
283
+ OpenSSL emits zero TLS 1.3 tickets. Replace a transport to change client identity.
284
+
285
+ `host.drain_tls(grace: 5)` captures the current generation cutoff and retires
286
+ only older accepted connections, including pending handshakes. Old later streams
287
+ receive 503 while admitted streams finish. Pending handshakes abort immediately.
288
+ A persistent retirement floor prevents old pending sockets from becoming live.
289
+ The listener and current/newer generations survive concurrent reloads/drains.
290
+ Close supersedes drains. Renewal alone does not instantly revoke existing peers.
291
+ There are no file watchers, ACME, secret services or authentication policy DSLs.
292
+
293
+ ## Native application and managed TLS
294
+
295
+ `Application.new(server:, fallback: nil)` is a reusable Protocol::HTTP callable.
296
+ It routes whole registered service prefixes to core, splits the request target once, forwards the raw query to core, and returns
297
+ bare 404 or calls the native fallback for unrelated paths. HTTP/1 requests do
298
+ not need a stream object. Keep generic compression/cache middleware on fallback
299
+ so core owns RPC encoded bytes and limits.
300
+
301
+ Host accepts `tls_provider:`, `refresh_interval: 60`, `refresh_jitter: 0.1`,
302
+ `refresh_timeout: 10` and `on_tls_error:`. `refresh_tls` coalesces immediate source
303
+ checks; `tls_status` exposes safe state/generation and separate monotonic
304
+ source-check/publication ages. Managed `reload_tls` conflicts with provider
305
+ ownership. `TLSConfiguration` also exposes `start`, `refresh`, complete static
306
+ `reload`, `close`, `generation` and `status`. Construction is reactor/thread
307
+ bound; initial static/provider compilation happens on `start`, before accepts.
308
+
309
+ Providers return exactly `version`, `certificate_chain`, `private_key` and
310
+ `client_ca` symbol keys. A nonempty opaque String version identifies validated
311
+ canonical content. Same version/content preserves generation; changed content
312
+ under that version fails. A different previous version can deliberately roll
313
+ back through a fresh generation. Missing/nil data never disables TLS. Background
314
+ errors retain the last good snapshot and emit only safe immutable diagnostics.
315
+ Concurrent callers join one owned attempt, and waiter cancellation does not
316
+ cancel it. Timer intervals start after each completed attempt. Timeout and
317
+ shutdown remain cooperative for arbitrary native/CPU application code.
318
+
319
+ The earliest served identity-chain expiry is checked against actual wall time
320
+ at accept and again at pending handshake start. There is no maximum staleness
321
+ setting; established connections retain negotiated state until drain/close.
322
+ The [Falcon guide](../connectrpc-falcon/README.md) explains complete provider
323
+ semantics and safe diagnostics shared by both hosts.
324
+
325
+ `ServerSession` is the small hosting SPI: start the worker session, synchronously
326
+ `accept(raw)` before scheduling, then call the opaque accepted handle's `serve`.
327
+ `schedule` attaches accepted work to the independent session lifetime. The handle
328
+ also supports `close`, `wait_closed`, `timeout=` and `remote_address`, and exposes no SSLContext.
329
+ `wait_closed` waits for actual connection retirement, including owned task
330
+ unwind and final disposal, rather than merely observing invalidation. Retiring
331
+ server connections retain their admission slot until this boundary. The H2
332
+ client's private connection manager removes a retiring connection's slot and
333
+ wakes waiting calls before joining its connection close.
334
+ The session forwards TLS lifecycle/identity operations, bounded `close` and
335
+ raw-first `abort` for listener/setup failure.
336
+ The optional `stop_accepting:` callback runs after affinity/grace validation,
337
+ before refresh stops and connection drain begins. Its one HTTP/1 construction hook is for the optional Falcon-local resource;
338
+ HTTP/2 resources do not pretend to implement HTTP/1. The standalone Host keeps
339
+ its strict h2 listener and composes the shared session.
340
+ The shared session's private resource completion and lifetime-anchor operations
341
+ are source-coupled to Falcon's local HTTP/1 resource. Use matched Async/Falcon
342
+ package versions; these internals are not an additional public API or SPI.
343
+
344
+ ## Backend boundaries and verification
345
+
346
+ Enable gzip with `encoding: :gzip` on the core client and server. Client
347
+ `accept_encoding:` controls response negotiation independently. Core's
348
+ `request_limit:` and `response_limit:` bound serialized/decompressed messages;
349
+ the separate wire limits default to those same caps. The transport's
350
+ `TransportRequest.response_limit` remains the encoded wire read cap. Defaults
351
+ remain identity, and neither host nor transport performs transparent inflation.
352
+
353
+ Core owns codecs, errors, metadata and serialized byte limits. The backend
354
+ reads bounded chunks, reconstructs declared length from backend body.length,
355
+ preserves raw repeated leading fields and leading `trailer-*`, and ignores real
356
+ HTTP/2 trailing HEADERS for core metadata. The stack normalizes lengths using
357
+ Ruby Integer: `+10`, hexadecimal and underscores can normalize before core;
358
+ malformed/duplicate lengths may reset/close connections, and singleton header
359
+ policies can produce bare errors before core. These outcomes are tested and
360
+ are not a full protocol conformance claim. Settings use 16KiB frames, 64KiB
361
+ stream windows and bounded stream admission; they do not bound total process
362
+ memory or HPACK/header-continuation buffering.
363
+
364
+ Native HTTP/2 request and response inputs use a private coalescing queue. Stream
365
+ WINDOW_UPDATE replenishes only application-consumed or deliberately discarded
366
+ DATA bytes. Padding, including its length octet, uses wire credit and is discarded
367
+ without entering body bytes or Content-Length. Connection credit is independent,
368
+ so unread bodies do not prevent healthy streams or control frames from advancing.
369
+ The shared reader never waits for application consumption; serialized socket writes
370
+ can still block. Stream over-credit resets that stream with FLOW_CONTROL_ERROR;
371
+ connection over-credit fails the connection and its siblings. Upstream parsing and
372
+ stream-state errors remain authoritative. This follows [HTTP/2 flow control](https://www.rfc-editor.org/rfc/rfc9113.html#section-5.2).
373
+
374
+ With the current settings, each queue retains at most 65,536 payload bytes in at
375
+ most five data entries, independent of frame count. Each returned native chunk is
376
+ at most 16,384 bytes. The core input adapter can retain one such chunk's remainder;
377
+ the shared reader can hold one decoded DATA frame of at most 16,384 bytes.
378
+ Coalescing/copying can temporarily retain old and new bounded chunks. String byte
379
+ length does not bound Ruby backing allocations or RSS. Caller-retained strings,
380
+ buffered unary bodies, codecs/compression, socket/TLS buffers and HPACK/headers
381
+ are separate allocations. These are ingress payload bounds, not a total memory
382
+ or general denial-of-service guarantee. Streaming adds bounded output ownership without changing these ingress limits.
383
+
384
+ Normal producer EOF drains preceding payload. Closing the consumer releases its
385
+ queue once; later DATA is discarded while local output remains live, following
386
+ the pinned backend's finish-input and NO_ERROR policy. Reset/error/owned teardown
387
+ wakes readers and retires input references. Only peer CANCEL signals explicit
388
+ RPC cancellation; credit accounting adds no lifecycle instrumentation.
389
+
390
+ Discover `async:setup`, `async:test`, `async:check`, `async:build`, `async:package`,
391
+ `async:example`, `async:interop`, and `async:verify` with `mise tasks`. The separate
392
+ checksummed bundle uses `vendor/async` and coverage uses `coverage/async`.
393
+ Independent connect-go tests exercise both directions/codecs over h2c/TLS/mTLS,
394
+ with observed HTTP/2/ALPN and large typed wire-tail drains. Original core fixture
395
+ and official conformance harnesses remain separate. Installed package checks use
396
+ normal offline dependencies and a fresh GEM_HOME outside the checkout. This
397
+ graph activates OpenSSL 4.0.2/JSON 3.1.0; installed core NetHTTP TLS/JSON with
398
+ identity and gzip is also exercised under that activation. Focused combined
399
+ specs cover actual gzip bytes over h2c/TLS in both codecs and independent host
400
+ wire/inflated and transport wire/client inflated caps. Nothing is published.
401
+ Original project work is [MIT licensed](LICENSE), copyright 2026 Jim Myhrberg.
402
+ Both archives include the complete license and pin `allowed_push_host` to
403
+ `https://rubygems.org`; only the [release workflow](../../docs/releasing.md)
404
+ publishes them.
405
+
406
+ ## RPC observation
407
+
408
+ Configure `instrumentation:` on the core Client/Server to enable the fixed
409
+ neutral per-call lifecycle or the optional [OpenTelemetry adapter](../connectrpc-otel/README.md).
410
+ Native hosts create explicit handles before request admission, activate the
411
+ actual handler tasks, and finish after full-write/reset/cleanup evidence.
412
+ Shared H2 client connection attempts run outside every RPC lifecycle, so tracing
413
+ never parents connection setup to the call that triggered it. Use the transport's
414
+ `connection_observer` for connection events.
415
+
416
+ ## Static signatures
417
+
418
+ This package owns `sig/` and its discovery manifest. Add
419
+ `library "connectrpc-async"` to a strict Steep target with this gem installed.
420
+ See the [RBS guide](../../docs/rbs.md) for supported APIs, extension contracts,
421
+ partial records, runtime validation and framework-signature limits.
422
+ `mise run rbs:check` checks application callers for this package; strict core
423
+ source checking is a separate target from backend implementation checking.
424
+
425
+ Safe unary GET uses core `Client.new(use_get: true)` and declared
426
+ `:no_side_effects` methods. Native HTTP/2 requests carry HEADERS with END_STREAM
427
+ and a nil body; core owns query encoding, parsing and independent URL/payload
428
+ limits. The transport never retries or falls back to POST.
429
+
430
+ ## Explicit unary cancellation
431
+
432
+ Pass `cancellation: source.token` to core or generated client calls, where
433
+ `source = ConnectRPC::CancellationSource.new`. The source is permanent and
434
+ shareable; only `source.cancel` initiates cancellation. `timeout: nil` keeps no
435
+ RPC deadline or wire timeout. Context offers cooperative `cancelled?`,
436
+ `cancellation_reason` and `check!`. See the [core guide](../../README.md) for
437
+ listener, arbitration and registration lifetime rules.
438
+
439
+ The Async transport accepts foreign-thread cancellation through a Promise;
440
+ only the owner reactor stops private SDK tasks and performs stream cleanup.
441
+ Healthy multiplexed siblings survive. A stuck frame retains the one 100 ms
442
+ raw-first whole-connection cleanup fallback. Native HTTP/2 Context observes
443
+ only an actual peer RST_STREAM(CANCEL), before input teardown. Other resets,
444
+ normal half-close, connection loss and host shutdown do not fabricate explicit
445
+ cancellation. Processing completion detaches the source independently of delivery.
446
+
447
+ ## Native streaming
448
+
449
+ The core [streaming guide](../../docs/streaming.md) documents all three scoped
450
+ Connect streaming shapes, typed generated clients/handlers, early headers,
451
+ per-message limits and bounded ownership. Native Async/Falcon HTTP/2 supports
452
+ both codecs and identity/gzip over h2c/TLS/mTLS. Falcon additionally serves Connect server and client
453
+ streaming over HTTP/1.1,
454
+ with protobuf/ProtoJSON and identity/gzip over TCP/TLS/mTLS. The standalone
455
+ Async Host and original Async Transport remain H2-only. Use the independent
456
+ `HTTP1Transport` below for Connect unary, server streaming and client streaming. See the streaming guide for Falcon's cooperative 500 ms total
457
+ final-close budget and unchanged 100 ms / 256 KiB discard limits.
458
+ The client's one-second absolute HTTP tail policy applies even with a nil RPC
459
+ deadline and can reject otherwise valid late EOF or slow intermediaries.
460
+
461
+ Use `async:conformance` and `async:interop:streaming` for the exact official native
462
+ client/server inventories and independent pinned Go streaming matrix. Both belong
463
+ to `async:verify`; original unary interoperability remains separate.
464
+
465
+ ## Native gRPC
466
+
467
+ Native HTTP/2 supports unary, server streaming, client streaming and bidi gRPC
468
+ with protobuf, identity/gzip, h2c, verified TLS and mTLS. Configure the core
469
+ Client with `protocol: :grpc` and the Server with `:grpc` or `:auto`. Generated
470
+ clients and handlers reuse the existing declarations. Portable HTTP/1 execution
471
+ retains its early rejection guard. See the [gRPC guide](../../docs/grpc.md) for
472
+ status codecs, actual final HEADERS, independent status bounds and native field
473
+ retention limits. Connect's one-second terminal tail policy remains Connect-only.
474
+
475
+ Use `async:conformance:grpc` for exact official inventories and
476
+ `async:interop:grpc` for generated official grpc-go peer checks. Both belong
477
+ to the owning verify task. `grpc:generation:check` reproduces official Go and
478
+ Ruby peer outputs; `grpc:status:check` reproduces private Any/Status descriptors.
479
+
480
+ ## gRPC-Web
481
+
482
+ The native H2 server supports explicitly enabled binary/text unary and server-stream gRPC-Web. Browser incremental delivery uses text.
483
+ See the [Web guide](../../docs/grpc-web.md) for protocol, CORS, bounds and completion rules.
484
+
485
+ ## Explicit HTTP/1.1 client
486
+
487
+ ```ruby
488
+ Async do
489
+ transport = ConnectRPC::Async::HTTP1Transport.new(origin: "https://rpc.example.com")
490
+ client = ConnectRPC::Client.new(base_url: "https://rpc.example.com", binding: binding,
491
+ transport: transport, use_get: true)
492
+ begin
493
+ client.call(method, message)
494
+ # Existing generated clients and core server_stream/client_stream use it too.
495
+ ensure
496
+ transport.close
497
+ end
498
+ end.wait
499
+ ```
500
+
501
+ `HTTP1Transport.new(origin:, ca_file: nil, certificate_chain: nil, private_key:
502
+ nil, connection_limit: 4, max_requests: 128, connect_timeout: 10)` is an explicit
503
+ Connect H1 client. It supports safe unary GET, both codecs and identity/gzip
504
+ through core, and SS/CS through the existing streaming SPI. It has no gRPC or
505
+ bidi capability. Standalone Async Host remains H2-only; Falcon owns Ruby H1
506
+ hosting. Ordinary-thread persistence remains issue #29.
507
+
508
+ Each connection has one exclusive lease through setup, upload, full HTTP EOF
509
+ and cleanup. Pending dials and retiring connections count toward the hard cap;
510
+ lease waits count toward admission. Four long streams can occupy every default
511
+ slot. Configure capacity or give waiters cancellation/deadlines; fairness is not
512
+ promised. `close(grace: 5)` stops admission and drains admitted operations,
513
+ including waiters, returning false on forced teardown. Owner exit aborts owned
514
+ resources. There is no hidden thread, pipelining, retry, replay or fallback.
515
+
516
+ Finite RPC deadlines cover every wait including cleanup. Nil RPC deadlines add
517
+ no watchdog; setup still has its own combined connect timeout. Foreign-thread
518
+ cancellation only signals the reactor. Full native EOF, a successful joined
519
+ writer and successful cleanup permit reuse. Any abandoned/failed exchange
520
+ retires its connection. Clean close-delimited unary EOF is supported, but
521
+ cannot detect an otherwise valid semantic truncation without Content-Length.
522
+ TLS unexpected EOF without close_notify remains a transport error.
523
+
524
+ TLS offers only `http/1.1`, accepts absent or H1 ALPN, and rejects every other
525
+ negotiation before request bytes. Peer/hostname verification and mTLS remain
526
+ enabled. TCP rejects TLS options. Replace the transport to change TLS identity.
527
+
528
+ Response reads are independent of a blocked upload. A complete unary non-200
529
+ response stops pending output destructively so core can validate its error;
530
+ HTTP 200 requires writer success. For streaming, `write` alone reports a writer
531
+ failure. Core may then recover a remote error through its bounded terminal-only
532
+ policy. An unreported upload-tail failure returns unavailable even if a remote
533
+ error terminal arrived, masking that error. `close_send` never raises writer
534
+ failure; `finish` requires full HTTP EOF and joined cleanup under the supplied
535
+ completion budget. A write-only caller without a deadline can still block when
536
+ a peer neither consumes nor closes. See [streaming](../../docs/streaming.md#explicit-http11-client).
537
+
538
+ `async:conformance:http1` runs separate strict official 1.0.6 native H1 CLIENT
539
+ inventories: 386 identity and 352 gzip cases, including safe GET, both codecs,
540
+ TCP/TLS and client certificates. `async:interop:http1` runs independent pinned
541
+ connect-go H1-only TCP/TLS/mTLS listeners, with actual HTTP/1.1 and ALPN evidence.
542
+ Existing H2, NetHTTP and Falcon server inventories remain separate.