connectrpc 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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +501 -0
  4. data/docs/design.md +504 -0
  5. data/docs/development.md +566 -0
  6. data/docs/grpc-web.md +165 -0
  7. data/docs/grpc.md +75 -0
  8. data/docs/rbs.md +469 -0
  9. data/docs/streaming.md +251 -0
  10. data/exe/protoc-gen-connectrpc-ruby +23 -0
  11. data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
  12. data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
  13. data/lib/connectrpc/cancellation.rb +220 -0
  14. data/lib/connectrpc/client.rb +260 -0
  15. data/lib/connectrpc/compression.rb +120 -0
  16. data/lib/connectrpc/deadline.rb +81 -0
  17. data/lib/connectrpc/error.rb +72 -0
  18. data/lib/connectrpc/generator/names.rb +105 -0
  19. data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
  20. data/lib/connectrpc/generator/renderer.rb +115 -0
  21. data/lib/connectrpc/generator/runtime_names.rb +84 -0
  22. data/lib/connectrpc/generator/schema.rb +218 -0
  23. data/lib/connectrpc/generator/streaming_methods.rb +29 -0
  24. data/lib/connectrpc/generator.rb +42 -0
  25. data/lib/connectrpc/get_query.rb +159 -0
  26. data/lib/connectrpc/grpc.rb +200 -0
  27. data/lib/connectrpc/grpc_client.rb +23 -0
  28. data/lib/connectrpc/grpc_server.rb +158 -0
  29. data/lib/connectrpc/grpc_web.rb +131 -0
  30. data/lib/connectrpc/grpc_web_cors.rb +135 -0
  31. data/lib/connectrpc/grpc_web_io.rb +156 -0
  32. data/lib/connectrpc/grpc_web_server.rb +207 -0
  33. data/lib/connectrpc/instrumentation.rb +216 -0
  34. data/lib/connectrpc/message_io.rb +47 -0
  35. data/lib/connectrpc/metadata.rb +105 -0
  36. data/lib/connectrpc/method.rb +68 -0
  37. data/lib/connectrpc/protocol.rb +189 -0
  38. data/lib/connectrpc/server.rb +398 -0
  39. data/lib/connectrpc/streaming/client.rb +104 -0
  40. data/lib/connectrpc/streaming/client_execution.rb +87 -0
  41. data/lib/connectrpc/streaming/client_scope.rb +105 -0
  42. data/lib/connectrpc/streaming/framing.rb +123 -0
  43. data/lib/connectrpc/streaming/framing_io.rb +61 -0
  44. data/lib/connectrpc/streaming/method.rb +33 -0
  45. data/lib/connectrpc/streaming/server.rb +122 -0
  46. data/lib/connectrpc/streaming/server_execution.rb +88 -0
  47. data/lib/connectrpc/streaming/server_terminal.rb +70 -0
  48. data/lib/connectrpc/streaming/session.rb +143 -0
  49. data/lib/connectrpc/streaming/session_completion.rb +134 -0
  50. data/lib/connectrpc/streaming/session_io.rb +107 -0
  51. data/lib/connectrpc/streaming/session_messages.rb +119 -0
  52. data/lib/connectrpc/streaming/strategy.rb +95 -0
  53. data/lib/connectrpc/streaming/streams.rb +116 -0
  54. data/lib/connectrpc/streaming/terminal.rb +52 -0
  55. data/lib/connectrpc/transports/net_http.rb +246 -0
  56. data/lib/connectrpc/version.rb +5 -0
  57. data/lib/connectrpc.rb +38 -0
  58. data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
  59. data/sig/connectrpc.rbs +772 -0
  60. data/sig/manifest.yaml +2 -0
  61. data/third_party/README.md +16 -0
  62. data/third_party/google/LICENSE +201 -0
  63. data/third_party/google/rpc/status.proto +48 -0
  64. data/third_party/protobuf-LICENSE +32 -0
  65. metadata +106 -0
