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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -2
- data/Cargo.lock +5 -5
- data/README.md +100 -11
- data/ext/tonic_rails_native/Cargo.toml +2 -2
- data/ext/tonic_rails_native/src/body.rs +113 -49
- data/ext/tonic_rails_native/src/dispatch.rs +44 -7
- data/ext/tonic_rails_native/src/framing.rs +50 -0
- data/ext/tonic_rails_native/src/gvl.rs +87 -21
- data/ext/tonic_rails_native/src/lib.rs +5 -1
- data/ext/tonic_rails_native/src/log.rs +131 -0
- data/ext/tonic_rails_native/src/net.rs +7 -1
- data/ext/tonic_rails_native/src/server.rs +46 -6
- data/lib/puma/plugin/tonic_rails.rb +116 -0
- data/lib/tonic/rails/base.rb +69 -22
- data/lib/tonic/rails/configuration.rb +2 -1
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/railtie.rb +2 -2
- data/lib/tonic/rails/rpc_error.rb +73 -6
- data/lib/tonic/rails/server.rb +59 -12
- data/lib/tonic/rails/testing.rb +32 -3
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +2 -0
- metadata +5 -1
|
@@ -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
|
|
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: `
|
|
201
|
-
|
|
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(
|
|
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
|
-
|
|
367
|
-
let _ = response_tx.try_send(StreamMessage::
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|