tonic-rails 0.1.0 → 0.3.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/Cargo.lock +5 -5
  4. data/README.md +204 -13
  5. data/ext/tonic_rails_native/Cargo.toml +2 -2
  6. data/ext/tonic_rails_native/src/body.rs +113 -49
  7. data/ext/tonic_rails_native/src/dispatch.rs +67 -8
  8. data/ext/tonic_rails_native/src/framing.rs +50 -0
  9. data/ext/tonic_rails_native/src/gvl.rs +109 -21
  10. data/ext/tonic_rails_native/src/lib.rs +6 -1
  11. data/ext/tonic_rails_native/src/log.rs +131 -0
  12. data/ext/tonic_rails_native/src/net.rs +7 -1
  13. data/ext/tonic_rails_native/src/server.rs +67 -6
  14. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  15. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  16. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  17. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  18. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  19. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  20. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  21. data/lib/puma/plugin/tonic_rails.rb +116 -0
  22. data/lib/tonic/rails/base.rb +169 -23
  23. data/lib/tonic/rails/configuration.rb +27 -4
  24. data/lib/tonic/rails/health.rb +105 -0
  25. data/lib/tonic/rails/instrumentation.rb +78 -0
  26. data/lib/tonic/rails/metadata.rb +50 -0
  27. data/lib/tonic/rails/peer.rb +60 -0
  28. data/lib/tonic/rails/railtie.rb +2 -2
  29. data/lib/tonic/rails/router.rb +1 -0
  30. data/lib/tonic/rails/rpc_error.rb +73 -6
  31. data/lib/tonic/rails/server.rb +82 -12
  32. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  33. data/lib/tonic/rails/testing/errors.rb +47 -0
  34. data/lib/tonic/rails/testing/minitest.rb +44 -0
  35. data/lib/tonic/rails/testing/rspec.rb +53 -0
  36. data/lib/tonic/rails/testing.rb +35 -4
  37. data/lib/tonic/rails/version.rb +1 -1
  38. data/lib/tonic-rails.rb +4 -0
  39. metadata +17 -1
@@ -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,16 @@ 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,
372
+ pub peer: Peer,
373
+ }
374
+
375
+ /// The client connection: remote address and, with mutual TLS, its
376
+ /// certificate chain (DER, leaf first).
377
+ #[derive(Default)]
378
+ pub struct Peer {
379
+ pub address: Option<String>,
380
+ pub certificates: Vec<Vec<u8>>,
311
381
  }
312
382
 
313
383
  /// Bounds on how long one thread keeps the GVL to drain a busy queue.
@@ -357,17 +427,15 @@ pub fn dispatch(first: Box<DispatchContext>, queue: &crossbeam_channel::Receiver
357
427
 
358
428
  fn run_guarded(ctx: DispatchContext) {
359
429
  let response_tx = ctx.response_tx.clone();
430
+ let log = ctx.log.clone();
360
431
  if let Err(panic) = catch_unwind(AssertUnwindSafe(|| run_handler(ctx))) {
361
432
  let msg = panic
362
433
  .downcast_ref::<String>()
363
434
  .map(String::as_str)
364
435
  .or_else(|| panic.downcast_ref::<&str>().copied())
365
436
  .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
- ))));
437
+ log.log(Level::Error, &format!("panic in dispatch: {msg}"));
438
+ let _ = response_tx.try_send(StreamMessage::error(tonic::Code::Internal, "internal error"));
371
439
  }
372
440
  }
373
441
 
@@ -381,9 +449,12 @@ fn run_handler(ctx: DispatchContext) {
381
449
  response_tx,
382
450
  request_rx,
383
451
  request_error,
452
+ log,
453
+ peer,
384
454
  } = ctx;
385
455
 
