kino 0.2.1 → 0.4.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.
@@ -41,6 +41,44 @@ 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,
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
+ }
44
82
  }
45
83
 
46
84
  impl Drop for RequestCtx {
@@ -230,7 +268,7 @@ pub fn respond_simple(
230
268
  body: RString,
231
269
  ) -> Result<bool, Error> {
232
270
  let ctx = request.0.borrow();
233
- let builder = build_head(status, headers)?;
271
+ let builder = timed(&ctx, build_head(status, headers)?);
234
272
  let bytes = body_bytes(&ctx, body);
235
273
  let response = builder
236
274
  .body(full_body(bytes))
@@ -249,6 +287,13 @@ fn body_bytes(ctx: &RequestCtx, body: RString) -> Bytes {
249
287
  }
250
288
 
251
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
+
252
297
  /// Next chunk of the request body, at most `max_len` bytes; nil at EOF.
253
298
  /// Blocks (GVL released) until the client sends more.
254
299
  pub fn read_body(
@@ -303,7 +348,7 @@ impl Request {
303
348
  headers: RHash,
304
349
  ) -> Result<bool, Error> {
305
350
  let ctx = rb_self.0.borrow();
306
- let builder = build_head(status, headers)?;
351
+ let builder = timed(&ctx, build_head(status, headers)?);
307
352
  ctx.responder
308
353
  .send_stream_head(builder)
309
354
  .map_err(|e| invalid_response(ruby, e))
@@ -425,6 +470,11 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
425
470
  slot: None,
426
471
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
427
472
  responder: Arc::new(Responder::new(head_tx)),
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,
428
478
  })
429
479
  }
430
480
 
@@ -2,7 +2,7 @@
2
2
  //! intake. Ruby is never on these threads; the only contact points are the
3
3
  //! flume queue (in) and each request's Responder (out).
4
4
 
5
- use std::net::SocketAddr;
5
+ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
6
6
  use std::sync::atomic::Ordering;
7
7
  use std::sync::Arc;
8
8
  use std::time::Duration;
@@ -13,6 +13,7 @@ use hyper_util::rt::TokioIo;
13
13
  use magnus::{Error, Ruby};
14
14
  use parking_lot::{Mutex, RwLock};
15
15
 
16
+ use crate::listen::Listener;
16
17
  use crate::registry::{self, BoxedCtx, ServerInner, WorkerSlot};
17
18
  use crate::request::RequestCtx;
18
19
  use crate::response::{plain_response, HyperResponse, Responder};
@@ -44,8 +45,10 @@ fn cfg_opt<T: magnus::TryConvert>(
44
45
  /// at boot, so Hash-lookup cost is irrelevant and the interface stays
45
46
  /// extensible. Binding is synchronous so address errors raise in Ruby at
46
47
  /// `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> {
48
+ /// (bad cert/key) also raise here, before any traffic. The third element
49
+ /// of the return tuple is the control-plane port (nil unless control_bind
50
+ /// is configured).
51
+ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Option<u16>), Error> {
49
52
  let bind: String = cfg(ruby, config, "bind")?;
50
53
  let port: u16 = cfg(ruby, config, "port")?;
51
54
  let queue_depth: usize = cfg(ruby, config, "queue_depth")?;
@@ -58,6 +61,10 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
58
61
  let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
59
62
  let lanes: bool = cfg_opt(ruby, config, "lanes")?.unwrap_or(false);
60
63
  let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
64
+ let mode: String = cfg_opt(ruby, config, "mode")?.unwrap_or_else(|| "threaded".to_string());
65
+ let workers: usize = cfg_opt(ruby, config, "workers")?.unwrap_or(0);
66
+ let threads: usize = cfg_opt(ruby, config, "threads")?.unwrap_or(0);
67
+ let batch: usize = cfg_opt(ruby, config, "batch")?.unwrap_or(1);
61
68
  let acceptor = match (&tls_cert, &tls_key) {
62
69
  (Some(cert), Some(key)) => Some(
63
70
  crate::tls::build_acceptor(cert, key)
@@ -72,15 +79,32 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
72
79
  }
73
80
  };
74
81
 
75
- let listener = std::net::TcpListener::bind((bind.as_str(), port))
76
- .map_err(|e| io_error(ruby, "bind failed", e))?;
77
- listener
78
- .set_nonblocking(true)
79
- .map_err(|e| io_error(ruby, "listener setup failed", e))?;
82
+ let listener =
83
+ Listener::bind(&bind, port).map_err(|e| io_error(ruby, "bind failed", e))?;
84
+ // Ruby refuses this combination up front; this guards embedders
85
+ // calling the native layer directly.
86
+ if acceptor.is_some() && matches!(listener, Listener::Unix(..)) {
87
+ return Err(Error::new(
88
+ ruby.exception_arg_error(),
89
+ "TLS is not supported on a unix socket bind",
90
+ ));
91
+ }
80
92
  let local_port = listener
81
- .local_addr()
82
- .map_err(|e| io_error(ruby, "listener setup failed", e))?
83
- .port();
93
+ .port()
94
+ .map_err(|e| io_error(ruby, "listener setup failed", e))?;
95
+ let unix_path = match &listener {
96
+ Listener::Unix(_, path) => Some(path.clone()),
97
+ Listener::Tcp(_) => None,
98
+ };
99
+
100
+ let control_bind_addr: Option<String> = cfg_opt(ruby, config, "control_bind")?;
101
+ let control_token: Option<String> = cfg_opt(ruby, config, "control_token")?;
102
+ let control_bind = control_bind_addr
103
+ .as_deref()
104
+ .map(|addr| {
105
+ crate::control::bind_control(addr).map_err(|e| io_error(ruby, "control bind failed", e))
106
+ })
107
+ .transpose()?;
84
108
 
85
109
  let mut builder = tokio::runtime::Builder::new_multi_thread();
86
110
  builder.enable_all().thread_name("kino-tokio");
@@ -108,17 +132,22 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
108
132
  request_timeout_ms,
109
133
  max_body_size,
110
134
  timeouts: std::sync::atomic::AtomicU64::new(0),
135
+ state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
136
+ respawns: std::sync::atomic::AtomicU64::new(0),
137
+ quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
138
+ topology: registry::Topology { mode, workers, threads, batch },
111
139
  https: acceptor.is_some(),
140
+ unix_path,
112
141
  access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
113
142
  lanes,
114
143
  lane_cursor: std::sync::atomic::AtomicUsize::new(0),
115
144
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
145
+ queue_histogram: registry::QueueHistogram::new(),
116
146
  });
