kino 0.2.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.
data/ext/kino/src/lib.rs CHANGED
@@ -4,9 +4,11 @@
4
4
  #[global_allocator]
5
5
  static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
6
6
 
7
+ mod control;
7
8
  mod env_strings;
8
9
  mod gvl;
9
10
  mod logsink;
11
+ mod mono;
10
12
  mod pin;
11
13
  mod queue;
12
14
  mod registry;
@@ -40,6 +42,16 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
40
42
  native.define_singleton_method("close_queue", function!(server::close_queue, 1))?;
41
43
  native.define_singleton_method("queue_stats", function!(server::queue_stats, 1))?;
42
44
  native.define_singleton_method("server_stats", function!(server::server_stats, 1))?;
45
+ native.define_singleton_method("worker_stats", function!(server::worker_stats, 1))?;
46
+ native.define_singleton_method("queue_time", function!(server::queue_time, 1))?;
47
+ native.define_singleton_method("quarantine_slot", function!(server::quarantine_slot, 2))?;
48
+ native.define_singleton_method(
49
+ "record_quarantine_replacement",
50
+ function!(server::record_quarantine_replacement, 1),
51
+ )?;
52
+ native.define_singleton_method("control_ready", function!(server::control_ready, 1))?;
53
+ native.define_singleton_method("record_respawn", function!(server::record_respawn, 1))?;
54
+ native.define_singleton_method("control_stop", function!(control::control_stop, 1))?;
43
55
  native.define_singleton_method("abort_inflight", function!(server::abort_inflight, 2))?;
