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 +4 -4
- data/CHANGELOG.md +39 -2
- data/Cargo.lock +5 -5
- data/README.md +100 -11
- data/ext/tonic_rails_native/Cargo.toml +2 -2
- data/ext/tonic_rails_native/src/body.rs +113 -49
- data/ext/tonic_rails_native/src/dispatch.rs +44 -7
- data/ext/tonic_rails_native/src/framing.rs +50 -0
- data/ext/tonic_rails_native/src/gvl.rs +87 -21
- data/ext/tonic_rails_native/src/lib.rs +5 -1
- data/ext/tonic_rails_native/src/log.rs +131 -0
- data/ext/tonic_rails_native/src/net.rs +7 -1
- data/ext/tonic_rails_native/src/server.rs +46 -6
- data/lib/puma/plugin/tonic_rails.rb +116 -0
- data/lib/tonic/rails/base.rb +69 -22
- data/lib/tonic/rails/configuration.rb +2 -1
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/railtie.rb +2 -2
- data/lib/tonic/rails/rpc_error.rb +73 -6
- data/lib/tonic/rails/server.rb +59 -12
- data/lib/tonic/rails/testing.rb +32 -3
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f526af49b4f7647168bcdf7d64131c00c63880d3372f0355d60aa7e347240856
|
|
4
|
+
data.tar.gz: 7698d7d70a8e3c75ef3ee63ec46ce032267688e12cb86fbca35bf74e41f6f29f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
472
|
+
version = "0.9.2"
|
|
473
473
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
474
|
-
checksum = "
|
|
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.
|
|
484
|
+
version = "0.9.1"
|
|
485
485
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
486
|
-
checksum = "
|
|
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.
|
|
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.**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
320
|
-
| `auto_start` | `false` | Start
|
|
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
|
|
336
|
-
|
|
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.
|
|
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.
|
|
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::{
|
|
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,
|
|
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
|
-
|
|
34
|
-
pub log_requests: AtomicBool,
|
|
34
|
+
pub log: LogSink,
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
impl ServerStats {
|
|
38
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
let
|
|
61
|
-
let
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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) ->
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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::
|
|
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
|
|
66
|
-
Box::pin(async move { Ok(
|
|
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
|
|
224
|
+
deadline,
|
|
200
225
|
max_message_size,
|
|
201
226
|
finished: false,
|
|
202
227
|
};
|
|
203
|
-
|
|
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
|
|