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 +7 -0
- data/LICENSE +21 -0
- data/README.md +542 -0
- data/lib/connectrpc/async/application.rb +173 -0
- data/lib/connectrpc/async/completion_join.rb +249 -0
- data/lib/connectrpc/async/connection.rb +711 -0
- data/lib/connectrpc/async/connection_manager.rb +358 -0
- data/lib/connectrpc/async/connection_policy.rb +60 -0
- data/lib/connectrpc/async/host.rb +171 -0
- data/lib/connectrpc/async/http1_connection.rb +144 -0
- data/lib/connectrpc/async/http1_exchange.rb +27 -0
- data/lib/connectrpc/async/http1_stream.rb +76 -0
- data/lib/connectrpc/async/http1_transport.rb +642 -0
- data/lib/connectrpc/async/ingress.rb +100 -0
- data/lib/connectrpc/async/internal.rb +270 -0
- data/lib/connectrpc/async/server_session.rb +457 -0
- data/lib/connectrpc/async/stream_exchange.rb +324 -0
- data/lib/connectrpc/async/tls.rb +124 -0
- data/lib/connectrpc/async/tls_configuration.rb +309 -0
- data/lib/connectrpc/async/transport.rb +488 -0
- data/lib/connectrpc/async/version.rb +7 -0
- data/lib/connectrpc/async.rb +39 -0
- data/sig/connectrpc-async.rbs +165 -0
- data/sig/manifest.yaml +4 -0
- metadata +202 -0
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.
|