44
56
  native.define_singleton_method(
45
57
  "abort_all_inflight",
@@ -0,0 +1,26 @@
1
+ //! Process-monotonic millisecond clock, shared by the request hot path
2
+ //! (recording) and the control thread (reading busy age). Independent of
3
+ //! wall-clock and of Ruby, so it is identical in :ractor and :threaded.
4
+
5
+ use std::sync::OnceLock;
6
+ use std::time::Instant;
7
+
8
+ static MONO_EPOCH: OnceLock<Instant> = OnceLock::new();
9
+
10
+ /// Milliseconds since the first call anywhere in the process. The u128
11
+ /// millis fit u64 for any realistic uptime (u64 ms is ~584 million years).
12
+ pub fn mono_ms() -> u64 {
13
+ MONO_EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
14
+ }
15
+
16
+ #[cfg(test)]
17
+ mod tests {
18
+ use super::*;
19
+
20
+ #[test]
21
+ fn mono_ms_is_monotonic_nondecreasing() {
22
+ let a = mono_ms();
23
+ let b = mono_ms();
24
+ assert!(b >= a, "clock went backwards: {a} then {b}");
25
+ }
26
+ }
@@ -116,6 +116,12 @@ fn admit(
116
116
  mut ctx: BoxedCtx,
117
117
  ) -> Result<RHash, Error> {
118
118
  server.served.fetch_add(1, Ordering::Relaxed);
119
+ slot.served.fetch_add(1, Ordering::Relaxed);
120
+ slot.last_started_ms.store(crate::mono::mono_ms(), Ordering::Relaxed);
121
+ slot.in_flight.fetch_add(1, Ordering::Relaxed);
122
+ server
123
+ .queue_histogram
124
+ .record(ctx.enqueued_at.elapsed().as_micros() as u64);
119
125
  slot.current.lock().push(Arc::downgrade(&ctx.responder));
120
126
  // Wire the slot into the request so blocked body reads/writes are
121
127
  // interruptible the same way the queue pop is.
@@ -137,6 +143,7 @@ fn checkout(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Chec
137
143
 
138
144
  // The previous batch is fully answered once the worker comes back.
139
145
  slot.current.lock().clear();
146
+ slot.in_flight.store(0, Ordering::Relaxed);
140
147
  slot.interrupted.store(false, Ordering::SeqCst);
141
148
 
142
149
  Ok(block_take(&server, &slot)?.map(|ctx| (server, slot, ctx)))
@@ -11,6 +11,20 @@ use parking_lot::{Mutex, RwLock};
11
11
  use crate::request::RequestCtx;
12
12
  use crate::response::Responder;
13
13
 
14
+ /// Lifecycle as seen by the control plane's /ready.
15
+ pub const STATE_BOOTING: u8 = 0;
16
+ pub const STATE_READY: u8 = 1;
17
+ pub const STATE_DRAINING: u8 = 2;
18
+
19
+ /// Boot-time configuration echoed by /stats. Stored resolved: mode is
20
+ /// "ractor" or "threaded", never "auto".
21
+ pub struct Topology {
22
+ pub mode: String,
23
+ pub workers: usize,
24
+ pub threads: usize,
25
+ pub batch: usize,
26
+ }
27
+
14
28
  /// Requests travel through channels boxed: one heap allocation at accept
15
29
  /// time instead of moving ~300 bytes by value through every channel hop.
16
30
  pub type BoxedCtx = Box<RequestCtx>;
@@ -45,6 +59,15 @@ pub struct ServerInner {
45
59
  /// 413 (truthful Content-Length) or a mid-stream abort (chunked/lying).
46
60
  pub max_body_size: usize,
47
61
  pub timeouts: AtomicU64,
62
+ /// Lifecycle for /ready: booting until Ruby reports the workers up,
63
+ /// draining once stop_accepting runs. Relaxed everywhere (advisory).
64
+ pub state: std::sync::atomic::AtomicU8,
65
+ /// Worker respawns, recorded from the Ruby supervisor. Lives here so
66
+ /// the control plane reads it without touching Ruby.
67
+ pub respawns: AtomicU64,
68
+ /// Replacements spawned by the quarantine monitor (Relaxed, advisory).
69
+ pub quarantine_replacements: AtomicU64,
70
+ pub topology: Topology,
48
71
  pub https: bool,
49
72
  /// Native access log sink (None unless log_requests is on).
50
73
  pub access_log: Option<crate::logsink::Sink>,
@@ -55,6 +78,9 @@ pub struct ServerInner {
55
78
  /// GC roots for zero-copy response buffers (pin.rs). The Ruby Server
56
79
  /// object holds the marking PinKeeper for this slab.
57
80
  pub pin_slab: Arc<crate::pin::PinSlab>,
81
+ /// Queue-wait histogram: recorded at admit (queue.rs), emitted by the
82
+ /// control plane.
83
+ pub queue_histogram: QueueHistogram,
58
84
  }
59
85
 
60
86
  /// One per worker *thread* (slot count = workers × threads). The interrupt
@@ -72,12 +98,90 @@ pub struct WorkerSlot {
72
98
  pub lane_tx: Mutex<Option<flume::Sender<BoxedCtx>>>,
73
99
  pub lane_rx: Option<flume::Receiver<BoxedCtx>>,
74
100
  pub parked: std::sync::atomic::AtomicBool,
101
+ /// Per-slot sensors (Relaxed, advisory). served/in_flight mirror the
102
+ /// global counters at slot granularity; last_started_ms (stamped on
103
+ /// admit) drives busy-age (wedge) reporting.
104
+ pub served: AtomicU64,
105
+ pub in_flight: AtomicUsize,
106
+ pub last_started_ms: AtomicU64,
107
+ /// Set by the quarantine monitor when this slot is abandoned as wedged:
108
+ /// excluded from wedge detection, and its busy_ms is reported as 0.
109
+ pub quarantined: std::sync::atomic::AtomicBool,
75
110
  }
76
111
 
77
112
  /// Per-lane depth cap: small, so a slow handler can only ever delay this
78
113
  /// many queued neighbors (work stealing rescues them anyway).
79
114
  pub const LANE_DEPTH: usize = 4;
80
115
 
116
+ /// Fixed queue-wait bucket boundaries in microseconds (0.5ms .. 10s),
117
+ /// ascending. Emitted in seconds. Not a knob (YAGNI).
118
+ 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,
121
+ ];
122
+
123
+ /// Queue-wait histogram: per-bucket counts plus an overflow (the implicit
124
+ /// +Inf bucket), the sum of waits, and the total count. Relaxed atomics,
125
+ /// advisory like the other counters.
126
+ pub struct QueueHistogram {
127
+ pub buckets: [AtomicU64; QUEUE_BOUNDS_US.len()],
128
+ pub overflow: AtomicU64,
129
+ pub sum_us: AtomicU64,
130
+ pub count: AtomicU64,
131
+ }
132
+
133
+ /// A plain (non-atomic) snapshot for the control thread to emit.
134
+ pub struct QueueHistogramSnapshot {
135
+ pub buckets: [u64; QUEUE_BOUNDS_US.len()],
136
+ pub overflow: u64,
137
+ pub sum_us: u64,
138
+ pub count: u64,
139
+ }
140
+
141
+ impl QueueHistogram {
142
+ pub fn new() -> Self {
143
+ QueueHistogram {
144
+ buckets: std::array::from_fn(|_| AtomicU64::new(0)),
145
+ overflow: AtomicU64::new(0),
146
+ sum_us: AtomicU64::new(0),
147
+ count: AtomicU64::new(0),
148
+ }
149
+ }
150
+
151
+ /// Place one wait in its bucket (first bound >= wait, else overflow) and
152
+ /// update sum and count. A linear scan over 14 bounds is trivial.
153
+ pub fn record(&self, wait_us: u64) {
154
+ match QUEUE_BOUNDS_US.iter().position(|&bound| wait_us <= bound) {
155
+ Some(i) => self.buckets[i].fetch_add(1, Ordering::Relaxed),
156
+ None => self.overflow.fetch_add(1, Ordering::Relaxed),
157
+ };
158
+ self.sum_us.fetch_add(wait_us, Ordering::Relaxed);
159
+ self.count.fetch_add(1, Ordering::Relaxed);
160
+ }
161
+
162
+ pub fn snapshot(&self) -> QueueHistogramSnapshot {
163
+ QueueHistogramSnapshot {
164
+ buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)),
165
+ overflow: self.overflow.load(Ordering::Relaxed),
166
+ sum_us: self.sum_us.load(Ordering::Relaxed),
167
+ count: self.count.load(Ordering::Relaxed),
168
+ }
169
+ }
170
+ }
171
+
172
+ impl Default for QueueHistogram {
173
+ fn default() -> Self {
174
+ Self::new()
175
+ }
176
+ }
177
+
178
+ impl QueueHistogramSnapshot {
179
+ /// Summed queue wait, converted from the stored microseconds to seconds.
180
+ pub fn sum_seconds(&self) -> f64 {
181
+ self.sum_us as f64 / 1_000_000.0
182
+ }
183
+ }
184
+
81
185
  impl WorkerSlot {
82
186
  fn new(lanes: bool) -> Self {
83
187
  let (lane_tx, lane_rx) = if lanes {
@@ -92,6 +196,10 @@ impl WorkerSlot {
92
196
  lane_tx: Mutex::new(lane_tx),
93
197
  lane_rx,
94
198
  parked: std::sync::atomic::AtomicBool::new(false),
199
+ served: AtomicU64::new(0),
200
+ in_flight: AtomicUsize::new(0),
201
+ last_started_ms: AtomicU64::new(0),
202
+ quarantined: std::sync::atomic::AtomicBool::new(false),
95
203
  }
96
204
  }
97
205
  }
@@ -188,11 +296,16 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
188
296
  request_timeout_ms: 0,
189
297
  max_body_size: 0,
190
298
  timeouts: AtomicU64::new(0),
299
+ state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
300
+ respawns: AtomicU64::new(0),
301
+ quarantine_replacements: AtomicU64::new(0),
302
+ topology: Topology { mode: "threaded".to_string(), workers: 0, threads: 0, batch: 1 },
191
303
  https: false,
192
304
  access_log: None,
193
305
  lanes,
194
306
  lane_cursor: AtomicUsize::new(0),
195
307
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
308
+ queue_histogram: QueueHistogram::new(),
196
309
  })
