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/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.rpcB StatusProtoPZ7google.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