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.
@@ -0,0 +1,718 @@
1
+ //! Read-only control plane: stats, metrics and probes served off-runtime;
2
+ //! see also server.rs for the data plane.
3
+
4
+ use std::collections::HashMap;
5
+ use std::sync::atomic::Ordering;
6
+ use std::sync::{Arc, OnceLock};
7
+ use std::time::Duration;
8
+
9
+ use parking_lot::Mutex;
10
+
11
+ use crate::registry::{ServerInner, STATE_DRAINING, STATE_READY};
12
+
13
+ /// One dispatch slot's sensors, captured for a response. busy_ms is the
14
+ /// age of the current in-flight work (0 when idle), the wedge signal.
15
+ pub struct WorkerStat {
16
+ pub index: usize,
17
+ pub served: u64,
18
+ pub in_flight: usize,
19
+ pub busy_ms: u64,
20
+ pub quarantined: bool,
21
+ }
22
+
23
+ /// Read every slot's per-worker sensors in one pass under the slots read
24
+ /// lock (the same lock lane_depths uses), computing busy_ms against one
25
+ /// "now". No Ruby, so it is safe on the control thread and below the GVL.
26
+ pub fn collect_worker_status(server: &ServerInner) -> Vec<WorkerStat> {
27
+ let now = crate::mono::mono_ms();
28
+ server
29
+ .slots
30
+ .read()
31
+ .iter()
32
+ .enumerate()
33
+ .map(|(index, slot)| {
34
+ let quarantined = slot.quarantined.load(Ordering::Relaxed);
35
+ let in_flight = slot.in_flight.load(Ordering::Relaxed);
36
+ let started = slot.last_started_ms.load(Ordering::Relaxed);
37
+ WorkerStat {
38
+ index,
39
+ served: slot.served.load(Ordering::Relaxed),
40
+ in_flight,
41
+ // A quarantined slot is a known, handled wedge: report 0 so
42
+ // it never re-trips detection or reads as a live wedge.
43
+ busy_ms: if quarantined || in_flight == 0 {
44
+ 0
45
+ } else {
46
+ now.saturating_sub(started)
47
+ },
48
+ quarantined,
49
+ }
50
+ })
51
+ .collect()
52
+ }
53
+
54
+ /// Everything the endpoints report, captured in one pass so a response
55
+ /// is internally consistent.
56
+ pub struct StatsSnapshot {
57
+ pub mode: String,
58
+ pub lanes: bool,
59
+ pub workers: usize,
60
+ pub threads: usize,
61
+ pub batch: usize,
62
+ pub respawns: u64,
63
+ pub queued: usize,
64
+ pub in_flight: usize,
65
+ pub served: u64,
66
+ pub rejected: u64,
67
+ pub timeouts: u64,
68
+ pub lane_depths: Option<Vec<usize>>,
69
+ pub state: u8,
70
+ pub worker_status: Vec<WorkerStat>,
71
+ pub quarantined_count: usize,
72
+ pub quarantine_replacements: u64,
73
+ pub queue_histogram: crate::registry::QueueHistogramSnapshot,
74
+ }
75
+
76
+ impl StatsSnapshot {
77
+ pub fn take(server: &ServerInner) -> Self {
78
+ let worker_status = collect_worker_status(server);
79
+ let quarantined_count = worker_status.iter().filter(|w| w.quarantined).count();
80
+ StatsSnapshot {
81
+ mode: server.topology.mode.clone(),
82
+ lanes: server.lanes,
83
+ workers: server.topology.workers,
84
+ threads: server.topology.threads,
85
+ batch: server.topology.batch,
86
+ respawns: server.respawns.load(Ordering::Relaxed),
87
+ queued: server.queued(),
88
+ in_flight: server.in_flight.load(Ordering::Relaxed),
89
+ served: server.served.load(Ordering::Relaxed),
90
+ rejected: server.rejected.load(Ordering::Relaxed),
91
+ timeouts: server.timeouts.load(Ordering::Relaxed),
92
+ lane_depths: server.lane_depths(),
93
+ state: server.state.load(Ordering::Relaxed),
94
+ worker_status,
95
+ quarantined_count,
96
+ quarantine_replacements: server.quarantine_replacements.load(Ordering::Relaxed),
97
+ queue_histogram: server.queue_histogram.snapshot(),
98
+ }
99
+ }
100
+
101
+ pub fn state_name(&self) -> &'static str {
102
+ match self.state {
103
+ STATE_READY => "ready",
104
+ STATE_DRAINING => "draining",
105
+ _ => "booting",
106
+ }
107
+ }
108
+ }
109
+
110
+ /// Same vocabulary as Server#stats (plus state and version); mode and
111
+ /// state are fixed identifiers, so no JSON escaping is needed.
112
+ pub fn stats_json(s: &StatsSnapshot) -> String {
113
+ use std::fmt::Write;
114
+ let mut out = String::with_capacity(256);
115
+ write!(
116
+ out,
117
+ r#"{{"mode":"{}","lanes":{},"workers":{},"threads":{},"batch":{},"respawns":{},"queued":{},"in_flight":{},"served":{},"rejected":{},"timeouts":{}"#,
118
+ s.mode, s.lanes, s.workers, s.threads, s.batch, s.respawns,
119
+ s.queued, s.in_flight, s.served, s.rejected, s.timeouts
120
+ )
121
+ .expect("writing to a String cannot fail");
122
+ if let Some(depths) = &s.lane_depths {
123
+ let list = depths.iter().map(|d| d.to_string()).collect::<Vec<_>>().join(",");
124
+ write!(out, r#","lane_depths":[{list}]"#).expect("writing to a String cannot fail");
125
+ }
126
+ out.push_str(r#","worker_status":["#);
127
+ for (i, w) in s.worker_status.iter().enumerate() {
128
+ if i > 0 {
129
+ out.push(',');
130
+ }
131
+ write!(
132
+ out,
133
+ r#"{{"index":{},"served":{},"in_flight":{},"busy_ms":{},"quarantined":{}}}"#,
134
+ w.index, w.served, w.in_flight, w.busy_ms, w.quarantined
135
+ )
136
+ .expect("writing to a String cannot fail");
137
+ }
138
+ out.push(']');
139
+ write!(
140
+ out,
141
+ r#","queue_time":{{"count":{},"sum_seconds":{}}}"#,
142
+ s.queue_histogram.count,
143
+ s.queue_histogram.sum_seconds()
144
+ )
145
+ .expect("writing to a String cannot fail");
146
+ write!(
147
+ out,
148
+ r#","quarantined":{},"state":"{}","version":"{}"}}"#,
149
+ s.quarantined_count, s.state_name(),
150
+ env!("CARGO_PKG_VERSION")
151
+ )
152
+ .expect("writing to a String cannot fail");
153
+ out
154
+ }
155
+
156
+ /// One HELP/TYPE/sample triple for a single-value metric.
157
+ fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) {
158
+ use std::fmt::Write;
159
+ writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}\n{name} {value}")
160
+ .expect("writing to a String cannot fail");
161
+ }
162
+
163
+ /// One HELP/TYPE header followed by one `name{label="key"} value` line per
164
+ /// row, for the labeled per-lane and per-worker series.
165
+ fn series<K: std::fmt::Display, V: std::fmt::Display>(
166
+ out: &mut String,
167
+ name: &str,
168
+ kind: &str,
169
+ help: &str,
170
+ label: &str,
171
+ rows: impl Iterator<Item = (K, V)>,
172
+ ) {
173
+ use std::fmt::Write;
174
+ writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}").expect("writing to a String cannot fail");
175
+ for (key, value) in rows {
176
+ writeln!(out, "{name}{{{label}=\"{key}\"}} {value}").expect("writing to a String cannot fail");
177
+ }
178
+ }
179
+
180
+ /// Prometheus text exposition (version 0.0.4) for every stat in `s`.
181
+ pub fn metrics_text(s: &StatsSnapshot) -> String {
182
+ use std::fmt::Write;
183
+ let mut out = String::with_capacity(1024);
184
+ metric(&mut out, "kino_requests_served_total", "counter", "Requests handed to Ruby workers.", s.served);
185
+ metric(&mut out, "kino_requests_rejected_total", "counter", "Requests rejected with a 503.", s.rejected);
186
+ metric(&mut out, "kino_request_timeouts_total", "counter", "Responses past the request timeout (client got a 504).", s.timeouts);
187
+ metric(&mut out, "kino_worker_respawns_total", "counter", "Crashed workers respawned by the supervisor.", s.respawns);
188
+ metric(&mut out, "kino_queue_depth", "gauge", "Requests waiting for a worker.", s.queued);
189
+ metric(&mut out, "kino_requests_in_flight", "gauge", "Requests currently inside Ruby workers.", s.in_flight);
190
+ metric(&mut out, "kino_workers", "gauge", "Configured worker count.", s.workers);
191
+ metric(&mut out, "kino_threads_per_worker", "gauge", "Configured threads per worker.", s.threads);
192
+ metric(&mut out, "kino_ready", "gauge", "1 when serving, 0 while booting or draining.",
193
+ if s.state == STATE_READY { "1" } else { "0" });
194
+ if let Some(depths) = &s.lane_depths {
195
+ series(&mut out, "kino_lane_depth", "gauge", "Queued requests in each worker lane.", "lane",
196
+ depths.iter().enumerate().map(|(lane, depth)| (lane, *depth)));
197
+ }
198
+ series(&mut out, "kino_worker_requests_served_total", "counter", "Requests handed to each dispatch slot.", "worker",
199
+ s.worker_status.iter().map(|w| (w.index, w.served)));
200
+ series(&mut out, "kino_worker_in_flight", "gauge", "Requests executing in each dispatch slot.", "worker",
201
+ s.worker_status.iter().map(|w| (w.index, w.in_flight)));
202
+ series(&mut out, "kino_worker_busy_ms", "gauge", "Age in ms of the current in-flight request per slot (0 when idle).", "worker",
203
+ s.worker_status.iter().map(|w| (w.index, w.busy_ms)));
204
+ metric(&mut out, "kino_quarantined_workers", "gauge", "Dispatch slots abandoned as wedged.", s.quarantined_count);
205
+ metric(&mut out, "kino_quarantine_replacements_total", "counter", "Replacement workers spawned after a wedge.", s.quarantine_replacements);
206
+ let h = &s.queue_histogram;
207
+ out.push_str("# HELP kino_request_queue_seconds Seconds requests waited in the queue before a worker admitted them.\n# TYPE kino_request_queue_seconds histogram\n");
208
+ let mut cumulative = 0u64;
209
+ for (i, bound_us) in crate::registry::QUEUE_BOUNDS_US.iter().enumerate() {
210
+ cumulative += h.buckets[i];
211
+ let le = *bound_us as f64 / 1_000_000.0;
212
+ writeln!(out, "kino_request_queue_seconds_bucket{{le=\"{le}\"}} {cumulative}")
213
+ .expect("writing to a String cannot fail");
214
+ }
215
+ let total = cumulative + h.overflow;
216
+ writeln!(out, "kino_request_queue_seconds_bucket{{le=\"+Inf\"}} {total}")
217
+ .expect("writing to a String cannot fail");
218
+ writeln!(out, "kino_request_queue_seconds_sum {}", h.sum_seconds())
219
+ .expect("writing to a String cannot fail");
220
+ writeln!(out, "kino_request_queue_seconds_count {total}")
221
+ .expect("writing to a String cannot fail");
222
+ out
223
+ }
224
+
225
+ /// Constant-time comparison against "Bearer <token>": the length check is
226
+ /// not part of the secret, so it may return early; once the lengths match,
227
+ /// every byte of both strings is visited regardless of where they first
228
+ /// differ.
229
+ pub fn token_ok(expected: &str, authorization: Option<&str>) -> bool {
230
+ let presented = authorization
231
+ .and_then(|h| h.strip_prefix("Bearer "))
232
+ .unwrap_or("");
233
+ let e = expected.as_bytes();
234
+ let p = presented.as_bytes();
235
+ if e.len() != p.len() {
236
+ return false;
237
+ }
238
+ let mut diff = 0u8;
239
+ for i in 0..e.len() {
240
+ diff |= e[i] ^ p[i];
241
+ }
242
+ diff == 0
243
+ }
244
+
245
+ /// (status, content type, body). The token, when configured, guards the
246
+ /// data endpoints only; orchestrator probes must never need credentials.
247
+ pub fn route(
248
+ method: &str,
249
+ path: &str,
250
+ authorization: Option<&str>,
251
+ token: Option<&str>,
252
+ snapshot: &StatsSnapshot,
253
+ ) -> (u16, &'static str, String) {
254
+ if method != "GET" && method != "HEAD" {
255
+ return (405, "text/plain", "method not allowed\n".to_string());
256
+ }
257
+ let path = path.split('?').next().unwrap_or(path);
258
+ match path {
259
+ "/live" => (200, "text/plain", "ok\n".to_string()),
260
+ "/ready" => {
261
+ if snapshot.state == STATE_READY {
262
+ (200, "text/plain", "ok\n".to_string())
263
+ } else {
264
+ (503, "text/plain", format!("{}\n", snapshot.state_name()))
265
+ }
266
+ }
267
+ "/stats" | "/metrics" => {
268
+ if let Some(expected) = token {
269
+ if !token_ok(expected, authorization) {
270
+ return (401, "text/plain", "unauthorized\n".to_string());
271
+ }
272
+ }
273
+ if path == "/stats" {
274
+ (200, "application/json", stats_json(snapshot))
275
+ } else {
276
+ (200, "text/plain; version=0.0.4", metrics_text(snapshot))
277
+ }
278
+ }
279
+ _ => (404, "text/plain", "not found\n".to_string()),
280
+ }
281
+ }
282
+
283
+ /// One request is tiny (request line plus a header or two); anything
284
+ /// larger is not a monitoring client.
285
+ const CONTROL_MAX_REQUEST_BYTES: usize = 8192;
286
+ /// Whole-connection deadline, accept to close. Keep-alive is off, so
287
+ /// this bounds exactly one request.
288
+ const CONTROL_DEADLINE: Duration = Duration::from_secs(5);
289
+ /// Concurrent monitoring connections; probes and scrapers need a
290
+ /// handful, connections past the cap are dropped at accept.
291
+ const CONTROL_MAX_CONNECTIONS: usize = 16;
292
+
293
+ /// A bound control listener: TCP with its resolved port, or a unix socket
294
+ /// with its path.
295
+ pub enum ControlBind {
296
+ Tcp(std::net::TcpListener, u16),
297
+ Unix(std::os::unix::net::UnixListener, std::path::PathBuf),
298
+ }
299
+
300
+ /// Claim the control address. Both arms bind synchronously so a
301
+ /// conflict raises at boot, like the main listener.
302
+ pub fn bind_control(addr: &str) -> std::io::Result<ControlBind> {
303
+ if let Some(path) = addr.strip_prefix("unix://") {
304
+ let path = std::path::PathBuf::from(path);
305
+ // A path that already exists is either a live listener (refuse: do
306
+ // not steal it) or a stale file left behind by a crashed process
307
+ // (safe to unlink and reclaim). Probe with a connect: a successful
308
+ // connect means someone is accepting on it right now.
309
+ match std::os::unix::net::UnixStream::connect(&path) {
310
+ Ok(_) => {
311
+ return Err(std::io::Error::new(
312
+ std::io::ErrorKind::AddrInUse,
313
+ "control socket is in use",
314
+ ));
315
+ }
316
+ Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
317
+ match std::fs::remove_file(&path) {
318
+ Ok(()) => {}
319
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
320
+ Err(e) => return Err(e),
321
+ }
322
+ }
323
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
324
+ Err(e) => return Err(e),
325
+ }
326
+ let listener = std::os::unix::net::UnixListener::bind(&path)?;
327
+ listener.set_nonblocking(true)?;
328
+ Ok(ControlBind::Unix(listener, path))
329
+ } else {
330
+ let listener = std::net::TcpListener::bind(addr)?;
331
+ listener.set_nonblocking(true)?;
332
+ let port = listener.local_addr()?.port();
333
+ Ok(ControlBind::Tcp(listener, port))
334
+ }
335
+ }
336
+
337
+ struct ControlHandle {
338
+ stop_tx: tokio::sync::watch::Sender<bool>,
339
+ join: std::thread::JoinHandle<()>,
340
+ unix_path: Option<std::path::PathBuf>,
341
+ }
342
+
343
+ static CONTROL: OnceLock<Mutex<HashMap<u64, ControlHandle>>> = OnceLock::new();
344
+
345
+ fn control_registry() -> &'static Mutex<HashMap<u64, ControlHandle>> {
346
+ CONTROL.get_or_init(|| Mutex::new(HashMap::new()))
347
+ }
348
+
349
+ /// Spawn the kino-control thread. Returns the TCP port (None for unix
350
+ /// sockets). The thread owns its own single-threaded runtime, so the
351
+ /// endpoints answer independently of the data plane and the GVL.
352
+ pub fn start(
353
+ bind: ControlBind,
354
+ server: Arc<ServerInner>,
355
+ token: Option<String>,
356
+ ) -> std::io::Result<Option<u16>> {
357
+ let (port, unix_path) = match &bind {
358
+ ControlBind::Tcp(_, port) => (Some(*port), None),
359
+ ControlBind::Unix(_, path) => (None, Some(path.clone())),
360
+ };
361
+ let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
362
+ let id = server.id;
363
+ let join = match std::thread::Builder::new()
364
+ .name("kino-control".to_string())
365
+ .spawn(move || run(bind, server, token, stop_rx))
366
+ {
367
+ Ok(join) => join,
368
+ Err(e) => {
369
+ // The thread never started, so control_stop will never run to
370
+ // reclaim the socket file; without this the path is left
371
+ // behind and the next bind_control for it fails outright.
372
+ if let Some(path) = &unix_path {
373
+ let _ = std::fs::remove_file(path);
374
+ }
375
+ return Err(e);
376
+ }
377
+ };
378
+ control_registry().lock().insert(
379
+ id,
380
+ ControlHandle { stop_tx, join, unix_path },
381
+ );
382
+ Ok(port)
383
+ }
384
+
385
+ /// Stop the control thread and clean up; a no-op for unknown ids, so
386
+ /// shutdown stays idempotent. Called from Ruby after the main runtime
387
+ /// is gone (the control thread must be the last thing reporting).
388
+ pub fn control_stop(_ruby: &magnus::Ruby, server_id: u64) -> Result<(), magnus::Error> {
389
+ let handle = control_registry().lock().remove(&server_id);
390
+ if let Some(handle) = handle {
391
+ let _ = handle.stop_tx.send(true);
392
+ let _ = handle.join.join();
393
+ if let Some(path) = handle.unix_path {
394
+ let _ = std::fs::remove_file(path);
395
+ }
396
+ }
397
+ Ok(())
398
+ }
399
+
400
+ fn run(
401
+ bind: ControlBind,
402
+ server: Arc<ServerInner>,
403
+ token: Option<String>,
404
+ stop_rx: tokio::sync::watch::Receiver<bool>,
405
+ ) {
406
+ let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
407
+ Ok(runtime) => runtime,
408
+ Err(e) => return crate::server::log_error(format!("control runtime failed: {e}")),
409
+ };
410
+ runtime.block_on(async move {
411
+ match bind {
412
+ ControlBind::Tcp(listener, _) => match tokio::net::TcpListener::from_std(listener) {
413
+ Ok(listener) => serve(TcpOrUnix::Tcp(listener), server, token, stop_rx).await,
414
+ Err(e) => crate::server::log_error(format!("control listener failed: {e}")),
415
+ },
416
+ ControlBind::Unix(listener, _) => match tokio::net::UnixListener::from_std(listener) {
417
+ Ok(listener) => serve(TcpOrUnix::Unix(listener), server, token, stop_rx).await,
418
+ Err(e) => crate::server::log_error(format!("control listener failed: {e}")),
419
+ },
420
+ }
421
+ });
422
+ }
423
+
424
+ enum TcpOrUnix {
425
+ Tcp(tokio::net::TcpListener),
426
+ Unix(tokio::net::UnixListener),
427
+ }
428
+
429
+ async fn serve(
430
+ listener: TcpOrUnix,
431
+ server: Arc<ServerInner>,
432
+ token: Option<String>,
433
+ mut stop_rx: tokio::sync::watch::Receiver<bool>,
434
+ ) {
435
+ let permits = Arc::new(tokio::sync::Semaphore::new(CONTROL_MAX_CONNECTIONS));
436
+ // Accept errors (EMFILE and friends) back off instead of exiting: a dead
437
+ // control loop reads as a dead process to liveness probes, which must
438
+ // never happen while we serve.
439
+ macro_rules! conn {
440
+ ($accepted:expr) => {
441
+ match $accepted {
442
+ Ok((stream, _)) => {
443
+ let Ok(permit) = permits.clone().try_acquire_owned() else { continue };
444
+ spawn_connection(stream, permit, server.clone(), token.clone());
445
+ }
446
+ Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
447
+ }
448
+ };
449
+ }
450
+ loop {
451
+ match &listener {
452
+ TcpOrUnix::Tcp(l) => tokio::select! {
453
+ _ = stop_rx.changed() => return,
454
+ accepted = l.accept() => conn!(accepted),
455
+ },
456
+ TcpOrUnix::Unix(l) => tokio::select! {
457
+ _ = stop_rx.changed() => return,
458
+ accepted = l.accept() => conn!(accepted),
459
+ },
460
+ }
461
+ }
462
+ }
463
+
464
+ fn spawn_connection<S>(
465
+ stream: S,
466
+ permit: tokio::sync::OwnedSemaphorePermit,
467
+ server: Arc<ServerInner>,
468
+ token: Option<String>,
469
+ ) where
470
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
471
+ {
472
+ // A panic inside the task dies with the task; the accept loop and
473
+ // its siblings keep serving.
474
+ tokio::spawn(async move {
475
+ let _permit = permit;
476
+ let service = hyper::service::service_fn(move |req| {
477
+ let server = server.clone();
478
+ let token = token.clone();
479
+ async move { Ok::<_, std::convert::Infallible>(handle(&server, token.as_deref(), &req)) }
480
+ });
481
+ let conn = hyper::server::conn::http1::Builder::new()
482
+ .keep_alive(false)
483
+ .max_buf_size(CONTROL_MAX_REQUEST_BYTES)
484
+ .serve_connection(hyper_util::rt::TokioIo::new(stream), service);
485
+ let _ = tokio::time::timeout(CONTROL_DEADLINE, conn).await;
486
+ });
487
+ }
488
+
489
+ fn handle(
490
+ server: &ServerInner,
491
+ token: Option<&str>,
492
+ req: &hyper::Request<hyper::body::Incoming>,
493
+ ) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
494
+ let authorization = req
495
+ .headers()
496
+ .get(hyper::header::AUTHORIZATION)
497
+ .and_then(|v| v.to_str().ok());
498
+ let snapshot = StatsSnapshot::take(server);
499
+ let (status, content_type, body) =
500
+ route(req.method().as_str(), req.uri().path(), authorization, token, &snapshot);
501
+ let mut builder = hyper::Response::builder()
502
+ .status(status)
503
+ .header("content-type", content_type);
504
+ if status == 401 {
505
+ builder = builder.header("www-authenticate", "Bearer");
506
+ }
507
+ let bytes = if req.method() == hyper::Method::HEAD {
508
+ bytes::Bytes::new()
509
+ } else {
510
+ bytes::Bytes::from(body)
511
+ };
512
+ builder
513
+ .body(http_body_util::Full::new(bytes))
514
+ .expect("static response parts always build")
515
+ }
516
+
517
+ #[cfg(test)]
518
+ mod tests {
519
+ use super::*;
520
+
521
+ fn snapshot(state: u8) -> StatsSnapshot {
522
+ StatsSnapshot {
523
+ mode: "ractor".to_string(), lanes: false, workers: 8, threads: 1,
524
+ batch: 1, respawns: 2, queued: 3, in_flight: 4, served: 100,
525
+ rejected: 5, timeouts: 6, lane_depths: None, state, worker_status: vec![],
526
+ quarantined_count: 0, quarantine_replacements: 0,
527
+ queue_histogram: crate::registry::QueueHistogramSnapshot { buckets: [0; crate::registry::QUEUE_BOUNDS_US.len()], overflow: 0, sum_us: 0, count: 0 },
528
+ }
529
+ }
530
+
531
+ #[test]
532
+ fn stats_json_reports_every_field_and_the_state_name() {
533
+ let json = stats_json(&snapshot(crate::registry::STATE_READY));
534
+ for needle in [
535
+ r#""mode":"ractor""#, r#""lanes":false"#, r#""workers":8"#,
536
+ r#""threads":1"#, r#""batch":1"#, r#""respawns":2"#,
537
+ r#""queued":3"#, r#""in_flight":4"#, r#""served":100"#,
538
+ r#""rejected":5"#, r#""timeouts":6"#, r#""state":"ready""#,
539
+ r#""version":""#,
540
+ ] {
541
+ assert!(json.contains(needle), "missing {needle} in {json}");
542
+ }
543
+ assert!(!json.contains("lane_depths"));
544
+ }
545
+
546
+ #[test]
547
+ fn stats_json_includes_lane_depths_when_lanes_are_on() {
548
+ let mut s = snapshot(crate::registry::STATE_READY);
549
+ s.lane_depths = Some(vec![1, 0]);
550
+ assert!(stats_json(&s).contains(r#""lane_depths":[1,0]"#));
551
+ }
552
+
553
+ #[test]
554
+ fn metrics_text_is_prometheus_shaped() {
555
+ let text = metrics_text(&snapshot(crate::registry::STATE_READY));
556
+ assert!(text.contains("# TYPE kino_requests_served_total counter"));
557
+ assert!(text.contains("kino_requests_served_total 100"));
558
+ assert!(text.contains("kino_ready 1"));
559
+ let draining = metrics_text(&snapshot(crate::registry::STATE_DRAINING));
560
+ assert!(draining.contains("kino_ready 0"));
561
+ }
562
+
563
+ #[test]
564
+ fn metrics_text_includes_lane_depth_samples_when_lanes_are_on() {
565
+ let mut s = snapshot(crate::registry::STATE_READY);
566
+ s.lane_depths = Some(vec![2, 0]);
567
+ let text = metrics_text(&s);
568
+ assert!(text.contains("# HELP kino_lane_depth Queued requests in each worker lane."));
569
+ assert!(text.contains("# TYPE kino_lane_depth gauge"));
570
+ assert!(text.contains(r#"kino_lane_depth{lane="0"} 2"#));
571
+ assert!(text.contains(r#"kino_lane_depth{lane="1"} 0"#));
572
+ }
573
+
574
+ #[test]
575
+ fn token_check_wants_the_exact_bearer_token() {
576
+ assert!(token_ok("s3cret", Some("Bearer s3cret")));
577
+ assert!(!token_ok("s3cret", Some("Bearer wrong")));
578
+ assert!(!token_ok("s3cret", Some("s3cret")));
579
+ assert!(!token_ok("s3cret", None));
580
+ assert!(!token_ok("s3cret", Some("Bearer s3cret-and-more")));
581
+ }
582
+
583
+ #[test]
584
+ fn token_check_rejects_nul_padded_forgeries() {
585
+ // A truncating length comparison (e.g. casting the XOR of lengths to
586
+ // u8) would wrap a 256-byte overage back to zero and let NUL padding
587
+ // stand in for the missing bytes; the exact-length check must catch
588
+ // both a large and a minimal version of that forgery.
589
+ let padded = format!("Bearer s3cret{}", "\0".repeat(256));
590
+ assert!(!token_ok("s3cret", Some(&padded)));
591
+ assert!(!token_ok("s3cret", Some("Bearer s3cret\0")));
592
+ }
593
+
594
+ #[test]
595
+ fn routing_matrix() {
596
+ let ready = snapshot(crate::registry::STATE_READY);
597
+ assert_eq!(route("GET", "/live", None, None, &ready).0, 200);
598
+ assert_eq!(route("HEAD", "/live", None, None, &ready).0, 200);
599
+ assert_eq!(route("GET", "/ready", None, None, &ready).0, 200);
600
+ assert_eq!(route("GET", "/stats", None, None, &ready).0, 200);
601
+ assert_eq!(route("GET", "/metrics", None, None, &ready).0, 200);
602
+ assert_eq!(route("GET", "/nope", None, None, &ready).0, 404);
603
+ assert_eq!(route("POST", "/stats", None, None, &ready).0, 405);
604
+ assert_eq!(route("GET", "/stats?x=1", None, None, &ready).0, 200);
605
+
606
+ let booting = snapshot(crate::registry::STATE_BOOTING);
607
+ let (code, _, body) = route("GET", "/ready", None, None, &booting);
608
+ assert_eq!((code, body.as_str()), (503, "booting\n"));
609
+
610
+ // The token guards stats and metrics; the probes stay open.
611
+ assert_eq!(route("GET", "/stats", None, Some("t"), &ready).0, 401);
612
+ assert_eq!(route("GET", "/metrics", Some("Bearer t"), Some("t"), &ready).0, 200);
613
+ assert_eq!(route("GET", "/ready", None, Some("t"), &ready).0, 200);
614
+ assert_eq!(route("GET", "/live", None, Some("t"), &ready).0, 200);
615
+ }
616
+
617
+ #[test]
618
+ fn stats_json_emits_worker_status_array() {
619
+ let mut s = snapshot(crate::registry::STATE_READY);
620
+ s.worker_status = vec![
621
+ WorkerStat { index: 0, served: 10, in_flight: 1, busy_ms: 4, quarantined: false },
622
+ WorkerStat { index: 1, served: 7, in_flight: 0, busy_ms: 0, quarantined: false },
623
+ ];
624
+ let json = stats_json(&s);
625
+ assert!(json.contains(r#""worker_status":[{"index":0,"served":10,"in_flight":1,"busy_ms":4,"quarantined":false},{"index":1,"served":7,"in_flight":0,"busy_ms":0,"quarantined":false}]"#), "got {json}");
626
+ }
627
+
628
+ #[test]
629
+ fn stats_json_worker_status_is_empty_array_with_no_slots() {
630
+ let s = snapshot(crate::registry::STATE_READY);
631
+ assert!(stats_json(&s).contains(r#""worker_status":[]"#));
632
+ }
633
+
634
+ #[test]
635
+ fn metrics_text_emits_per_worker_series() {
636
+ let mut s = snapshot(crate::registry::STATE_READY);
637
+ s.worker_status = vec![
638
+ WorkerStat { index: 0, served: 10, in_flight: 1, busy_ms: 4, quarantined: false },
639
+ WorkerStat { index: 1, served: 7, in_flight: 0, busy_ms: 0, quarantined: false },
640
+ ];
641
+ let text = metrics_text(&s);
642
+ assert!(text.contains("# TYPE kino_worker_requests_served_total counter"));
643
+ assert!(text.contains(r#"kino_worker_requests_served_total{worker="0"} 10"#));
644
+ assert!(text.contains(r#"kino_worker_in_flight{worker="1"} 0"#));
645
+ assert!(text.contains("# TYPE kino_worker_busy_ms gauge"));
646
+ assert!(text.contains(r#"kino_worker_busy_ms{worker="0"} 4"#));
647
+ }
648
+
649
+ #[test]
650
+ fn quarantined_slot_reports_zero_busy_ms() {
651
+ let server = crate::registry::test_server(false, 4);
652
+ server.register_worker();
653
+ {
654
+ let slots = server.slots.read();
655
+ slots[0].in_flight.store(1, std::sync::atomic::Ordering::Relaxed);
656
+ slots[0].last_started_ms.store(0, std::sync::atomic::Ordering::Relaxed);
657
+ slots[0].quarantined.store(true, std::sync::atomic::Ordering::Relaxed);
658
+ }
659
+ let rows = collect_worker_status(&server);
660
+ assert!(rows[0].quarantined);
661
+ assert_eq!(rows[0].busy_ms, 0);
662
+ }
663
+
664
+ #[test]
665
+ fn stats_json_reports_quarantine() {
666
+ let mut s = snapshot(crate::registry::STATE_READY);
667
+ s.quarantined_count = 1;
668
+ s.worker_status = vec![
669
+ WorkerStat { index: 0, served: 3, in_flight: 1, busy_ms: 0, quarantined: true },
670
+ WorkerStat { index: 1, served: 9, in_flight: 1, busy_ms: 5, quarantined: false },
671
+ ];
672
+ let json = stats_json(&s);
673
+ assert!(json.contains(r#""quarantined":1"#), "top-level count: {json}");
674
+ assert!(json.contains(r#"{"index":0,"served":3,"in_flight":1,"busy_ms":0,"quarantined":true}"#), "{json}");
675
+ assert!(json.contains(r#""quarantined":false"#));
676
+ }
677
+
678
+ #[test]
679
+ fn metrics_text_reports_quarantine() {
680
+ let mut s = snapshot(crate::registry::STATE_READY);
681
+ s.quarantined_count = 2;
682
+ s.quarantine_replacements = 7;
683
+ let text = metrics_text(&s);
684
+ assert!(text.contains("# TYPE kino_quarantined_workers gauge"));
685
+ assert!(text.contains("kino_quarantined_workers 2"));
686
+ assert!(text.contains("# TYPE kino_quarantine_replacements_total counter"));
687
+ assert!(text.contains("kino_quarantine_replacements_total 7"));
688
+ }
689
+
690
+ #[test]
691
+ fn metrics_text_emits_a_cumulative_queue_histogram() {
692
+ let mut s = snapshot(crate::registry::STATE_READY);
693
+ let mut buckets = [0u64; crate::registry::QUEUE_BOUNDS_US.len()];
694
+ buckets[0] = 3; // <= 0.0005s
695
+ buckets[2] = 1; // <= 0.0025s
696
+ s.queue_histogram = crate::registry::QueueHistogramSnapshot {
697
+ buckets, overflow: 1, sum_us: 3 * 100 + 2_000 + 20_000_000, count: 5,
698
+ };
699
+ let text = metrics_text(&s);
700
+ assert!(text.contains("# TYPE kino_request_queue_seconds histogram"));
701
+ assert!(text.contains(r#"kino_request_queue_seconds_bucket{le="0.0005"} 3"#));
702
+ // cumulative: le=0.0025 includes buckets 0..=2 = 3 + 0 + 1 = 4
703
+ assert!(text.contains(r#"kino_request_queue_seconds_bucket{le="0.0025"} 4"#));
704
+ assert!(text.contains(r#"kino_request_queue_seconds_bucket{le="+Inf"} 5"#));
705
+ assert!(text.contains("kino_request_queue_seconds_count 5"));
706
+ assert!(text.contains("kino_request_queue_seconds_sum "));
707
+ }
708
+
709
+ #[test]
710
+ fn stats_json_reports_queue_time() {
711
+ let mut s = snapshot(crate::registry::STATE_READY);
712
+ s.queue_histogram = crate::registry::QueueHistogramSnapshot {
713
+ buckets: [0; crate::registry::QUEUE_BOUNDS_US.len()], overflow: 0, sum_us: 1_500_000, count: 2,
714
+ };
715
+ let json = stats_json(&s);
716
+ assert!(json.contains(r#""queue_time":{"count":2,"sum_seconds":1.5}"#), "{json}");
717
+ }
718
+ }