197
310
  }
198
311
 
@@ -273,4 +386,46 @@ mod tests {
273
386
  let b = next_server_id();
274
387
  assert_ne!(a, b);
275
388
  }
389
+
390
+ #[test]
391
+ fn servers_boot_in_the_booting_state_with_zero_respawns() {
392
+ let server = test_server(false, 4);
393
+ assert_eq!(server.state.load(Ordering::Relaxed), STATE_BOOTING);
394
+ assert_eq!(server.respawns.load(Ordering::Relaxed), 0);
395
+ assert_eq!(server.topology.batch, 1);
396
+ }
397
+
398
+ #[test]
399
+ fn fresh_slot_has_zeroed_per_worker_sensors() {
400
+ let server = test_server(false, 4);
401
+ server.register_worker();
402
+ let slots = server.slots.read();
403
+ let slot = &slots[0];
404
+ assert_eq!(slot.served.load(Ordering::Relaxed), 0);
405
+ assert_eq!(slot.in_flight.load(Ordering::Relaxed), 0);
406
+ assert_eq!(slot.last_started_ms.load(Ordering::Relaxed), 0);
407
+ }
408
+
409
+ #[test]
410
+ fn fresh_slot_is_not_quarantined() {
411
+ let server = test_server(false, 4);
412
+ server.register_worker();
413
+ assert!(!server.slots.read()[0].quarantined.load(Ordering::Relaxed));
414
+ assert_eq!(server.quarantine_replacements.load(Ordering::Relaxed), 0);
415
+ }
416
+
417
+ #[test]
418
+ fn queue_histogram_buckets_by_wait() {
419
+ 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
423
+ h.record(20_000_000); // > last bound -> overflow
424
+ let s = h.snapshot();
425
+ assert_eq!(s.buckets[0], 2);
426
+ assert_eq!(s.buckets[1], 1);
427
+ assert_eq!(s.overflow, 1);
428
+ assert_eq!(s.count, 4);
429
+ assert_eq!(s.sum_us, 400 + 500 + 600 + 20_000_000);
430
+ }
276
431
  }
