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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +501 -0
- data/docs/design.md +504 -0
- data/docs/development.md +566 -0
- data/docs/grpc-web.md +165 -0
- data/docs/grpc.md +75 -0
- data/docs/rbs.md +469 -0
- data/docs/streaming.md +251 -0
- data/exe/protoc-gen-connectrpc-ruby +23 -0
- data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
- data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
- data/lib/connectrpc/cancellation.rb +220 -0
- data/lib/connectrpc/client.rb +260 -0
- data/lib/connectrpc/compression.rb +120 -0
- data/lib/connectrpc/deadline.rb +81 -0
- data/lib/connectrpc/error.rb +72 -0
- data/lib/connectrpc/generator/names.rb +105 -0
- data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
- data/lib/connectrpc/generator/renderer.rb +115 -0
- data/lib/connectrpc/generator/runtime_names.rb +84 -0
- data/lib/connectrpc/generator/schema.rb +218 -0
- data/lib/connectrpc/generator/streaming_methods.rb +29 -0
- data/lib/connectrpc/generator.rb +42 -0
- data/lib/connectrpc/get_query.rb +159 -0
- data/lib/connectrpc/grpc.rb +200 -0
- data/lib/connectrpc/grpc_client.rb +23 -0
- data/lib/connectrpc/grpc_server.rb +158 -0
- data/lib/connectrpc/grpc_web.rb +131 -0
- data/lib/connectrpc/grpc_web_cors.rb +135 -0
- data/lib/connectrpc/grpc_web_io.rb +156 -0
- data/lib/connectrpc/grpc_web_server.rb +207 -0
- data/lib/connectrpc/instrumentation.rb +216 -0
- data/lib/connectrpc/message_io.rb +47 -0
- data/lib/connectrpc/metadata.rb +105 -0
- data/lib/connectrpc/method.rb +68 -0
- data/lib/connectrpc/protocol.rb +189 -0
- data/lib/connectrpc/server.rb +398 -0
- data/lib/connectrpc/streaming/client.rb +104 -0
- data/lib/connectrpc/streaming/client_execution.rb +87 -0
- data/lib/connectrpc/streaming/client_scope.rb +105 -0
- data/lib/connectrpc/streaming/framing.rb +123 -0
- data/lib/connectrpc/streaming/framing_io.rb +61 -0
- data/lib/connectrpc/streaming/method.rb +33 -0
- data/lib/connectrpc/streaming/server.rb +122 -0
- data/lib/connectrpc/streaming/server_execution.rb +88 -0
- data/lib/connectrpc/streaming/server_terminal.rb +70 -0
- data/lib/connectrpc/streaming/session.rb +143 -0
- data/lib/connectrpc/streaming/session_completion.rb +134 -0
- data/lib/connectrpc/streaming/session_io.rb +107 -0
- data/lib/connectrpc/streaming/session_messages.rb +119 -0
- data/lib/connectrpc/streaming/strategy.rb +95 -0
- data/lib/connectrpc/streaming/streams.rb +116 -0
- data/lib/connectrpc/streaming/terminal.rb +52 -0
- data/lib/connectrpc/transports/net_http.rb +246 -0
- data/lib/connectrpc/version.rb +5 -0
- data/lib/connectrpc.rb +38 -0
- data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
- data/sig/connectrpc.rbs +772 -0
- data/sig/manifest.yaml +2 -0
- data/third_party/README.md +16 -0
- data/third_party/google/LICENSE +201 -0
- data/third_party/google/rpc/status.proto +48 -0
- data/third_party/protobuf-LICENSE +32 -0
- metadata +106 -0
data/docs/streaming.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Native RPC streaming
|
|
2
|
+
|
|
3
|
+
The [supported 0.x contract](support.md) defines the target compatibility
|
|
4
|
+
boundary and the remaining adoption gates.
|
|
5
|
+
|
|
6
|
+
Connect server streaming, client streaming and bidirectional streaming use the
|
|
7
|
+
same dependency-free core engine. The optional Async transport and Async/Falcon
|
|
8
|
+
native HTTP/2 hosts support protobuf and ProtoJSON, identity/gzip, h2c, verified
|
|
9
|
+
TLS and mTLS. Falcon also serves Connect server and client streaming over
|
|
10
|
+
HTTP/1.1 with both codecs and identity/gzip over TCP/TLS/mTLS. The explicit
|
|
11
|
+
`ConnectRPC::Async::HTTP1Transport` provides unary, SS and CS on the owning
|
|
12
|
+
reactor. The standalone Async Host and original Async Transport remain H2-only. NetHTTP, Rack, Rails and Testing remain unary.
|
|
13
|
+
Unsupported clients reject a streaming operation before encoding or I/O. Native
|
|
14
|
+
listeners explicitly select their actual protocol capabilities. Portable adapters reject streaming
|
|
15
|
+
media before reading input or invoking handlers, including Rails callbacks.
|
|
16
|
+
|
|
17
|
+
Native gRPC reuses the declarations and session engine below, including unary
|
|
18
|
+
calls through the exchange SPI. Its protobuf-only negotiation, final HTTP/2
|
|
19
|
+
HEADERS, status codec and independent bounds are described in [gRPC](grpc.md).
|
|
20
|
+
The Connect terminal envelope and one-second tail policy below apply to Connect.
|
|
21
|
+
|
|
22
|
+
## Falcon HTTP/1.1 server
|
|
23
|
+
|
|
24
|
+
Custom hosts opt in with `Server#call_stream(capabilities: [:server_stream,
|
|
25
|
+
:client_stream], ...)`. The default `capabilities: nil` retains portable
|
|
26
|
+
rejection; legacy `http2: true` grants all three shapes. Explicit capabilities
|
|
27
|
+
must be a nonempty, unique Array of supported shape symbols and cannot be
|
|
28
|
+
combined with `http2: true`. Omitted SS/CS returns bounded `unimplemented`;
|
|
29
|
+
omitted known bidi returns host-neutral bare 505 before input or codecs.
|
|
30
|
+
Falcon turns every native H1 bidi 505 into a nonpersistent response with exactly
|
|
31
|
+
one `Connection: close`. Web negotiation and CORS retain precedence. Native
|
|
32
|
+
gRPC remains H2-only, and HTTP/1.0 Connect streaming is rejected.
|
|
33
|
+
|
|
34
|
+
The original processor retains handlers, codecs, authenticated peer identity,
|
|
35
|
+
trace activation and application cleanup. Response consumers transmit prepared
|
|
36
|
+
bytes. Headers can publish during input reads, but successful HTTP terminal
|
|
37
|
+
chunk delivery waits for original application cleanup. Leading metadata and
|
|
38
|
+
propagation exclude actual chunked trailers, including `connect-timeout-ms`.
|
|
39
|
+
H1 peer EOF does not synthesize Context cancellation.
|
|
40
|
+
|
|
41
|
+
When original framed input is incomplete at response-head resolution, Falcon
|
|
42
|
+
disables reuse and delivers the complete response before best-effort input
|
|
43
|
+
hygiene. Hygiene has one absolute 100 ms allowance and a 256 KiB entity-byte cap,
|
|
44
|
+
including the adapter remainder. Every additional framed read reserves a full
|
|
45
|
+
64 KiB, so retirement can stop below the cap. Progress never restarts the timer;
|
|
46
|
+
Connect does not use `grpc-timeout`.
|
|
47
|
+
|
|
48
|
+
A successfully delivered response on a retiring connection uses one absolute
|
|
49
|
+
final-close expiry at the actual final HTTP flush plus 500 ms. A private body EOF
|
|
50
|
+
marker anchors the pinned writer's next successful final flush before body-close
|
|
51
|
+
callbacks or lifecycle observation; payload bytes cannot trigger it. Delayed
|
|
52
|
+
callbacks consume the same total budget. After completed delivery, Falcon
|
|
53
|
+
half-closes raw TCP writes and performs bounded discard. When discard stops,
|
|
54
|
+
Falcon waits only the remaining part of that original 500 ms budget, without
|
|
55
|
+
reading more. It does not add another 500 ms after discard. Earlier owner
|
|
56
|
+
abort/shutdown can shorten either phase. Failed or incomplete delivery gets no
|
|
57
|
+
success grace. Late hygiene/grace failures preserve completed processing and
|
|
58
|
+
delivery facts.
|
|
59
|
+
|
|
60
|
+
Admission and socket ownership remain held until retirement and original
|
|
61
|
+
processing join. Retirement never parses or dispatches a pipelined request,
|
|
62
|
+
even if the original request finishes. Fully consumed nonretiring input retains
|
|
63
|
+
persistence. Raw socket abort precedes framed/Finishable close on forced teardown.
|
|
64
|
+
Both timers are cooperative and can overshoot under scheduler delay. Remaining
|
|
65
|
+
grace retains resources and is best effort reset avoidance, not a guarantee of
|
|
66
|
+
response delivery under arbitrary network or peer failure.
|
|
67
|
+
|
|
68
|
+
The production official H1 profiles pass 174/174 identity and 160/160 gzip cases
|
|
69
|
+
on both pinned Rubies, including the original oversized CS cases over TCP/TLS.
|
|
70
|
+
No official assertion or inventory changed. This is a bounded, response-first variant of
|
|
71
|
+
Go's conditional reset avoidance: connect-go may consume the full oversized
|
|
72
|
+
declared message, and net/http may drain before flushing or reuse a connection.
|
|
73
|
+
Literal Go parity is not claimed. Native H1 Web retains its separate policy:
|
|
74
|
+
original input cleanup gates the staged Web terminal, and an incomplete upload
|
|
75
|
+
also gates a rejection head to avoid stopping the client's upload. This Web-only
|
|
76
|
+
gate never delays Connect response delivery. The historical Web TCP/TLS failures,
|
|
77
|
+
diagnosis and separate baseline repair are recorded in the
|
|
78
|
+
[conformance status](../conformance/README.md#connect-http11-server-profiles).
|
|
79
|
+
|
|
80
|
+
## Completion policy
|
|
81
|
+
|
|
82
|
+
After a terminal envelope, the client requires HTTP EOF and transport completion
|
|
83
|
+
within one **absolute second**, with at most one extra byte read. Any extra byte
|
|
84
|
+
is a protocol error. A stalled tail is `unavailable`, including with `timeout: nil`.
|
|
85
|
+
A shorter real RPC deadline or explicit cancellation can win first. Otherwise
|
|
86
|
+
valid responses with late EOF or slow intermediaries can be rejected. This is
|
|
87
|
+
this library's interoperability policy, not a Connect requirement, RPC deadline,
|
|
88
|
+
wire timeout header or general stream watchdog.
|
|
89
|
+
|
|
90
|
+
An incidental send I/O failure permits terminal-only recovery of at most 262,144
|
|
91
|
+
additional wire body bytes in one absolute 100 ms, including response headers,
|
|
92
|
+
buffered remainders, prefixes, skipped data, terminal tail and transport finish.
|
|
93
|
+
Recovery skips data incrementally without decoding or inflating it. It cannot
|
|
94
|
+
steal a live reader or resume normal reading after discarding data. Only a valid
|
|
95
|
+
remote error with verified HTTP tail can replace the generic write failure.
|
|
96
|
+
Remote success cannot erase an unhandled send failure; an application may handle
|
|
97
|
+
a send failure and complete its normal response path. Recovery and the existing
|
|
98
|
+
100 ms destructive cleanup stage are separate, up to 200 ms together. Native or
|
|
99
|
+
application code that blocks the reactor remains outside hard timing guarantees.
|
|
100
|
+
|
|
101
|
+
## Clients and declarations
|
|
102
|
+
|
|
103
|
+
`ServerStreamMethod[Q,R]`, `ClientStreamMethod[Q,R]` and `BidiStreamMethod[Q,R]`
|
|
104
|
+
are invariant siblings of unary `Method[Q,R]`. A mixed `Service` can contain all
|
|
105
|
+
four. Each operation requires a block:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
client.server_stream(watch, request, timeout: nil) do |reader|
|
|
109
|
+
reader.headers # Waits for leading metadata without consuming a message.
|
|
110
|
+
reader.each { |message| consume(message) }
|
|
111
|
+
reader.trailers
|
|
112
|
+
end # => nil
|
|
113
|
+
|
|
114
|
+
response = client.client_stream(collect) do |writer|
|
|
115
|
+
inputs.each { |message| writer.send_message(message) }
|
|
116
|
+
end # Auto close_send, exactly one response, terminal and tail, then Response[R].
|
|
117
|
+
|
|
118
|
+
client.bidi_stream(chat, cancellation: source.token) do |duplex|
|
|
119
|
+
duplex.send_message(request)
|
|
120
|
+
reply = duplex.receive
|
|
121
|
+
duplex.close_send
|
|
122
|
+
duplex.each { |message| consume(message) }
|
|
123
|
+
end # => nil
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Generated mixed-service wrappers preserve these blocks, all options, actual nil
|
|
127
|
+
timeouts and cancellation tokens. The same configured `Client(use_get: true)`
|
|
128
|
+
uses GET for eligible unary calls and POST for streaming, without mode changes,
|
|
129
|
+
retry, fallback or resend. Legacy `mode: :unary` remains accepted.
|
|
130
|
+
|
|
131
|
+
`send_message` returns nil after bounded local ownership transfer, not wire
|
|
132
|
+
flush or peer receipt. Nil is forbidden as a streaming message. `receive` returns
|
|
133
|
+
nil only after successful validated EOF; an empty/default protobuf is a message.
|
|
134
|
+
`each` is block-only. `close_send` is idempotent and sends HTTP END_STREAM, never
|
|
135
|
+
a request terminal. Sending afterward fails locally before codecs or I/O.
|
|
136
|
+
|
|
137
|
+
One reader and one writer may run concurrently on the owning reactor/thread.
|
|
138
|
+
Overlapping operations in the same direction fail locally. Applications own
|
|
139
|
+
explicit Async concurrency; no hidden producer thread or stream Enumerator is
|
|
140
|
+
created. SS/bidi ordinary exit without EOF raises `StreamNotConsumedError` and
|
|
141
|
+
aborts. Explicit `abandon` allows intentional SS/bidi exit without claiming
|
|
142
|
+
success; CS abandonment raises because no Response exists. Nonlocal break/return
|
|
143
|
+
keeps Ruby semantics and runs bounded abandonment. No abandonment drain occurs.
|
|
144
|
+
Escaped I/O fails locally; already captured metadata remains readable. Independent
|
|
145
|
+
user exceptions retain identity/backtrace over cancellation or cleanup. SDK
|
|
146
|
+
operation failures retain call arbitration. A call never completes its shared
|
|
147
|
+
application cancellation source.
|
|
148
|
+
|
|
149
|
+
## Handlers and publication
|
|
150
|
+
|
|
151
|
+
Handlers receive `(Q, ResponseWriter[R], StreamingContext)` for SS,
|
|
152
|
+
`(RequestReader[Q], StreamingContext) -> R` for CS, and
|
|
153
|
+
`(ServerDuplex[Q,R], StreamingContext)` for bidi. SS/bidi return values are ignored;
|
|
154
|
+
only `send_message` emits a response. SS validates exactly one input and request
|
|
155
|
+
EOF before invocation. CS success requires exactly one response. Cardinality
|
|
156
|
+
violations are `unimplemented`.
|
|
157
|
+
|
|
158
|
+
`context.send_headers(metadata: {})`, also delegated by response writers and
|
|
159
|
+
server duplexes, publishes once. It can precede a handler delay or CS input.
|
|
160
|
+
First payload/terminal implicitly publishes. Leading metadata is deeply frozen
|
|
161
|
+
at publication; trailers remain mutable until terminal serialization, then freeze.
|
|
162
|
+
There is no synthetic nil message for publishing headers.
|
|
163
|
+
|
|
164
|
+
The original processor task owns authenticated peer identity, cancellation and
|
|
165
|
+
instrumentation through handlers, codecs and cleanup. A separate publication
|
|
166
|
+
promise exposes response headers while processing continues. Body tasks consume
|
|
167
|
+
prepared bytes. Successful HTTP EOF waits for original processor completion;
|
|
168
|
+
late cleanup failure aborts/reset. Admission and graceful drain include processing,
|
|
169
|
+
wire tail and cleanup. Only actual peer RST_STREAM(CANCEL) signals explicit server
|
|
170
|
+
Context cancellation. EOF, half-close, other reset codes and host shutdown do not.
|
|
171
|
+
|
|
172
|
+
## Bounds and protocol
|
|
173
|
+
|
|
174
|
+
Each direction retains at most one queued 16,384-byte output fragment and one
|
|
175
|
+
writer-owned in-flight fragment. One current encoded and decoded message,
|
|
176
|
+
5-byte prefix and at most one 16 KiB input remainder are separate allocations.
|
|
177
|
+
Native ingress retains at most 65,536 bytes in at most five entries, with chunks
|
|
178
|
+
at most 16,384 bytes. Stream credit follows consumption/discard; connection credit
|
|
179
|
+
allows healthy siblings/control traffic to advance. These are payload accounting
|
|
180
|
+
bounds, not RSS, CPU, HPACK, socket/TLS, caller retention or native codec guarantees.
|
|
181
|
+
|
|
182
|
+
`request_limit`, `response_limit` and their independent wire limits apply per
|
|
183
|
+
message (default 4 MiB). `terminal_wire_limit` and `terminal_limit` independently
|
|
184
|
+
default to 65,536 bytes, excluding prefix; JSON nesting is limited to 64. Lengths
|
|
185
|
+
are checked before payload allocation. Oversized terminals become a bounded
|
|
186
|
+
`resource_exhausted` without details/trailers, or reset if the minimal error cannot
|
|
187
|
+
fit. Observation retains original and actual replacement failures separately.
|
|
188
|
+
|
|
189
|
+
Flags 0/1 carry data, flag 2 an uncompressed response terminal. Compressed terminal
|
|
190
|
+
3 is accepted only with negotiated gzip. Request terminal and reserved bits are
|
|
191
|
+
`invalid_argument` before compression checks. The chosen pinned-reference policy
|
|
192
|
+
maps request DATA flag 1 under identity/absent coding to `internal`. Other malformed
|
|
193
|
+
request framing/truncation/gzip is `invalid_argument`; malformed responses are
|
|
194
|
+
`internal`. Bounds remain `resource_exhausted`. Per-message gzip state is independent.
|
|
195
|
+
|
|
196
|
+
Bounded tolerant terminal input accepts `error: null`, and maps absent/null/unknown
|
|
197
|
+
string codes to `unknown`, preserving valid message/details. Numeric codes,
|
|
198
|
+
nonobjects, malformed details and metadata containers fail. Invalid individual
|
|
199
|
+
metadata fields are omitted. Output always uses valid Connect terminal errors.
|
|
200
|
+
A terminal is only a candidate until its HTTP tail and transport cleanup succeed.
|
|
201
|
+
|
|
202
|
+
## Custom transport SPI
|
|
203
|
+
|
|
204
|
+
Unary `_Transport#call`, `TransportRequest`, `TransportResponse` and `Client#call`
|
|
205
|
+
remain compatible. Streaming requires explicit `streaming_capabilities` and
|
|
206
|
+
`open_stream(StreamRequest) { |exchange| ... }`. The exchange is writable after
|
|
207
|
+
request-head dispatch **before** waiting for response headers. It provides
|
|
208
|
+
`response_head(timeout: nil)`, bounded `read(n, timeout: nil)`, `write(bytes)`,
|
|
209
|
+
`close_send`, `finish(timeout: nil)` and `abort`. Timeouts cap each requested wait;
|
|
210
|
+
the core retains the absolute budget across calls. `finish` attests owned transport
|
|
211
|
+
completion and cleanup, never peer receipt. See [RBS](rbs.md) for the typed SPI,
|
|
212
|
+
records, normal package discovery and explicit framework compatibility bridges.
|
|
213
|
+
|
|
214
|
+
Explicit gRPC-Web server streaming uses the same declarations/session ownership
|
|
215
|
+
on native Async H2 and Falcon H1/H2, with a staged body terminal. Browser
|
|
216
|
+
incrementality uses text; binary XHR buffers. Portable adapters remain unary.
|
|
217
|
+
See the [Web guide](grpc-web.md) for its capability SPI and identity-only bounds.
|
|
218
|
+
|
|
219
|
+
## Explicit HTTP/1.1 client
|
|
220
|
+
|
|
221
|
+
Use `ConnectRPC::Async::HTTP1Transport` from the existing Async package through
|
|
222
|
+
the core or generated client's `transport:` option. It advertises only SS/CS;
|
|
223
|
+
core rejects bidi and gRPC before encoding or I/O. Construction and calls stay
|
|
224
|
+
on the application's owning reactor/thread. No hidden thread is created.
|
|
225
|
+
|
|
226
|
+
The default four exclusive connections include pending setup and retirement.
|
|
227
|
+
Four long streams can occupy every slot. Waiters count toward `max_requests:
|
|
228
|
+
128`; use deadlines/cancellation or configure more connections to avoid
|
|
229
|
+
starvation. There is no fairness promise, pipelining, retry or protocol fallback.
|
|
230
|
+
A stream's request head is flushed before yielding the writable exchange, and
|
|
231
|
+
response reads progress independently of a blocked upload. A write-only caller
|
|
232
|
+
without a deadline can still block if a peer neither consumes nor closes.
|
|
233
|
+
|
|
234
|
+
Writer success, full native HTTP EOF and owned cleanup all precede successful
|
|
235
|
+
`finish`. `close_send` is idempotent and never reports writer failure. A writer
|
|
236
|
+
failure surfaced through `write` permits core's bounded terminal-only remote
|
|
237
|
+
error recovery, but always retires the connection. If failure first appears
|
|
238
|
+
after the last accepted write, `finish` returns unavailable even when the
|
|
239
|
+
terminal contains a remote error. This existing-SPI limit can mask that remote
|
|
240
|
+
error; the transport does not decode terminals or invent early success.
|
|
241
|
+
Unhandled send failures still win over remote success under core's policy.
|
|
242
|
+
|
|
243
|
+
The supplied completion timeout and original RPC deadline also cover cleanup;
|
|
244
|
+
cleanup has at most its existing 100 ms allowance. Destructive retirement closes
|
|
245
|
+
raw TCP before canceling/joining writers/readers and buffered/TLS cleanup. Abort
|
|
246
|
+
after successful finish is harmless; abandonment otherwise retires the lease.
|
|
247
|
+
TLS offers only `http/1.1` and accepts absent ALPN as legacy H1, with peer and
|
|
248
|
+
hostname verification enabled. Unary close-delimited responses accept clean EOF;
|
|
249
|
+
without Content-Length, EOF cannot detect a semantically truncated payload that
|
|
250
|
+
is otherwise valid. Core still validates protobuf/JSON; unexpected TLS EOF
|
|
251
|
+
without close_notify remains a transport failure.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
unless ARGV.empty?
|
|
5
|
+
warn "protoc-gen-connectrpc-ruby accepts no CLI arguments; invoke it through protoc or Buf"
|
|
6
|
+
exit 1
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
require "connectrpc/generator"
|
|
11
|
+
rescue LoadError => e
|
|
12
|
+
warn e.message
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
request = Google::Protobuf::Compiler::CodeGeneratorRequest.decode($stdin.binmode.read)
|
|
18
|
+
rescue Google::Protobuf::ParseError => e
|
|
19
|
+
warn "Invalid CodeGeneratorRequest protobuf: #{e.message}"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
response = ConnectRPC::Generator.generate(request)
|
|
23
|
+
$stdout.binmode.write(Google::Protobuf::Compiler::CodeGeneratorResponse.encode(response))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
�
|
|
3
|
+
google/protobuf/any.protogoogle.protobuf"6
|
|
4
|
+
Any
|
|
5
|
+
type_url ( RtypeUrl
|
|
6
|
+
value (RvalueBv
|
|
7
|
+
com.google.protobufBAnyProtoPZ,google.golang.org/protobuf/types/known/anypb�GPB�Google.Protobuf.WellKnownTypesbproto3
|
|
8
|
+
�
|
|
9
|
+
google/rpc/status.proto
|
|
10
|
+
google.rpcgoogle/protobuf/any.proto"f
|
|
11
|
+
Status
|
|
12
|
+
code (Rcode
|
|
13
|
+
message ( Rmessage.
|
|
14
|
+
details (2.google.protobuf.AnyRdetailsB^
|
|
15
|
+
com.google.rpcBStatusProtoPZ7google.golang.org/genproto/googleapis/rpc/status;status�RPCbproto3
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../connectrpc"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
gem "google-protobuf", ">= 4.36.2", "< 5"
|
|
7
|
+
require "google/protobuf"
|
|
8
|
+
require "google/protobuf/any_pb"
|
|
9
|
+
require "google/protobuf/descriptor_pb"
|
|
10
|
+
rescue LoadError => e
|
|
11
|
+
raise LoadError, "ConnectRPC GoogleProtobuf binding requires google-protobuf >= 4.36.2, < 5: #{e.message}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ConnectRPC
|
|
15
|
+
module Bindings
|
|
16
|
+
class GoogleProtobuf
|
|
17
|
+
WRAPPERS = %w[
|
|
18
|
+
google.protobuf.DoubleValue google.protobuf.FloatValue google.protobuf.Int64Value google.protobuf.UInt64Value
|
|
19
|
+
google.protobuf.Int32Value google.protobuf.UInt32Value google.protobuf.BoolValue google.protobuf.StringValue
|
|
20
|
+
google.protobuf.BytesValue
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def encode(message, type:, codec:)
|
|
24
|
+
Protocol.codec!(codec)
|
|
25
|
+
raise CodecError, "Wrong protobuf message type" unless message.is_a?(type)
|
|
26
|
+
|
|
27
|
+
codec == :proto ? type.encode(message) : type.encode_json(message)
|
|
28
|
+
rescue Google::Protobuf::Error, TypeError, ArgumentError, RuntimeError, JSON::JSONError => e
|
|
29
|
+
raise CodecError, e.message
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def decode(bytes, type:, codec:)
|
|
33
|
+
Protocol.codec!(codec)
|
|
34
|
+
return type.decode(bytes) if codec == :proto
|
|
35
|
+
return decode_wrapper(bytes, type) if WRAPPERS.include?(type.descriptor.name)
|
|
36
|
+
|
|
37
|
+
type.decode_json(bytes, ignore_unknown_fields: true)
|
|
38
|
+
rescue Google::Protobuf::Error, TypeError, ArgumentError, RuntimeError, JSON::JSONError => e
|
|
39
|
+
raise CodecError, e.message
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def encode_status(error)
|
|
43
|
+
status, any = status_types
|
|
44
|
+
details = error.details.map do |detail|
|
|
45
|
+
any.new(type_url: "type.googleapis.com/#{detail.type}", value: detail.value)
|
|
46
|
+
end
|
|
47
|
+
status.encode(status.new(code: GRPCProtocol::CODES.index(error.code), message: error.message, details: details))
|
|
48
|
+
rescue Google::Protobuf::Error, TypeError, ArgumentError, RuntimeError => e
|
|
49
|
+
raise CodecError, e.message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def decode_status(bytes, code:) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
53
|
+
unless code.positive? && code <= GRPCProtocol::UINT32_MAX
|
|
54
|
+
raise CodecError, "Expected a nonzero uint32 gRPC status"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
status, = status_types
|
|
58
|
+
decoded = status.decode(bytes)
|
|
59
|
+
raise CodecError, "gRPC status code mismatch" unless decoded.code == code
|
|
60
|
+
|
|
61
|
+
details = decoded.details.map do |detail|
|
|
62
|
+
name = detail.type_url.split("/", -1).last
|
|
63
|
+
raise CodecError, "Invalid gRPC detail type URL" unless detail.type_url.include?("/") && name && !name.empty?
|
|
64
|
+
|
|
65
|
+
ErrorDetail.new(type: name, value: detail.value)
|
|
66
|
+
end
|
|
67
|
+
Error.new(GRPCProtocol::CODES[code] || :unknown, decoded.message, details: details)
|
|
68
|
+
rescue Google::Protobuf::Error, TypeError, ArgumentError, RuntimeError => e
|
|
69
|
+
raise CodecError, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def status_types
|
|
75
|
+
# This pool is intentionally detached from generated_pool and public constants.
|
|
76
|
+
pool = Google::Protobuf::DescriptorPool.new
|
|
77
|
+
set = Google::Protobuf::FileDescriptorSet.decode(File.binread(File.join(__dir__, "descriptors/status.bin")))
|
|
78
|
+
set.file.each { |file| pool.add_serialized_file(Google::Protobuf::FileDescriptorProto.encode(file)) }
|
|
79
|
+
[pool.lookup("google.rpc.Status").msgclass, pool.lookup("google.protobuf.Any").msgclass]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def decode_wrapper(bytes, type)
|
|
83
|
+
# Native protobuf 4.36 cannot parse a scalar wrapper at the JSON root.
|
|
84
|
+
# Its native Any decoder can. A standard Any envelope keeps all protobuf
|
|
85
|
+
# value validation in the backend, then supplies binary bytes to type.
|
|
86
|
+
envelope = JSON.generate("@type" => "type.googleapis.com/#{type.descriptor.name}", "value" => JSON.parse(bytes))
|
|
87
|
+
any = Google::Protobuf::Any.decode_json(envelope, ignore_unknown_fields: true)
|
|
88
|
+
type.decode(any.value)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
# A listener can be unregistered until invocation is claimed. It never waits
|
|
5
|
+
# for a running listener, including when called by that listener itself.
|
|
6
|
+
class CancellationRegistration
|
|
7
|
+
def initialize(owner, &callback)
|
|
8
|
+
@owner = owner
|
|
9
|
+
@callback = callback
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@state = :registered
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def unregister
|
|
15
|
+
# @type var removed: bool
|
|
16
|
+
removed = @mutex.synchronize do
|
|
17
|
+
next false unless @state == :registered
|
|
18
|
+
|
|
19
|
+
@state = :unregistered
|
|
20
|
+
@callback = nil
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
@owner.remove(self) if removed
|
|
24
|
+
removed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Keep invocation, isolation and reference release in one ensure.
|
|
28
|
+
def invoke # rubocop:disable Metrics/MethodLength
|
|
29
|
+
# @type var callback: (^() -> void)?
|
|
30
|
+
callback = @mutex.synchronize do
|
|
31
|
+
next unless @state == :registered
|
|
32
|
+
|
|
33
|
+
@state = :running
|
|
34
|
+
selected = @callback
|
|
35
|
+
@callback = nil
|
|
36
|
+
selected
|
|
37
|
+
end
|
|
38
|
+
return unless callback
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
callback.call
|
|
42
|
+
rescue StandardError
|
|
43
|
+
# One application listener must not prevent cancellation of other calls.
|
|
44
|
+
nil
|
|
45
|
+
ensure
|
|
46
|
+
@mutex.synchronize { @state = :finished }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Internal notification state shared by a source and its read-only token.
|
|
52
|
+
class CancellationSignal
|
|
53
|
+
def initialize
|
|
54
|
+
@mutex = Mutex.new
|
|
55
|
+
@cancelled = false
|
|
56
|
+
@registrations = []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cancelled? = @mutex.synchronize { @cancelled }
|
|
60
|
+
|
|
61
|
+
def cancel # rubocop:disable Naming/PredicateMethod
|
|
62
|
+
pending = @mutex.synchronize do
|
|
63
|
+
return false if @cancelled
|
|
64
|
+
|
|
65
|
+
@cancelled = true
|
|
66
|
+
selected = @registrations
|
|
67
|
+
@registrations = []
|
|
68
|
+
selected
|
|
69
|
+
end
|
|
70
|
+
pending.each(&:invoke)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def on_cancel(&)
|
|
75
|
+
registration = CancellationRegistration.new(self, &)
|
|
76
|
+
immediate = @mutex.synchronize do
|
|
77
|
+
@registrations << registration unless @cancelled
|
|
78
|
+
@cancelled
|
|
79
|
+
end
|
|
80
|
+
registration.invoke if immediate
|
|
81
|
+
registration
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def remove(registration)
|
|
85
|
+
@mutex.synchronize { @registrations.delete(registration) }
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Permanent, shareable cancellation. Only the source can initiate it.
|
|
91
|
+
class CancellationSource
|
|
92
|
+
attr_reader :token
|
|
93
|
+
|
|
94
|
+
def initialize
|
|
95
|
+
@signal = CancellationSignal.new
|
|
96
|
+
@token = CancellationToken.new(@signal)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cancel = @signal.cancel
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class CancellationToken
|
|
103
|
+
def initialize(signal)
|
|
104
|
+
@signal = signal
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def cancelled? = @signal.cancelled?
|
|
108
|
+
|
|
109
|
+
def reason
|
|
110
|
+
:canceled if cancelled?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check!
|
|
114
|
+
raise Error.new(:canceled, "Call canceled"), cause: nil if cancelled?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Runs in the cancelling thread, or synchronously when already cancelled.
|
|
118
|
+
# Listeners must be short and nonblocking. StandardError is isolated.
|
|
119
|
+
def on_cancel(&) = @signal.on_cancel(&)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One owner arbitrates a call's terminal outcome. Source cancellation requests
|
|
123
|
+
# acceptance; it never completes another call that shares the source.
|
|
124
|
+
class CallCancellation
|
|
125
|
+
# Direct transports accept a token and deadline separately. Core clients
|
|
126
|
+
# already supply their arbiter, which must stay open through message decode.
|
|
127
|
+
def self.with_request(request) # rubocop:disable Metrics/MethodLength
|
|
128
|
+
cancellation = request.cancellation
|
|
129
|
+
return yield request unless cancellation || request.deadline
|
|
130
|
+
|
|
131
|
+
case cancellation
|
|
132
|
+
when CallCancellation then return yield request
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
state = new(deadline: request.deadline, cancellation: cancellation)
|
|
136
|
+
outgoing = request.dup
|
|
137
|
+
outgoing.cancellation = state
|
|
138
|
+
begin
|
|
139
|
+
result = yield outgoing
|
|
140
|
+
state.commit!
|
|
141
|
+
result
|
|
142
|
+
rescue StandardError
|
|
143
|
+
state.commit!
|
|
144
|
+
raise
|
|
145
|
+
ensure
|
|
146
|
+
state.close
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def initialize(deadline: nil, cancellation: nil)
|
|
151
|
+
@deadline = deadline
|
|
152
|
+
@source = cancellation
|
|
153
|
+
@mutex = Mutex.new
|
|
154
|
+
@terminal = nil
|
|
155
|
+
@signal = CancellationSignal.new
|
|
156
|
+
@registration = cancellation&.on_cancel { reason }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def reason
|
|
160
|
+
observe(false)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def cancelled? = !reason.nil?
|
|
164
|
+
|
|
165
|
+
def check!
|
|
166
|
+
raise_reason(reason)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def on_cancel(&)
|
|
170
|
+
reason
|
|
171
|
+
@signal.on_cancel(&)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def commit!
|
|
175
|
+
raise_reason(observe(true))
|
|
176
|
+
ensure
|
|
177
|
+
close
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def close
|
|
181
|
+
@registration&.unregister
|
|
182
|
+
@registration = nil
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Keep the terminal read, precedence and acceptance adjacent.
|
|
189
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
190
|
+
def observe(commit)
|
|
191
|
+
terminal = @mutex.synchronize { @terminal }
|
|
192
|
+
return terminal == :finished ? nil : terminal if terminal
|
|
193
|
+
|
|
194
|
+
# Clocks and extension SPI methods can run application code. Sample them
|
|
195
|
+
# outside the mutex; the serialized acceptance below owns the decision.
|
|
196
|
+
# @type var observed: cancellation_reason?
|
|
197
|
+
observed = @deadline&.expired? ? :deadline_exceeded : @source&.reason
|
|
198
|
+
# A read-only extension query may yield while the real deadline expires.
|
|
199
|
+
observed = :deadline_exceeded if @deadline&.expired?
|
|
200
|
+
# @type var accepted: :finished | cancellation_reason | nil
|
|
201
|
+
finished = :finished #: :finished
|
|
202
|
+
accepted = @mutex.synchronize do
|
|
203
|
+
@terminal ||= observed || (finished if commit)
|
|
204
|
+
@terminal
|
|
205
|
+
end
|
|
206
|
+
reason = accepted == :finished ? nil : accepted
|
|
207
|
+
@signal.cancel if reason
|
|
208
|
+
reason
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
212
|
+
|
|
213
|
+
def raise_reason(reason)
|
|
214
|
+
return unless reason
|
|
215
|
+
|
|
216
|
+
message = reason == :canceled ? "Call canceled" : "Deadline exceeded"
|
|
217
|
+
raise Error.new(reason, message), cause: nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|