tonic-rails 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -190,6 +190,35 @@ pub fn sanitize_for_log(message: &str) -> String {
190
190
  .collect()
191
191
  }
192
192
 
193
+ /// Headers a handler may not set: they belong to HTTP/2 or the gRPC
194
+ /// protocol itself.
195
+ fn is_reserved_metadata(name: &str) -> bool {
196
+ matches!(
197
+ name,
198
+ "content-type" | "te" | "user-agent" | "content-length" | "connection" | "transfer-encoding"
199
+ ) || (name.starts_with("grpc-") && name != "grpc-status-details-bin")
200
+ }
201
+
202
+ /// Append handler-supplied metadata to `headers`, skipping names or values
203
+ /// that are invalid HTTP/2 headers or reserved by gRPC. Returns the names
204
+ /// that were skipped so the caller can report them.
205
+ pub fn append_metadata(headers: &mut HeaderMap, metadata: &[(String, String)]) -> Vec<String> {
206
+ let mut skipped = Vec::new();
207
+ for (name, value) in metadata {
208
+ let header = (!is_reserved_metadata(name))
209
+ .then(|| http::HeaderName::from_bytes(name.as_bytes()).ok())
210
+ .flatten()
211
+ .filter(|n| n.as_str() == name.as_str()); // must already be lower-case
212
+ match (header, HeaderValue::from_str(value)) {
213
+ (Some(header), Ok(value)) => {
214
+ headers.append(header, value);
215
+ }
216
+ _ => skipped.push(name.clone()),
217
+ }
218
+ }
219
+ skipped
220
+ }
221
+
193
222
  pub fn status_trailers(code: u32, message: &str) -> HeaderMap {
194
223
  let mut trailers = HeaderMap::with_capacity(2);
195
224
  trailers.insert("grpc-status", HeaderValue::from(code));
@@ -342,6 +371,27 @@ mod tests {
342
371
  assert!(t.get("grpc-message").is_none());
343
372
  }
344
373
 
374
+ #[test]
375
+ fn append_metadata_skips_reserved_and_invalid() {
376
+ let mut headers = HeaderMap::new();
377
+ let skipped = append_metadata(
378
+ &mut headers,
379
+ &[
380
+ ("x-request-id".into(), "abc".into()),
381
+ ("x-trace-bin".into(), "AAE=".into()),
382
+ ("grpc-status-details-bin".into(), "CAU=".into()),
383
+ ("grpc-status".into(), "0".into()),
384
+ ("content-type".into(), "text/plain".into()),
385
+ ("Upper".into(), "x".into()),
386
+ ("x-bad".into(), "line\nbreak".into()),
387
+ ],
388
+ );
389
+ assert_eq!(headers["x-request-id"], "abc");
390
+ assert_eq!(headers["x-trace-bin"], "AAE=");
391
+ assert_eq!(headers["grpc-status-details-bin"], "CAU=");
392
+ assert_eq!(skipped, ["grpc-status", "content-type", "Upper", "x-bad"]);
393
+ }
394
+
345
395
  #[test]
346
396
  fn sanitize_for_log_strips_controls() {
347
397
  assert_eq!(sanitize_for_log("a\nb\tc"), "a b c");
@@ -15,7 +15,11 @@ use std::sync::Arc;
15
15
  use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
16
16
  use std::task::{Context, Poll, Wake, Waker};
17
17
 
18
- use magnus::{Error, RString, Ruby, Value, prelude::*, typed_data::DataTypeFunctions, value::Opaque};
18
+ use crate::log::{Level, LogSink};
19
+ use magnus::{
20
+ Error, RHash, RString, Ruby, Value, prelude::*, r_hash::ForEach, typed_data::DataTypeFunctions,
21
+ value::Opaque,
22
+ };
19
23
  use parking_lot::Mutex;
20
24
  use rb_sys::{RB_NOGVL_INTR_FAIL, rb_nogvl, rb_thread_call_with_gvl};
21
25
  use tokio::sync::{Notify, mpsc};
@@ -193,35 +197,91 @@ pub fn send_with_interrupts<T>(ruby: &Ruby, tx: &mpsc::Sender<T>, mut msg: T) ->
193
197
 
194
198
  // ─── Response stream ──────────────────────────────────────────────
195
199
 
200
+ /// gRPC metadata as (lower-case name, value) pairs; `-bin` values are
201
+ /// already base64-encoded.
202
+ pub type Metadata = Vec<(String, String)>;
203
+
196
204
  /// Messages from the Ruby handler to the tokio response body.
197
205
  pub enum StreamMessage {
206
+ /// Response headers (initial metadata). Only ever the first message.
207
+ Headers(Metadata),
198
208
  /// One encoded protobuf response message.
199
209
  Data(Vec<u8>),
200
- /// End of stream: `None` = OK, `Some((code, message))` = error.
201
- End(Option<(u32, String)>),
210
+ /// End of stream: `status` None = OK, `Some((code, message))` = error,
211
+ /// plus trailing metadata.
212
+ End {
213
+ status: Option<(u32, String)>,
214
+ trailers: Metadata,
215
+ },
216
+ }
217
+
218
+ impl StreamMessage {
219
+ pub fn error(code: tonic::Code, message: &str) -> Self {
220
+ StreamMessage::End {
221
+ status: Some((code as u32, message.to_owned())),
222
+ trailers: Vec::new(),
223
+ }
224
+ }
225
+ }
226
+
227
+ /// Convert a Ruby Hash of String => String into metadata pairs.
228
+ fn metadata_from_hash(hash: RHash) -> Result<Metadata, Error> {
229
+ let mut pairs = Vec::with_capacity(hash.len());
230
+ hash.foreach(|name: String, value: String| {
231
+ pairs.push((name, value));
232
+ Ok(ForEach::Continue)
233
+ })?;
234
+ Ok(pairs)
202
235
  }
203
236
 
204
237
  /// `TonicRails::Native::StreamSender` — pushes encoded response messages
205
- /// to the client with backpressure.
238
+ /// (and response metadata) to the client with backpressure.
206
239
  #[derive(magnus::TypedData)]
207
240
  #[magnus(class = "TonicRails::Native::StreamSender", free_immediately)]
208
241
  pub struct StreamSender {
209
242
  tx: mpsc::Sender<StreamMessage>,
243
+ trailers: Arc<Mutex<Metadata>>,
244
+ started: AtomicBool,
210
245
  }
211
246
 
212
247
  impl StreamSender {
248
+ fn closed(ruby: &Ruby) -> Error {
249
+ Error::new(ruby.exception_io_error(), "response stream closed by peer")
250
+ }
251
+
213
252
  /// Raises `IOError` if the client has gone away.
214
253
  pub fn push(ruby: &Ruby, this: &Self, data: RString) -> Result<(), Error> {
254
+ this.started.store(true, Ordering::Release);
215
255
  let bytes = unsafe { data.as_slice() }.to_vec();
216
256
  if send_with_interrupts(ruby, &this.tx, StreamMessage::Data(bytes))? {
217
257
  Ok(())
218
258
  } else {
219
- Err(Error::new(
220
- ruby.exception_io_error(),
221
- "response stream closed by peer",
222
- ))
259
+ Err(Self::closed(ruby))
223
260
  }
224
261
  }
262
+
263
+ /// Send response headers. Must come before any message.
264
+ pub fn send_headers(ruby: &Ruby, this: &Self, headers: RHash) -> Result<(), Error> {
265
+ if this.started.swap(true, Ordering::AcqRel) {
266
+ return Err(Error::new(
267
+ ruby.exception_runtime_error(),
268
+ "response headers must be sent before any response message",
269
+ ));
270
+ }
271
+ let headers = metadata_from_hash(headers)?;
272
+ if send_with_interrupts(ruby, &this.tx, StreamMessage::Headers(headers))? {
273
+ Ok(())
274
+ } else {
275
+ Err(Self::closed(ruby))
276
+ }
277
+ }
278
+
279
+ /// Add trailing metadata, sent with the final status.
280
+ pub fn add_trailers(&self, trailers: RHash) -> Result<(), Error> {
281
+ let trailers = metadata_from_hash(trailers)?;
282
+ self.trailers.lock().extend(trailers);
283
+ Ok(())
284
+ }
225
285
  }
226
286
 
227
287
  impl DataTypeFunctions for StreamSender {}
@@ -308,6 +368,7 @@ pub struct DispatchContext {
308
368
  pub response_tx: mpsc::Sender<StreamMessage>,
309
369
  pub request_rx: mpsc::Receiver<Vec<u8>>,
310
370
  pub request_error: Arc<RequestError>,
371
+ pub log: LogSink,
311
372
  }
312
373
 
313
374
  /// Bounds on how long one thread keeps the GVL to drain a busy queue.
@@ -357,17 +418,15 @@ pub fn dispatch(first: Box<DispatchContext>, queue: &crossbeam_channel::Receiver
357
418
 
358
419
  fn run_guarded(ctx: DispatchContext) {
359
420
  let response_tx = ctx.response_tx.clone();
421
+ let log = ctx.log.clone();
360
422
  if let Err(panic) = catch_unwind(AssertUnwindSafe(|| run_handler(ctx))) {
361
423
  let msg = panic
362
424
  .downcast_ref::<String>()
363
425
  .map(String::as_str)
364
426
  .or_else(|| panic.downcast_ref::<&str>().copied())
365
427
  .unwrap_or("unknown panic");
366
- eprintln!("tonic-rails: panic in dispatch: {msg}");
367
- let _ = response_tx.try_send(StreamMessage::End(Some((
368
- tonic::Code::Internal as u32,
369
- "internal error".into(),
370
- ))));
428
+ log.log(Level::Error, &format!("panic in dispatch: {msg}"));
429
+ let _ = response_tx.try_send(StreamMessage::error(tonic::Code::Internal, "internal error"));
371
430
  }
372
431
  }
373
432
 
@@ -381,9 +440,11 @@ fn run_handler(ctx: DispatchContext) {
381
440
  response_tx,
382
441
  request_rx,
383
442
  request_error,
443
+ log,
384
444
  } = ctx;
385
445
 
386
- let end = match call_handler(
446
+ let trailers = Arc::new(Mutex::new(Metadata::new()));
447
+ let result = call_handler(
387
448
  &ruby,
388
449
  callback,
389
450
  &path,
@@ -392,9 +453,11 @@ fn run_handler(ctx: DispatchContext) {
392
453
  &response_tx,
393
454
  request_rx,
394
455
  request_error,
395
- ) {
396
- Ok(()) => StreamMessage::End(None),
397
- Err(e) => StreamMessage::End(Some(error_status(&e))),
456
+ trailers.clone(),
457
+ );
458
+ let end = StreamMessage::End {
459
+ status: result.err().map(|e| error_status(&e, &log)),
460
+ trailers: std::mem::take(&mut *trailers.lock()),
398
461
  };
399
462
  // Ignore failures: the client may already be gone.
400
463
  let _ = send_with_interrupts(&ruby, &response_tx, end);
@@ -410,6 +473,7 @@ fn call_handler(
410
473
  response_tx: &mpsc::Sender<StreamMessage>,
411
474
  request_rx: mpsc::Receiver<Vec<u8>>,
412
475
  request_error: Arc<RequestError>,
476
+ trailers: Arc<Mutex<Metadata>>,
413
477
  ) -> Result<(), Error> {
414
478
  let md = ruby.hash_new_capa(metadata.len());
415
479
  for (k, v) in metadata {
@@ -425,6 +489,8 @@ fn call_handler(
425
489
  ruby.to_symbol("sender"),
426
490
  StreamSender {
427
491
  tx: response_tx.clone(),
492
+ trailers,
493
+ started: AtomicBool::new(false),
428
494
  },
429
495
  )?;
430
496
  if let Some(ms) = timeout_ms {
@@ -451,7 +517,7 @@ fn call_handler(
451
517
  /// Map a Ruby exception to a gRPC status. Only exceptions carrying an
452
518
  /// Integer `@code` (i.e. `Tonic::Rails::RpcError`) expose their message;
453
519
  /// anything else becomes a generic INTERNAL so details never leak.
454
- fn error_status(e: &Error) -> (u32, String) {
520
+ fn error_status(e: &Error, log: &LogSink) -> (u32, String) {
455
521
  if let Some(exc) = e.value() {
456
522
  let code = exc
457
523
  .funcall::<_, _, Option<i64>>("instance_variable_get", ("@code",))
@@ -464,9 +530,9 @@ fn error_status(e: &Error) -> (u32, String) {
464
530
  let description = exc
465
531
  .funcall::<_, _, String>("inspect", ())
466
532
  .unwrap_or_else(|_| "unknown exception".into());
467
- eprintln!(
468
- "tonic-rails: unhandled exception in dispatch: {}",
469
- crate::framing::sanitize_for_log(&description)
533
+ log.log(
534
+ Level::Error,
535
+ &format!("unhandled exception in dispatch: {description}"),
470
536
  );
471
537
  }
472
538
  (tonic::Code::Internal as u32, "internal error".into())
@@ -22,6 +22,7 @@
22
22
  //! - [`body`] — response bodies and request stats
23
23
  //! - [`dispatch`] — the fallback tower service that queues RPCs for Ruby
24
24
  //! - [`gvl`] — GVL release/acquire and the Ruby stream objects
25
+ //! - [`log`] — transport log events for Ruby's logger
25
26
  //! - [`net`] — listener and connection cap
26
27
  //! - [`server`] — `TonicRails::Native::Server`
27
28
 
@@ -29,6 +30,7 @@ mod body;
29
30
  mod dispatch;
30
31
  mod framing;
31
32
  mod gvl;
33
+ mod log;
32
34
  mod net;
33
35
  mod server;
34
36
 
@@ -60,7 +62,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
60
62
  "set_dispatch_queue_size",
61
63
  method!(NativeServer::set_dispatch_queue_size, 1),
62
64
  )?;
63
- class.define_method("set_log_requests", method!(NativeServer::set_log_requests, 1))?;
64
65
  class.define_method("set_compression", method!(NativeServer::set_compression, 1))?;
65
66
  class.define_method("set_reflection", method!(NativeServer::set_reflection, 1))?;
66
67
  class.define_method("set_routes", method!(NativeServer::set_routes, 1))?;
@@ -74,12 +75,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
74
75
  class.define_method("wait_for_drain", method!(NativeServer::wait_for_drain, 1))?;
75
76
  class.define_method("stop_workers", method!(NativeServer::stop_workers, 0))?;
76
77
  class.define_method("stop_server", method!(NativeServer::stop_server, 0))?;
78
+ class.define_method("next_log_events", method!(NativeServer::next_log_events, 1))?;
77
79
  class.define_method("running?", method!(NativeServer::is_running, 0))?;
78
80
  class.define_method("stats", method!(NativeServer::stats, 0))?;
79
81
 
80
82
  let sender = native.define_class("StreamSender", ruby.class_object())?;
81
83
  sender.undef_default_alloc_func();
82
84
  sender.define_method("push", method!(StreamSender::push, 1))?;
85
+ sender.define_method("send_headers", method!(StreamSender::send_headers, 1))?;
86
+ sender.define_method("add_trailers", method!(StreamSender::add_trailers, 1))?;
83
87
 
84
88
  let reader = native.define_class("RequestReader", ruby.class_object())?;
85
89
  reader.undef_default_alloc_func();
@@ -0,0 +1,131 @@
1
+ //! Transport log events, delivered to Ruby's logger.
2
+ //!
3
+ //! tokio threads cannot call Ruby, so events go through a bounded channel
4
+ //! that a Ruby thread drains (`Server#next_log_events`). If nobody is
5
+ //! draining, or the channel is full, events fall back to stderr.
6
+
7
+ use crossbeam_channel::{Receiver, Sender, TrySendError};
8
+ use tonic::Code;
9
+
10
+ use crate::framing::sanitize_for_log;
11
+
12
+ /// Events buffered before falling back to stderr.
13
+ const CAPACITY: usize = 1024;
14
+
15
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
16
+ pub enum Level {
17
+ Info,
18
+ Warn,
19
+ Error,
20
+ }
21
+
22
+ impl Level {
23
+ /// Client-caused outcomes are informational; overload is a warning;
24
+ /// anything that suggests a server fault is an error.
25
+ pub fn for_code(code: u32) -> Level {
26
+ match Code::from_i32(code as i32) {
27
+ Code::Internal | Code::Unknown | Code::DataLoss => Level::Error,
28
+ Code::ResourceExhausted | Code::Unavailable => Level::Warn,
29
+ _ => Level::Info,
30
+ }
31
+ }
32
+
33
+ pub fn as_str(self) -> &'static str {
34
+ match self {
35
+ Level::Info => "info",
36
+ Level::Warn => "warn",
37
+ Level::Error => "error",
38
+ }
39
+ }
40
+ }
41
+
42
+ const STATUS_NAMES: [&str; 17] = [
43
+ "OK",
44
+ "CANCELLED",
45
+ "UNKNOWN",
46
+ "INVALID_ARGUMENT",
47
+ "DEADLINE_EXCEEDED",
48
+ "NOT_FOUND",
49
+ "ALREADY_EXISTS",
50
+ "PERMISSION_DENIED",
51
+ "RESOURCE_EXHAUSTED",
52
+ "FAILED_PRECONDITION",
53
+ "ABORTED",
54
+ "OUT_OF_RANGE",
55
+ "UNIMPLEMENTED",
56
+ "INTERNAL",
57
+ "UNAVAILABLE",
58
+ "DATA_LOSS",
59
+ "UNAUTHENTICATED",
60
+ ];
61
+
62
+ /// The canonical gRPC status name, e.g. `NOT_FOUND`.
63
+ pub fn status_name(code: u32) -> &'static str {
64
+ STATUS_NAMES.get(code as usize).copied().unwrap_or("UNKNOWN")
65
+ }
66
+
67
+ pub struct LogEvent {
68
+ pub level: Level,
69
+ pub message: String,
70
+ }
71
+
72
+ #[derive(Clone)]
73
+ pub struct LogSink {
74
+ tx: Sender<LogEvent>,
75
+ }
76
+
77
+ impl LogSink {
78
+ pub fn new() -> (Self, Receiver<LogEvent>) {
79
+ let (tx, rx) = crossbeam_channel::bounded(CAPACITY);
80
+ (Self { tx }, rx)
81
+ }
82
+
83
+ pub fn log(&self, level: Level, message: &str) {
84
+ let event = LogEvent {
85
+ level,
86
+ message: sanitize_for_log(message),
87
+ };
88
+ match self.tx.try_send(event) {
89
+ Ok(()) => {}
90
+ Err(TrySendError::Full(event) | TrySendError::Disconnected(event)) => {
91
+ eprintln!("tonic-rails: [{}] {}", event.level.as_str(), event.message);
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ impl Default for LogSink {
98
+ /// A sink nobody drains: every event goes to stderr.
99
+ fn default() -> Self {
100
+ Self::new().0
101
+ }
102
+ }
103
+
104
+ #[cfg(test)]
105
+ mod tests {
106
+ use super::*;
107
+
108
+ #[test]
109
+ fn levels_by_status() {
110
+ assert_eq!(Level::for_code(Code::Internal as u32), Level::Error);
111
+ assert_eq!(Level::for_code(Code::ResourceExhausted as u32), Level::Warn);
112
+ assert_eq!(Level::for_code(Code::Unimplemented as u32), Level::Info);
113
+ assert_eq!(Level::for_code(Code::DeadlineExceeded as u32), Level::Info);
114
+ }
115
+
116
+ #[test]
117
+ fn status_names() {
118
+ assert_eq!(status_name(0), "OK");
119
+ assert_eq!(status_name(12), "UNIMPLEMENTED");
120
+ assert_eq!(status_name(99), "UNKNOWN");
121
+ }
122
+
123
+ #[test]
124
+ fn events_reach_the_receiver_sanitized() {
125
+ let (sink, rx) = LogSink::new();
126
+ sink.log(Level::Warn, "queue full\nforged line");
127
+ let event = rx.try_recv().expect("event");
128
+ assert_eq!(event.level, Level::Warn);
129
+ assert_eq!(event.message, "queue full forged line");
130
+ }
131
+ }
@@ -14,6 +14,8 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore};
14
14
  use tokio_stream::Stream;
15
15
  use tonic::transport::server::{Connected, TcpConnectInfo};
16
16
 
17
+ use crate::log::{Level, LogSink};
18
+
17
19
  /// Bind with SO_REUSEADDR + SO_REUSEPORT so several worker processes can
18
20
  /// share a port (the kernel load-balances accepts) and restarts don't
19
21
  /// fail on TIME_WAIT. Must be called inside a tokio runtime context.
@@ -110,6 +112,7 @@ pub struct LimitedIncoming<S> {
110
112
  pub inner: S,
111
113
  pub semaphore: Arc<Semaphore>,
112
114
  pub active: Arc<AtomicUsize>,
115
+ pub log: LogSink,
113
116
  }
114
117
 
115
118
  impl<S> Stream for LimitedIncoming<S>
@@ -138,7 +141,10 @@ where
138
141
  }
139
142
  Err(_) => {
140
143
  let active = self.active.load(Ordering::Relaxed);
141
- eprintln!("tonic-rails: connection refused: max_connections reached ({active} active)");
144
+ self.log.log(
145
+ Level::Warn,
146
+ &format!("connection refused: max_connections reached ({active} active)"),
147
+ );
142
148
  }
143
149
  }
144
150
  }
@@ -32,6 +32,7 @@ use tonic_health::server::HealthReporter;
32
32
  use crate::body::ServerStats;
33
33
  use crate::dispatch::{DispatchInner, DispatchService};
34
34
  use crate::gvl::{self, Cancel, DispatchContext};
35
+ use crate::log::{Level, LogEvent, LogSink};
35
36
  use crate::net::{LimitedIncoming, bind};
36
37
 
37
38
  /// The overall-server health entry (empty service name).
@@ -91,6 +92,8 @@ pub struct NativeServer {
91
92
  stats: Arc<ServerStats>,
92
93
  compression: Arc<AtomicBool>,
93
94
  active_connections: Arc<AtomicUsize>,
95
+ /// Transport log events waiting for Ruby (`next_log_events`).
96
+ log_rx: crossbeam_channel::Receiver<LogEvent>,
94
97
  }
95
98
 
96
99
  impl DataTypeFunctions for NativeServer {
@@ -128,15 +131,20 @@ impl NativeServer {
128
131
  bind_address
129
132
  .parse::<SocketAddr>()
130
133
  .map_err(|e| arg_error(ruby, format!("invalid bind address {bind_address:?}: {e}")))?;
134
+ let (log, log_rx) = LogSink::new();
131
135
  Ok(Self {
132
136
  bind_address,
133
137
  max_message_size,
134
138
  callback: Mutex::new(None),
135
139
  options: Mutex::new(Options::default()),
136
140
  running: Mutex::new(None),
137
- stats: Arc::new(ServerStats::default()),
141
+ stats: Arc::new(ServerStats {
142
+ log,
143
+ ..ServerStats::default()
144
+ }),
138
145
  compression: Arc::new(AtomicBool::new(false)),
139
146
  active_connections: Arc::new(AtomicUsize::new(0)),
147
+ log_rx,
140
148
  })
141
149
  }
142
150
 
@@ -187,10 +195,6 @@ impl NativeServer {
187
195
  self.options.lock().dispatch_queue_size = size.max(1);
188
196
  }
189
197
 
190
- pub fn set_log_requests(&self, enabled: bool) {
191
- self.stats.log_requests.store(enabled, Ordering::Relaxed);
192
- }
193
-
194
198
  pub fn set_compression(&self, enabled: bool) {
195
199
  self.compression.store(enabled, Ordering::Relaxed);
196
200
  }
@@ -353,16 +357,18 @@ impl NativeServer {
353
357
  inner: tokio_stream::wrappers::TcpListenerStream::new(listener),
354
358
  semaphore: Arc::new(Semaphore::new(permits)),
355
359
  active: this.active_connections.clone(),
360
+ log: this.stats.log.clone(),
356
361
  };
357
362
 
358
363
  let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
359
364
  let (done_tx, done_rx) = crossbeam_channel::bounded(1);
365
+ let log = this.stats.log.clone();
360
366
  runtime.spawn(async move {
361
367
  let signal = async move {
362
368
  let _ = shutdown_rx.wait_for(|stop| *stop).await;
363
369
  };
364
370
  if let Err(e) = router.serve_with_incoming_shutdown(incoming, signal).await {
365
- eprintln!("tonic-rails: server error: {e}");
371
+ log.log(Level::Error, &format!("server error: {e}"));
366
372
  }
367
373
  let _ = done_tx.send(());
368
374
  });
@@ -478,6 +484,40 @@ impl NativeServer {
478
484
  }
479
485
  }
480
486
 
487
+ /// Wait up to `timeout` seconds (GVL released) for transport log
488
+ /// events and return them as `[[level, message], ...]`, oldest first.
489
+ /// Returns an empty Array on timeout.
490
+ pub fn next_log_events(ruby: &Ruby, this: &Self, timeout: f64) -> Result<RArray, Error> {
491
+ let cancel = Cancel::default();
492
+ let rx = &this.log_rx;
493
+ let deadline = Instant::now() + secs(timeout);
494
+ let first = gvl::without_gvl(&cancel, || {
495
+ while !cancel.is_cancelled() {
496
+ let remaining = deadline.saturating_duration_since(Instant::now());
497
+ if remaining.is_zero() {
498
+ return None;
499
+ }
500
+ match rx.recv_timeout(remaining.min(Duration::from_millis(100))) {
501
+ Ok(event) => return Some(event),
502
+ Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
503
+ Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return None,
504
+ }
505
+ }
506
+ None
507
+ })
508
+ .flatten();
509
+
510
+ let events = ruby.ary_new();
511
+ for event in first.into_iter().chain(rx.try_iter().take(255)) {
512
+ events.push((ruby.to_symbol(event.level.as_str()), event.message))?;
513
+ }
514
+ if events.is_empty() {
515
+ // Interrupted: let Ruby run trap handlers / raise before retrying.
516
+ ruby.thread_check_ints()?;
517
+ }
518
+ Ok(events)
519
+ }
520
+
481
521
  pub fn is_running(&self) -> bool {
482
522
  self.running.lock().is_some()
483
523
  }
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma/plugin"
4
+
5
+ # Runs the tonic-rails gRPC server alongside Puma:
6
+ #
7
+ # # config/puma.rb
8
+ # plugin :tonic_rails
9
+ #
10
+ # The gRPC server runs as one child process of the Puma master, in single
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
13
+ # stops or restarts, stops itself if Puma goes away, and if it dies
14
+ # unexpectedly Puma shuts down too, so a supervisor can restart both.
15
+ Puma::Plugin.create do
16
+ def start(launcher)
17
+ @launcher = launcher
18
+ unless Process.respond_to?(:fork)
19
+ log "tonic-rails: the Puma plugin needs fork(), which this platform lacks; " \
20
+ "run `bin/rails tonic_rails:serve` as a separate process instead"
21
+ return
22
+ end
23
+
24
+ @puma_pid = Process.pid
25
+ @grpc_pid = nil
26
+
27
+ events = launcher.events
28
+ if events.respond_to?(:after_booted) # Puma 7+
29
+ events.after_booted { start_grpc }
30
+ events.after_stopped { stop_grpc }
31
+ events.before_restart { stop_grpc }
32
+ else
33
+ events.on_booted { start_grpc }
34
+ events.on_stopped { stop_grpc }
35
+ events.on_restart { stop_grpc }
36
+ end
37
+
38
+ in_background { monitor_grpc }
39
+ end
40
+
41
+ private
42
+
43
+ def start_grpc
44
+ @grpc_pid = fork { run_grpc_server }
45
+ log "tonic-rails: gRPC server started (pid #{@grpc_pid})"
46
+ end
47
+
48
+ def run_grpc_server
49
+ # The web listeners belong to Puma; don't hold them open in this process.
50
+ @launcher.binder.ios.each { |io| io.close rescue nil } # rubocop:disable Style/RescueModifier
51
+ load_rails_app
52
+ Thread.new { stop_when_puma_exits }
53
+ Tonic::Rails.server.start
54
+ end
55
+
56
+ # `rails server` and preload_app! boot the app before forking; plain
57
+ # `puma` in cluster mode does not, so boot it here.
58
+ def load_rails_app
59
+ return if defined?(Rails.application) && Rails.application&.initialized?
60
+
61
+ require File.expand_path("config/environment", Dir.pwd)
62
+ end
63
+
64
+ # 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
+ # then kill it.
67
+ def stop_grpc
68
+ pid = @grpc_pid
69
+ return unless pid
70
+
71
+ @grpc_pid = nil
72
+ log "tonic-rails: stopping gRPC server (pid #{pid})"
73
+ Process.kill(:TERM, pid)
74
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + stop_timeout
75
+ until Process.waitpid(pid, Process::WNOHANG)
76
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
77
+ log "tonic-rails: gRPC server (pid #{pid}) did not stop in #{stop_timeout}s; killing it"
78
+ Process.kill(:KILL, pid)
79
+ Process.wait(pid)
80
+ break
81
+ end
82
+ sleep 0.05
83
+ end
84
+ rescue Errno::ESRCH, Errno::ECHILD
85
+ nil
86
+ end
87
+
88
+ def stop_timeout
89
+ drain = defined?(Tonic::Rails) ? Tonic::Rails.configuration.drain_timeout.to_f : 10.0
90
+ drain + 5
91
+ end
92
+
93
+ def monitor_grpc
94
+ loop do
95
+ sleep 1
96
+ pid = @grpc_pid
97
+ next unless pid && Process.waitpid(pid, Process::WNOHANG)
98
+
99
+ @grpc_pid = nil
100
+ log "tonic-rails: gRPC server (pid #{pid}) exited unexpectedly; stopping Puma"
101
+ Process.kill(:TERM, @puma_pid)
102
+ break
103
+ rescue Errno::ECHILD
104
+ nil
105
+ end
106
+ end
107
+
108
+ def stop_when_puma_exits
109
+ sleep 1 while Process.ppid == @puma_pid
110
+ Tonic::Rails.server.stop
111
+ end
112
+
113
+ def log(message)
114
+ @launcher.log_writer.log(message)
115
+ end
116
+ end