tonic-rails 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6c9da1fa1dde14dab1bd46c64821154a392dd9e5b5bc31faf396cf8e51dfc28
4
- data.tar.gz: f0988c4f232fb3016b205d094ea0970085e7515f71f6845e55bd8bc6d43c7d70
3
+ metadata.gz: f526af49b4f7647168bcdf7d64131c00c63880d3372f0355d60aa7e347240856
4
+ data.tar.gz: 7698d7d70a8e3c75ef3ee63ec46ce032267688e12cb86fbca35bf74e41f6f29f
5
5
  SHA512:
6
- metadata.gz: 82a6eb4a8da4daaf1f91e6ead904b1223c13cc5b02f90e9bebf810ca7fc25bd71cb30cce07e539fde05b81b12fd89e84d540fc5aa5a22d38d7697aacb746a1c0
7
- data.tar.gz: 87dbd3f3abcbdefd3d6f47ad8b93ea992672efe67c643bcf47e75e255e920983ab6695ecfea8eb4273db390f57e6de419b9c369ad5067f0e07a0d086a7c9e218
6
+ metadata.gz: ad814eb2ab41e6009649bd5307a8f304e3f1baab030f00a25045b65ec0bd1279aa7eceac041d141932281073ce650172191e0fcfbec9c74066f5c224b64e811c
7
+ data.tar.gz: b1cc149c6beb0f3bb2f99579cc5b4ef4e13964654a93969b431bf0032a25f9fa624f5888748eb0fe37fef1c13762146b881aec6d3347cbc27fe0fee468990275
data/CHANGELOG.md CHANGED
@@ -7,7 +7,43 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.0]
10
+ ## [0.2.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - Instrumentation: every RPC publishes a `process_rpc.tonic_rails`
15
+ ActiveSupport::Notifications event (path, service, rpc, metadata, status,
16
+ code) for APM agents, OpenTelemetry and custom subscribers. With no
17
+ subscribers it costs nothing measurable.
18
+ - Response metadata: handlers set `response_metadata` (headers) and
19
+ `trailing_metadata` (trailers, sent on success or failure).
20
+ - Rich errors: `RpcError.new(..., metadata:, details:)` sends trailing
21
+ metadata and a `google.rpc.Status` with packed details in
22
+ `grpc-status-details-bin`, which official gRPC clients decode.
23
+ - Puma plugin: `plugin :tonic_rails` in `config/puma.rb` runs the gRPC
24
+ server as a child process of Puma, in single and cluster mode, with
25
+ graceful stop and restart, and a shutdown if either side dies.
26
+ - Windows: precompiled `x64-mingw-ucrt` gems, tested in CI.
27
+ - `Tonic::Rails::Testing.invoke` returns the response together with its
28
+ headers and trailers.
29
+
30
+ ### Changed
31
+
32
+ - Response headers are sent with the handler's first output instead of
33
+ before the handler runs, so handlers can set them. The client deadline
34
+ applies while waiting.
35
+ - `log_requests` logs one line per RPC through the configured (Rails)
36
+ logger instead of stderr.
37
+ - Transport events from the native extension (rejected calls, expired
38
+ deadlines, refused connections, faults) go through the configured (Rails)
39
+ logger with severities instead of stderr. Handler outcomes, such as a
40
+ `NOT_FOUND` raised by a handler, are no longer logged by the transport;
41
+ use `log_requests` or the instrumentation event for those.
42
+ - `Server#stop` signals shutdown through a queue instead of a pipe, so
43
+ signal handling works the same on every platform.
44
+ - Dependencies: magnus 0.9, google-protobuf 4.36.
45
+
46
+ ## [0.1.0] - 2026-10-10
11
47
 
12
48
  First public release.
13
49
 
@@ -48,5 +84,6 @@ First public release.
48
84
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
49
85
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
50
86
 
