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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1578d591791ff33ce8365ae3fe5b5a6bb5dfbac8759e4f9762c7c511e71057a
4
- data.tar.gz: e23782ccd2cf964d5db2cf25c98547780525e20d7880f0dab85a0c4c956b45cd
3
+ metadata.gz: 234f197083161b48c0fa820f8e9e8bb20138208c131e8467279cd40d9b73d872
4
+ data.tar.gz: a5ec7ea58b4325c881045c7550468a19de879b7119212e212500c4632e581f56
5
5
  SHA512:
6
- metadata.gz: 616f736a4e29cd3fcf8fd4297d6159d9abb145f3973b27a08f6f2c153bf7f5ae87422870ef304dab552e62f8ef7b3037716432c89bfd5d12088ef97e1ae58166
7
- data.tar.gz: 07a9749e9188700473883761291731a3f0b947bcdf4d986cd55df81dcb658229863149cfda1174a186a0cfe321e8e09810b7c1113232fefdc38c0ca2f1bba962
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.3.0...HEAD
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.3.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
- ### Sizing threads
495
+ ### Threads or processes
453
496
 
454
497
  Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
455
- HTTP) releases the GVL, so more threads means more concurrent I/O. CPU
456
- work does not parallelise within a process. Set threads to the
457
- concurrency your I/O needs, size the database pool to match, and add
458
- processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
459
- several processes can bind the same port.
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.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 enables SO_REUSEPORT for multi-process (Puma worker) scaling.
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::{TcpListener, TcpStream};
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
- /// Bind with SO_REUSEADDR + SO_REUSEPORT so several worker processes can
20
- /// share a port (the kernel load-balances accepts) and restarts don't
21
- /// fail on TIME_WAIT. Must be called inside a tokio runtime context.
22
- pub fn bind(addr: SocketAddr, backlog: i32) -> std::io::Result<TcpListener> {
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
- TcpListener::from_std(socket.into())
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 listener = {
422
+ let accept = {
302
423
  let _guard = runtime.enter();
303
- bind(addr, 1024).map_err(|e| runtime_error(ruby, format!("failed to bind {addr}: {e}")))?
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
- let router = builder.add_routes(grpc_routes);
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: tokio_stream::wrappers::TcpListenerStream::new(listener),
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. It starts once Puma has booted, drains gracefully when Puma
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 + 5
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
@@ -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"
@@ -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
- def start(install_signal_handlers: true)
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
- @native = build_native_server
60
- @native.start_server
61
- log_pump = spawn_log_pump
62
- @health.attach(@native)
63
- @state = :running
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
- if config.tls?
134
- native.set_tls(config.tls_cert_path, config.tls_key_path)
135
- native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
136
- end
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
- native
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
- logger&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
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
- logger&.info("tonic-rails: stopped")
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.info(
288
- "tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} " \
289
- "(#{config.dispatch_threads} dispatch threads)",
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -19,6 +19,7 @@ require "tonic/rails/instrumentation"
19
19
  require "tonic/rails/base"
20
20
  require "tonic/rails/health"
21
21
  require "tonic/rails/reflection"
22
+ require "tonic/rails/cluster"
22
23
  require "tonic/rails/server"
23
24
 
24
25
  module Tonic
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.3.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