117
147
 
118
148
  let tokio_listener = {
119
149
  let _guard = runtime.enter();
120
- tokio::net::TcpListener::from_std(listener)
121
- .map_err(|e| io_error(ruby, "listener setup failed", e))?
150
+ AsyncListener::from_std(listener).map_err(|e| io_error(ruby, "listener setup failed", e))?
122
151
  };
123
152
  runtime.spawn(accept_loop(
124
153
  tokio_listener,
@@ -130,8 +159,28 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
130
159
  *server.runtime.lock() = Some(runtime);
131
160
 
132
161
  let id = server.id;
162
+ let control_port = match control_bind {
163
+ // Not yet in the registry: on failure Ruby never learns this id, so
164
+ // nothing could ever reach it to shut it down. The accept loop's
165
+ // task holds its own Arc back to `server` (stored inside its own
166
+ // `runtime` field), so just dropping our handle would leak the
167
+ // runtime forever; take it out and stop it explicitly instead.
168
+ // Safe to block here: this is the plain Ruby thread, no async
169
+ // context above it.
170
+ Some(bind) => match crate::control::start(bind, server.clone(), control_token) {
171
+ Ok(port) => port,
172
+ Err(e) => {
173
+ // A plain drop blocks until the accept loop's task (its
174
+ // only task, idling on accept/shutdown) is torn down; the
175
+ // runtime only ever had this one thing to cancel.
176
+ drop(server.runtime.lock().take());
177
+ return Err(io_error(ruby, "control start failed", e));
178
+ }
179
+ },
180
+ None => None,
181
+ };
133
182
  registry::insert(server);
134
- Ok((id, local_port))
183
+ Ok((id, local_port, control_port))
135
184
  }
136
185
 
137
186
  /// Slowloris guard for TLS: a client that completes the TCP connect but then
@@ -142,8 +191,59 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
142
191
  /// timeout: not a knob.
143
192
  const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
144
193
 
194
+ /// What a unix-socket connection reports as its addresses. The peer is
195
+ /// local by definition (REMOTE_ADDR 127.0.0.1), and a socket has no port,
196
+ /// so SERVER_PORT falls back to http's default when the Host header names
197
+ /// none, the way Puma reports unix-socket requests.
198
+ const UNIX_PEER: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
199
+ const UNIX_LOCAL: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 80);
200
+
201
+ /// The accept loop's listener: TCP (optionally behind TLS), or a unix
202
+ /// socket, which carries plain HTTP only.
203
+ enum AsyncListener {
204
+ Tcp(tokio::net::TcpListener),
205
+ Unix(tokio::net::UnixListener),
206
+ }
207
+
208
+ /// One accepted connection, before the protocol layer sees it.
209
+ enum Conn {
210
+ Tcp(tokio::net::TcpStream),
211
+ Unix(tokio::net::UnixStream),
212
+ }
213
+
214
+ impl AsyncListener {
215
+ /// Register the bound listener with the current runtime.
216
+ fn from_std(listener: Listener) -> std::io::Result<AsyncListener> {
217
+ Ok(match listener {
218
+ Listener::Tcp(listener) => AsyncListener::Tcp(tokio::net::TcpListener::from_std(listener)?),
219
+ Listener::Unix(listener, _) => {
220
+ AsyncListener::Unix(tokio::net::UnixListener::from_std(listener)?)
221
+ }
222
+ })
223
+ }
224
+
225
+ /// The next connection with its (peer, local) addresses.
226
+ async fn accept(&self) -> std::io::Result<(Conn, SocketAddr, SocketAddr)> {
227
+ match self {
228
+ AsyncListener::Tcp(listener) => {
229
+ let (stream, remote_addr) = listener.accept().await?;
230
+ // Small responses must not wait on Nagle + delayed ACK.
231
+ let _ = stream.set_nodelay(true);
232
+ let local_addr = stream
233
+ .local_addr()
234
+ .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
235
+ Ok((Conn::Tcp(stream), remote_addr, local_addr))
236
+ }
237
+ AsyncListener::Unix(listener) => {
238
+ let (stream, _) = listener.accept().await?;
239
+ Ok((Conn::Unix(stream), UNIX_PEER, UNIX_LOCAL))
240
+ }
241
+ }
242
+ }
243
+ }
244
+
145
245
  async fn accept_loop(
146
- listener: tokio::net::TcpListener,
246
+ listener: AsyncListener,
147
247
  acceptor: Option<tokio_rustls::TlsAcceptor>,
148
248
  server: Arc<ServerInner>,
149
249
  max_connections: usize,
@@ -162,25 +262,20 @@ async fn accept_loop(
162
262
  Err(_) => break, // semaphore closed
163
263
  },
164
264
  };