51
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...HEAD
87
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...HEAD
88
+ [0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
52
89
  [0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
data/Cargo.lock CHANGED
@@ -469,9 +469,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
469
469
 
470
470
  [[package]]
471
471
  name = "magnus"
472
- version = "0.8.3"
472
+ version = "0.9.2"
473
473
  source = "registry+https://github.com/rust-lang/crates.io-index"
474
- checksum = "86a4f30dd400bf26769435129d7ecc529f56e3d45012ee6c394d6dbff3296bf9"
474
+ checksum = "b9068710e00d21be762edca59b9c4c8946dbdb29820d4f76ad559afea07982c9"
475
475
  dependencies = [
476
476
  "magnus-macros",
477
477
  "rb-sys",
@@ -481,9 +481,9 @@ dependencies = [
481
481
 
482
482
  [[package]]
483
483
  name = "magnus-macros"
484
- version = "0.8.0"
484
+ version = "0.9.1"
485
485
  source = "registry+https://github.com/rust-lang/crates.io-index"
486
- checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892"
486
+ checksum = "9dafde2eeb66f090e0f326112323eb8324b12cb32b5d1210e541d3df6b0ce77e"
487
487
  dependencies = [
488
488
  "proc-macro2",
489
489
  "quote",
@@ -1023,7 +1023,7 @@ dependencies = [
1023
1023
 
1024
1024
  [[package]]
1025
1025
  name = "tonic_rails_native"
1026
- version = "0.1.0"
1026
+ version = "0.2.0"
1027
1027
  dependencies = [
1028
1028
  "axum",
1029
1029
  "bytes",
data/README.md CHANGED
@@ -30,10 +30,11 @@ end
30
30
 
31
31
  ## Why tonic-rails
32
32
 
33
- - **Fast where it counts.** Transport overhead is small: an empty RPC
34
- runs at roughly 75–80% of a pure-Rust tonic server's throughput. I/O-bound
35
- handlers scale with dispatch threads because Ruby releases the GVL
36
- while waiting. See [PERF.md](PERF.md).
33
+ - **Fast where it counts.** Inside a Rails app, light RPCs ran at 5–6×
34
+ the throughput of an equivalent Rails JSON endpoint and 1.4–3× the
35
+ official `grpc` gem, and server streaming at 4–7× the `grpc` gem.
36
+ Handlers dominated by slow queries or heavy object building see smaller
37
+ gains. See [PERF.md](PERF.md).
37
38
  - **All four RPC kinds.** Unary, server streaming, client streaming and
38
39
  bidirectional streaming, with backpressure in both directions.
39
40
  - **Production defaults.** Graceful draining on SIGTERM, health checks
@@ -44,7 +45,7 @@ end
44
45
  Reflection is off unless you turn it on. TLS problems stop the server
45
46
  instead of silently falling back to plaintext.
46
47
  - **No Rust toolchain needed** on the platforms we ship precompiled gems
47
- for (Linux and macOS, x86_64 and arm64).
48
+ for: Linux and macOS (x86_64 and arm64) and Windows (x64).
48
49
 
49
50
  ## Installation
50
51
 
@@ -139,7 +140,8 @@ bin/rails tonic_rails:routes # lists registered methods
139
140
  ```
140
141
 
141
142
  Run it as its own process (a separate Procfile entry, container or
142
- Kubernetes deployment) next to your web server.
143
+ Kubernetes deployment) next to your web server, or let Puma run it for
144
+ you (see [Running with Puma](#running-with-puma)).
143
145
 
144
146
  The [example app](example/) is a complete, runnable version of all of the
145
147
  above.
@@ -248,6 +250,39 @@ end
248
250
  When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
249
251
  check `deadline_exceeded?` in long loops to stop wasted work.
250
252
 
253
+ ### Response metadata
254
+
255
+ Set response headers and trailers from a handler:
256
+
257
+ ```ruby
258
+ def get_item(request)
259
+ response_metadata["x-cache"] = "hit" # headers
260
+ trailing_metadata["x-cost-ms"] = "3" # trailers, sent with the status
261
+ response_metadata["x-trace-bin"] = trace_bytes # -bin names take binary values
262
+ Demo::V1::Item.new(id: request.id)
263
+ end
264
+ ```
265
+
266
+ For streaming responses, set headers before the first message is sent.
267
+ Trailers are sent whether the call succeeds or fails.
268
+
269
+ ### Rich errors
270
+
271
+ `RpcError` can carry trailing metadata and
272
+ [rich error details](https://grpc.io/docs/guides/error/#richer-error-model):
273
+ protobuf messages packed into a `google.rpc.Status` in the standard
274
+ `grpc-status-details-bin` trailer, which official gRPC clients decode for
275
+ you.
276
+
277
+ ```ruby
278
+ raise Tonic::Rails::RpcError.new(
279
+ "invalid item",
280
+ code: :invalid_argument,
281
+ metadata: { "x-request-id" => request_id },
282
+ details: [Google::Rpc::BadRequest.new(field_violations: violations)],
283
+ )
284
+ ```
285
+
251
286
  ### Interceptors
252
287
 
253
288
  Interceptors wrap every call, outermost first. Call `next_call` to
@@ -280,6 +315,11 @@ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
280
315
 
281
316
  # Streaming: pass an Array of requests, get an Array of responses
282
317
  Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
318
+
319
+ # Response metadata
320
+ result = Tonic::Rails::Testing.invoke(ItemService, :GetItem, { id: 1 })
321
+ result.headers["x-cache"] # => "hit"
322
+ result.trailers["x-cost-ms"] # => "3"
283
323
  ```
284
324
 
285
325
  ## Configuration
@@ -316,8 +356,8 @@ end
316
356
  | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
317
357
  | `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
318
358
  | `interceptors` | `[]` | See [Interceptors](#interceptors) |
319
- | `log_requests` | `false` | Log every call to stderr (errors are always logged) |
320
- | `auto_start` | `false` | Start inside `rails server` (see below) |
359
+ | `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
360
+ | `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
321
361
 
322
362
  ### Sizing threads
323
363
 
@@ -328,13 +368,31 @@ concurrency your I/O needs, size the database pool to match, and add
328
368
  processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
329
369
  several processes can bind the same port.
330
370
 
371
+ ### Running with Puma
372
+
373
+ Add the plugin to `config/puma.rb`:
374
+
375
+ ```ruby
376
+ plugin :tonic_rails
377
+ ```
378
+
379
+ Puma then runs the gRPC server as one child process, in single and cluster
380
+ mode alike, so gRPC gets its own process and GVL instead of competing with
381
+ web workers. It starts after Puma boots and drains gracefully when Puma
382
+ stops or restarts. If the gRPC process dies, Puma shuts down too, so your
383
+ supervisor restarts both. It works with `rails server`, plain `puma`, and
384
+ `preload_app!`. (Not on Windows, which has no `fork()`: run
385
+ `tonic_rails:serve` as its own process there.)
386
+
387
+ For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
388
+ (scaled separately) is still the most flexible option.
389
+
331
390
  ### auto_start
332
391
 
333
392
  `auto_start = true` starts the gRPC server in a background thread of
334
393
  `rails server` (never in consoles, rake tasks or tests). It's handy in
335
- development. In production prefer a dedicated `tonic_rails:serve`
336
- process: forking servers such as Puma in cluster mode would need to
337
- start one per worker.
394
+ development; in production use the Puma plugin or a dedicated process.
395
+ Don't combine it with the plugin.
338
396
 
339
397
  ## Operations
340
398
 
@@ -347,12 +405,43 @@ start one per worker.
347
405
  in-flight calls finish for up to `drain_timeout` seconds, then exits.
348
406
  - **Load shedding.** When every dispatch thread is busy and the queue is
349
407
  full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
408
+ - **Instrumentation.** Every RPC publishes a `process_rpc.tonic_rails`
409
+ event through `ActiveSupport::Notifications`, so APM agents,
410
+ OpenTelemetry and your own subscribers can trace calls. See
411
+ [Instrumentation](#instrumentation).
412
+ - **Logs.** Everything goes through your (Rails) logger. With
413
+ `log_requests = true`, each RPC logs one line, e.g.
414
+ `gRPC /demo.v1.ItemService/GetItem OK in 1.4ms`. Calls the transport
415
+ rejects before reaching Ruby (unknown methods, a full queue, expired
416
+ deadlines, oversized messages, refused connections) are always logged,
417
+ at info for client-caused problems, warn for overload and error for faults.
350
418
  - **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
351
419
  successes, errors, cancellations, deadline and resource errors, active
352
420
  connections and queued calls.
353
421
  - **Reflection.** Turn on `reflection` in development to use
354
422
  [grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
355
423
 
424
+ ## Instrumentation
425
+
426
+ Every RPC is wrapped in an `ActiveSupport::Notifications` event, inside the
427
+ Rails executor, so subscribers see the same context as your handler:
428
+
429
+ ```ruby
430
+ ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
431
+ event.payload[:path] # "/demo.v1.ItemService/GetItem"
432
+ event.payload[:service] # "demo.v1.ItemService"
433
+ event.payload[:rpc] # "GetItem"
434
+ event.payload[:metadata] # request metadata
435
+ event.payload[:status] # :ok, :not_found, :internal, ...
436
+ event.payload[:code] # numeric gRPC status
437
+ event.duration # milliseconds
438
+ end
439
+ ```
440
+
441
+ When a call fails, `event.payload[:exception_object]` is the
442
+ `Tonic::Rails::RpcError` sent to the client. If a handler raised something
443
+ unexpected, that error's `cause` is the original exception.
444
+
356
445
  ## How it works
357
446
 
358
447
  ```
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tonic_rails_native"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  publish = false
@@ -23,7 +23,7 @@ crate-type = ["cdylib"]
23
23
  #
24
24
  # socket2 enables SO_REUSEPORT for multi-process (Puma worker) scaling.
25
25
  [dependencies]
26
- magnus = "0.8"
26
+ magnus = "0.9"
27
27
  rb-sys = "0.9"
28
28
  tonic = { version = "0.14", default-features = false, features = ["transport", "router", "server", "tls-ring"] }
29
29
  tonic-reflection = { version = "0.14", default-features = false, features = ["server"] }
@@ -7,7 +7,7 @@
7
7
  use std::future::Future;
8
8
  use std::pin::Pin;
9
9
  use std::sync::Arc;
10
- use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10
+ use std::sync::atomic::{AtomicUsize, Ordering};
11
11
  use std::task::{Context, Poll};
12
12
  use std::time::Instant;
13
13
 
@@ -17,8 +17,9 @@ use http_body::{Body, Frame};
17
17
  use tokio::sync::mpsc;
18
18
  use tonic::{Code, Status};
19
19
 
20
- use crate::framing::{COMPRESSION_THRESHOLD, frame, sanitize_for_log, status_trailers};
20
+ use crate::framing::{COMPRESSION_THRESHOLD, append_metadata, frame, status_trailers};
21
21
  use crate::gvl::StreamMessage;
22
+ use crate::log::{Level, LogSink, status_name};
22
23
 
23
24
  /// Request counters, exposed to Ruby via `Server#stats`.
24
25
  #[derive(Default)]
@@ -30,12 +31,14 @@ pub struct ServerStats {
30
31
  pub deadline_exceeded: AtomicUsize,
31
32
  pub resource_exhausted: AtomicUsize,
32
33
  pub unimplemented: AtomicUsize,
33
- /// Log every request (including OK) to stderr. Errors are always logged.
34
- pub log_requests: AtomicBool,
34
+ pub log: LogSink,
35
35
  }
36
36
 
37
37
  impl ServerStats {
38
- pub fn record(&self, path: &str, code: u32, message: &str, start: Instant) {
38
+ /// Count a finished call. `transport` marks outcomes decided here in
39
+ /// Rust (rejections, deadlines, limits) rather than by the Ruby handler;
40
+ /// only those are logged, since Ruby instruments and logs its own calls.
41
+ pub fn record(&self, path: &str, code: u32, message: &str, start: Instant, transport: bool) {
39
42
  let counter = match Code::from_i32(code as i32) {
40
43
  Code::Ok => &self.successful,
41
44
  Code::Cancelled => &self.cancelled,
@@ -55,16 +58,15 @@ impl ServerStats {
55
58
  _ => {}
56
59
  }
57
60
 
58
- let is_error = !matches!(Code::from_i32(code as i32), Code::Ok | Code::Cancelled);
59
- if is_error || self.log_requests.load(Ordering::Relaxed) {
60
- let path = sanitize_for_log(path);
61
- let dur_us = start.elapsed().as_micros();
62
- if message.is_empty() {
63
- eprintln!("tonic-rails: path={path} code={code} duration_us={dur_us}");
61
+ if transport && code != Code::Ok as u32 {
62
+ let ms = start.elapsed().as_secs_f64() * 1000.0;
63
+ let status = status_name(code);
64
+ let line = if message.is_empty() {
65
+ format!("gRPC {path} {status} in {ms:.1}ms")
64
66
  } else {
65
- let message = sanitize_for_log(message);
66
- eprintln!("tonic-rails: path={path} code={code} duration_us={dur_us} message={message}");
67
- }
67
+ format!("gRPC {path} {status} in {ms:.1}ms: {message}")
68
+ };
69
+ self.log.log(Level::for_code(code), &line);
68
70
  }
69
71
  }
70
72
  }
@@ -102,6 +104,8 @@ impl Body for ErrorBody {
102
104
  /// channel. Each `Data` message becomes a gRPC frame; `End` (or the
103
105
  /// deadline) produces the trailers.
104
106
  pub struct ResponseBody {
107
+ /// A message already received while waiting for response headers.
108
+ pub pending: Option<StreamMessage>,
105
109
  pub receiver: Option<mpsc::Receiver<StreamMessage>>,
106
110
  pub stats: Arc<ServerStats>,
107
111
  pub path: String,
@@ -112,12 +116,63 @@ pub struct ResponseBody {
112
116
  pub finished: bool,
113
117
  }
114
118
 
119
+ type Polled = Poll<Option<Result<Frame<Bytes>, Status>>>;
120
+
115
121
  impl ResponseBody {
116
- fn finish(&mut self, code: Code, message: &str) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
122
+ fn finish(&mut self, code: Code, message: &str, transport: bool) -> Polled {
123
+ self.finish_with(code, message, transport, &[])
124
+ }
125
+
126
+ fn finish_with(
127
+ &mut self,
128
+ code: Code,
129
+ message: &str,
130
+ transport: bool,
131
+ metadata: &[(String, String)],
132
+ ) -> Polled {
117
133
  self.receiver = None;
118
134
  self.finished = true;
119
- self.stats.record(&self.path, code as u32, message, self.start);
120
- Poll::Ready(Some(Ok(Frame::trailers(status_trailers(code as u32, message)))))
135
+ self.stats
136
+ .record(&self.path, code as u32, message, self.start, transport);
137
+ let mut trailers = status_trailers(code as u32, message);
138
+ let skipped = append_metadata(&mut trailers, metadata);
139
+ if !skipped.is_empty() {
140
+ self.stats.log.log(
141
+ Level::Warn,
142
+ &format!(
143
+ "gRPC {} dropped invalid or reserved trailers: {}",
144
+ self.path,
145
+ skipped.join(", ")
146
+ ),
147
+ );
148
+ }
149
+ Poll::Ready(Some(Ok(Frame::trailers(trailers))))
150
+ }
151
+
152
+ /// Turn one handler message into a frame (None: nothing to emit).
153
+ fn handle(&mut self, message: StreamMessage) -> Option<Polled> {
154
+ match message {
155
+ StreamMessage::Data(bytes) => {
156
+ if bytes.len() > self.max_message_size {
157
+ return Some(self.finish(
158
+ Code::ResourceExhausted,
159
+ "response message exceeds max_message_size",
160
+ true,
161
+ ));
162
+ }
163
+ let compress = self.compress && bytes.len() > COMPRESSION_THRESHOLD;
164
+ Some(Poll::Ready(Some(Ok(Frame::data(frame(&bytes, compress))))))
165
+ }
166
+ StreamMessage::End { status, trailers } => Some(match status {
167
+ None => self.finish_with(Code::Ok, "", false, &trailers),
168
+ Some((code, message)) => {
169
+ self.finish_with(Code::from_i32(code as i32), &message, false, &trailers)
170
+ }
171
+ }),
172
+ // Headers are only valid first and are consumed by the
173
+ // dispatcher; a late one has nowhere to go.
174
+ StreamMessage::Headers(_) => None,
175
+ }
121
176
  }
122
177
  }
123
178
 
@@ -125,39 +180,36 @@ impl Body for ResponseBody {
125
180
  type Data = Bytes;
126
181
  type Error = Status;
127
182
 
128
- fn poll_frame(
129
- mut self: Pin<&mut Self>,
130
- cx: &mut Context<'_>,
131
- ) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
132
- let Some(receiver) = self.receiver.as_mut() else {
133
- return Poll::Ready(None);
134
- };
183
+ fn poll_frame(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Polled {
184
+ if let Some(message) = self.pending.take()
185
+ && let Some(poll) = self.handle(message)
186
+ {
187
+ return poll;
188
+ }
135
189
 
136
190
  // Drain ready messages before checking the deadline so a response
137
191
  // that made it in time is never discarded.
138
- match receiver.poll_recv(cx) {
139
- Poll::Ready(Some(StreamMessage::Data(bytes))) => {
140
- if bytes.len() > self.max_message_size {
141
- return self.finish(
142
- Code::ResourceExhausted,
143
- "response message exceeds max_message_size",
144
- );
192
+ loop {
193
+ let Some(receiver) = self.receiver.as_mut() else {
194
+ return Poll::Ready(None);
195
+ };
196
+ match receiver.poll_recv(cx) {
197
+ Poll::Ready(Some(message)) => {
198
+ if let Some(poll) = self.handle(message) {
199
+ return poll;
200
+ }
145
201
  }
146
- let compress = self.compress && bytes.len() > COMPRESSION_THRESHOLD;
147
- return Poll::Ready(Some(Ok(Frame::data(frame(&bytes, compress)))));
148
- }
149
- Poll::Ready(Some(StreamMessage::End(None))) => return self.finish(Code::Ok, ""),
150
- Poll::Ready(Some(StreamMessage::End(Some((code, message))))) => {
151
- return self.finish(Code::from_i32(code as i32), &message);
202
+ Poll::Ready(None) => {
203
+ return self.finish(Code::Internal, "handler ended without a response", true);
204
+ }
205
+ Poll::Pending => break,
152
206
  }
153
- Poll::Ready(None) => return self.finish(Code::Internal, "handler ended without a response"),
154
- Poll::Pending => {}
155
207
  }
156
208
 
157
209
  if let Some(deadline) = self.deadline.as_mut()
158
210
  && deadline.as_mut().poll(cx).is_ready()
159
211
  {
160
- return self.finish(Code::DeadlineExceeded, "deadline exceeded");
212
+ return self.finish(Code::DeadlineExceeded, "deadline exceeded", true);
161
213
  }
162
214
  Poll::Pending
163
215
  }
@@ -173,12 +225,6 @@ impl Drop for ResponseBody {
173
225
  // (RST_STREAM / disconnect) — count it as cancelled.
174
226
  if !self.finished {
175
227
  self.stats.cancelled.fetch_add(1, Ordering::Relaxed);
176
- if self.stats.log_requests.load(Ordering::Relaxed) {
177
- eprintln!(
178
- "tonic-rails: path={} code=1 message=cancelled by client",
179
- sanitize_for_log(&self.path)
180
- );
181
- }
182
228
  }
183
229
  }
184
230
  }
@@ -197,6 +243,7 @@ mod tests {
197
243
 
198
244
  fn response_body(rx: mpsc::Receiver<StreamMessage>, stats: Arc<ServerStats>) -> ResponseBody {
199
245
  ResponseBody {
246
+ pending: None,
200
247
  receiver: Some(rx),
201
248
  stats,
202
249
  path: "/test.Svc/Method".into(),
@@ -231,7 +278,11 @@ mod tests {
231
278
  let stats = Arc::new(ServerStats::default());
232
279
  let mut body = response_body(rx, stats.clone());
233
280
  tx.try_send(StreamMessage::Data(b"abc".to_vec())).ok();
234
- tx.try_send(StreamMessage::End(None)).ok();
281
+ tx.try_send(StreamMessage::End {
282
+ status: None,
283
+ trailers: vec![("x-done".into(), "yes".into())],
284
+ })
285
+ .ok();
235
286
 
236
287
  match poll(&mut body) {
237
288
  Poll::Ready(Some(Ok(frame))) => {
@@ -242,7 +293,9 @@ mod tests {
242
293
  }
243
294
  _ => panic!("expected data"),
244
295
  }
245
- assert_eq!(trailers(poll(&mut body))["grpc-status"], "0");
296
+ let t = trailers(poll(&mut body));
297
+ assert_eq!(t["grpc-status"], "0");
298
+ assert_eq!(t["x-done"], "yes");
246
299
  assert!(matches!(poll(&mut body), Poll::Ready(None)));
247
300
  drop(body);
248
301
  assert_eq!(stats.successful.load(Ordering::Relaxed), 1);
@@ -263,12 +316,23 @@ mod tests {
263
316
  fn response_body_reports_handler_error() {
264
317
  let (tx, rx) = mpsc::channel(4);
265
318
  let mut body = response_body(rx, Arc::new(ServerStats::default()));
266
- tx.try_send(StreamMessage::End(Some((7, "denied".into())))).ok();
319
+ tx.try_send(StreamMessage::error(Code::PermissionDenied, "denied"))
320
+ .ok();
267
321
  let t = trailers(poll(&mut body));
268
322
  assert_eq!(t["grpc-status"], "7");
269
323
  assert_eq!(t["grpc-message"], "denied");
270
324
  }
271
325
 
326
+ #[test]
327
+ fn response_body_starts_with_a_pending_message() {
328
+ let (tx, rx) = mpsc::channel(4);
329
+ let mut body = response_body(rx, Arc::new(ServerStats::default()));
330
+ body.pending = Some(StreamMessage::Data(b"x".to_vec()));
331
+ tx.try_send(StreamMessage::error(Code::Aborted, "stop")).ok();
332
+ assert!(matches!(poll(&mut body), Poll::Ready(Some(Ok(f))) if f.is_data()));
333
+ assert_eq!(trailers(poll(&mut body))["grpc-status"], "10");
334
+ }
335
+
272
336
  #[test]
273
337
  fn response_body_closed_channel_is_internal() {
274
338
  let (tx, rx) = mpsc::channel(4);
@@ -23,8 +23,9 @@ use tonic::Code;
23
23
  use tonic::body::Body as TonicBody;
24
24
 
25
25
  use crate::body::{ErrorBody, ResponseBody, ServerStats};
26
- use crate::framing::{MessageReader, parse_grpc_timeout};
26
+ use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout};
27
27
  use crate::gvl::{DispatchContext, RequestError, StreamMessage};
28
+ use crate::log::Level;
28
29
 
29
30
  /// Messages buffered per direction before backpressure applies.
30
31
  const STREAM_BUFFER: usize = 32;
@@ -62,8 +63,8 @@ impl tower::Service<Request<axum::body::Body>> for DispatchService {
62
63
  }
63
64
 
64
65
  fn call(&mut self, req: Request<axum::body::Body>) -> Self::Future {
65
- let response = self.0.handle(req);
66
- Box::pin(async move { Ok(response) })
66
+ let inner = self.0.clone();
67
+ Box::pin(async move { Ok(inner.handle(req).await) })
67
68
  }
68
69
  }
69
70
 
@@ -83,11 +84,11 @@ fn grpc_response(body: TonicBody, compressed: bool) -> Response<TonicBody> {
83
84
 
84
85
  impl DispatchInner {
85
86
  fn reject(&self, path: &str, start: Instant, code: Code, message: &str) -> Response<TonicBody> {
86
- self.stats.record(path, code as u32, message, start);
87
+ self.stats.record(path, code as u32, message, start, true);
87
88
  grpc_response(TonicBody::new(ErrorBody::new(code, message)), false)
88
89
  }
89
90
 
90
- fn handle(&self, req: Request<axum::body::Body>) -> Response<TonicBody> {
91
+ async fn handle(&self, req: Request<axum::body::Body>) -> Response<TonicBody> {
91
92
  let start = Instant::now();
92
93
  self.stats.total.fetch_add(1, Ordering::Relaxed);
93
94
  let path = req.uri().path().to_owned();
@@ -164,6 +165,7 @@ impl DispatchInner {
164
165
  response_tx,
165
166
  request_rx,
166
167
  request_error: request_error.clone(),
168
+ log: self.stats.log.clone(),
167
169
  });
168
170
 
169
171
  match self.request_tx.try_send(ctx) {
@@ -190,17 +192,52 @@ impl DispatchInner {
190
192
  gzip_request,
191
193
  ));
192
194
 
195
+ // Response headers wait for the handler's first output, so it can
196
+ // set initial metadata; unary calls send headers with the response
197
+ // as usual. The client's deadline applies while we wait.
198
+ let mut response_rx = response_rx;
199
+ let mut deadline = timeout.map(|t| Box::pin(tokio::time::sleep(t)));
200
+ let first = match deadline.as_mut() {
201
+ Some(deadline) => tokio::select! {
202
+ biased;
203
+ message = response_rx.recv() => Some(message),
204
+ _ = deadline.as_mut() => None,
205
+ },
206
+ None => Some(response_rx.recv().await),
207
+ };
208
+ let (headers, pending) = match first {
209
+ None => return self.reject(&path, start, Code::DeadlineExceeded, "deadline exceeded"),
210
+ Some(None) => {
211
+ return self.reject(&path, start, Code::Internal, "handler ended without a response");
212
+ }
213
+ Some(Some(StreamMessage::Headers(headers))) => (headers, None),
214
+ Some(Some(message)) => (Vec::new(), Some(message)),
215
+ };
216
+
193
217
  let body = ResponseBody {
218
+ pending,
194
219
  receiver: Some(response_rx),
195
220
  stats: self.stats.clone(),
196
221
  path,
197
222
  start,
198
223
  compress,
199
- deadline: timeout.map(|t| Box::pin(tokio::time::sleep(t))),
224
+ deadline,
200
225
  max_message_size,
201
226
  finished: false,
202
227
  };
203
- grpc_response(TonicBody::new(body), compress)
228
+ let path = body.path.clone();
229
+ let mut response = grpc_response(TonicBody::new(body), compress);
230
+ let skipped = append_metadata(response.headers_mut(), &headers);
231
+ if !skipped.is_empty() {
232
+ self.stats.log.log(
233
+ Level::Warn,
234
+ &format!(
235
+ "gRPC {path} dropped invalid or reserved response headers: {}",
236
+ skipped.join(", ")
237
+ ),
238
+ );
239
+ }
240
+ response
204
241
  }
205
242
  }
206
243