tonic-rails 0.3.0-aarch64-linux-gnu → 0.5.0-aarch64-linux-gnu

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: 0cd959f610ed733aa69ee1f52681c13806034c5d4616518330a9099a06b8fc67
4
- data.tar.gz: 82df7fc13c7b139faebd96d20af68578e960fe6a294358b1cbca9bc7212f6b76
3
+ metadata.gz: 83071391bb348390f9956aebc1bfc867e92624981e5ceb2e3b71750698d0fe37
4
+ data.tar.gz: f23b34b99227c1c4e45ae18718f59338c4671ca59fc8ed9f57d605c238dd7698
5
5
  SHA512:
6
- metadata.gz: eece269128b405c5b950230e6d7ff23120bf7804ada06a643d8e1bd35d6846c4acb27fd253a0d7c876f7c93fae168b77977155f12428171935b601c2b142d574
7
- data.tar.gz: 02f60f7efd124eaf131e61c6f7ffde60c68efa6031d5c7f2416242390b0c4339aa4bb54abfc1a3e8a7a1aeffd05714a281650f339d9b44e5c35660db0ecc7eea
6
+ metadata.gz: 6318e95784a88fc5969ef9b4efe6cc6302b841f9bd0ea7db694ea364224e45a2913e8b451d071a5b8efe54a6466e297ff61c73ed12d13529d21394d38351ab56
7
+ data.tar.gz: f72d308a70f5a8fd446fb61ae35affd0a02f468d70f9ab49d80ca11dca673d7b0ecb0d5d067e0665b8d1a5f1a8e542c9a7713c71dd5cf02425d0ca69bb7fceda
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/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
 
@@ -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: aarch64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
@@ -67,6 +67,7 @@ files:
67
67
  - lib/tonic/rails/3.4/tonic_rails_native.so
68
68
  - lib/tonic/rails/4.0/tonic_rails_native.so
69
69
  - lib/tonic/rails/base.rb
70
+ - lib/tonic/rails/cluster.rb
70
71
  - lib/tonic/rails/configuration.rb
71
72
  - lib/tonic/rails/health.rb
72
73
  - lib/tonic/rails/instrumentation.rb