tonic-rails 0.3.0 → 0.5.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 +32 -1
- data/Cargo.lock +35 -1
- data/README.md +98 -8
- data/ext/tonic_rails_native/Cargo.toml +5 -2
- data/ext/tonic_rails_native/src/lib.rs +4 -0
- data/ext/tonic_rails_native/src/net.rs +121 -6
- data/ext/tonic_rails_native/src/server.rs +142 -5
- data/lib/puma/plugin/tonic_rails.rb +5 -3
- data/lib/tonic/rails/cluster.rb +222 -0
- data/lib/tonic/rails/configuration.rb +65 -0
- data/lib/tonic/rails/railtie.rb +5 -0
- data/lib/tonic/rails/server.rb +101 -37
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 234f197083161b48c0fa820f8e9e8bb20138208c131e8467279cd40d9b73d872
|
|
4
|
+
data.tar.gz: a5ec7ea58b4325c881045c7550468a19de879b7119212e212500c4632e581f56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 59c579e49bfc33f7f551ba522823fac67e253d939723c0fcdf4368560f558094b519a1339ca325d81cd2e06fa3dd51e1b7486b790b11da711ab04a24bfe2d375
|
|
7
|
+
data.tar.gz: 229844b0f0d86b85f0545c715bb04c70148ecff829ea699283d5bf0a624aab91e788224af557ad513db559b3f42ed9c381350f0dd00238e0c78f0be67731437f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ minor versions may contain breaking changes.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Multi-process mode: `config.processes = 4` runs four server processes
|
|
15
|
+
on one port, so services that spend their time in Ruby use four cores
|
|
16
|
+
instead of one. The app loads once and forks the workers. They share
|
|
17
|
+
the listening socket, with new connections going to the least busy
|
|
18
|
+
worker. Workers that die are replaced, and SIGTERM drains them all.
|
|
19
|
+
`on_worker_boot` runs in each worker after fork. Works with
|
|
20
|
+
`tonic_rails:serve` and the Puma plugin; Unix only. Closes #23.
|
|
21
|
+
- Benchmark: `PROCESSES` and `CONNECTIONS` for `bench/run_bench.sh`, and
|
|
22
|
+
`-connections` for the Go client.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- The Puma plugin waits `drain_timeout` + 10 seconds (was + 5) for the
|
|
27
|
+
gRPC server to stop before killing it, which leaves a multi-process
|
|
28
|
+
server time to stop its own workers.
|
|
29
|
+
|
|
30
|
+
## [0.4.0] - 2026-10-11
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- gRPC-Web: `config.grpc_web = true` serves browser clients (unary and
|
|
35
|
+
server-streaming RPCs, binary and text encodings) without a proxy, on the
|
|
36
|
+
same port as native gRPC, with CORS via `grpc_web_origins`,
|
|
37
|
+
`grpc_web_allowed_headers` and `grpc_web_exposed_headers`. Closes #22.
|
|
38
|
+
|
|
10
39
|
## [0.3.0] - 2026-10-11
|
|
11
40
|
|
|
12
41
|
### Added
|
|
@@ -116,7 +145,9 @@ First public release.
|
|
|
116
145
|
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
117
146
|
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
118
147
|
|
|
119
|
-
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.
|
|
148
|
+
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...HEAD
|
|
149
|
+
[0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
|
|
150
|
+
[0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
|
|
120
151
|
[0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
|
|
121
152
|
[0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
|
|
122
153
|
[0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
|
data/Cargo.lock
CHANGED
|
@@ -1021,9 +1021,27 @@ dependencies = [
|
|
|
1021
1021
|
"tonic-prost",
|
|
1022
1022
|
]
|
|
1023
1023
|
|
|
1024
|
+
[[package]]
|
|
1025
|
+
name = "tonic-web"
|
|
1026
|
+
version = "0.14.6"
|
|
1027
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1028
|
+
checksum = "b5e6a1b6319ca4b61a4c0f0c94d439c8f3ed344cca56fe0df40e1fe4be11380b"
|
|
1029
|
+
dependencies = [
|
|
1030
|
+
"base64",
|
|
1031
|
+
"bytes",
|
|
1032
|
+
"http",
|
|
1033
|
+
"http-body",
|
|
1034
|
+
"pin-project",
|
|
1035
|
+
"tokio-stream",
|
|
1036
|
+
"tonic",
|
|
1037
|
+
"tower-layer",
|
|
1038
|
+
"tower-service",
|
|
1039
|
+
"tracing",
|
|
1040
|
+
]
|
|
1041
|
+
|
|
1024
1042
|
[[package]]
|
|
1025
1043
|
name = "tonic_rails_native"
|
|
1026
|
-
version = "0.
|
|
1044
|
+
version = "0.5.0"
|
|
1027
1045
|
dependencies = [
|
|
1028
1046
|
"axum",
|
|
1029
1047
|
"bytes",
|
|
@@ -1043,7 +1061,9 @@ dependencies = [
|
|
|
1043
1061
|
"tonic",
|
|
1044
1062
|
"tonic-health",
|
|
1045
1063
|
"tonic-reflection",
|
|
1064
|
+
"tonic-web",
|
|
1046
1065
|
"tower",
|
|
1066
|
+
"tower-http",
|
|
1047
1067
|
]
|
|
1048
1068
|
|
|
1049
1069
|
[[package]]
|
|
@@ -1065,6 +1085,20 @@ dependencies = [
|
|
|
1065
1085
|
"tracing",
|
|
1066
1086
|
]
|
|
1067
1087
|
|
|
1088
|
+
[[package]]
|
|
1089
|
+
name = "tower-http"
|
|
1090
|
+
version = "0.6.11"
|
|
1091
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1092
|
+
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
|
1093
|
+
dependencies = [
|
|
1094
|
+
"bitflags",
|
|
1095
|
+
"bytes",
|
|
1096
|
+
"http",
|
|
1097
|
+
"pin-project-lite",
|
|
1098
|
+
"tower-layer",
|
|
1099
|
+
"tower-service",
|
|
1100
|
+
]
|
|
1101
|
+
|
|
1068
1102
|
[[package]]
|
|
1069
1103
|
name = "tower-layer"
|
|
1070
1104
|
version = "0.3.3"
|
data/README.md
CHANGED
|
@@ -363,6 +363,44 @@ Tonic::Rails.configure do |config|
|
|
|
363
363
|
end
|
|
364
364
|
```
|
|
365
365
|
|
|
366
|
+
## Browser clients (gRPC-Web)
|
|
367
|
+
|
|
368
|
+
Browsers can't speak native gRPC, but they can speak
|
|
369
|
+
[gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md).
|
|
370
|
+
Turn it on and your existing handlers serve web frontends directly, with
|
|
371
|
+
no Envoy or other proxy:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
Tonic::Rails.configure do |config|
|
|
375
|
+
config.grpc_web = true
|
|
376
|
+
config.grpc_web_origins = ["https://app.example.com"] # CORS; omit for same-origin only
|
|
377
|
+
config.grpc_web_exposed_headers = %w[x-request-id] # response metadata browsers may read
|
|
378
|
+
end
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Then call it from the browser with any gRPC-Web client, for example
|
|
382
|
+
[Connect](https://connectrpc.com/docs/web/using-clients):
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createClient } from "@connectrpc/connect";
|
|
386
|
+
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
|
387
|
+
import { ItemService } from "./gen/demo/v1/items_pb";
|
|
388
|
+
|
|
389
|
+
const client = createClient(ItemService, createGrpcWebTransport({ baseUrl: "https://api.example.com" }));
|
|
390
|
+
const item = await client.getItem({ id: 1n });
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- Unary and server-streaming RPCs work; browsers can't do client or
|
|
394
|
+
bidirectional streaming.
|
|
395
|
+
- Errors, response headers and trailers reach the browser as usual.
|
|
396
|
+
- Native gRPC clients keep working on the same port. Enabling gRPC-Web
|
|
397
|
+
also accepts HTTP/1.1, which browsers use without TLS; over HTTPS they
|
|
398
|
+
negotiate HTTP/2.
|
|
399
|
+
- `grpc_web_origins` sets CORS: listed origins may call with credentials
|
|
400
|
+
(cookies); `"*"` allows any origin without credentials (logged as a
|
|
401
|
+
warning). `grpc_web_allowed_headers` adds request headers beyond the
|
|
402
|
+
standard ones (`authorization` is already allowed).
|
|
403
|
+
|
|
366
404
|
## Testing handlers
|
|
367
405
|
|
|
368
406
|
`Tonic::Rails::Testing` calls a handler in-process, with real protobuf
|
|
@@ -432,6 +470,8 @@ end
|
|
|
432
470
|
|--------|---------|-------------|
|
|
433
471
|
| `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
|
|
434
472
|
| `dispatch_threads` | `4` | Ruby threads running handlers; match your database pool |
|
|
473
|
+
| `processes` | `1` | Server processes sharing the port (see [Threads or processes](#threads-or-processes)); Unix only |
|
|
474
|
+
| `on_worker_boot` | `nil` | Callable run in each worker process after fork, given its index |
|
|
435
475
|
| `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
|
|
436
476
|
| `worker_threads` | CPU count | Tokio threads for the transport |
|
|
437
477
|
| `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
|
|
@@ -441,6 +481,9 @@ end
|
|
|
441
481
|
| `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
|
|
442
482
|
| `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
|
|
443
483
|
| `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
|
|
484
|
+
| `grpc_web` | `false` | Serve gRPC-Web for browser clients (see [Browser clients](#browser-clients-grpc-web)) |
|
|
485
|
+
| `grpc_web_origins` | `[]` | Browser origins allowed via CORS; `"*"` for any (no credentials) |
|
|
486
|
+
| `grpc_web_allowed_headers`, `grpc_web_exposed_headers` | `[]` | Extra CORS request / response headers |
|
|
444
487
|
| `health_check` | `nil` | Callable; while false or raising, health reports `NOT_SERVING` |
|
|
445
488
|
| `health_check_interval` | `5` | Seconds between health checks |
|
|
446
489
|
| `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
|
|
@@ -449,14 +492,60 @@ end
|
|
|
449
492
|
| `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
|
|
450
493
|
| `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
|
|
451
494
|
|
|
452
|
-
###
|
|
495
|
+
### Threads or processes
|
|
453
496
|
|
|
454
497
|
Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
|
|
455
|
-
HTTP) releases
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
498
|
+
HTTP) releases Ruby's global VM lock, so more threads means more
|
|
499
|
+
concurrent I/O. Ruby code itself runs one thread at a time per process,
|
|
500
|
+
though: a service that spends its time in Ruby (building large
|
|
501
|
+
responses, serializing, computing) tops out at one core however many
|
|
502
|
+
threads it has.
|
|
503
|
+
|
|
504
|
+
So:
|
|
505
|
+
|
|
506
|
+
- **Mostly waiting on I/O?** Add threads. They're cheap, and one process
|
|
507
|
+
goes a long way. Size the database pool to match.
|
|
508
|
+
- **Mostly running Ruby?** Add processes. `processes = 4` runs four
|
|
509
|
+
copies of the server on the same port and uses four cores.
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
config.tonic_rails.processes = ENV.fetch("GRPC_PROCESSES", 4).to_i
|
|
513
|
+
config.tonic_rails.dispatch_threads = 5 # per process
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Most services mix both. A reasonable start is one process per core and
|
|
517
|
+
a handful of threads in each. Every process has its own database pool,
|
|
518
|
+
so the database sees up to `processes × dispatch_threads` connections.
|
|
519
|
+
|
|
520
|
+
With `processes` above 1, `bin/rails tonic_rails:serve` loads the app
|
|
521
|
+
once and forks the workers, which share memory until they write to it.
|
|
522
|
+
The process you started supervises them:
|
|
523
|
+
|
|
524
|
+
- Workers share one listening socket, and a worker with fewer open
|
|
525
|
+
connections picks up new ones first. gRPC clients keep connections
|
|
526
|
+
open and send every call over them, so a single client connection
|
|
527
|
+
is served by a single worker: spread load with several connections
|
|
528
|
+
or several clients.
|
|
529
|
+
- If a worker dies, it's logged and replaced.
|
|
530
|
+
- SIGTERM or SIGINT to the supervisor drains every worker (health
|
|
531
|
+
reports `NOT_SERVING`, in-flight calls finish) and it exits once all
|
|
532
|
+
have stopped. Workers stop on their own if the supervisor disappears.
|
|
533
|
+
- If a worker fails while starting (a bad configuration, say), the
|
|
534
|
+
server doesn't start.
|
|
535
|
+
|
|
536
|
+
Active Record reconnects in each worker by itself. Reopen anything else
|
|
537
|
+
that can't be shared across `fork` in `on_worker_boot`:
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
config.tonic_rails.on_worker_boot = ->(index) { MyQueueClient.reconnect! }
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Health checks, `health_check`, `serving!`, `not_serving!` and `stats`
|
|
544
|
+
work per worker: calling `serving!` or `not_serving!` in the supervisor
|
|
545
|
+
after the server has started doesn't reach the workers.
|
|
546
|
+
|
|
547
|
+
Multi-process mode needs `fork()`, so it isn't available on Windows. Run
|
|
548
|
+
one server process per core there, each on its own port.
|
|
460
549
|
|
|
461
550
|
### Running with Puma
|
|
462
551
|
|
|
@@ -472,7 +561,8 @@ web workers. It starts after Puma boots and drains gracefully when Puma
|
|
|
472
561
|
stops or restarts. If the gRPC process dies, Puma shuts down too, so your
|
|
473
562
|
supervisor restarts both. It works with `rails server`, plain `puma`, and
|
|
474
563
|
`preload_app!`. (Not on Windows, which has no `fork()`: run
|
|
475
|
-
`tonic_rails:serve` as its own process there.)
|
|
564
|
+
`tonic_rails:serve` as its own process there.) With `processes` above 1,
|
|
565
|
+
that child process supervises the gRPC workers.
|
|
476
566
|
|
|
477
567
|
For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
|
|
478
568
|
(scaled separately) is still the most flexible option.
|
|
@@ -482,7 +572,7 @@ For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
|
|
|
482
572
|
`auto_start = true` starts the gRPC server in a background thread of
|
|
483
573
|
`rails server` (never in consoles, rake tasks or tests). It's handy in
|
|
484
574
|
development; in production use the Puma plugin or a dedicated process.
|
|
485
|
-
Don't combine it with the plugin
|
|
575
|
+
Don't combine it with the plugin, and it can't run multiple `processes`.
|
|
486
576
|
|
|
487
577
|
## Operations
|
|
488
578
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "tonic_rails_native"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.88"
|
|
6
6
|
publish = false
|
|
@@ -21,13 +21,16 @@ crate-type = ["cdylib"]
|
|
|
21
21
|
# and no service definitions are compiled into the extension — routes
|
|
22
22
|
# are registered from Ruby at runtime.
|
|
23
23
|
#
|
|
24
|
-
# socket2
|
|
24
|
+
# socket2 configures the listener (SO_REUSEADDR/SO_REUSEPORT, backlog) and
|
|
25
|
+
# adopts the listening socket worker processes inherit in multi-process mode.
|
|
25
26
|
[dependencies]
|
|
26
27
|
magnus = "0.9"
|
|
27
28
|
rb-sys = "0.9"
|
|
28
29
|
tonic = { version = "0.14", default-features = false, features = ["transport", "router", "server", "tls-ring"] }
|
|
29
30
|
tonic-reflection = { version = "0.14", default-features = false, features = ["server"] }
|
|
30
31
|
tonic-health = { version = "0.14", default-features = false }
|
|
32
|
+
tonic-web = { version = "0.14", default-features = false }
|
|
33
|
+
tower-http = { version = "0.6", default-features = false, features = ["cors"] }
|
|
31
34
|
axum = { version = "0.8", default-features = false }
|
|
32
35
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] }
|
|
33
36
|
tokio-stream = { version = "0.1", features = ["net"] }
|
|
@@ -43,6 +43,8 @@ use server::NativeServer;
|
|
|
43
43
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
44
44
|
let native = ruby.define_module("TonicRails")?.define_module("Native")?;
|
|
45
45
|
|
|
46
|
+
native.define_module_function("bind_listener", function!(server::bind_listener, 1))?;
|
|
47
|
+
|
|
46
48
|
let class = native.define_class("Server", ruby.class_object())?;
|
|
47
49
|
class.define_singleton_method("new", function!(NativeServer::new, 2))?;
|
|
48
50
|
class.define_method("on_request", method!(NativeServer::on_request, 1))?;
|
|
@@ -64,7 +66,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
64
66
|
)?;
|
|
65
67
|
class.define_method("set_compression", method!(NativeServer::set_compression, 1))?;
|
|
66
68
|
class.define_method("set_reflection", method!(NativeServer::set_reflection, 1))?;
|
|
69
|
+
class.define_method("set_grpc_web", method!(NativeServer::set_grpc_web, 3))?;
|
|
67
70
|
class.define_method("set_routes", method!(NativeServer::set_routes, 1))?;
|
|
71
|
+
class.define_method("set_listener_fd", method!(NativeServer::set_listener_fd, 1))?;
|
|
68
72
|
class.define_method(
|
|
69
73
|
"set_reflection_descriptors",
|
|
70
74
|
method!(NativeServer::set_reflection_descriptors, 1),
|
|
@@ -9,17 +9,44 @@ use std::time::Duration;
|
|
|
9
9
|
|
|
10
10
|
use socket2::{Domain, Protocol, Socket, Type};
|
|
11
11
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
|
12
|
-
use tokio::net::
|
|
12
|
+
use tokio::net::TcpStream;
|
|
13
13
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
|
14
14
|
use tokio_stream::Stream;
|
|
15
15
|
use tonic::transport::server::{Connected, TcpConnectInfo};
|
|
16
16
|
|
|
17
17
|
use crate::log::{Level, LogSink};
|
|
18
18
|
|
|
19
|
-
///
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
/// A stream of accepted connections.
|
|
20
|
+
pub type Accept = Pin<Box<dyn Stream<Item = std::io::Result<TcpStream>> + Send>>;
|
|
21
|
+
|
|
22
|
+
/// Bind `addr` and accept on it. Must be called inside a tokio runtime
|
|
23
|
+
/// context.
|
|
24
|
+
pub fn listen(addr: SocketAddr, backlog: i32) -> std::io::Result<Accept> {
|
|
25
|
+
let listener = tokio::net::TcpListener::from_std(bind(addr, backlog)?)?;
|
|
26
|
+
Ok(Box::pin(tokio_stream::wrappers::TcpListenerStream::new(listener)))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Accept on a listening socket inherited from a parent process, shared
|
|
30
|
+
/// with sibling workers (multi-process mode). The fd is duplicated, so
|
|
31
|
+
/// the caller keeps ownership of the original. `active` is this
|
|
32
|
+
/// process's open connection count. Must be called inside a tokio
|
|
33
|
+
/// runtime context.
|
|
34
|
+
#[cfg(unix)]
|
|
35
|
+
pub fn listen_inherited(fd: i32, active: Arc<AtomicUsize>) -> std::io::Result<Accept> {
|
|
36
|
+
Ok(Box::pin(SharedAccept::new(inherited(fd)?, active)?))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[cfg(not(unix))]
|
|
40
|
+
pub fn listen_inherited(_fd: i32, _active: Arc<AtomicUsize>) -> std::io::Result<Accept> {
|
|
41
|
+
Err(std::io::Error::new(
|
|
42
|
+
std::io::ErrorKind::Unsupported,
|
|
43
|
+
"inherited listeners need fork(), which this platform lacks",
|
|
44
|
+
))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Bind with SO_REUSEADDR + SO_REUSEPORT so restarts don't fail on
|
|
48
|
+
/// TIME_WAIT. The listener is non-blocking, ready for tokio.
|
|
49
|
+
pub fn bind(addr: SocketAddr, backlog: i32) -> std::io::Result<std::net::TcpListener> {
|
|
23
50
|
let domain = if addr.is_ipv4() {
|
|
24
51
|
Domain::IPV4
|
|
25
52
|
} else {
|
|
@@ -35,7 +62,95 @@ pub fn bind(addr: SocketAddr, backlog: i32) -> std::io::Result<TcpListener> {
|
|
|
35
62
|
socket.set_nonblocking(true)?;
|
|
36
63
|
socket.bind(&addr.into())?;
|
|
37
64
|
socket.listen(backlog)?;
|
|
38
|
-
|
|
65
|
+
Ok(socket.into())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[cfg(unix)]
|
|
69
|
+
fn inherited(fd: i32) -> std::io::Result<std::net::TcpListener> {
|
|
70
|
+
use std::os::fd::BorrowedFd;
|
|
71
|
+
|
|
72
|
+
// SAFETY: the caller guarantees `fd` is open for the duration of
|
|
73
|
+
// this call; we only duplicate it.
|
|
74
|
+
let owned = unsafe { BorrowedFd::borrow_raw(fd) }.try_clone_to_owned()?;
|
|
75
|
+
let socket = Socket::from(owned);
|
|
76
|
+
socket.set_nonblocking(true)?;
|
|
77
|
+
Ok(socket.into())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Accepts on a listening socket shared with sibling worker processes.
|
|
81
|
+
///
|
|
82
|
+
/// Every process is woken for each new connection and the first to
|
|
83
|
+
/// accept wins, so gRPC's few long-lived connections tend to pile onto
|
|
84
|
+
/// whichever process wakes fastest. Instead, when a connection is
|
|
85
|
+
/// waiting, a process holds back for `backoff` per connection it already
|
|
86
|
+
/// has (up to `max_backoff`) before trying to accept, so the least busy
|
|
87
|
+
/// process usually gets it, as with Puma's `wait_for_less_busy_worker`.
|
|
88
|
+
#[cfg(unix)]
|
|
89
|
+
struct SharedAccept {
|
|
90
|
+
listener: tokio::io::unix::AsyncFd<std::net::TcpListener>,
|
|
91
|
+
active: Arc<AtomicUsize>,
|
|
92
|
+
backoff: Duration,
|
|
93
|
+
max_backoff: Duration,
|
|
94
|
+
pause: Option<Pin<Box<tokio::time::Sleep>>>,
|
|
95
|
+
/// The pause for the waiting connection is over: accept right away.
|
|
96
|
+
paused: bool,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[cfg(unix)]
|
|
100
|
+
impl SharedAccept {
|
|
101
|
+
fn new(listener: std::net::TcpListener, active: Arc<AtomicUsize>) -> std::io::Result<Self> {
|
|
102
|
+
Ok(Self {
|
|
103
|
+
listener: tokio::io::unix::AsyncFd::new(listener)?,
|
|
104
|
+
active,
|
|
105
|
+
backoff: Duration::from_millis(1),
|
|
106
|
+
max_backoff: Duration::from_millis(10),
|
|
107
|
+
pause: None,
|
|
108
|
+
paused: false,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[cfg(unix)]
|
|
114
|
+
impl Stream for SharedAccept {
|
|
115
|
+
type Item = std::io::Result<TcpStream>;
|
|
116
|
+
|
|
117
|
+
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
118
|
+
let this = &mut *self;
|
|
119
|
+
loop {
|
|
120
|
+
if let Some(pause) = this.pause.as_mut() {
|
|
121
|
+
if pause.as_mut().poll(cx).is_pending() {
|
|
122
|
+
return Poll::Pending;
|
|
123
|
+
}
|
|
124
|
+
this.pause = None;
|
|
125
|
+
this.paused = true;
|
|
126
|
+
}
|
|
127
|
+
let mut guard = match this.listener.poll_read_ready(cx) {
|
|
128
|
+
Poll::Ready(Ok(guard)) => guard,
|
|
129
|
+
Poll::Ready(Err(e)) => return Poll::Ready(Some(Err(e))),
|
|
130
|
+
Poll::Pending => return Poll::Pending,
|
|
131
|
+
};
|
|
132
|
+
let active = this.active.load(Ordering::Relaxed);
|
|
133
|
+
if !this.paused && active > 0 {
|
|
134
|
+
let pause = this.backoff.saturating_mul(active as u32).min(this.max_backoff);
|
|
135
|
+
this.pause = Some(Box::pin(tokio::time::sleep(pause)));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Whether we accept or a sibling beat us to it, the next
|
|
139
|
+
// connection gets a fresh pause.
|
|
140
|
+
this.paused = false;
|
|
141
|
+
match guard.try_io(|listener| listener.get_ref().accept()) {
|
|
142
|
+
Ok(Ok((stream, _))) => {
|
|
143
|
+
return Poll::Ready(Some(
|
|
144
|
+
stream
|
|
145
|
+
.set_nonblocking(true)
|
|
146
|
+
.and_then(|()| TcpStream::from_std(stream)),
|
|
147
|
+
));
|
|
148
|
+
}
|
|
149
|
+
Ok(Err(e)) => return Poll::Ready(Some(Err(e))),
|
|
150
|
+
Err(_would_block) => continue,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
39
154
|
}
|
|
40
155
|
|
|
41
156
|
/// TCP_NODELAY for latency; TCP keepalive to reap half-open peers that
|
|
@@ -29,11 +29,14 @@ use tokio::sync::{Semaphore, watch};
|
|
|
29
29
|
use tonic_health::ServingStatus;
|
|
30
30
|
use tonic_health::server::HealthReporter;
|
|
31
31
|
|
|
32
|
+
use http::{HeaderName, HeaderValue, Method};
|
|
33
|
+
use tower_http::cors::{AllowOrigin, CorsLayer};
|
|
34
|
+
|
|
32
35
|
use crate::body::ServerStats;
|
|
33
36
|
use crate::dispatch::{DispatchInner, DispatchService};
|
|
34
37
|
use crate::gvl::{self, Cancel, DispatchContext};
|
|
35
38
|
use crate::log::{Level, LogEvent, LogSink};
|
|
36
|
-
use crate::net::{LimitedIncoming, bind};
|
|
39
|
+
use crate::net::{LimitedIncoming, bind, listen, listen_inherited};
|
|
37
40
|
|
|
38
41
|
/// The overall-server health entry (empty service name).
|
|
39
42
|
const OVERALL: &str = "";
|
|
@@ -47,9 +50,12 @@ struct Options {
|
|
|
47
50
|
keepalive_interval: Duration,
|
|
48
51
|
keepalive_timeout: Duration,
|
|
49
52
|
reflection: bool,
|
|
53
|
+
grpc_web: Option<GrpcWebOptions>,
|
|
50
54
|
dispatch_queue_size: usize,
|
|
51
55
|
routes: Vec<String>,
|
|
52
56
|
descriptors: Vec<Vec<u8>>,
|
|
57
|
+
/// Accept on this inherited listening socket instead of binding.
|
|
58
|
+
listener_fd: Option<i32>,
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
impl Default for Options {
|
|
@@ -63,11 +69,83 @@ impl Default for Options {
|
|
|
63
69
|
keepalive_interval: Duration::from_secs(15),
|
|
64
70
|
keepalive_timeout: Duration::from_secs(5),
|
|
65
71
|
reflection: false,
|
|
72
|
+
grpc_web: None,
|
|
66
73
|
dispatch_queue_size: 128,
|
|
67
74
|
routes: Vec::new(),
|
|
68
75
|
descriptors: Vec::new(),
|
|
76
|
+
listener_fd: None,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// gRPC-Web and the CORS policy browsers need to use it.
|
|
82
|
+
struct GrpcWebOptions {
|
|
83
|
+
/// Allowed browser origins; empty = same-origin only; ["*"] = any.
|
|
84
|
+
origins: Vec<String>,
|
|
85
|
+
allow_headers: Vec<String>,
|
|
86
|
+
expose_headers: Vec<String>,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Request headers gRPC-Web clients send, beyond the CORS-safelisted ones.
|
|
90
|
+
const GRPC_WEB_REQUEST_HEADERS: [&str; 5] = [
|
|
91
|
+
"content-type",
|
|
92
|
+
"x-grpc-web",
|
|
93
|
+
"x-user-agent",
|
|
94
|
+
"grpc-timeout",
|
|
95
|
+
"authorization",
|
|
96
|
+
];
|
|
97
|
+
/// Response headers browsers must be allowed to read for gRPC-Web.
|
|
98
|
+
const GRPC_WEB_RESPONSE_HEADERS: [&str; 3] = ["grpc-status", "grpc-message", "grpc-status-details-bin"];
|
|
99
|
+
|
|
100
|
+
fn header_names(ruby: &Ruby, defaults: &[&str], extra: &[String]) -> Result<Vec<HeaderName>, Error> {
|
|
101
|
+
defaults
|
|
102
|
+
.iter()
|
|
103
|
+
.map(|name| name.to_string())
|
|
104
|
+
.chain(extra.iter().map(|name| name.to_ascii_lowercase()))
|
|
105
|
+
.map(|name| {
|
|
106
|
+
HeaderName::from_bytes(name.as_bytes())
|
|
107
|
+
.map_err(|_| arg_error(ruby, format!("invalid gRPC-Web header name: {name:?}")))
|
|
108
|
+
})
|
|
109
|
+
.collect()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// None when no cross-origin access is configured (same-origin only).
|
|
113
|
+
fn cors_layer(ruby: &Ruby, web: &GrpcWebOptions) -> Result<Option<CorsLayer>, Error> {
|
|
114
|
+
if web.origins.is_empty() {
|
|
115
|
+
return Ok(None);
|
|
116
|
+
}
|
|
117
|
+
let layer = CorsLayer::new()
|
|
118
|
+
.allow_methods([Method::POST])
|
|
119
|
+
.allow_headers(header_names(ruby, &GRPC_WEB_REQUEST_HEADERS, &web.allow_headers)?)
|
|
120
|
+
.expose_headers(header_names(
|
|
121
|
+
ruby,
|
|
122
|
+
&GRPC_WEB_RESPONSE_HEADERS,
|
|
123
|
+
&web.expose_headers,
|
|
124
|
+
)?)
|
|
125
|
+
.max_age(Duration::from_secs(24 * 60 * 60));
|
|
126
|
+
if web.origins.iter().any(|origin| origin == "*") {
|
|
127
|
+
if web.origins.len() > 1 {
|
|
128
|
+
return Err(arg_error(
|
|
129
|
+
ruby,
|
|
130
|
+
"grpc_web_origins: \"*\" can't be combined with other origins",
|
|
131
|
+
));
|
|
69
132
|
}
|
|
133
|
+
// Browsers refuse credentials with a wildcard origin, so none here.
|
|
134
|
+
return Ok(Some(layer.allow_origin(AllowOrigin::any())));
|
|
70
135
|
}
|
|
136
|
+
let origins = web
|
|
137
|
+
.origins
|
|
138
|
+
.iter()
|
|
139
|
+
.map(|origin| {
|
|
140
|
+
HeaderValue::from_str(origin.trim_end_matches('/'))
|
|
141
|
+
.map_err(|_| arg_error(ruby, format!("invalid gRPC-Web origin: {origin:?}")))
|
|
142
|
+
})
|
|
143
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
144
|
+
Ok(Some(
|
|
145
|
+
layer
|
|
146
|
+
.allow_origin(AllowOrigin::list(origins))
|
|
147
|
+
.allow_credentials(true),
|
|
148
|
+
))
|
|
71
149
|
}
|
|
72
150
|
|
|
73
151
|
/// State that exists only while the server is running.
|
|
@@ -116,6 +194,29 @@ fn read_file(ruby: &Ruby, what: &str, path: &str) -> Result<Vec<u8>, Error> {
|
|
|
116
194
|
std::fs::read(path).map_err(|e| runtime_error(ruby, format!("cannot read TLS {what} {path}: {e}")))
|
|
117
195
|
}
|
|
118
196
|
|
|
197
|
+
/// `TonicRails::Native.bind_listener(address)`: bind and listen the way
|
|
198
|
+
/// the server does, returning the socket's fd for worker processes to
|
|
199
|
+
/// inherit and accept on (`Server#set_listener_fd`). The caller owns it.
|
|
200
|
+
#[cfg(unix)]
|
|
201
|
+
pub fn bind_listener(ruby: &Ruby, address: String) -> Result<i32, Error> {
|
|
202
|
+
use std::os::fd::IntoRawFd;
|
|
203
|
+
|
|
204
|
+
let addr: SocketAddr = address
|
|
205
|
+
.parse()
|
|
206
|
+
.map_err(|e| arg_error(ruby, format!("invalid bind address {address:?}: {e}")))?;
|
|
207
|
+
let listener =
|
|
208
|
+
bind(addr, 1024).map_err(|e| runtime_error(ruby, format!("failed to bind {addr}: {e}")))?;
|
|
209
|
+
Ok(listener.into_raw_fd())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[cfg(not(unix))]
|
|
213
|
+
pub fn bind_listener(ruby: &Ruby, _address: String) -> Result<i32, Error> {
|
|
214
|
+
Err(Error::new(
|
|
215
|
+
ruby.exception_not_imp_error(),
|
|
216
|
+
"multi-process mode needs fork(), which this platform lacks",
|
|
217
|
+
))
|
|
218
|
+
}
|
|
219
|
+
|
|
119
220
|
fn secs(value: f64) -> Duration {
|
|
120
221
|
Duration::from_secs_f64(value.max(0.0))
|
|
121
222
|
}
|
|
@@ -199,10 +300,30 @@ impl NativeServer {
|
|
|
199
300
|
self.compression.store(enabled, Ordering::Relaxed);
|
|
200
301
|
}
|
|
201
302
|
|
|
303
|
+
/// Enable gRPC-Web (and HTTP/1.1, which browsers use without TLS).
|
|
304
|
+
pub fn set_grpc_web(
|
|
305
|
+
&self,
|
|
306
|
+
origins: Vec<String>,
|
|
307
|
+
allow_headers: Vec<String>,
|
|
308
|
+
expose_headers: Vec<String>,
|
|
309
|
+
) {
|
|
310
|
+
self.options.lock().grpc_web = Some(GrpcWebOptions {
|
|
311
|
+
origins,
|
|
312
|
+
allow_headers,
|
|
313
|
+
expose_headers,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
202
317
|
pub fn set_reflection(&self, enabled: bool) {
|
|
203
318
|
self.options.lock().reflection = enabled;
|
|
204
319
|
}
|
|
205
320
|
|
|
321
|
+
/// Accept on a listening socket bound by a parent process (see
|
|
322
|
+
/// `bind_listener`) instead of binding `bind_address`.
|
|
323
|
+
pub fn set_listener_fd(&self, fd: i32) {
|
|
324
|
+
self.options.lock().listener_fd = Some(fd);
|
|
325
|
+
}
|
|
326
|
+
|
|
206
327
|
/// gRPC paths (`/package.Service/Method`) served by Ruby handlers.
|
|
207
328
|
pub fn set_routes(&self, routes: Vec<String>) {
|
|
208
329
|
self.options.lock().routes = routes;
|
|
@@ -298,9 +419,15 @@ impl NativeServer {
|
|
|
298
419
|
.build()
|
|
299
420
|
.map_err(|e| runtime_error(ruby, format!("failed to create tokio runtime: {e}")))?;
|
|
300
421
|
|
|
301
|
-
let
|
|
422
|
+
let accept = {
|
|
302
423
|
let _guard = runtime.enter();
|
|
303
|
-
|
|
424
|
+
match options.listener_fd {
|
|
425
|
+
Some(fd) => listen_inherited(fd, this.active_connections.clone()).map_err(|e| {
|
|
426
|
+
runtime_error(ruby, format!("cannot use inherited listener (fd {fd}): {e}"))
|
|
427
|
+
})?,
|
|
428
|
+
None => listen(addr, 1024)
|
|
429
|
+
.map_err(|e| runtime_error(ruby, format!("failed to bind {addr}: {e}")))?,
|
|
430
|
+
}
|
|
304
431
|
};
|
|
305
432
|
|
|
306
433
|
let (health, health_service) = tonic_health::server::health_reporter();
|
|
@@ -347,14 +474,24 @@ impl NativeServer {
|
|
|
347
474
|
.tls_config(tls)
|
|
348
475
|
.map_err(|e| runtime_error(ruby, format!("invalid TLS configuration: {e}")))?;
|
|
349
476
|
}
|
|
350
|
-
|
|
477
|
+
// CORS outermost, so preflight requests are answered before the
|
|
478
|
+
// gRPC-Web translation sees them. Both are no-ops when disabled.
|
|
479
|
+
let (cors, grpc_web) = match &options.grpc_web {
|
|
480
|
+
Some(web) => (cors_layer(ruby, web)?, Some(tonic_web::GrpcWebLayer::new())),
|
|
481
|
+
None => (None, None),
|
|
482
|
+
};
|
|
483
|
+
let router = builder
|
|
484
|
+
.accept_http1(options.grpc_web.is_some())
|
|
485
|
+
.layer(tower::util::option_layer(cors))
|
|
486
|
+
.layer(tower::util::option_layer(grpc_web))
|
|
487
|
+
.add_routes(grpc_routes);
|
|
351
488
|
|
|
352
489
|
let permits = match options.max_connections {
|
|
353
490
|
0 => Semaphore::MAX_PERMITS,
|
|
354
491
|
n => n,
|
|
355
492
|
};
|
|
356
493
|
let incoming = LimitedIncoming {
|
|
357
|
-
inner:
|
|
494
|
+
inner: accept,
|
|
358
495
|
semaphore: Arc::new(Semaphore::new(permits)),
|
|
359
496
|
active: this.active_connections.clone(),
|
|
360
497
|
log: this.stats.log.clone(),
|
|
@@ -9,7 +9,8 @@ require "puma/plugin"
|
|
|
9
9
|
#
|
|
10
10
|
# The gRPC server runs as one child process of the Puma master, in single
|
|
11
11
|
# and cluster mode alike, so it has its own GVL and never competes with web
|
|
12
|
-
# workers.
|
|
12
|
+
# workers. With `config.processes > 1`, that child supervises the gRPC
|
|
13
|
+
# worker processes. It starts once Puma has booted, drains gracefully when Puma
|
|
13
14
|
# stops or restarts, stops itself if Puma goes away, and if it dies
|
|
14
15
|
# unexpectedly Puma shuts down too, so a supervisor can restart both.
|
|
15
16
|
Puma::Plugin.create do
|
|
@@ -62,7 +63,8 @@ Puma::Plugin.create do
|
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
# Puma fires its stop hooks from its TERM trap handler, so this must
|
|
65
|
-
# not block indefinitely: allow the server's drain timeout plus a margin
|
|
66
|
+
# not block indefinitely: allow the server's drain timeout plus a margin
|
|
67
|
+
# (longer than the one a multi-process server gives its own workers),
|
|
66
68
|
# then kill it.
|
|
67
69
|
def stop_grpc
|
|
68
70
|
pid = @grpc_pid
|
|
@@ -87,7 +89,7 @@ Puma::Plugin.create do
|
|
|
87
89
|
|
|
88
90
|
def stop_timeout
|
|
89
91
|
drain = defined?(Tonic::Rails) ? Tonic::Rails.configuration.drain_timeout.to_f : 10.0
|
|
90
|
-
drain +
|
|
92
|
+
drain + 10
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
def monitor_grpc
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Tonic
|
|
6
|
+
module Rails
|
|
7
|
+
# Multi-process mode (`config.processes > 1`).
|
|
8
|
+
#
|
|
9
|
+
# The process that calls Server#start becomes a supervisor: it binds the
|
|
10
|
+
# listening socket, then forks one worker per process. Workers inherit
|
|
11
|
+
# the loaded app (copy-on-write) and the socket, and each runs a full
|
|
12
|
+
# server (its own tokio runtime, created after the fork) accepting on
|
|
13
|
+
# it. The supervisor never starts a runtime itself, so nothing
|
|
14
|
+
# multi-threaded crosses a fork.
|
|
15
|
+
#
|
|
16
|
+
# Sharing one socket rather than binding with SO_REUSEPORT per worker
|
|
17
|
+
# means connections spread across workers on every Unix (macOS doesn't
|
|
18
|
+
# load-balance SO_REUSEPORT), port 0 works, a bind error surfaces once,
|
|
19
|
+
# and connections waiting in the backlog survive a worker's death.
|
|
20
|
+
#
|
|
21
|
+
# Lifecycle:
|
|
22
|
+
# - startup fails if a worker exits before it's serving (usually a
|
|
23
|
+
# configuration or boot error, which a retry wouldn't fix)
|
|
24
|
+
# - a worker that dies later is logged and replaced, with backoff if
|
|
25
|
+
# replacements keep failing to boot
|
|
26
|
+
# - stopping (TERM/INT or Server#stop) closes the supervisor's copy of
|
|
27
|
+
# the socket and sends TERM to every worker, which flips its health to
|
|
28
|
+
# NOT_SERVING and drains; workers still running after `drain_timeout`
|
|
29
|
+
# plus a margin are killed
|
|
30
|
+
# - a worker whose supervisor disappears stops itself
|
|
31
|
+
class Cluster
|
|
32
|
+
# Seconds to wait beyond drain_timeout before killing a worker.
|
|
33
|
+
STOP_MARGIN = 5
|
|
34
|
+
MAX_BACKOFF = 30
|
|
35
|
+
|
|
36
|
+
Worker = Struct.new(:index, :pid, :ready_io, :ready)
|
|
37
|
+
|
|
38
|
+
def initialize(server, configuration, stop_signal)
|
|
39
|
+
@server = server
|
|
40
|
+
@configuration = configuration
|
|
41
|
+
@stop_signal = stop_signal
|
|
42
|
+
@workers = []
|
|
43
|
+
@failures = Hash.new(0)
|
|
44
|
+
@respawn_at = {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Run the workers until stopped.
|
|
48
|
+
# @yield once every worker is serving
|
|
49
|
+
def run(install_signal_handlers:)
|
|
50
|
+
@listener = Socket.for_fd(TonicRails::Native.bind_listener(@configuration.bind_address))
|
|
51
|
+
@listener.autoclose = true
|
|
52
|
+
previous_handlers = install_signal_handlers ? trap_signals : {}
|
|
53
|
+
@configuration.processes.times { |index| @workers << spawn(index) }
|
|
54
|
+
return unless booted?
|
|
55
|
+
|
|
56
|
+
@started = true
|
|
57
|
+
yield if block_given?
|
|
58
|
+
supervise
|
|
59
|
+
ensure
|
|
60
|
+
@listener&.close
|
|
61
|
+
stop_workers
|
|
62
|
+
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def logger = @configuration.logger
|
|
68
|
+
|
|
69
|
+
def trap_signals
|
|
70
|
+
server = @server
|
|
71
|
+
%w[TERM INT].to_h { |signal| [signal, Signal.trap(signal) { server.stop }] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def spawn(index)
|
|
75
|
+
reader, writer = IO.pipe
|
|
76
|
+
pid = fork do
|
|
77
|
+
reader.close
|
|
78
|
+
@workers.each { |worker| worker&.ready_io&.close }
|
|
79
|
+
run_worker(index, writer)
|
|
80
|
+
end
|
|
81
|
+
writer.close
|
|
82
|
+
Worker.new(index, pid, reader, false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# In the worker process.
|
|
86
|
+
def run_worker(index, ready)
|
|
87
|
+
Process.setproctitle("tonic-rails worker #{index}")
|
|
88
|
+
supervisor = Process.ppid
|
|
89
|
+
Thread.new do
|
|
90
|
+
sleep 1 while Process.ppid == supervisor
|
|
91
|
+
@server.stop
|
|
92
|
+
end
|
|
93
|
+
@server.run_worker(@listener, index) do
|
|
94
|
+
ready.write("1")
|
|
95
|
+
ready.close
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Tonic::Rails.log_exception(e, "worker #{index}")
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Wait until every worker is serving: true once they are, false if a
|
|
103
|
+
# stop was requested first. Raises if a worker exited before serving.
|
|
104
|
+
def booted?
|
|
105
|
+
until @workers.all?(&:ready)
|
|
106
|
+
return false if @stop_signal.pop(timeout: 0.05)
|
|
107
|
+
|
|
108
|
+
@workers.each do |worker|
|
|
109
|
+
next if ready?(worker)
|
|
110
|
+
|
|
111
|
+
status = reap(worker)
|
|
112
|
+
raise Error, "tonic-rails worker #{worker.index} exited during startup (#{describe(status)})" if status
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def supervise
|
|
119
|
+
loop do
|
|
120
|
+
break if @stop_signal.pop(timeout: 0.5)
|
|
121
|
+
|
|
122
|
+
@workers.each_index { |index| supervise_worker(index) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def supervise_worker(index)
|
|
127
|
+
worker = @workers[index]
|
|
128
|
+
if worker.nil?
|
|
129
|
+
@workers[index] = spawn(index) if monotonic >= @respawn_at[index]
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
ready?(worker)
|
|
134
|
+
status = reap(worker)
|
|
135
|
+
return unless status
|
|
136
|
+
|
|
137
|
+
replace(worker, status)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A worker died after startup. Replace it right away if it had been
|
|
141
|
+
# serving; if replacements keep dying before they serve, back off.
|
|
142
|
+
def replace(worker, status)
|
|
143
|
+
index = worker.index
|
|
144
|
+
@failures[index] = worker.ready ? 0 : @failures[index] + 1
|
|
145
|
+
delay = @failures[index].zero? ? 0 : [2**(@failures[index] - 1), MAX_BACKOFF].min
|
|
146
|
+
logger&.error(
|
|
147
|
+
"tonic-rails: worker #{index} (pid #{worker.pid}) exited unexpectedly (#{describe(status)}); " \
|
|
148
|
+
"starting a replacement#{" in #{delay}s" if delay.positive?}",
|
|
149
|
+
)
|
|
150
|
+
@workers[index] = nil
|
|
151
|
+
@respawn_at[index] = monotonic + delay
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ready?(worker)
|
|
155
|
+
return true if worker.ready
|
|
156
|
+
|
|
157
|
+
case worker.ready_io.read_nonblock(1, exception: false)
|
|
158
|
+
when String
|
|
159
|
+
worker.ready = true
|
|
160
|
+
worker.ready_io.close
|
|
161
|
+
logger&.info("tonic-rails: worker #{worker.index} (pid #{worker.pid}) serving") if @started
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The worker's exit status if it has exited, otherwise nil.
|
|
169
|
+
def reap(worker)
|
|
170
|
+
_, status = Process.waitpid2(worker.pid, Process::WNOHANG)
|
|
171
|
+
worker.ready_io.close if status
|
|
172
|
+
status
|
|
173
|
+
rescue Errno::ECHILD
|
|
174
|
+
worker.ready_io.close
|
|
175
|
+
:unknown
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stop_workers
|
|
179
|
+
live = @workers.compact
|
|
180
|
+
return if live.empty?
|
|
181
|
+
|
|
182
|
+
logger&.info(
|
|
183
|
+
"tonic-rails: shutting down #{live.size} workers, draining for up to #{@configuration.drain_timeout}s",
|
|
184
|
+
)
|
|
185
|
+
live.each { |worker| signal(worker, :TERM) }
|
|
186
|
+
deadline = monotonic + @configuration.drain_timeout.to_f + STOP_MARGIN
|
|
187
|
+
until (live = live.reject { |worker| reap(worker) }).empty?
|
|
188
|
+
if monotonic > deadline
|
|
189
|
+
logger&.warn("tonic-rails: #{live.size} worker(s) still running after the drain timeout; killing them")
|
|
190
|
+
live.each { |worker| kill(worker) }
|
|
191
|
+
break
|
|
192
|
+
end
|
|
193
|
+
sleep 0.05
|
|
194
|
+
end
|
|
195
|
+
@workers = []
|
|
196
|
+
logger&.info("tonic-rails: stopped")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def kill(worker)
|
|
200
|
+
signal(worker, :KILL)
|
|
201
|
+
Process.wait(worker.pid)
|
|
202
|
+
rescue Errno::ECHILD
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def signal(worker, name)
|
|
207
|
+
Process.kill(name, worker.pid)
|
|
208
|
+
rescue Errno::ESRCH
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def describe(status)
|
|
213
|
+
return "status unknown" unless status.is_a?(Process::Status)
|
|
214
|
+
return "signal #{Signal.signame(status.termsig)}" if status.signaled?
|
|
215
|
+
|
|
216
|
+
"exit status #{status.exitstatus}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -26,6 +26,22 @@ module Tonic
|
|
|
26
26
|
# (RAILS_MAX_THREADS). Default 4.
|
|
27
27
|
attr_accessor :dispatch_threads
|
|
28
28
|
|
|
29
|
+
# Server processes. Ruby runs one thread at a time per process, so a
|
|
30
|
+
# CPU-bound service uses one core however many dispatch threads it
|
|
31
|
+
# has; N processes use N. Above 1, the app is loaded once and forked
|
|
32
|
+
# into N workers that share the listening socket, supervised by the
|
|
33
|
+
# process that started the server. Unix only. Default 1.
|
|
34
|
+
attr_accessor :processes
|
|
35
|
+
|
|
36
|
+
# Called in each worker process after it's forked, with the worker's
|
|
37
|
+
# index (multi-process mode only), to reopen connections that can't
|
|
38
|
+
# be shared across fork:
|
|
39
|
+
#
|
|
40
|
+
# config.on_worker_boot = ->(index) { Redis.current.disconnect! }
|
|
41
|
+
#
|
|
42
|
+
# Active Record reconnects by itself. Default nil.
|
|
43
|
+
attr_accessor :on_worker_boot
|
|
44
|
+
|
|
29
45
|
# RPCs that may wait for a free dispatch thread. Beyond this the
|
|
30
46
|
# server answers RESOURCE_EXHAUSTED immediately. Default 128.
|
|
31
47
|
attr_accessor :dispatch_queue_size
|
|
@@ -66,6 +82,25 @@ module Tonic
|
|
|
66
82
|
# API schema; keep it off in production. Default false.
|
|
67
83
|
attr_accessor :reflection
|
|
68
84
|
|
|
85
|
+
# Accept gRPC-Web requests, so browser apps can call services
|
|
86
|
+
# directly (unary and server-streaming RPCs). Also enables HTTP/1.1,
|
|
87
|
+
# which browsers use when there's no TLS. Default false.
|
|
88
|
+
attr_accessor :grpc_web
|
|
89
|
+
|
|
90
|
+
# Browser origins allowed to call gRPC-Web services (CORS), e.g.
|
|
91
|
+
# ["https://app.example.com"]. Empty = same-origin only. "*" allows
|
|
92
|
+
# any origin, without credentials (cookies). Default [].
|
|
93
|
+
attr_accessor :grpc_web_origins
|
|
94
|
+
|
|
95
|
+
# Extra request headers browsers may send (beyond content-type,
|
|
96
|
+
# x-grpc-web, x-user-agent, grpc-timeout and authorization).
|
|
97
|
+
attr_accessor :grpc_web_allowed_headers
|
|
98
|
+
|
|
99
|
+
# Extra response headers browsers may read, e.g. custom response
|
|
100
|
+
# metadata (grpc-status, grpc-message and grpc-status-details-bin are
|
|
101
|
+
# always exposed).
|
|
102
|
+
attr_accessor :grpc_web_exposed_headers
|
|
103
|
+
|
|
69
104
|
# HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
|
|
70
105
|
attr_accessor :keepalive_interval, :keepalive_timeout
|
|
71
106
|
|
|
@@ -91,6 +126,8 @@ module Tonic
|
|
|
91
126
|
@logger = Logger.new($stdout)
|
|
92
127
|
@worker_threads = nil
|
|
93
128
|
@dispatch_threads = 4
|
|
129
|
+
@processes = 1
|
|
130
|
+
@on_worker_boot = nil
|
|
94
131
|
@dispatch_queue_size = 128
|
|
95
132
|
@tls_cert_path = nil
|
|
96
133
|
@tls_key_path = nil
|
|
@@ -105,10 +142,18 @@ module Tonic
|
|
|
105
142
|
@keepalive_interval = 15
|
|
106
143
|
@keepalive_timeout = 5
|
|
107
144
|
@reload_handlers = false
|
|
145
|
+
@grpc_web = false
|
|
146
|
+
@grpc_web_origins = []
|
|
147
|
+
@grpc_web_allowed_headers = []
|
|
148
|
+
@grpc_web_exposed_headers = []
|
|
108
149
|
@health_check = nil
|
|
109
150
|
@health_check_interval = 5
|
|
110
151
|
end
|
|
111
152
|
|
|
153
|
+
def multi_process?
|
|
154
|
+
processes.is_a?(Integer) && processes > 1
|
|
155
|
+
end
|
|
156
|
+
|
|
112
157
|
def tls?
|
|
113
158
|
!tls_cert_path.nil? && !tls_key_path.nil?
|
|
114
159
|
end
|
|
@@ -117,6 +162,7 @@ module Tonic
|
|
|
117
162
|
def validate!
|
|
118
163
|
positive_integer!(:dispatch_threads)
|
|
119
164
|
positive_integer!(:dispatch_queue_size)
|
|
165
|
+
positive_integer!(:processes)
|
|
120
166
|
positive_integer!(:max_message_size)
|
|
121
167
|
non_negative_integer!(:max_connections)
|
|
122
168
|
non_negative_integer!(:concurrency_limit_per_connection)
|
|
@@ -128,14 +174,33 @@ module Tonic
|
|
|
128
174
|
raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
|
|
129
175
|
|
|
130
176
|
validate_callables!
|
|
177
|
+
validate_grpc_web!
|
|
178
|
+
validate_processes!
|
|
131
179
|
|
|
132
180
|
self
|
|
133
181
|
end
|
|
134
182
|
|
|
135
183
|
private
|
|
136
184
|
|
|
185
|
+
def validate_processes!
|
|
186
|
+
return unless multi_process?
|
|
187
|
+
return if Process.respond_to?(:fork)
|
|
188
|
+
|
|
189
|
+
raise ArgumentError, "processes > 1 needs fork(), which this platform lacks; run one server process per core instead"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_grpc_web!
|
|
193
|
+
%i[grpc_web_origins grpc_web_allowed_headers grpc_web_exposed_headers].each do |name|
|
|
194
|
+
value = public_send(name)
|
|
195
|
+
next if value.is_a?(Array) && value.all?(String)
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "#{name} must be an Array of Strings, got #{value.inspect}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
137
201
|
def validate_callables!
|
|
138
202
|
raise ArgumentError, "health_check must respond to #call" if health_check && !health_check.respond_to?(:call)
|
|
203
|
+
raise ArgumentError, "on_worker_boot must respond to #call" if on_worker_boot && !on_worker_boot.respond_to?(:call)
|
|
139
204
|
unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
|
|
140
205
|
raise ArgumentError, "health_check_interval must be a positive number, got #{health_check_interval.inspect}"
|
|
141
206
|
end
|
data/lib/tonic/rails/railtie.rb
CHANGED
|
@@ -31,6 +31,11 @@ module Tonic
|
|
|
31
31
|
# tasks, or test runs. With Puma, prefer `plugin :tonic_rails`
|
|
32
32
|
# (lib/puma/plugin/tonic_rails.rb), which also covers cluster mode.
|
|
33
33
|
if configuration.auto_start && defined?(::Rails::Server)
|
|
34
|
+
if configuration.multi_process?
|
|
35
|
+
raise Error, "auto_start can't run multiple processes (processes = #{configuration.processes}); " \
|
|
36
|
+
"use `plugin :tonic_rails` in config/puma.rb or `bin/rails tonic_rails:serve`"
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
server = Tonic::Rails.server
|
|
35
40
|
thread = Thread.new do
|
|
36
41
|
Thread.current.name = "tonic-rails"
|
data/lib/tonic/rails/server.rb
CHANGED
|
@@ -36,8 +36,9 @@ module Tonic
|
|
|
36
36
|
# Start serving and block until stopped.
|
|
37
37
|
# @param install_signal_handlers [Boolean] trap TERM and INT to stop.
|
|
38
38
|
# Pass false when embedding in a process that owns its signals.
|
|
39
|
-
# @yield once the server is accepting connections
|
|
40
|
-
|
|
39
|
+
# @yield once the server is accepting connections (with
|
|
40
|
+
# `processes > 1`, once every worker is)
|
|
41
|
+
def start(install_signal_handlers: true, &)
|
|
41
42
|
@lock.synchronize do
|
|
42
43
|
raise Error, "server is already running" unless @state == :stopped
|
|
43
44
|
|
|
@@ -50,40 +51,31 @@ module Tonic
|
|
|
50
51
|
|
|
51
52
|
@configuration.validate!
|
|
52
53
|
Tonic::Rails.native_extension!
|
|
53
|
-
Tonic::Rails._set_shutting_down(false)
|
|
54
54
|
# A Queue rather than a self-pipe: pushing to it is safe from trap
|
|
55
55
|
# handlers, and unlike pipes it can be waited on with a timeout
|
|
56
56
|
# on every platform (Windows select() only handles sockets).
|
|
57
57
|
@stop_signal = Thread::Queue.new
|
|
58
58
|
stop if @stop_requested # requested while we were starting up
|
|
59
|
-
@
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
request_log = subscribe_request_logger
|
|
66
|
-
previous_handlers = install_signal_handlers ? trap_signals : {}
|
|
67
|
-
workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
|
|
68
|
-
log_startup
|
|
69
|
-
yield self if block_given?
|
|
70
|
-
|
|
71
|
-
wait_for_stop(workers)
|
|
72
|
-
@health.detach
|
|
73
|
-
shutdown(workers)
|
|
59
|
+
if @configuration.multi_process?
|
|
60
|
+
run_cluster(install_signal_handlers, &)
|
|
61
|
+
else
|
|
62
|
+
serve(install_signal_handlers: install_signal_handlers, &)
|
|
63
|
+
end
|
|
74
64
|
ensure
|
|
75
|
-
ActiveSupport::Notifications.unsubscribe(request_log) if request_log
|
|
76
|
-
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
77
|
-
@health.detach
|
|
78
|
-
@native&.stop_server
|
|
79
|
-
stop_log_pump(log_pump)
|
|
80
|
-
@native = nil
|
|
81
65
|
@stop_signal = nil
|
|
82
|
-
Tonic::Rails._set_shutting_down(false)
|
|
83
66
|
@state = :stopped
|
|
84
67
|
end
|
|
85
68
|
end
|
|
86
69
|
|
|
70
|
+
# @api private
|
|
71
|
+
# The body of a worker process in multi-process mode (see Cluster):
|
|
72
|
+
# serve on the listening socket the parent bound.
|
|
73
|
+
def run_worker(listener, index, &)
|
|
74
|
+
@worker_index = index
|
|
75
|
+
@configuration.on_worker_boot&.call(index)
|
|
76
|
+
serve(install_signal_handlers: true, listener: listener, &)
|
|
77
|
+
end
|
|
78
|
+
|
|
87
79
|
# Request a graceful stop. Safe to call from signal handlers and
|
|
88
80
|
# other threads; returns immediately.
|
|
89
81
|
def stop
|
|
@@ -125,24 +117,84 @@ module Tonic
|
|
|
125
117
|
|
|
126
118
|
private
|
|
127
119
|
|
|
120
|
+
def run_cluster(install_signal_handlers)
|
|
121
|
+
@state = :running
|
|
122
|
+
Cluster.new(self, @configuration, @stop_signal).run(install_signal_handlers: install_signal_handlers) do
|
|
123
|
+
log_startup
|
|
124
|
+
yield self if block_given?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def serve(install_signal_handlers:, listener: nil)
|
|
129
|
+
Tonic::Rails._set_shutting_down(false)
|
|
130
|
+
@native = build_native_server
|
|
131
|
+
@native.set_listener_fd(listener.fileno) if listener
|
|
132
|
+
@native.start_server
|
|
133
|
+
# The transport holds its own copy now; let go of ours, so the
|
|
134
|
+
# socket closes once every worker has stopped accepting.
|
|
135
|
+
listener&.close
|
|
136
|
+
log_pump = spawn_log_pump
|
|
137
|
+
@health.attach(@native)
|
|
138
|
+
@state = :running
|
|
139
|
+
|
|
140
|
+
request_log = subscribe_request_logger
|
|
141
|
+
previous_handlers = install_signal_handlers ? trap_signals : {}
|
|
142
|
+
workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
|
|
143
|
+
log_startup unless worker?
|
|
144
|
+
yield self if block_given?
|
|
145
|
+
|
|
146
|
+
wait_for_stop(workers)
|
|
147
|
+
@health.detach
|
|
148
|
+
shutdown(workers)
|
|
149
|
+
ensure
|
|
150
|
+
ActiveSupport::Notifications.unsubscribe(request_log) if request_log
|
|
151
|
+
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
152
|
+
@health.detach
|
|
153
|
+
@native&.stop_server
|
|
154
|
+
stop_log_pump(log_pump)
|
|
155
|
+
@native = nil
|
|
156
|
+
Tonic::Rails._set_shutting_down(false)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# True in a worker process of a multi-process server, whose parent
|
|
160
|
+
# logs startup and shutdown for all of them.
|
|
161
|
+
def worker?
|
|
162
|
+
!@worker_index.nil?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def worker_label
|
|
166
|
+
worker? ? "worker #{@worker_index}: " : ""
|
|
167
|
+
end
|
|
168
|
+
|
|
128
169
|
def build_native_server
|
|
129
170
|
config = @configuration
|
|
130
171
|
native = TonicRails::Native::Server.new(config.bind_address, config.max_message_size)
|
|
131
172
|
native.on_request(build_callback)
|
|
132
173
|
native.set_routes(@router.paths)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
apply_connection_settings(native, config)
|
|
175
|
+
apply_features(native, config)
|
|
176
|
+
native
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def apply_connection_settings(native, config)
|
|
137
180
|
native.set_max_connections(config.max_connections)
|
|
138
181
|
native.set_concurrency_limit_per_connection(config.concurrency_limit_per_connection)
|
|
139
182
|
native.set_worker_threads(config.worker_threads || 0)
|
|
140
183
|
native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
|
|
141
184
|
native.set_dispatch_queue_size(config.dispatch_queue_size)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def apply_features(native, config)
|
|
188
|
+
if config.tls?
|
|
189
|
+
native.set_tls(config.tls_cert_path, config.tls_key_path)
|
|
190
|
+
native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
|
|
191
|
+
end
|
|
142
192
|
native.set_compression(config.compression ? true : false)
|
|
143
193
|
native.set_reflection(config.reflection ? true : false)
|
|
144
194
|
native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
|
|
145
|
-
|
|
195
|
+
return unless config.grpc_web
|
|
196
|
+
|
|
197
|
+
native.set_grpc_web(config.grpc_web_origins, config.grpc_web_allowed_headers, config.grpc_web_exposed_headers)
|
|
146
198
|
end
|
|
147
199
|
|
|
148
200
|
# The proc the transport calls for every RPC:
|
|
@@ -256,13 +308,15 @@ module Tonic
|
|
|
256
308
|
|
|
257
309
|
def shutdown(workers)
|
|
258
310
|
logger = @configuration.logger
|
|
311
|
+
# In a worker, the parent logs shutdown once for all of them.
|
|
312
|
+
lifecycle = worker? ? nil : logger
|
|
259
313
|
@state = :stopping
|
|
260
314
|
Tonic::Rails._set_shutting_down(true)
|
|
261
|
-
|
|
315
|
+
lifecycle&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
|
|
262
316
|
|
|
263
317
|
@native.begin_shutdown
|
|
264
318
|
unless @native.wait_for_drain(@configuration.drain_timeout.to_f)
|
|
265
|
-
logger&.warn("tonic-rails: drain timeout expired; closing remaining connections")
|
|
319
|
+
logger&.warn("tonic-rails: #{worker_label}drain timeout expired; closing remaining connections")
|
|
266
320
|
end
|
|
267
321
|
@native.stop_workers
|
|
268
322
|
workers.each do |worker|
|
|
@@ -270,7 +324,17 @@ module Tonic
|
|
|
270
324
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
271
325
|
# Already logged by the worker.
|
|
272
326
|
end
|
|
273
|
-
|
|
327
|
+
lifecycle&.info("tonic-rails: stopped")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def log_grpc_web(logger, config)
|
|
331
|
+
return unless config.grpc_web
|
|
332
|
+
|
|
333
|
+
origins = config.grpc_web_origins
|
|
334
|
+
logger.info("tonic-rails: gRPC-Web enabled (#{origins.empty? ? 'same-origin only' : "origins: #{origins.join(', ')}"})")
|
|
335
|
+
return unless origins.include?("*")
|
|
336
|
+
|
|
337
|
+
logger.warn("tonic-rails: gRPC-Web allows any origin; any website can call these services from a visitor's browser")
|
|
274
338
|
end
|
|
275
339
|
|
|
276
340
|
def log_startup
|
|
@@ -284,10 +348,10 @@ module Tonic
|
|
|
284
348
|
logger.warn("tonic-rails: TLS is not configured; gRPC traffic is plaintext")
|
|
285
349
|
end
|
|
286
350
|
logger.info("tonic-rails: reflection enabled") if config.reflection
|
|
287
|
-
logger
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
351
|
+
log_grpc_web(logger, config)
|
|
352
|
+
threads = "#{config.dispatch_threads} dispatch threads"
|
|
353
|
+
threads = "#{config.processes} processes × #{threads} each" if config.multi_process?
|
|
354
|
+
logger.info("tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} (#{threads})")
|
|
291
355
|
end
|
|
292
356
|
end
|
|
293
357
|
end
|
data/lib/tonic/rails/version.rb
CHANGED
data/lib/tonic-rails.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tonic-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matt Tanous
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- lib/puma/plugin/tonic_rails.rb
|
|
91
91
|
- lib/tonic-rails.rb
|
|
92
92
|
- lib/tonic/rails/base.rb
|
|
93
|
+
- lib/tonic/rails/cluster.rb
|
|
93
94
|
- lib/tonic/rails/configuration.rb
|
|
94
95
|
- lib/tonic/rails/health.rb
|
|
95
96
|
- lib/tonic/rails/instrumentation.rb
|