data/docs/design.md ADDED
@@ -0,0 +1,504 @@
1
+ # Design and implemented profile
2
+
3
+ The [supported 0.x contract](support.md) defines the target compatibility
4
+ boundary and the remaining adoption gates.
5
+
6
+ The gem implements typed Connect RPCs and native HTTP/2 gRPC in all four RPC shapes.
7
+ Falcon also serves Connect HTTP/1.1 server/client streaming. The explicit Async
8
+ HTTP1Transport supports Connect unary and server/client streaming; standalone
9
+ Async Host remains H2-only. The streaming guide records
10
+ the response-first discard limits and cooperative 500 ms total final-close
11
+ policy. Shared protocol code is independent of HTTP hosting and concrete
12
+ protobuf classes. The public require is `connectrpc`, and the Ruby namespace is
13
+ `ConnectRPC`. The API is experimental.
14
+
15
+ ## Shared boundaries
16
+
17
+ `Method` holds validated service/method names, request/response binding types,
18
+ `cardinality: :unary`, `idempotency: :unknown` (or `:no_side_effects` / `:idempotent`),
19
+ and a case-sensitive path. Only `:no_side_effects` admits GET. `Service` validates its method
20
+ set. Streaming uses invariant sibling declarations, keeping unary Method
21
+ assignability intact. Generated service glue reuses these objects.
22
+ The [streaming guide](streaming.md) owns its scoped APIs, publication/completion
23
+ boundaries, finite transport SPI and absolute recovery/tail policies.
24
+
25
+ `Client` owns request encoding, protocol headers, response validation, errors,
26
+ metadata, byte limits and the deadline. Base URL path prefixes are retained.
27
+ A binding provides `encode(message, type:, codec:)` and
28
+ `decode(bytes, type:, codec:)`. Custom bindings raise `CodecError` for invalid
29
+ messages. The optional Google binding delegates binary and
30
+ [protobuf JSON](https://protobuf.dev/programming-guides/json/) to native
31
+ protobuf APIs. It ignores unknown JSON fields. Scalar wrapper roots use a
32
+ standard Any envelope internally to work around protobuf 4.36's direct-root
33
+ decoder limitation; the backend still validates the protobuf values.
34
+
35
+ A transport provides `call(request)` with these host-neutral value objects:
36
+
37
+ | Object | Fields |
38
+ | --- | --- |
39
+ | `TransportRequest` | `url` (URI), `http_method`, `headers` (lower-case keys, string arrays), `body` (bytes), `response_limit`, `deadline`, `lifecycle`, `cancellation` |
40
+ | `TransportResponse` | `status` (integer), `headers` (repeated string arrays), `body` (bounded bytes) |
41
+ | `Response` | `message` (binding type), `metadata`, `trailers` (Metadata objects) |
42
+
43
+ `TransportRequest.deadline` is a `Deadline` or `nil`. Custom transports must
44
+ enforce a supplied deadline and read-only `cancellation` SPI during their owned
45
+ I/O and bound the body incrementally.
46
+ An absent deadline means no aggregate RPC expiry or wire timeout. Client validation checks their output again but
47
+ cannot undo an oversized allocation or interrupt their work.
48
+
49
+ `Server` owns negotiation, bounded input, decoding, invocation, serialization,
50
+ and safe errors. `Rack` only translates request/response HTTP state. It wraps
51
+ an optional downstream app and routes `PATH_INFO`; unrelated service paths
52
+ fall through, while unknown methods within registered services fail explicitly.
53
+ Neither core nor handlers need Rack envs. The optional `connectrpc-rack` package
54
+ owns `connectrpc/rack` exclusively and loads core through `require "connectrpc"`.
55
+ Applications add `gem "connectrpc-rack", "~> 0.1.0"` while keeping the require
56
+ and `ConnectRPC::Rack` API unchanged. Core has no middleware file or runtime
57
+ dependencies; requiring middleware without its package raises an ordinary
58
+ `LoadError`. Google loads only through `connectrpc/bindings/google_protobuf`;
59
+ its missing/incompatible dependency diagnostic remains useful. The middleware
60
+ retains its own Rack version activation diagnostic.
61
+
62
+ A default invoker calls the registered plain handler with `(request, context)`.
63
+ An explicit `invoker` callable receives `(method, request, context)` and can
64
+ supply framework execution later. Every invocation has a fresh `Context` with
65
+ request metadata, response leading/trailing metadata, and optional deadline.
66
+ Handlers are reusable; application-owned shared state needs its own locking.
67
+
68
+ ## Wire behavior
69
+
70
+ The [native gRPC guide](grpc.md) describes protobuf-only native HTTP/2 execution,
71
+ protocol configuration and status/trailer policies. The behavior below describes
72
+ the default Connect profile; portable adapters support Connect and explicitly enabled gRPC-Web unary.
73
+ The [gRPC-Web guide](grpc-web.md) owns its independent media, CORS, bounds and terminal rules.
74
+
75
+ The implemented [Connect profile](https://connectrpc.com/docs/protocol/) is
76
+ unary POST and safe unary GET, identity or opt-in gzip encoding, and `application/proto` or `application/json`.
77
+ JSON also accepts `charset=utf-8`. Clients send protocol version `1`; servers
78
+ accept an absent version and reject any other supplied value. A 200 response
79
+ must match the request codec. Non-application response types produce `unknown`;
80
+ mismatched application codecs and unsupported/unadvertised response encodings produce
81
+ `internal`. JSON errors are only parsed for an appropriate JSON content type.
82
+ Single identity/gzip content codings are case-insensitive; empty, repeated,
83
+ stacked and unknown content codings are rejected. Identity-only servers continue
84
+ to reject gzip. Client response preferences use distinct symbol arrays and
85
+ normalize identity to the last fallback. Server gzip capability negotiates
86
+ supported nonidentity accept tokens before identity, ignoring unknown/quality
87
+ value tokens. Absent accept inherits request coding; empty accept selects identity.
88
+ Repeated accept fields combine in field order. Gzip-selected empty serialization
89
+ produces a valid nonempty member; zero-length HTTP content bypasses inflation
90
+ after header validation. Every concatenated member validates CRC/ISIZE/completion;
91
+ truncation and trailing data fail under an aggregate decoded cap.
92
+
93
+ Other verbs get bare 405 with `Allow: GET, POST` for safe methods, otherwise `POST`; unsupported types, including
94
+ Connect streaming and gRPC variants on portable adapters, get bare 415 with `Accept-Post`.
95
+ Unsupported request encoding returns `unimplemented` listing supported codings. Invalid
96
+ version, timeout, or request decoding returns `invalid_argument`. Limits return
97
+ `resource_exhausted`. Unexpected application exceptions return generic
98
+ `internal`; deliberate `Error` messages are application-controlled safe text.
99
+ Standard Connect-to-HTTP and fallback HTTP-to-Connect tables are used. Null, absent and unrecognized wire codes use the HTTP fallback while preserving
100
+ valid message and details. Malformed bodies, code types or details use the
101
+ HTTP fallback without decoding those error fields. Error details preserve a type name and binary bytes, with
102
+ unpadded base64 on output and validated padded/unpadded input.
103
+
104
+ `Metadata` stores lower-case keys and repeated printable ASCII values and
105
+ rejects protocol/framing overrides and header injection. Binary `-bin` helpers
106
+ handle base64 and comma coalescing. Trailing metadata becomes `trailer-`
107
+ response fields. Other fields are never blindly comma-split. The middleware
108
+ emits separate arrays under `Rack::Lint`, and NetHTTP preserves raw repeated
109
+ response fields. NetHTTP joins outgoing request arrays with comma+space.
110
+ rackup/WEBrick additionally coalesces incoming and response fields; interop
111
+ asserts those observed values explicitly. Received fields outside the metadata
112
+ name/printable-ASCII contract are omitted during extraction, including an
113
+ entire field with any invalid repeated value. Valid adjacent leading/trailing
114
+ fields survive. Protocol validation is separate, and outbound metadata remains
115
+ strictly validated.
116
+
117
+ GET uses the existing codec, compression, deadline and metadata paths. The
118
+ client opts in with `use_get: true`; `get_url_limit: 8192` bounds the complete
119
+ absolute URI before transport dispatch, without fallback. Canonical controls
120
+ are `connect=v1`, optional `base64=1`, optional `compression=gzip`, `encoding`,
121
+ and `message`. Binary/gzip use unpadded URL-safe base64; JSON identity uses
122
+ UTF-8 form encoding. Input accepts padded URL-safe base64, arbitrary parameter
123
+ order, unknown/repeated unknown fields and binary unknown values. Form decoding
124
+ happens once: `+` is space, `%2B` is plus, and `;` is literal data.
125
+
126
+ The bounded parser validates escapes everywhere and rejects duplicate known
127
+ names after name decoding as a local ambiguity policy. Only text message values
128
+ require UTF-8. Query syntax errors can precede codec rejection. Missing or
129
+ unsupported query encoding gets bare 415. Content-Length syntax follows, then
130
+ positive declared/actual content gets bare 415 after at most one requested
131
+ byte. Query compression, version, message presence and deferred timeout errors
132
+ follow in that order. Missing/empty compression means identity; missing/empty
133
+ connect version is accepted. Other supplied versions must be `v1`. Query
134
+ controls supersede irrelevant POST protocol headers. Valid deadlines start
135
+ before parsing and are checked cooperatively throughout processing.
136
+
137
+ Unsafe GET gets bare 405 before query/header/input work. An unknown owned
138
+ suffix can negotiate GET and then return `unimplemented`; it never invokes a
139
+ handler or supplies a fabricated safe Method to observation. Every GET response,
140
+ including bare errors, merges Vary after application/error metadata, preserving
141
+ case-insensitive tokens and wildcard `*`. Cache-Control belongs to the app.
142
+ Native NetHTTP/Async requests have no body or synthetic content headers.
143
+
144
+ `Server.call(query: "")` keeps path and raw query separate. The raw-query default
145
+ is `3 * (4 * ((validated_request_wire_limit + 2) / 3)) + 1024`, including fully
146
+ percent-escaped padded base64 and bounded parameter overhead. It is independent
147
+ of decoded query payload and inflated message caps. Parsing retains only five
148
+ controls, validates ignored values without storing them, and checks expansion
149
+ before appending. Incoming URLs and native serialization already exist before
150
+ these checks; no total allocation bound is claimed.
151
+
152
+ GET request byte observation measures the percent/base64-decoded transport
153
+ payload before inflation. Unknown measurements stay nil; only measured empty
154
+ payloads are zero. No URL, query or payload enters RPC observation. Host/proxy
155
+ logs and generic HTTP tracing remain application-owned. Ruby's official native
156
+ protobuf encoder has no deterministic flag; same-object within-process encoding
157
+ and gzip are tested, but map encodings vary across processes. No semantic or
158
+ cross-process canonical cache-key promise is made.
159
+
160
+ ## Bounds and lifecycle
161
+
162
+ Default serialized/decompressed request/response limits are 4 MiB each.
163
+ Independent wire limits default to the corresponding message limits, preserving
164
+ both bounds for existing callers. `TransportRequest.response_limit` is the
165
+ encoded response read cap. Declared lengths are validated against wire bytes and
166
+ oversized lengths rejected before reading. Unknown-length input stops at the
167
+ first excess byte. Identity satisfies both caps. Outgoing serialization is checked
168
+ before compression; compressed output, including framing/footer, must fit its
169
+ wire cap without fallback. Explicitly larger wire limits permit small-message
170
+ expansion without increasing message bounds.
171
+
172
+ Gzip uses default Zlib gzip-only streams with bounded input and block output,
173
+ checking size before every append. At most one 16 KiB native output callback can
174
+ exceed retained payload, plus native state. Every member/chunk checks cooperative
175
+ expiry; streams close on every outcome. Wire and decoded buffers coexist, and
176
+ native protobuf/JSON serialization can allocate before its output check. Custom
177
+ transports may allocate before returning; core revalidation cannot undo that.
178
+ There is no total allocation, hard CPU or independent header-size guarantee.
179
+
180
+ Server errors remain identity and use the smaller response cap, including the
181
+ safe replacement and empty-429 fallback. Valid gzip peer errors are decoded
182
+ before JSON parsing. Malformed gzip or JSON on non-200 uses HTTP fallback with
183
+ valid metadata/trailers; malformed gzip 200 is internal. Header rejection stays
184
+ internal, local size excess stays resource_exhausted, and actual expiry stays
185
+ deadline_exceeded. No generic error-body rescue changes those classifications.
186
+
187
+ The default timeout is 30 seconds. Explicit `timeout: nil` means no logical
188
+ client deadline, timeout header or expiry watchdog. Native operation waits still
189
+ use the exact `9_999_999.999` second cap, returning `unavailable` on timeout.
190
+ Sustained progress can outlast this per-operation cap. For finite positive
191
+ timeouts, one monotonic client deadline begins before encoding. Remaining timeout milliseconds are
192
+ rounded up and clamped to ten digits without extending actual expiry. NetHTTP
193
+ recomputes the header at dispatch and checks the budget around connection,
194
+ codec and body progress. Each call owns one connection; a finite deadline also owns one watchdog. The
195
+ watchdog closes the owned raw socket on expiry, including during header
196
+ trickle, stalled reads and blocked writes, and is joined on every exit.
197
+ NetHTTP depends on the pinned stdlib BufferedIO socket layout for that close.
198
+ Request preparation finishes before connecting, with an expiry check afterward.
199
+ The connection checks expiry and recomputes the wire timeout immediately before
200
+ dispatch, and rejects Net::HTTP's implicit reconnect of a closed socket. This
201
+ keeps every dispatched byte on the one socket owned by the watchdog.
202
+ TCP/TLS/read/write timeout arguments and watchdog waits are capped at exactly
203
+ `9_999_999.999` seconds, the Connect wire maximum, for safe native time conversion.
204
+ Individual native operations are independently bounded: an operation timeout
205
+ before logical expiry returns `unavailable`, while actual expiry returns
206
+ `deadline_exceeded`. Logical expiry remains unchanged; the watchdog re-evaluates
207
+ it after each capped wait and only closes the socket at actual expiry. Local
208
+ huge-budget calls verify conversions on the exercised plain TCP paths, not an
209
+ actual 115-day duration or runtime TLS behavior.
210
+
211
+ DNS/native resolution may outlast the budget. Net::HTTP's internal connection
212
+ implementation can itself use `Timeout.timeout` on Ruby 3.4; ConnectRPC never
213
+ calls it or asynchronously interrupts application code. TLS handshake timeout
214
+ uses the remaining budget after TCP/DNS. Peer/hostname verification stays on.
215
+ There is no pooling, proxy-env routing, retries, redirect following, automatic
216
+ decompression. NetHTTP constructs requests with the actual explicit accept
217
+ advertisement so the default HTTP gem never transparently inflates or rewrites
218
+ content encoding/length; transports carry opaque encoded bytes.
219
+
220
+ Server timeout validation creates a per-call monotonic deadline. Checks run
221
+ around input, decoding, handler execution and encoding. `cancelled?` combines explicit cancellation and expiry;
222
+ `cancellation_reason` identifies `:canceled` or `:deadline_exceeded`, and handlers
223
+ can call `check!` cooperatively. No header means no server
224
+ expiry. Synchronous Rack cannot interrupt blocked input, arbitrary handlers or
225
+ codec work, or detect every disconnect. No hard server cancellation is claimed.
226
+
227
+ ## Explicit cancellation ownership
228
+
229
+ `CancellationSource` alone initiates permanent cancellation. Its read-only
230
+ `CancellationToken` can be shared across calls. `cancel` returns true only on
231
+ its first transition. Registration and invocation claims are synchronized;
232
+ callbacks run outside locks in the cancelling thread. Already-cancelled
233
+ registration invokes before returning. `unregister` returns true only for a
234
+ pending registration, suppresses even snapshotted pending callbacks and releases
235
+ references. Running/finished/unregistered listeners return false without waiting.
236
+ Listeners must be short and nonblocking; `StandardError` does not stop siblings.
237
+ Internal resource retirement separately guards callbacks that already started.
238
+
239
+ Client and Context each own distinct terminal arbitration. While open, they
240
+ accept an already-expired real deadline first, then directly observed source
241
+ cancellation, then a result/error. Accepted reasons never flip. A source's
242
+ transition requests cancellation; its timestamp does not determine the call's
243
+ serialized winner. Completion directly rechecks source state, including after
244
+ decoding or exceptional codec/transport paths. HTTP response receipt alone
245
+ cannot complete the client. Instrumentation and callbacks run outside state
246
+ locks, and only the executing fibers complete instrumentation. Original failures
247
+ remain available to lifecycle observers; normalized canceled/deadline/transport
248
+ errors have fixed safe messages and no sensitive rescued cause.
249
+
250
+ `TransportRequest.deadline` remains the actual Deadline or nil.
251
+ `TransportRequest.cancellation` implements `_Cancellation`: `cancelled?`,
252
+ `reason`, `check!`, and `on_cancel`. Transports only observe this call-owned SPI;
253
+ they cannot complete the client. Server accepts the same read-only seam and
254
+ creates its own processing arbitration. Testing forwards it without completing
255
+ the client's state. Context exposes its combined reason, with no token reader.
256
+
257
+ NetHTTP installs socket ownership before startup, attaches raw TCP at the TLS
258
+ hook or after plain startup, and closes a newly exposed socket immediately if
259
+ cancellation already won. TLS/read/write/header waits are interruptible; native
260
+ DNS/TCP before exposure remains cooperative. Per-call retirement prevents late
261
+ callbacks from accessing completed/recycled resources. Nil deadlines have no
262
+ watchdog or wire timeout; the exact independent native cap remains
263
+ `9_999_999.999`. Retry/reconnect guards and TLS verification remain enabled.
264
+
265
+ Async foreign-thread listeners only resolve a tagged Promise. Its owner reactor
266
+ arbitrates, stops private operation/setup tasks and performs bounded cleanup.
267
+ Promise resolution order does not decide the RPC outcome. Healthy stream reset
268
+ preserves the shared connection and siblings; a stuck deferred frame retains the one
269
+ 100 ms cleanup budget and raw-first whole-connection fallback.
270
+
271
+ Native HTTP/2 creates a request cancellation source before processing,
272
+ independently of instrumentation. Only peer RST_STREAM with CANCEL (0x8) signals
273
+ it, before upstream input teardown can resume reads. Exceptional input paths
274
+ record their original failure and check cancellation before normalization.
275
+ Processing detaches this source at completion; delivery observation continues
276
+ independently. NO_ERROR/other resets, END_STREAM, half-close, EOF, connection loss
277
+ and host/fatal-owner abort add no explicit cancellation guarantee. Portable
278
+ Rack/Rails and native Falcon HTTP/1 add no token injection or peer detection.
279
+
280
+ ## Evidence and future direction
281
+
282
+ Specs exercise real custom bindings/transports/invokers, ProtoJSON mapping,
283
+ Rack::Lint success and errors, real localhost HTTP, byte limits and lifecycle.
284
+ The pinned connect-go Client and Handler verify both cross-language directions
285
+ and codecs against one shared descriptor fixture, including details,
286
+ host-visible metadata and a request limit. The same Go client scenarios also
287
+ run against the Go server as a reference baseline, with raw repeated Go fields
288
+ instead of WEBrick's coalesced values. There are 18 fixture cases: six reference
289
+ baseline cases and twelve cross-language cases. This is a unary subset, not
290
+ full conformance. The separate [official conformance harness](../conformance/README.md)
291
+ requires 96 client and 90 server identity passes, including two GET successes
292
+ in each direction. A separate gzip inventory requires 88 client and 82 server
293
+ passes. All four caller-cancellation cases pass unwaived with actual request
294
+ writes and socket cleanup, for 184 client and 172 server passes in total.
295
+ Separate Go gzip scenarios
296
+ exercise both directions/codecs, negotiation, errors and bounds with wire
297
+ observations. The pinned Go client rewrites accept preferences, so independent
298
+ raw Go HTTP requests additionally prove gzip-request/identity-response behavior.
299
+ The pinned Go server prefers request gzip over explicit identity acceptance;
300
+ Ruby clients reject that unadvertised response. There is no full-conformance claim.
301
+
302
+ Wire protocol, HTTP transport, and message binding remain separate choices.
303
+ [Native gRPC](grpc.md) and [gRPC-Web](grpc-web.md) have distinct implemented
304
+ profiles. Interceptors and Faraday remain future work; production readiness
305
+ remains subject to the support contract's adoption gates. Unsupported
306
+ modes/options are rejected rather than represented as buffered streaming.
307
+
308
+ The optional [connectrpc-on-rails gem](../gems/connectrpc-on-rails/README.md) uses the
309
+ invoker boundary to run native controller callbacks, authentication,
310
+ `rescue_from` and instrumentation after bounded core decode. It depends on `connectrpc-rack` for Rack translation and keeps hosting
311
+ dependencies out of core. It preserves native
312
+ CSRF/session policy, reports unexpected errors through Rails.error and keeps
313
+ generated files non-reloadable outside Zeitwerk paths. A mounted Rack app alone
314
+ does not supply the Rails controller lifecycle.
315
+ See the [Rails controller guide](https://guides.rubyonrails.org/action_controller_overview.html).
316
+
317
+ The optional [connectrpc-async package](../gems/connectrpc-async/README.md)
318
+ selects Async HTTP/2 for standalone unary/streaming hosting and a matching transport.
319
+ It uses the existing transport/server seams, generated definitions and Google
320
+ binding without changing core Context or wire contracts. The application owns
321
+ the reactor and close lifecycle. Private, version-bounded wrappers own writes,
322
+ stream cancellation, request tasks and wire-complete drain; no frame/HPACK
323
+ engine or global monkeypatch is added. Real h2c and verified TLS `h2` are
324
+ required. Full TLS snapshots enable required client authentication, same-listener
325
+ credential reload and older-generation retirement. The exact-task client leaf
326
+ accessor is the optional authorization seam; peer-presented chains are not
327
+ claimed to be verified chains. See the package guide for session isolation,
328
+ cleanup's 100ms allowance and deliberate whole-connection failure for a stuck
329
+ frame. Native streaming uses the same ingress and cleanup boundaries. No production daemon claim is made.
330
+ The H2 client's private, reactor-owned connection manager runs shared connection
331
+ attempts outside every RPC lifecycle and owns stream accounting, capped jittered
332
+ backoff, opt-in wait-for-ready and count/request-byte admission. A call returns
333
+ to acquisition only when its connection became unusable before any stream ID or
334
+ request byte; nothing that may have reached the server is resent. The package
335
+ guide describes the policy, states and observer events.
336
+
337
+ Connect streaming carries end-stream metadata in its body; native gRPC requires
338
+ actual final HTTP/2 HEADERS, as described in the [gRPC guide](grpc.md).
339
+ For Connect, the Async backend preserves leading repeated fields and leading
340
+ `trailer-*` metadata while ignoring actual trailing HEADERS after backend validation.
341
+ It reconstructs lengths normalized by the backend; malformed lengths and some
342
+ singleton repeats can fail before core. Conservative frame/window settings do
343
+ not provide a hard process-memory/header-size guarantee. Independent connect-go
344
+ unary h2c/TLS/mTLS results are separate from core's identity and gzip conformance
345
+ profiles. Combined gzip specs exercise opaque bytes and wire/decompressed bounds
346
+ over the actual Async host and transport. NetHTTP remains HTTP/1.1; each future
347
+ transport needs its own verified capability scope.
348
+
349
+ The reusable `ConnectRPC::Async::Application` translates native Protocol::HTTP
350
+ requests independently of hosting. `TLSConfiguration` owns complete versioned
351
+ provider results and immutable worker-local compilation; `ServerSession` owns
352
+ raw-accept generation capture, setup/admission, exact-task identity and reviewed
353
+ HTTP/2 resource cleanup. The strict standalone Host composes that session.
354
+ The optional [Falcon package](../gems/connectrpc-falcon/README.md) composes it with
355
+ Falcon's process workers, raw endpoints and HTTP server/run lifecycle. Its local
356
+ HTTP/1 resource uses upstream parse/write hooks and preserves wire completion
357
+ and callback boundaries during retirement. A private response-body EOF marker
358
+ pairs with the pinned writer's next successful final flush to establish one
359
+ absolute 500 ms final-close expiry before callbacks. Successfully delivered
360
+ retiring Connect responses half-close raw TCP writes, retain the original
361
+ 100 ms / 256 KiB bounded discard, then wait only the remaining total budget
362
+ without further reads. Admission/socket ownership joins original processing and
363
+ retirement; owner shutdown can shorten either phase. These cooperative timers
364
+ can overshoot under scheduler delay and do not guarantee peer receipt. The
365
+ independent Web rejected-input policy is unchanged. Only explicit Rack fallback
366
+ receives Falcon's generic encoding/cache middleware. Native Connect streaming hosts
367
+ select explicit core shape capabilities. See the package guide for source failure, expiry, worker
368
+ stop and unsupported socket-ownership transfer policies.
369
+
370
+ The explicit optional `connectrpc/generator` entry point implements the standard
371
+ protoc request/response protocol through official `google/protobuf/plugin_pb`.
372
+ The extensionless executable reads raw protobuf to EOF, with no conformance
373
+ length framing. Malformed protobuf, missing/incompatible Google and CLI arguments
374
+ fail on stderr with nonzero status and empty stdout. Unsupported descriptors
375
+ return `CodeGeneratorResponse.error`, no partial files and exit zero. Unexpected
376
+ implementation exceptions remain visible. Only `FEATURE_PROTO3_OPTIONAL` is
377
+ advertised; editions are unsupported.
378
+
379
+ Each service-bearing target emits `_connect.rb`, requiring core, its official
380
+ `_pb.rb` and any additionally referenced message files. Imported streaming
381
+ services are not targets. Generated modules
382
+ contain frozen RPC declaration constants, `SERVICE`, a `Client` initialized with
383
+ `client:`, and `.handlers` with required callable RPC keywords. Unary wrappers forward `call(METHOD, request, **options)` unchanged. Streaming
384
+ wrappers forward the matching scoped operation, its required block and all options.
385
+ Partial handlers/custom invocation remain core Server features. No framework,
386
+ transport configuration or binding auto-require is added.
387
+ Optional [RBS signatures](rbs.md) preserve these runtime boundaries and require
388
+ `rbs=true` for service signature generation.
389
+ Concrete types come from the official generated descriptor pool, preserving
390
+ nested classes, imports, proto2/proto3 presence and `ruby_package` mappings.
391
+
392
+ Naming follows protoc 36.2's
393
+ [RubifyConstant and PackageToModule](https://github.com/protocolbuffers/protobuf/blob/v36.2/src/google/protobuf/compiler/ruby/ruby_generator.cc):
394
+ message/service names preserve underscores and use `PB_` for a nonalphabetic
395
+ initial character; package underscores delimit capitalized words. An explicit
396
+ `ruby_package` containing `::` is used component-wise; otherwise dot/package
397
+ conversion applies. RPC names use the gRPC Ruby acronym and lower/digit-to-upper
398
+ underscore rule. Keyword/inherited wrapper names (including private copy/object
399
+ hooks and implicit conversions), leading-underscore RPCs,
400
+ `BEGIN`/`END`, reserved constants, unsafe paths, unresolved/non-message types,
401
+ duplicates and namespace/output collisions fail before emitting files.
402
+ All runtime constant references are absolute. Namespace checks index the request's
403
+ package/message/enum graph and target services, including Ruby aliases and
404
+ class/module conflicts. Fixed runtime protection applies to package ancestors,
405
+ official message/enum assignments and target service declarations in the loaded
406
+ import/type closure. Compatible `Google`/`Google::Protobuf` package modules may
407
+ be shared, including equivalent `ruby_package` mappings. Direct replacement of
408
+ those modules, reserved roots such as `ConnectRPC`/`File`, and concrete protobuf
409
+ runtime names such as `Google::Protobuf::DescriptorPool` are rejected. Known
410
+ official protobuf types are accepted only under their canonical file, proto name
411
+ and Ruby namespace, preserving WKT imports without allowing application types to
412
+ replace them. The fixed policy follows the pinned protobuf sources, rather than
413
+ reflecting the generator process's constants. A service cannot merge with
414
+ another package. No global registry spans generator invocations.
415
+ Generated files guard directly defined `SERVICE`, `Client` and RPC constants;
416
+ normal `require` is idempotent, while deliberate `load`/redefinition fails clearly.
417
+ Application constants outside the request still need compatible namespaces.
418
+
419
+ A Rust descriptor-driven
420
+ backend using [prost-reflect](https://docs.rs/prost-reflect/latest/prost_reflect/)
421
+ remains an option; cross-backend message-class compatibility is not promised.
422
+ No custom protobuf parser is implemented. See the
423
+ [Ruby generated-code guide](https://protobuf.dev/reference/ruby/ruby-generated/)
424
+ and [Rack specification](https://github.com/rack/rack/blob/v3.2.7/SPEC.rdoc).
425
+
426
+ See the optional [in-process testing package](../gems/connectrpc-testing/README.md) for real encoded client/server round trips without sockets.
427
+
428
+ ## RPC lifecycle observation
429
+
430
+ `Instrumentation.new(subscribers:, on_error: nil)` freezes a copied subscriber
431
+ list. `start(call)` returns a per-call observer. Optional callbacks are fixed:
432
+ `attach` returns a local activation token; `detach(token)` unwinds successful
433
+ attachments in reverse registration order; `processing_finished(outcome)`
434
+ receives an immutable final processing snapshot; `finished(completion)` receives
435
+ one terminal processing/delivery observation. Core owns one application yield.
436
+ Each callback/reporter StandardError is isolated. Every successful attachment
437
+ token receives one detach attempt, including when another detach raises a
438
+ fatal exception or throws to a matching catch. Returning detaches preserve the
439
+ escaping exit; competing cleanup exits follow ordinary Ruby ensure semantics.
440
+ Observers must make attach/detach exception-safe: the dispatcher cannot repair
441
+ private context state mutated before a failing attach returns a token or
442
+ guarantee that a failing detach restores its own state. This cleanup does not
443
+ guarantee completion under arbitrary repeated asynchronous interruption.
444
+ Retained observers hold no globally active core context.
445
+
446
+ Call facts contain role, Connect protocol, recognized immutable Method or nil,
447
+ client endpoint host/port and a propagation carrier, never message objects.
448
+ The client starts before timeout/metadata validation and applies validated staged
449
+ injection transactionally to copied metadata. Server extraction uses defensive
450
+ read-only headers without moving validation ahead of 405. Outcomes distinguish
451
+ original exceptions, normalized/replacement exceptions, emitted Connect code,
452
+ HTTP status, monotonic processing duration and measured encoded byte counts.
453
+ Unknown byte counts are nil. Metadata snapshots retain repeated arrays.
454
+
455
+ `Server#start_call(path:, headers:, http_method: "POST")` is the explicit host
456
+ SPI. Hosts pass the actual method so Connect GET selection precedes subscriber
457
+ start, regardless of ignored POST content headers. It resolves a known method
458
+ and starts observers without reading input or validating protocol. The default
459
+ preserves POST behavior for existing callers; subclass overrides must accept
460
+ and forward the new keyword.
461
+ `Server#call(..., lifecycle: handle)` consumes that Server/path-bound handle once
462
+ and leaves terminal ownership to the host. `handle.activate { ... }` scopes
463
+ context to the executing task; `handle.finish(delivery:, error: nil,
464
+ decision: :processed, http_status: nil)` terminates idempotently. Ordinary
465
+ Server#call owns start/processing/unobserved completion. Optional
466
+ `TransportRequest.lifecycle` lets a transport activate independent RPC tasks.
467
+ `Context#record_failure(error)` retains the first original escaping failure
468
+ before framework normalization. It does not itself complete a call.
469
+
470
+ Processing state (`not_started`, `running`, `finished`), admission/host decision
471
+ and delivery observation are independent. A host abort while processing runs
472
+ is latched until processing cleanup, with processing callback before terminal
473
+ callback. Terminal state is committed before observers run, preventing reentry.
474
+ Admission-only calls have no fabricated processing result. Portable Rack
475
+ response-finished callbacks take precedence over body close and have their own
476
+ label; they never claim native full-write completion. A consumer that neither
477
+ closes a body nor runs promised callbacks can leave observation unfinished.
478
+ Native Async/Falcon own explicit request exchanges before admission, independent
479
+ handler activation and write/reset/cleanup evidence. Connection setup before an
480
+ RPC exists remains outside this scope. See the
481
+ [OTel guide](../gems/connectrpc-otel/README.md) for trace semantics, privacy,
482
+ HTTP instrumentation nesting and synchronous processor costs.
483
+
484
+ ## Explicit HTTP/1.1 client ownership
485
+
486
+ The Async package's independent `HTTP1Transport` uses exclusive reactor-owned
487
+ leases for Connect unary, safe GET and SS/CS. Reservations precede yielding
488
+ TCP/TLS setup; retiring resources count toward the cap until raw close and task
489
+ joins finish. Idle reuse requires normal writer completion, full HTTP EOF,
490
+ successful cleanup and native persistent/idle state. A nonblocking acquisition
491
+ probe retires FIN or unsolicited data before dispatch. No failed call is replayed.
492
+
493
+ A private H1 stream separates response reads from output flushing and caps native
494
+ body fragments at 16 KiB. Strict HTTP/1.1 status syntax preserves three-digit
495
+ extension statuses for core fallback. Framing validation precedes native header
496
+ mutation; leading metadata is snapshotted before chunk trailers. Core retains
497
+ terminal decoding and send/error arbitration. Complete unary non-200 responses
498
+ stop unfinished uploads destructively and return to core; HTTP 200 requires
499
+ writer success. Cleanup failures cannot turn into successful completion.
500
+
501
+ TLS offers only `http/1.1`, accepts nil or H1 ALPN, and rejects other negotiation
502
+ before dispatch. Hostname/peer verification and mTLS remain enabled. The shared
503
+ private TLS helper retains H2's existing default. H1 changes no server runtime
504
+ policy, H2 connection ownership, or Web rejection behavior.