@@ -41,6 +41,9 @@ pub struct RequestCtx {
41
41
  /// here and ride to hyper without a copy (pin.rs).
42
42
  pub pin_slab: Arc<crate::pin::PinSlab>,
43
43
  pub responder: Arc<Responder>,
44
+ /// When this request entered the queue, for the queue-wait histogram.
45
+ /// Stamped at ctx creation; read once at admit (queue.rs).
46
+ pub enqueued_at: std::time::Instant,
44
47
  }
45
48
 
46
49
  impl Drop for RequestCtx {
@@ -425,6 +428,7 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
425
428
  slot: None,
426
429
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
427
430
  responder: Arc::new(Responder::new(head_tx)),
431
+ enqueued_at: std::time::Instant::now(),
428
432
  })
429
433
  }
430
434
 
@@ -44,8 +44,10 @@ fn cfg_opt<T: magnus::TryConvert>(
44
44
  /// at boot, so Hash-lookup cost is irrelevant and the interface stays
45
45
  /// extensible. Binding is synchronous so address errors raise in Ruby at
46
46
  /// `start` time; returns the actual port for `port: 0`. TLS config errors
47
- /// (bad cert/key) also raise here, before any traffic.
48
- pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Error> {
47
+ /// (bad cert/key) also raise here, before any traffic. The third element
48
+ /// of the return tuple is the control-plane port (nil unless control_bind
49
+ /// is configured).
50
+ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Option<u16>), Error> {
49
51
  let bind: String = cfg(ruby, config, "bind")?;
50
52
  let port: u16 = cfg(ruby, config, "port")?;
51
53
  let queue_depth: usize = cfg(ruby, config, "queue_depth")?;
@@ -58,6 +60,10 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
58
60
  let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
59
61
  let lanes: bool = cfg_opt(ruby, config, "lanes")?.unwrap_or(false);
60
62
  let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
63
+ let mode: String = cfg_opt(ruby, config, "mode")?.unwrap_or_else(|| "threaded".to_string());
64
+ let workers: usize = cfg_opt(ruby, config, "workers")?.unwrap_or(0);
65
+ let threads: usize = cfg_opt(ruby, config, "threads")?.unwrap_or(0);
66
+ let batch: usize = cfg_opt(ruby, config, "batch")?.unwrap_or(1);
61
67
  let acceptor = match (&tls_cert, &tls_key) {
62
68
  (Some(cert), Some(key)) => Some(
63
69
  crate::tls::build_acceptor(cert, key)
@@ -82,6 +88,15 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
82
88
  .map_err(|e| io_error(ruby, "listener setup failed", e))?
83
89
  .port();
84
90
 
91
+ let control_bind_addr: Option<String> = cfg_opt(ruby, config, "control_bind")?;
92
+ let control_token: Option<String> = cfg_opt(ruby, config, "control_token")?;
93
+ let control_bind = control_bind_addr
94
+ .as_deref()
95
+ .map(|addr| {
96
+ crate::control::bind_control(addr).map_err(|e| io_error(ruby, "control bind failed", e))
97
+ })
98
+ .transpose()?;
99
+
85
100
  let mut builder = tokio::runtime::Builder::new_multi_thread();
86
101
  builder.enable_all().thread_name("kino-tokio");
87
102
  if tokio_threads > 0 {
@@ -108,11 +123,16 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
108
123
  request_timeout_ms,
109
124
  max_body_size,
110
125
  timeouts: std::sync::atomic::AtomicU64::new(0),
126
+ state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
127
+ respawns: std::sync::atomic::AtomicU64::new(0),
128
+ quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
129
+ topology: registry::Topology { mode, workers, threads, batch },
111
130
  https: acceptor.is_some(),
112
131
  access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
113
132
  lanes,
114
133
  lane_cursor: std::sync::atomic::AtomicUsize::new(0),
115
134
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
135
+ queue_histogram: registry::QueueHistogram::new(),
116
136
  });
117
137
 
118
138
  let tokio_listener = {
@@ -130,8 +150,28 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
130
150
  *server.runtime.lock() = Some(runtime);
131
151
 
132
152
  let id = server.id;
153
+ let control_port = match control_bind {
154
+ // Not yet in the registry: on failure Ruby never learns this id, so
155
+ // nothing could ever reach it to shut it down. The accept loop's
156
+ // task holds its own Arc back to `server` (stored inside its own
157
+ // `runtime` field), so just dropping our handle would leak the
158
+ // runtime forever; take it out and stop it explicitly instead.
159
+ // Safe to block here: this is the plain Ruby thread, no async
160
+ // context above it.
161
+ Some(bind) => match crate::control::start(bind, server.clone(), control_token) {
162
+ Ok(port) => port,
163
+ Err(e) => {
164
+ // A plain drop blocks until the accept loop's task (its
165
+ // only task, idling on accept/shutdown) is torn down; the
166
+ // runtime only ever had this one thing to cancel.
167
+ drop(server.runtime.lock().take());
168
+ return Err(io_error(ruby, "control start failed", e));
169
+ }
170
+ },
171
+ None => None,
172
+ };
133
173
  registry::insert(server);
134
- Ok((id, local_port))
174
+ Ok((id, local_port, control_port))
135
175
  }
136
176
 
137
177
  /// Slowloris guard for TLS: a client that completes the TCP connect but then
@@ -353,6 +393,7 @@ async fn handle_request(
353
393
  slot: None,
354
394
  pin_slab: server.pin_slab.clone(),
355
395
  responder,
396
+ enqueued_at: std::time::Instant::now(),
356
397
  });
357
398
 
358
399
  // Drop guard, not manual decrement: when a client aborts mid-request,
@@ -518,11 +559,28 @@ pub fn register_worker(ruby: &Ruby, server_id: u64) -> Result<usize, Error> {
518
559
 
519
560
  pub fn stop_accepting(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
520
561
  if let Some(server) = registry::try_get(server_id) {
562
+ server.state.store(registry::STATE_DRAINING, Ordering::Relaxed);
521
563
  let _ = server.shutdown_tx.send(true);
522
564
  }
523
565
  Ok(())
524
566
  }
525
567
 
568
+ /// Ruby reports the worker pool up; /ready starts answering 200.
569
+ pub fn control_ready(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
570
+ if let Some(server) = registry::try_get(server_id) {
571
+ server.state.store(registry::STATE_READY, Ordering::Relaxed);
572
+ }
573
+ Ok(())
574
+ }
575
+
576
+ /// One worker respawn, recorded by the Ruby supervisor.
577
+ pub fn record_respawn(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
578
+ if let Some(server) = registry::try_get(server_id) {
579
+ server.respawns.fetch_add(1, Ordering::Relaxed);
580
+ }
581
+ Ok(())
582
+ }
583
+
526
584
  pub fn close_queue(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
527
585
  if let Some(server) = registry::try_get(server_id) {
528
586
  server.req_tx.lock().take();
@@ -546,6 +604,12 @@ fn abort_slot(slot: &WorkerSlot) {
546
604
  responder.respond_500_if_unsent();
547
605
  }
548
606
  }
607
+ // A dead worker holds nothing: every request it had is answered above
608
+ // (or already was), so the slot is quiescent from here on. Without
609
+ // this, a crashed worker's slot reports in_flight>=1 forever (the
610
+ // supervisor never reuses a slot after a crash), wedging /stats,
611
+ // /metrics and server.stats with a phantom busy worker.
612
+ slot.in_flight.store(0, Ordering::Relaxed);
549
613
  // Lane mode: this worker is dead. Close its lane so the dispatcher
550
614
  // skips it, and drain anything queued; dropping each ctx fires the
551
615
  // Drop-500 backstop so those clients aren't left hanging.
@@ -608,14 +672,14 @@ pub fn log_error(message: String) {
608
672
  }
609
673
 
610
674
  /// Full stats snapshot: [queued, in_flight, served, rejected, timeouts,
611
- /// lane_depths]. lane_depths is nil unless lane dispatch is on.
675
+ /// respawns, lane_depths]. lane_depths is nil unless lane dispatch is on.
612
676
  #[allow(clippy::type_complexity)]
613
677
  pub fn server_stats(
614
678
  _ruby: &Ruby,
615
679
  server_id: u64,
616
- ) -> Result<(usize, usize, u64, u64, u64, Option<Vec<usize>>), Error> {
680
+ ) -> Result<(usize, usize, u64, u64, u64, u64, Option<Vec<usize>>), Error> {
617
681
  let Some(server) = registry::try_get(server_id) else {
618
- return Ok((0, 0, 0, 0, 0, None));
682
+ return Ok((0, 0, 0, 0, 0, 0, None));
619
683
  };
620
684
  let lane_depths = server.lane_depths();
621
685
  let queued = server.req_rx.len() + lane_depths.as_ref().map_or(0, |d| d.iter().sum::<usize>());
@@ -625,10 +689,56 @@ pub fn server_stats(
625
689
  server.served.load(Ordering::Relaxed),
626
690
  server.rejected.load(Ordering::Relaxed),
627
691
  server.timeouts.load(Ordering::Relaxed),
692
+ server.respawns.load(Ordering::Relaxed),
628
693
  lane_depths,
629
694
  ))
630
695
  }
631
696
 
697
+ /// Queue-wait count and summed seconds for Server#stats parity. Zeros when
698
+ /// the server is gone.
699
+ pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
700
+ let Some(server) = registry::try_get(server_id) else {
701
+ return Ok((0, 0.0));
702
+ };
703
+ let h = server.queue_histogram.snapshot();
704
+ Ok((h.count, h.sum_seconds()))
705
+ }
706
+
707
+ /// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
708
+ pub type WorkerStatRow = (usize, u64, usize, u64, bool);
709
+
710
+ /// Per-slot rows for Server#stats parity: [index, served, in_flight,
711
+ /// busy_ms, quarantined] each. Empty when the server is gone.
712
+ pub fn worker_stats(
713
+ _ruby: &Ruby,
714
+ server_id: u64,
715
+ ) -> Result<Vec<WorkerStatRow>, Error> {
716
+ let Some(server) = registry::try_get(server_id) else {
717
+ return Ok(Vec::new());
718
+ };
719
+ Ok(crate::control::collect_worker_status(&server)
720
+ .into_iter()
721
+ .map(|w| (w.index, w.served, w.in_flight, w.busy_ms, w.quarantined))
722
+ .collect())
723
+ }
724
+
725
+ /// Mark a slot quarantined (the monitor has abandoned it as wedged).
726
+ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
727
+ if let Some(server) = registry::try_get(server_id) {
728
+ let slot = server.slot(ruby, worker_id)?;
729
+ slot.quarantined.store(true, Ordering::Relaxed);
730
+ }
731
+ Ok(())
732
+ }
733
+
734
+ /// One replacement spawned by the quarantine monitor.
735
+ pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
736
+ if let Some(server) = registry::try_get(server_id) {
737
+ server.quarantine_replacements.fetch_add(1, Ordering::Relaxed);
738
+ }
739
+ Ok(())
740
+ }
741
+
632
742
  #[cfg(test)]