386
- let end = match call_handler(
456
+ let trailers = Arc::new(Mutex::new(Metadata::new()));
457
+ let result = call_handler(
387
458
  &ruby,
388
459
  callback,
389
460
  &path,
@@ -392,9 +463,12 @@ fn run_handler(ctx: DispatchContext) {
392
463
  &response_tx,
393
464
  request_rx,
394
465
  request_error,
395
- ) {
396
- Ok(()) => StreamMessage::End(None),
397
- Err(e) => StreamMessage::End(Some(error_status(&e))),
466
+ trailers.clone(),
467
+ &peer,
468
+ );
469
+ let end = StreamMessage::End {
470
+ status: result.err().map(|e| error_status(&e, &log)),
471
+ trailers: std::mem::take(&mut *trailers.lock()),
398
472
  };
399
473
  // Ignore failures: the client may already be gone.
400
474
  let _ = send_with_interrupts(&ruby, &response_tx, end);
@@ -410,6 +484,8 @@ fn call_handler(
410
484
  response_tx: &mpsc::Sender<StreamMessage>,
411
485
  request_rx: mpsc::Receiver<Vec<u8>>,
412
486
  request_error: Arc<RequestError>,
487
+ trailers: Arc<Mutex<Metadata>>,
488
+ peer: &Peer,
413
489
  ) -> Result<(), Error> {
414
490
  let md = ruby.hash_new_capa(metadata.len());
415
491
  for (k, v) in metadata {
@@ -425,11 +501,23 @@ fn call_handler(
425
501
  ruby.to_symbol("sender"),
426
502
  StreamSender {
427
503
  tx: response_tx.clone(),
504
+ trailers,
505
+ started: AtomicBool::new(false),
428
506
  },
429
507
  )?;
430
508
  if let Some(ms) = timeout_ms {
431
509
  call.aset(ruby.to_symbol("timeout_ms"), ms as u64)?;
432
510
  }
511
+ if let Some(address) = &peer.address {
512
+ call.aset(ruby.to_symbol("peer_address"), ruby.str_new(address))?;
513
+ }
514
+ if !peer.certificates.is_empty() {
515
+ let certificates = ruby.ary_new_capa(peer.certificates.len());
516
+ for der in &peer.certificates {
517
+ certificates.push(ruby.str_from_slice(der))?;
518
+ }
519
+ call.aset(ruby.to_symbol("peer_certificates"), certificates)?;
520
+ }
433
521
 
434
522
  let callback = ruby.get_inner(callback);
435
523
  let result = callback.funcall::<_, _, Value>("call", (ruby.str_new(path), md, call));
@@ -451,7 +539,7 @@ fn call_handler(
451
539
  /// Map a Ruby exception to a gRPC status. Only exceptions carrying an
452
540
  /// Integer `@code` (i.e. `Tonic::Rails::RpcError`) expose their message;
453
541
  /// anything else becomes a generic INTERNAL so details never leak.
454
- fn error_status(e: &Error) -> (u32, String) {
542
+ fn error_status(e: &Error, log: &LogSink) -> (u32, String) {
455
543
  if let Some(exc) = e.value() {
456
544
  let code = exc
457
545
  .funcall::<_, _, Option<i64>>("instance_variable_get", ("@code",))
@@ -464,9 +552,9 @@ fn error_status(e: &Error) -> (u32, String) {
464
552
  let description = exc
465
553
  .funcall::<_, _, String>("inspect", ())
466
554
  .unwrap_or_else(|_| "unknown exception".into());
467
- eprintln!(
468
- "tonic-rails: unhandled exception in dispatch: {}",
469
- crate::framing::sanitize_for_log(&description)
555
+ log.log(
556
+ Level::Error,
557
+ &format!("unhandled exception in dispatch: {description}"),
470
558
  );
471
559
  }
472
560
  (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))?;
@@ -70,16 +71,20 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
70
71
  )?;
71
72
  class.define_method("start_server", method!(NativeServer::start_server, 0))?;
72
73
  class.define_method("serve_worker", method!(NativeServer::serve_worker, 0))?;
74
+ class.define_method("set_health", method!(NativeServer::set_health, 2))?;
73
75
  class.define_method("begin_shutdown", method!(NativeServer::begin_shutdown, 0))?;
74
76
  class.define_method("wait_for_drain", method!(NativeServer::wait_for_drain, 1))?;
75
77
  class.define_method("stop_workers", method!(NativeServer::stop_workers, 0))?;
76
78
  class.define_method("stop_server", method!(NativeServer::stop_server, 0))?;
79
+ class.define_method("next_log_events", method!(NativeServer::next_log_events, 1))?;
77
80
  class.define_method("running?", method!(NativeServer::is_running, 0))?;
78
81
  class.define_method("stats", method!(NativeServer::stats, 0))?;
79
82
 
80
83
  let sender = native.define_class("StreamSender", ruby.class_object())?;
81
84
  sender.undef_default_alloc_func();
82
85
  sender.define_method("push", method!(StreamSender::push, 1))?;
86
+ sender.define_method("send_headers", method!(StreamSender::send_headers, 1))?;
87
+ sender.define_method("add_trailers", method!(StreamSender::add_trailers, 1))?;
83
88
 
84
89
  let reader = native.define_class("RequestReader", ruby.class_object())?;
85
90
  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
  });
@@ -403,6 +409,27 @@ impl NativeServer {
403
409
  Ok(())
404
410
  }
405
411
 
412
+ /// Set the grpc.health.v1 status of one service ("" = the whole
413
+ /// server). Returns false if the server isn't running.
414
+ pub fn set_health(&self, service: String, serving: bool) -> bool {
415
+ let Some(r) = self
416
+ .running
417
+ .lock()
418
+ .as_ref()
419
+ .map(|r| (r.runtime.handle().clone(), r.health.clone()))
420
+ else {
421
+ return false;
422
+ };
423
+ let (handle, health) = r;
424
+ let status = if serving {
425
+ ServingStatus::Serving
426
+ } else {
427
+ ServingStatus::NotServing
428
+ };
429
+ handle.block_on(health.set_service_status(service, status));
430
+ true
431
+ }
432
+
406
433
  /// Mark everything NOT_SERVING and stop accepting new connections.
407
434
  /// Existing connections receive GOAWAY; in-flight RPCs continue.
408
435
  pub fn begin_shutdown(&self) {
@@ -478,6 +505,40 @@ impl NativeServer {
478
505
  }
479
506
  }
480
507
 
508
+ /// Wait up to `timeout` seconds (GVL released) for transport log
509
+ /// events and return them as `[[level, message], ...]`, oldest first.
510
+ /// Returns an empty Array on timeout.
511
+ pub fn next_log_events(ruby: &Ruby, this: &Self, timeout: f64) -> Result<RArray, Error> {
512
+ let cancel = Cancel::default();
513
+ let rx = &this.log_rx;
514
+ let deadline = Instant::now() + secs(timeout);
515
+ let first = gvl::without_gvl(&cancel, || {
516
+ while !cancel.is_cancelled() {
517
+ let remaining = deadline.saturating_duration_since(Instant::now());
518
+ if remaining.is_zero() {
519
+ return None;
520
+ }
521
+ match rx.recv_timeout(remaining.min(Duration::from_millis(100))) {
522
+ Ok(event) => return Some(event),
523
+ Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
524
+ Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return None,
525
+ }
526
+ }
527
+ None
528
+ })
529
+ .flatten();
530
+
531
+ let events = ruby.ary_new();
532
+ for event in first.into_iter().chain(rx.try_iter().take(255)) {
533
+ events.push((ruby.to_symbol(event.level.as_str()), event.message))?;
534
+ }
535
+ if events.is_empty() {
536
+ // Interrupted: let Ruby run trap handlers / raise before retrying.
537
+ ruby.thread_check_ints()?;
538
+ }
539
+ Ok(events)
540
+ }
541
+
481
542
  pub fn is_running(&self) -> bool {
482
543
  self.running.lock().is_some()
483
544
  }
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TonicRails
6
+ module Generators
7
+ # rails generate tonic_rails:install
8
+ #
9
+ # Sets an app up for gRPC: an initializer (load path for generated
10
+ # protobuf code, configuration, handler registration), the proto and
11
+ # generated-code directories, and the Puma plugin.
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ class_option :skip_puma, type: :boolean, default: false,
16
+ desc: "Don't add `plugin :tonic_rails` to config/puma.rb"
17
+ class_option :skip_next_steps, type: :boolean, default: false, hide: true
18
+
19
+ def create_initializer
20
+ template "tonic_rails.rb.tt", "config/initializers/tonic_rails.rb"
21
+ end
22
+
23
+ def create_directories
24
+ create_file "app/protos/.keep"
25
+ create_file "lib/grpc/.keep"
26
+ end
27
+
28
+ def add_puma_plugin
29
+ return if options[:skip_puma]
30
+
31
+ puma = "config/puma.rb"
32
+ return unless File.exist?(File.join(destination_root, puma))
33
+ return if File.read(File.join(destination_root, puma)).match?(/plugin\s+:tonic_rails/)
34
+
35
+ append_to_file puma, "\n# Run the tonic-rails gRPC server alongside Puma.\nplugin :tonic_rails\n"
36
+ end
37
+
38
+ def show_next_steps
39
+ return if options[:skip_next_steps]
40
+
41
+ say "\nNext: rails generate tonic_rails:service Item GetItem ListItems:server", :green
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby code generated from app/protos by `bin/rails tonic_rails:compile_protos`
4
+ # lives in lib/grpc. Its files require each other by plain name, so put the
5
+ # directory on the load path and keep Zeitwerk away from it.
6
+ Rails.root.join("lib/grpc").then do |generated|
7
+ $LOAD_PATH.unshift(generated.to_s) unless $LOAD_PATH.include?(generated.to_s)
8
+ Rails.autoloaders.main.ignore(generated)
9
+ end
10
+
11
+ Tonic::Rails.configure do |config|
12
+ # config.bind_address = "0.0.0.0:50051"
13
+ # config.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
14
+ # config.reflection = Rails.env.development?
15
+ # config.health_check = -> { ActiveRecord::Base.connection.active? }
16
+ end
17
+
18
+ # Handlers in app/grpc are autoloaded; register them here so they're
19
+ # picked up again after code reloads.
20
+ Rails.application.config.to_prepare do
21
+ end