kino 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ //! Server log lines: lifecycle notices, crashes and respawns, hook
2
+ //! failures, the failed-request report, and whatever apps write to
3
+ //! rack.errors, all in one shape:
4
+ //!
5
+ //! ```text
6
+ //! kino[4213] worker-3: after_worker_boot hook raised RuntimeError: boom
7
+ //! ```
8
+ //!
9
+ //! The label is syslog's `ident[pid]` tag plus the source that spoke (the
10
+ //! ractor and/or thread name, `main` for neither), styled by level; the
11
+ //! message stays plain. Ruby builds the source, since only Ruby knows its
12
+ //! ractor and thread names, and hands the rest over; this side decides
13
+ //! color per stream and writes, so worker ractors never touch $stdout or
14
+ //! $stderr themselves. A multi-line message is labelled on its first line.
15
+
16
+ use std::io::Write;
17
+
18
+ use magnus::{Error, Ruby};
19
+
20
+ use crate::style::{self, Stream};
21
+
22
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
23
+ pub enum Level {
24
+ Info,
25
+ Warn,
26
+ Error,
27
+ }
28
+
29
+ impl Level {
30
+ /// The level named by Ruby ("info", "warn", "error").
31
+ pub fn parse(name: &str) -> Option<Level> {
32
+ match name {
33
+ "info" => Some(Level::Info),
34
+ "warn" => Some(Level::Warn),
35
+ "error" => Some(Level::Error),
36
+ _ => None,
37
+ }
38
+ }
39
+
40
+ /// Notes go to stdout; warnings and errors to stderr.
41
+ fn stream(self) -> Stream {
42
+ match self {
43
+ Level::Info => Stream::Stdout,
44
+ Level::Warn | Level::Error => Stream::Stderr,
45
+ }
46
+ }
47
+
48
+ fn sgr(self) -> &'static str {
49
+ match self {
50
+ Level::Info => style::DIM,
51
+ Level::Warn => style::WARN,
52
+ Level::Error => style::ERROR,
53
+ }
54
+ }
55
+ }
56
+
57
+ /// Write one line from `source` at `level`.
58
+ pub fn emit(level: Level, source: &str, message: &str) {
59
+ let stream = level.stream();
60
+ let label = label(std::process::id(), source);
61
+ let line = format_line(level, &label, message, style::enabled(stream));
62
+ match stream {
63
+ Stream::Stdout => {
64
+ let _ = writeln!(std::io::stdout().lock(), "{line}");
65
+ }
66
+ Stream::Stderr => {
67
+ let _ = writeln!(std::io::stderr().lock(), "{line}");
68
+ }
69
+ }
70
+ }
71
+
72
+ /// The `kino[<pid>] <source>:` tag.
73
+ pub fn label(pid: u32, source: &str) -> String {
74
+ format!("kino[{pid}] {source}:")
75
+ }
76
+
77
+ /// The label styled by level, then the message as given.
78
+ pub fn format_line(level: Level, label: &str, message: &str, color: bool) -> String {
79
+ format!("{} {message}", style::sgr(level.sgr(), label, color))
80
+ }
81
+
82
+ /// The Ruby entry point (Kino::Log): Ruby knows its ractor and thread,
83
+ /// the native side knows the terminal.
84
+ pub fn log_line(ruby: &Ruby, level: String, source: String, message: String) -> Result<(), Error> {
85
+ let level = Level::parse(&level).ok_or_else(|| {
86
+ Error::new(
87
+ ruby.exception_arg_error(),
88
+ format!("unknown log level {level:?}"),
89
+ )
90
+ })?;
91
+ emit(level, &source, &message);
92
+ Ok(())
93
+ }
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use super::{format_line, label, Level};
98
+
99
+ #[test]
100
+ fn label_is_a_syslog_tag_plus_the_source() {
101
+ assert_eq!(label(4213, "main"), "kino[4213] main:");
102
+ assert_eq!(
103
+ label(4213, "worker-3/thread-2"),
104
+ "kino[4213] worker-3/thread-2:"
105
+ );
106
+ }
107
+
108
+ #[test]
109
+ fn plain_line_is_label_then_message() {
110
+ assert_eq!(
111
+ format_line(Level::Info, "kino[1] main:", "hello", false),
112
+ "kino[1] main: hello"
113
+ );
114
+ }
115
+
116
+ #[test]
117
+ fn color_styles_only_the_label_by_level() {
118
+ assert_eq!(
119
+ format_line(Level::Info, "kino[1] main:", "hello", true),
120
+ "\x1b[90mkino[1] main:\x1b[0m hello"
121
+ );
122
+ assert_eq!(
123
+ format_line(Level::Warn, "kino[1] main:", "careful", true),
124
+ "\x1b[33mkino[1] main:\x1b[0m careful"
125
+ );
126
+ assert_eq!(
127
+ format_line(Level::Error, "kino[1] main:", "broke", true),
128
+ "\x1b[91mkino[1] main:\x1b[0m broke"
129
+ );
130
+ }
131
+
132
+ #[test]
133
+ fn a_report_is_labelled_on_its_first_line_only() {
134
+ assert_eq!(
135
+ format_line(
136
+ Level::Error,
137
+ "kino[1] main:",
138
+ "500 GET / · X: y\n a.rb:1",
139
+ false
140
+ ),
141
+ "kino[1] main: 500 GET / · X: y\n a.rb:1"
142
+ );
143
+ }
144
+
145
+ #[test]
146
+ fn levels_parse_from_their_ruby_names() {
147
+ assert_eq!(Level::parse("info"), Some(Level::Info));
148
+ assert_eq!(Level::parse("warn"), Some(Level::Warn));
149
+ assert_eq!(Level::parse("error"), Some(Level::Error));
150
+ assert_eq!(Level::parse("debug"), None);
151
+ }
152
+ }
@@ -117,11 +117,16 @@ fn admit(
117
117
  ) -> Result<RHash, Error> {
118
118
  server.served.fetch_add(1, Ordering::Relaxed);
119
119
  slot.served.fetch_add(1, Ordering::Relaxed);
120
- slot.last_started_ms.store(crate::mono::mono_ms(), Ordering::Relaxed);
120
+ slot.last_started_ms
121
+ .store(crate::mono::mono_ms(), Ordering::Relaxed);
121
122
  slot.in_flight.fetch_add(1, Ordering::Relaxed);
122
- server
123
- .queue_histogram
124
- .record(ctx.enqueued_at.elapsed().as_micros() as u64);
123
+ // One clock read serves the histogram and, for the access log, the
124
+ // request's queue wait and the start of its time in Ruby.
125
+ let now = std::time::Instant::now();
126
+ let wait = now.duration_since(ctx.enqueued_at);
127
+ server.queue_histogram.record(wait.as_micros() as u64);
128
+ ctx.wait = wait;
129
+ ctx.admitted_at = now;
125
130
  slot.current.lock().push(Arc::downgrade(&ctx.responder));
126
131
  // Wire the slot into the request so blocked body reads/writes are
127
132
  // interruptible the same way the queue pop is.
@@ -5,6 +5,7 @@
5
5
 
6
6
  use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7
7
  use std::sync::{Arc, OnceLock, Weak};
8
+ use std::time::{Duration, Instant};
8
9
 
9
10
  use parking_lot::{Mutex, RwLock};
10
11
 
@@ -32,6 +33,51 @@ pub type BoxedCtx = Box<RequestCtx>;
32
33
  /// Probed on every take; keys are our own ids, so ahash over SipHash.
33
34
  type HashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
34
35
 
36
+ /// What runs the server's I/O, taken out at shutdown. The default
37
+ /// multi-thread runtime and sharded I/O carry different teardown state,
38
+ /// so the shape stays explicit instead of parallel optional fields.
39
+ #[derive(Default)]
40
+ pub enum RuntimeHandle {
41
+ /// Not started yet, or already shut down.
42
+ #[default]
43
+ None,
44
+ MultiThread(tokio::runtime::Runtime),
45
+ /// The shards' final-teardown signal and every I/O thread (shards plus
46
+ /// the acceptor).
47
+ Shards {
48
+ shutdown_tx: tokio::sync::watch::Sender<bool>,
49
+ threads: Vec<std::thread::JoinHandle<()>>,
50
+ },
51
+ }
52
+
53
+ impl RuntimeHandle {
54
+ /// Stop the I/O side, giving in-flight work `timeout` to finish. A
55
+ /// thread that misses the deadline is abandoned rather than joined:
56
+ /// a wedged shard must not hang `Server#shutdown`, which promises to
57
+ /// return by its deadline.
58
+ pub fn shutdown(self, timeout: Duration) {
59
+ match self {
60
+ RuntimeHandle::None => {}
61
+ RuntimeHandle::MultiThread(runtime) => runtime.shutdown_timeout(timeout),
62
+ RuntimeHandle::Shards {
63
+ shutdown_tx,
64
+ threads,
65
+ } => {
66
+ let _ = shutdown_tx.send(true);
67
+ let deadline = Instant::now() + timeout;
68
+ for thread in threads {
69
+ while !thread.is_finished() && Instant::now() < deadline {
70
+ std::thread::sleep(Duration::from_millis(1));
71
+ }
72
+ if thread.is_finished() {
73
+ let _ = thread.join();
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+
35
81
  /// One per `Kino::Server`. Owns the tokio runtime, the request queue and the
36
82
  /// worker slots.
37
83
  pub struct ServerInner {
@@ -42,9 +88,9 @@ pub struct ServerInner {
42
88
  pub req_rx: flume::Receiver<BoxedCtx>,
43
89
  /// Signals the accept loop to stop. Watch channel: `true` = draining.
44
90
  pub shutdown_tx: tokio::sync::watch::Sender<bool>,
45
- /// Runtime is kept so we can shut it down explicitly; in an Option so
46
- /// `shutdown_runtime` can take ownership out of the Arc.
47
- pub runtime: Mutex<Option<tokio::runtime::Runtime>>,
91
+ /// Runtime is kept so we can shut it down explicitly; `shutdown_runtime`
92
+ /// takes ownership out of the Arc.
93
+ pub runtime: Mutex<RuntimeHandle>,
48
94
  pub slots: RwLock<Vec<Arc<WorkerSlot>>>,
49
95
  pub in_flight: AtomicUsize,
50
96
  /// Requests handed to Ruby workers (admitted), and requests rejected
@@ -69,6 +115,8 @@ pub struct ServerInner {
69
115
  pub quarantine_replacements: AtomicU64,
70
116
  pub topology: Topology,
71
117
  pub https: bool,
118
+ /// The socket file of a `unix://` bind, removed at shutdown.
119
+ pub unix_path: Option<std::path::PathBuf>,
72
120
  /// Native access log sink (None unless log_requests is on).
73
121
  pub access_log: Option<crate::logsink::Sink>,
74
122
  /// Lane-dispatch mode: per-worker queues, awake-preferring dispatch.
@@ -116,8 +164,8 @@ pub const LANE_DEPTH: usize = 4;
116
164
  /// Fixed queue-wait bucket boundaries in microseconds (0.5ms .. 10s),
117
165
  /// ascending. Emitted in seconds. Not a knob (YAGNI).
118
166
  pub const QUEUE_BOUNDS_US: [u64; 14] = [
119
- 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
120
- 250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, 10_000_000,
167
+ 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
168
+ 2_500_000, 5_000_000, 10_000_000,
121
169
  ];
122
170
 
123
171
  /// Queue-wait histogram: per-bucket counts plus an overflow (the implicit
@@ -287,7 +335,7 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
287
335
  req_tx: Mutex::new(Some(req_tx)),
288
336
  req_rx,
289
337
  shutdown_tx,
290
- runtime: Mutex::new(None),
338
+ runtime: Mutex::new(RuntimeHandle::None),
291
339
  slots: RwLock::new(Vec::new()),
292
340
  in_flight: AtomicUsize::new(0),
293
341
  served: AtomicU64::new(0),
@@ -299,8 +347,14 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
299
347
  state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
300
348
  respawns: AtomicU64::new(0),
301
349
  quarantine_replacements: AtomicU64::new(0),
302
- topology: Topology { mode: "threaded".to_string(), workers: 0, threads: 0, batch: 1 },
350
+ topology: Topology {
351
+ mode: "threaded".to_string(),
352
+ workers: 0,
353
+ threads: 0,
354
+ batch: 1,
355
+ },
303
356
  https: false,
357
+ unix_path: None,
304
358
  access_log: None,
305
359
  lanes,
306
360
  lane_cursor: AtomicUsize::new(0),
@@ -314,6 +368,44 @@ mod tests {
314
368
  use super::*;
315
369
  use crate::request::test_ctx;
316
370
 
371
+ #[test]
372
+ fn shards_shutdown_joins_threads_that_observe_the_signal() {
373
+ let (shutdown_tx, rx) = tokio::sync::watch::channel(false);
374
+ let thread = std::thread::spawn(move || {
375
+ while !*rx.borrow() {
376
+ std::thread::sleep(Duration::from_millis(1));
377
+ }
378
+ });
379
+
380
+ let start = Instant::now();
381
+ RuntimeHandle::Shards {
382
+ shutdown_tx,
383
+ threads: vec![thread],
384
+ }
385
+ .shutdown(Duration::from_secs(5));
386
+
387
+ // The thread exits on the signal, so the join comes nowhere near
388
+ // the deadline.
389
+ assert!(start.elapsed() < Duration::from_secs(1));
390
+ }
391
+
392
+ #[test]
393
+ fn shards_shutdown_abandons_a_wedged_thread_at_the_deadline() {
394
+ let (shutdown_tx, _rx) = tokio::sync::watch::channel(false);
395
+ let wedged = std::thread::spawn(|| std::thread::sleep(Duration::from_secs(30)));
396
+
397
+ let start = Instant::now();
398
+ RuntimeHandle::Shards {
399
+ shutdown_tx,
400
+ threads: vec![wedged],
401
+ }
402
+ .shutdown(Duration::from_millis(50));
403
+
404
+ let elapsed = start.elapsed();
405
+ assert!(elapsed >= Duration::from_millis(50));
406
+ assert!(elapsed < Duration::from_secs(5));
407
+ }
408
+
317
409
  #[test]
318
410
  fn worker_registration_hands_out_sequential_slot_ids() {
319
411
  let server = test_server(false, 4);
@@ -417,9 +509,9 @@ mod tests {
417
509
  #[test]
418
510
  fn queue_histogram_buckets_by_wait() {
419
511
  let h = QueueHistogram::new();
420
- h.record(400); // <= 500 -> bucket 0
421
- h.record(500); // == 500 -> bucket 0 (inclusive)
422
- h.record(600); // (500, 1000] -> bucket 1
512
+ h.record(400); // <= 500 -> bucket 0
513
+ h.record(500); // == 500 -> bucket 0 (inclusive)
514
+ h.record(600); // (500, 1000] -> bucket 1
423
515
  h.record(20_000_000); // > last bound -> overflow
424
516
  let s = h.snapshot();
425
517
  assert_eq!(s.buckets[0], 2);
@@ -44,6 +44,41 @@ pub struct RequestCtx {
44
44
  /// When this request entered the queue, for the queue-wait histogram.
45
45
  /// Stamped at ctx creation; read once at admit (queue.rs).
46
46
  pub enqueued_at: std::time::Instant,
47
+ /// Whether the access log wants timing: decided at intake, read on
48
+ /// the way out, so an idle log costs nothing per request.
49
+ pub timed: bool,
50
+ /// Queue wait, stamped at admit (queue.rs): the log's `wait`.
51
+ pub wait: std::time::Duration,
52
+ /// When a worker took the request; elapsed at the response head it is
53
+ /// the log's `ruby`.
54
+ pub admitted_at: std::time::Instant,
55
+ /// GC pause and objects allocated during the app call, when the
56
+ /// worker measured them (Request#timing).
57
+ pub gc: Option<(std::time::Duration, u64)>,
58
+ }
59
+
60
+ impl RequestCtx {
61
+ /// The timing this request carries to the access log.
62
+ fn timing(&self) -> crate::access_log::Timing {
63
+ crate::access_log::Timing {
64
+ wait: self.wait,
65
+ ruby: self.admitted_at.elapsed(),
66
+ gc: self.gc,
67
+ }
68
+ }
69
+ }
70
+
71
+ /// Attach the request's timing to a response head when the access log
72
+ /// wants it; the extension is the one allocation an idle log skips.
73
+ fn timed(
74
+ ctx: &RequestCtx,
75
+ builder: hyper::http::response::Builder,
76
+ ) -> hyper::http::response::Builder {
77
+ if ctx.timed {
78
+ builder.extension(ctx.timing())
79
+ } else {
80
+ builder
81
+ }
47
82
  }
48
83
 
49
84
  impl Drop for RequestCtx {
@@ -233,7 +268,7 @@ pub fn respond_simple(
233
268
  body: RString,
234
269
  ) -> Result<bool, Error> {
235
270
  let ctx = request.0.borrow();
236
- let builder = build_head(status, headers)?;
271
+ let builder = timed(&ctx, build_head(status, headers)?);
237
272
  let bytes = body_bytes(&ctx, body);
238
273
  let response = builder
239
274
  .body(full_body(bytes))
@@ -252,6 +287,13 @@ fn body_bytes(ctx: &RequestCtx, body: RString) -> Bytes {
252
287
  }
253
288
 
254
289
  impl Request {
290
+ /// The worker's measurements around the app call, for the access log's
291
+ /// breakdown: the GC pause in nanoseconds and the objects allocated.
292
+ /// Called only when the access log is on.
293
+ pub fn set_timing(_ruby: &Ruby, rb_self: &Request, gc_nanos: u64, allocs: u64) {
294
+ rb_self.0.borrow_mut().gc = Some((std::time::Duration::from_nanos(gc_nanos), allocs));
295
+ }
296
+
255
297
  /// Next chunk of the request body, at most `max_len` bytes; nil at EOF.
256
298
  /// Blocks (GVL released) until the client sends more.
257
299
  pub fn read_body(
@@ -306,7 +348,7 @@ impl Request {
306
348
  headers: RHash,
307
349
  ) -> Result<bool, Error> {
308
350
  let ctx = rb_self.0.borrow();
309
- let builder = build_head(status, headers)?;
351
+ let builder = timed(&ctx, build_head(status, headers)?);
310
352
  ctx.responder
311
353
  .send_stream_head(builder)
312
354
  .map_err(|e| invalid_response(ruby, e))
@@ -429,6 +471,10 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
429
471
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
430
472
  responder: Arc::new(Responder::new(head_tx)),
431
473
  enqueued_at: std::time::Instant::now(),
474
+ timed: false,
475
+ wait: std::time::Duration::ZERO,
476
+ admitted_at: std::time::Instant::now(),
477
+ gc: None,
432
478
  })
433
479
  }
434
480
 
@@ -115,10 +115,7 @@ pub fn plain_response(status: u16, message: &'static str) -> HyperResponse {
115
115
  mod tests {
116
116
  use super::*;
117
117
 
118
- fn pair() -> (
119
- Responder,
120
- tokio::sync::oneshot::Receiver<HyperResponse>,
121
- ) {
118
+ fn pair() -> (Responder, tokio::sync::oneshot::Receiver<HyperResponse>) {
122
119
  let (head_tx, head_rx) = tokio::sync::oneshot::channel();
123
120
  (Responder::new(head_tx), head_rx)
124
121
  }