633
743
  mod tests {
634
744
  use super::*;
data/lib/kino/cli.rb CHANGED
@@ -100,11 +100,14 @@ module Kino
100
100
  end.join
101
101
  end
102
102
 
103
- # One-line stats dump (the SIGUSR1 handler's output).
103
+ # One-line stats dump (the SIGUSR1 handler's output). Excludes
104
+ # worker_status: it's an array with one entry per execution slot, and
105
+ # printing it inline would break the one-line contract (see /stats for
106
+ # per-worker detail).
104
107
  # @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
105
108
  # @return [String]
106
109
  def stats_line(stats)
107
- dim("Kino stats: #{stats.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
110
+ dim("Kino stats: #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
108
111
  end
109
112
 
110
113
  # The two banner halves around Server#start: credits before, the ready
@@ -23,11 +23,19 @@ module Kino
23
23
  lanes: false,
24
24
  log_requests: false,
25
25
  on_error: nil,
26
+ after_boot: nil,
27
+ after_worker_boot: nil,
28
+ after_request_complete: nil,
29
+ on_worker_exit: nil,
26
30
  shutdown_timeout: 30,
27
31
  tokio_threads: nil,
28
32
  tls: nil,
29
33
  environment: nil,
30
34
  pidfile: nil,
35
+ control_bind: nil,
36
+ control_token: nil,
37
+ quarantine_timeout: nil,
38
+ quarantine_max: nil,
31
39
  rackup: nil
32
40
  }.freeze
33
41
 
@@ -185,6 +193,24 @@ module Kino
185
193
  # or a block. Must be Ractor-shareable in :ractor mode.
186
194
  def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
187
195
 
196
+ # Called once on the main thread after the worker pool is up. The
197
+ # readiness seam (wire sd_notify or a "server ready" metric here).
198
+ def after_boot(handler = nil, &block) = @config.set(:after_boot, handler || block)
199
+
200
+ # Called once inside each worker (a ractor in :ractor mode) before it
201
+ # serves, with the worker's slot id. Must be Ractor-shareable in
202
+ # :ractor mode (build it with Ractor.shareable_proc).
203
+ def after_worker_boot(handler = nil, &block) = @config.set(:after_worker_boot, handler || block)
204
+
205
+ # Called inside the worker after each successful response with
206
+ # (env, status). Hot path: leave unset for zero cost. Must be
207
+ # Ractor-shareable in :ractor mode.
208
+ def after_request_complete(handler = nil, &block) = @config.set(:after_request_complete, handler || block)
209
+
210
+ # Called on the main thread when a worker exits, with (worker_index,
211
+ # error_or_nil). error is the crash cause, or nil on a clean exit.
212
+ def on_worker_exit(handler = nil, &block) = @config.set(:on_worker_exit, handler || block)
213
+
188
214
  # Graceful-shutdown drain deadline in seconds.
189
215
  def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
190
216
 
@@ -200,6 +226,37 @@ module Kino
200
226
  # Write the master PID here on start.
201
227
  def pidfile(path) = @config.set(:pidfile, path.to_s)
202
228
 
229
+ # Serve the read-only control plane (live stats as JSON at /stats,
230
+ # Prometheus text at /metrics, /ready and /live probes) on this
231
+ # address: "host:port" or "unix://path". Off unless set.
232
+ def control_bind(addr) = @config.set(:control_bind, addr.to_s)
233
+
234
+ # When set, /stats and /metrics require "Authorization: Bearer <token>".
235
+ # The probes stay open; they carry no data.
236
+ def control_token(token) = @config.set(:control_token, token.to_s)
237
+
238
+ # Quarantine a dispatch slot whose current request has run longer
239
+ # than this many seconds, spawning a replacement to restore capacity.
240
+ # Off unless set. Set it above your slowest legitimate endpoint (and
241
+ # typically above request_timeout).
242
+ def quarantine_timeout(seconds)
243
+ seconds &&= Float(seconds)
244
+ if seconds && seconds <= 0
245
+ raise ArgumentError, "quarantine_timeout must be greater than 0 (got #{seconds})"
246
+ end
247
+ @config.set(:quarantine_timeout, seconds)
248
+ end
249
+
250
+ # Cap on the total number of replacement events over the process
251
+ # lifetime. Past the cap the monitor stops replacing and the server
252
+ # runs at reduced capacity. Default: the worker count in :ractor
253
+ # mode, workers x threads in :threaded.
254
+ def quarantine_max(count)
255
+ count = Integer(count)
256
+ raise ArgumentError, "quarantine_max must be >= 1 (got #{count})" if count < 1
257
+ @config.set(:quarantine_max, count)
258
+ end
259
+
203
260
  # Rackup file the `kino` CLI loads (positional argument wins).
204
261
  def rackup(path) = @config.set(:rackup, path.to_s)
205
262
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Fires a lifecycle hook and turns a raise into a logged line instead of
6
+ # letting it escape. Stateless and touches only its arguments plus
7
+ # Native.log_error (already called from inside worker ractors today), so
8
+ # it is safe to call from worker context: no main-ractor state is
9
+ # captured.
10
+ module HookFire
11
+ module_function
12
+
13
+ def fire(hook, name, *args)
14
+ return unless hook
15
+
16
+ begin
17
+ hook.call(*args)
18
+ rescue => e
19
+ Native.log_error("#{name} hook raised #{e.class}: #{e.message}")
20
+ end
21
+ end
22
+ end
23
+ end