165
- let (stream, remote_addr) = tokio::select! {
265
+ let (conn, remote_addr, local_addr) = tokio::select! {
166
266
  _ = shutdown_rx.changed() => break,
167
267
  accepted = listener.accept() => match accepted {
168
- Ok(pair) => pair,
268
+ Ok(accepted) => accepted,
169
269
  Err(_) => continue, // transient accept error; permit drops, retry
170
270
  },
171
271
  };
172
- // Small responses must not wait on Nagle + delayed ACK.
173
- let _ = stream.set_nodelay(true);
174
- let local_addr = stream
175
- .local_addr()
176
- .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
177
272
  let server = server.clone();
178
273
  let acceptor = acceptor.clone();
179
274
  tokio::spawn(async move {
180
275
  // Held for the connection's lifetime; dropping it frees a slot.
181
276
  let _permit = permit;
182
- match acceptor {
183
- Some(acceptor) => {
277
+ match (conn, acceptor) {
278
+ (Conn::Tcp(stream), Some(acceptor)) => {
184
279
  // Handshake failures (port scans, plain HTTP to a TLS
185
280
  // port) and stalled handshakes (slowloris) just drop the
186
281
  // connection; the timeout bounds the latter.
@@ -188,7 +283,13 @@ async fn accept_loop(
188
283
  let Ok(Ok(tls)) = handshake.await else { return };
189
284
  serve_connection(tls, server, remote_addr, local_addr).await;
190
285
  }
191
- None => serve_connection(stream, server, remote_addr, local_addr).await,
286
+ (Conn::Tcp(stream), None) => {
287
+ serve_connection(stream, server, remote_addr, local_addr).await
288
+ }
289
+ // TLS over a unix socket is refused at bind time.
290
+ (Conn::Unix(stream), _) => {
291
+ serve_connection(stream, server, remote_addr, local_addr).await
292
+ }
192
293
  }
193
294
  });
194
295
  }
@@ -270,18 +371,17 @@ async fn handle_request(
270
371
  let (parts, body) = req.into_parts();
271
372
 
272
373
  // Access-log metadata is captured only when logging is on: one Instant
273
- // read plus one small String per request.
274
- let log_meta = server.access_log.as_ref().map(|_| {
374
+ // read plus two small Strings per request. The arrival record is
375
+ // queued now, before the app sees the request, so a hang shows as an
376
+ // arrow with no answer; the completion record follows the response.
377
+ let log_meta = server.access_log.as_ref().map(|log| {
275
378
  let target = match parts.uri.query() {
276
379
  Some(q) => format!("{}?{}", parts.uri.path(), q),
277
380
  None => parts.uri.path().to_string(),
278
381
  };
279
- (
280
- std::time::Instant::now(),
281
- parts.method.to_string(),
282
- target,
283
- parts.version,
284
- )
382
+ let method = parts.method.to_string();
383
+ log.write_line(crate::access_log::arrival(&method, &target, remote_addr.ip()));
384
+ (std::time::Instant::now(), method, target)
285
385
  });
286
386
 
287
387
  // Body-size guard: an honestly-declared oversize body is refused with a
@@ -338,6 +438,7 @@ async fn handle_request(
338
438
 
339
439
  let (head_tx, head_rx) = tokio::sync::oneshot::channel();
340
440
  let responder = Arc::new(Responder::new(head_tx));
441
+ let now = std::time::Instant::now();
341
442
  let ctx = Box::new(RequestCtx {
342
443
  method: parts.method,
343
444
  uri: parts.uri,
@@ -353,6 +454,11 @@ async fn handle_request(
353
454
  slot: None,
354
455
  pin_slab: server.pin_slab.clone(),
355
456
  responder,
457
+ enqueued_at: now,
458
+ timed: server.access_log.is_some(),
459
+ wait: Duration::ZERO,
460
+ admitted_at: now,
461
+ gc: None,
356
462
  });
357
463
 
358
464
  // Drop guard, not manual decrement: when a client aborts mid-request,
@@ -431,17 +537,20 @@ async fn handle_request(
431
537
  }
432
538
  };
433
539
 
434
- if let (Some(log), Some((start, method, target, version))) =
435
- (server.access_log.as_ref(), log_meta)
436
- {
437
- let status = response.status().as_u16();
438
- let line = format!(
439
- "{} [{}] \"{method} {target} {version:?}\" {status} {:.1}ms",
440
- remote_addr.ip(),
441
- httpdate::fmt_http_date(std::time::SystemTime::now()),
442
- start.elapsed().as_secs_f64() * 1000.0
443
- );
444
- log.write_line(crate::style::status_colored(status, &line));
540
+ if let (Some(log), Some((start, method, target))) = (server.access_log.as_ref(), log_meta) {
541
+ // The worker attached its timing to the response head; a 503 or
542
+ // 504 never reached a worker and carries none.
543
+ let timing = response
544
+ .extensions()
545
+ .get::<crate::access_log::Timing>()
546
+ .copied();
547
+ log.write_line(crate::access_log::completion(
548
+ response.status().as_u16(),
549
+ &method,
550
+ &target,
551
+ start.elapsed(),
552
+ timing,
553
+ ));
445
554
  }
446
555
 
447
556
  Ok(branded(response))
@@ -518,11 +627,28 @@ pub fn register_worker(ruby: &Ruby, server_id: u64) -> Result<usize, Error> {
518
627
 
519
628
  pub fn stop_accepting(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
520
629
  if let Some(server) = registry::try_get(server_id) {
630
+ server.state.store(registry::STATE_DRAINING, Ordering::Relaxed);
521
631
  let _ = server.shutdown_tx.send(true);
522
632
  }
523
633
  Ok(())
524
634
  }
525
635
 
636
+ /// Ruby reports the worker pool up; /ready starts answering 200.
637
+ pub fn control_ready(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
638
+ if let Some(server) = registry::try_get(server_id) {
639
+ server.state.store(registry::STATE_READY, Ordering::Relaxed);
640
+ }
641
+ Ok(())
642
+ }
643
+
644
+ /// One worker respawn, recorded by the Ruby supervisor.
645
+ pub fn record_respawn(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
646
+ if let Some(server) = registry::try_get(server_id) {
647
+ server.respawns.fetch_add(1, Ordering::Relaxed);
648
+ }
649
+ Ok(())
650
+ }
651
+
526
652
  pub fn close_queue(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
527
653
  if let Some(server) = registry::try_get(server_id) {
528
654
  server.req_tx.lock().take();
@@ -546,6 +672,12 @@ fn abort_slot(slot: &WorkerSlot) {
546
672
  responder.respond_500_if_unsent();
547
673
  }
548
674
  }
675
+ // A dead worker holds nothing: every request it had is answered above
676
+ // (or already was), so the slot is quiescent from here on. Without
677
+ // this, a crashed worker's slot reports in_flight>=1 forever (the
678
+ // supervisor never reuses a slot after a crash), wedging /stats,
679
+ // /metrics and server.stats with a phantom busy worker.
680
+ slot.in_flight.store(0, Ordering::Relaxed);
549
681
  // Lane mode: this worker is dead. Close its lane so the dispatcher
550
682
  // skips it, and drain anything queued; dropping each ctx fires the
551
683
  // Drop-500 backstop so those clients aren't left hanging.
@@ -587,6 +719,10 @@ pub fn shutdown_runtime(_ruby: &Ruby, server_id: u64, timeout_ms: u64) -> Result
587
719
  if let Some(runtime) = server.runtime.lock().take() {
588
720
  runtime.shutdown_timeout(Duration::from_millis(timeout_ms));
589
721
  }
722
+ // The listener is closed with the runtime; its socket file is not.
723
+ if let Some(path) = &server.unix_path {
724
+ crate::listen::cleanup_unix(path);
725
+ }
590
726
  }
591
727
  Ok(())
592
728
  }
@@ -601,21 +737,15 @@ pub fn pin_keeper(
601
737
  Ok(ruby.obj_wrap(crate::pin::PinKeeper(server.pin_slab.clone())))
602
738
  }
603
739
 
604
- /// Errors print in red on color terminals. Covers worker errors,
605
- /// supervisor crash reports, and everything apps write to rack.errors.
606
- pub fn log_error(message: String) {
607
- eprintln!("{}", crate::style::red(&format!("[Kino] {message}")));
608
- }
609
-
610
740
  /// Full stats snapshot: [queued, in_flight, served, rejected, timeouts,
611
- /// lane_depths]. lane_depths is nil unless lane dispatch is on.
741
+ /// respawns, lane_depths]. lane_depths is nil unless lane dispatch is on.
612
742
  #[allow(clippy::type_complexity)]
613
743
  pub fn server_stats(
614
744
  _ruby: &Ruby,
615
745
  server_id: u64,
616
- ) -> Result<(usize, usize, u64, u64, u64, Option<Vec<usize>>), Error> {
746
+ ) -> Result<(usize, usize, u64, u64, u64, u64, Option<Vec<usize>>), Error> {
617
747
  let Some(server) = registry::try_get(server_id) else {
618
- return Ok((0, 0, 0, 0, 0, None));
748
+ return Ok((0, 0, 0, 0, 0, 0, None));
619
749
  };
620
750
  let lane_depths = server.lane_depths();
621
751
  let queued = server.req_rx.len() + lane_depths.as_ref().map_or(0, |d| d.iter().sum::<usize>());
@@ -625,10 +755,56 @@ pub fn server_stats(
625
755
  server.served.load(Ordering::Relaxed),
626
756
  server.rejected.load(Ordering::Relaxed),
627
757
  server.timeouts.load(Ordering::Relaxed),
758
+ server.respawns.load(Ordering::Relaxed),
628
759
  lane_depths,
629
760
  ))
630
761
  }
631
762
 
763
+ /// Queue-wait count and summed seconds for Server#stats parity. Zeros when
764
+ /// the server is gone.
765
+ pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
766
+ let Some(server) = registry::try_get(server_id) else {
767
+ return Ok((0, 0.0));
768
+ };
769
+ let h = server.queue_histogram.snapshot();
770
+ Ok((h.count, h.sum_seconds()))
771
+ }
772
+
773
+ /// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
774
+ pub type WorkerStatRow = (usize, u64, usize, u64, bool);
775
+
776
+ /// Per-slot rows for Server#stats parity: [index, served, in_flight,
777
+ /// busy_ms, quarantined] each. Empty when the server is gone.
778
+ pub fn worker_stats(
779
+ _ruby: &Ruby,
780
+ server_id: u64,
781
+ ) -> Result<Vec<WorkerStatRow>, Error> {
782
+ let Some(server) = registry::try_get(server_id) else {
783
+ return Ok(Vec::new());
784
+ };
785
+ Ok(crate::control::collect_worker_status(&server)
786
+ .into_iter()
787
+ .map(|w| (w.index, w.served, w.in_flight, w.busy_ms, w.quarantined))
788
+ .collect())
789
+ }
790
+
791
+ /// Mark a slot quarantined (the monitor has abandoned it as wedged).
792
+ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
793
+ if let Some(server) = registry::try_get(server_id) {
794
+ let slot = server.slot(ruby, worker_id)?;
795
+ slot.quarantined.store(true, Ordering::Relaxed);
796
+ }
797
+ Ok(())
798
+ }
799
+
800
+ /// One replacement spawned by the quarantine monitor.
801
+ pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
802
+ if let Some(server) = registry::try_get(server_id) {
803
+ server.quarantine_replacements.fetch_add(1, Ordering::Relaxed);
804
+ }
805
+ Ok(())
806
+ }
807
+
632
808
  #[cfg(test)]
633
809
  mod tests {
634
810
  use super::*;
@@ -1,18 +1,19 @@
1
- //! ANSI styling for the few places the native layer writes to the
2
- //! terminal: stderr error lines and the stdout access log. (The Ruby-side
3
- //! startup banner has its own twin in Kino::CLI.) Color-capability is
4
- //! decided once per stream; every styled string resets at its end so
5
- //! nothing bleeds.
1
+ //! ANSI styling for what the native layer writes to the terminal: the
2
+ //! access log on stdout and server log lines on either stream. (The
3
+ //! Ruby-side startup banner has its own twin in Kino::CLI.) Color
4
+ //! capability is decided once per stream; every styled string resets at
5
+ //! its end so nothing bleeds.
6
6
 
7
7
  use std::sync::OnceLock;
8
8
 
9
9
  #[derive(Clone, Copy)]
10
- enum Stream {
10
+ pub enum Stream {
11
11
  Stdout,
12
12
  Stderr,
13
13
  }
14
14
 
15
- fn enabled(stream: Stream) -> bool {
15
+ /// Whether `stream` is a color terminal: a tty, no NO_COLOR, TERM not dumb.
16
+ pub fn enabled(stream: Stream) -> bool {
16
17
  use std::io::IsTerminal;
17
18
  static STDOUT: OnceLock<bool> = OnceLock::new();
18
19
  static STDERR: OnceLock<bool> = OnceLock::new();
@@ -27,55 +28,51 @@ fn enabled(stream: Stream) -> bool {
27
28
  }
28
29
  }
29
30
 
30
- /// Wrap `text` in an SGR code (e.g. "31" red, "1" bold, "38;5;208"
31
- /// 256-color), plain when the stream isn't a color terminal.
32
- fn paint(stream: Stream, code: &str, text: &str) -> String {
33
- if enabled(stream) {
31
+ /// Wrap `text` in an SGR code ("1" bold, "91" bright red, "1;32" bold
32
+ /// green) when `color`, resetting at the end; plain otherwise.
33
+ pub fn sgr(code: &str, text: &str, color: bool) -> String {
34
+ if color {
34
35
  format!("\x1b[{code}m{text}\x1b[0m")
35
36
  } else {
36
37
  text.to_string()
37
38
  }
38
39
  }
39
40
 
40
- /// Errors on stderr are bright red (91): the base red slot (31) is
41
- /// remapped to odd hues by some terminal themes; 91 stays red.
42
- pub fn red(text: &str) -> String {
43
- paint(Stream::Stderr, "91", text)
44
- }
41
+ /// Dark gray: timestamps and the timing breakdown recede behind the record.
42
+ pub const DIM: &str = "90";
43
+ /// Bold bright white: the arrival line, which has no status to color by.
44
+ pub const BOLD_WHITE: &str = "1;97";
45
+ /// Warnings are yellow.
46
+ pub const WARN: &str = "33";
47
+ /// Errors are bright red (91): the base red slot (31) is remapped to odd
48
+ /// hues by some terminal themes; 91 stays red.
49
+ pub const ERROR: &str = "91";
45
50
 
46
- /// The SGR code for a status class (basic 16-color palette only):
47
- /// 2xx green, 3xx yellow, 4xx maroon (ANSI color 1, plain dark red),
48
- /// 5xx bright red, anything else uncolored.
49
- fn status_sgr(status: u16) -> Option<&'static str> {
51
+ /// The SGR code for a status class, bold so the record leads its line
52
+ /// (basic 16-color palette only): 2xx green, 3xx yellow, 4xx maroon
53
+ /// (ANSI color 1, plain dark red), 5xx bright red, anything else uncolored.
54
+ pub fn status_sgr(status: u16) -> Option<&'static str> {
50
55
  match status {
51
- 200..=299 => Some("32"),
52
- 300..=399 => Some("33"),
53
- 400..=499 => Some("31"),
54
- 500..=599 => Some("91"),
56
+ 200..=299 => Some("1;32"),
57
+ 300..=399 => Some("1;33"),
58
+ 400..=499 => Some("1;31"),
59
+ 500..=599 => Some("1;91"),
55
60
  _ => None,
56
61
  }
57
62
  }
58
63
 
59
- /// Access-log lines on stdout, tinted by status class.
60
- pub fn status_colored(status: u16, line: &str) -> String {
61
- match status_sgr(status) {
62
- Some(code) => paint(Stream::Stdout, code, line),
63
- None => line.to_string(),
64
- }
65
- }
66
-
67
64
  #[cfg(test)]
68
65
  mod tests {
69
- use super::status_sgr;
66
+ use super::{sgr, status_sgr};
70
67
 
71
68
  #[test]
72
69
  fn status_classes_map_to_their_colors() {
73
- assert_eq!(status_sgr(200), Some("32")); // green
74
- assert_eq!(status_sgr(299), Some("32"));
75
- assert_eq!(status_sgr(301), Some("33")); // yellow
76
- assert_eq!(status_sgr(404), Some("31")); // maroon
77
- assert_eq!(status_sgr(500), Some("91")); // bright red
78
- assert_eq!(status_sgr(599), Some("91"));
70
+ assert_eq!(status_sgr(200), Some("1;32")); // green
71
+ assert_eq!(status_sgr(299), Some("1;32"));
72
+ assert_eq!(status_sgr(301), Some("1;33")); // yellow
73
+ assert_eq!(status_sgr(404), Some("1;31")); // maroon
74
+ assert_eq!(status_sgr(500), Some("1;91")); // bright red
75
+ assert_eq!(status_sgr(599), Some("1;91"));
79
76
  }
80
77
 
81
78
  #[test]
@@ -84,4 +81,10 @@ mod tests {
84
81
  assert_eq!(status_sgr(199), None);
85
82
  assert_eq!(status_sgr(600), None);
86
83
  }
84
+
85
+ #[test]
86
+ fn sgr_wraps_and_resets_only_when_coloring() {
87
+ assert_eq!(sgr("1;32", "ok", true), "\x1b[1;32mok\x1b[0m");
88
+ assert_eq!(sgr("1;32", "ok", false), "ok");
89
+ }
87
90
  }