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.
data/README.md CHANGED
@@ -196,6 +196,12 @@ bundle exec kino # picks up config.ru + kino.rb, serves on :9292
196
196
  (After a standalone `gem install`, the `kino` command works without
197
197
  `bundle exec`.)
198
198
 
199
+ Prefer your framework's own command? Kino ships a Rack handler, so
200
+ `rails server -u kino` and `rackup -s kino` boot it too. They read the
201
+ same `kino.rb` (or `config/kino.rb`), the host's `-p`/`-b` flags win over
202
+ the file, and `rackup -s kino -O Workers=4 -O Mode=threaded` reaches the
203
+ rest (`rackup -s kino --help` lists them).
204
+
199
205
  No Rust compiler needed: released versions ship precompiled native gems
200
206
  for Linux (x86_64/aarch64, glibc and musl) and macOS (arm64). On other
201
207
  platforms the gem compiles at install time; that needs a Rust toolchain,
@@ -218,9 +224,9 @@ Or embedded, with everything spelled out:
218
224
 
219
225
  ```ruby
220
226
  server = Kino::Server.new(app,
221
- bind: "127.0.0.1",
227
+ bind: "127.0.0.1", # or "unix:///run/kino.sock" behind a proxy
222
228
  port: 9292, # 0 = ephemeral; read back via server.port
223
- workers: Etc.nprocessors, # ractors (parallelism)
229
+ workers: Kino.available_parallelism, # ractors (parallelism); the default
224
230
  threads: 1, # per worker; ractor default 1, threaded default 3
225
231
  mode: :auto, # :auto | :ractor | :threaded
226
232
  queue_depth: 1024, # bounded queue; overflow → 503
@@ -230,6 +236,8 @@ server = Kino::Server.new(app,
230
236
  max_body_size: 50 * 1024 * 1024, # bytes before a 413; nil = let a proxy handle it
231
237
  on_error: ->(e, env) { ErrorTracker.capture(e) }, # after the client got its 500
232
238
  shutdown_timeout: 30, # drain deadline
239
+ control_bind: "127.0.0.1:9293", # monitoring: /stats /metrics /ready /live; port 0 reads back via server.control_port
240
+ control_token: ENV["KINO_CONTROL_TOKEN"], # optional Bearer auth for /stats + /metrics
233
241
  tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM
234
242
  )
235
243
  server.start
@@ -253,8 +261,10 @@ server.shutdown # graceful: drain → deadline → abort straggler
253
261
 
254
262
  ## Config file and CLI
255
263
 
256
- Settings can live in a Puma-style Ruby DSL file. Precedence: explicit
257
- kwargs and CLI flags > config file > defaults.
264
+ Settings can live in a Puma-style Ruby DSL file: `kino.rb` in the
265
+ working directory, or `config/kino.rb` (the Rails layout), is picked up
266
+ automatically; `-C PATH` names any other. Precedence: explicit kwargs
267
+ and CLI flags > config file > defaults.
258
268
 
259
269
  ```ruby
260
270
  # kino.rb
@@ -320,6 +330,44 @@ after the client got its 500—the only place a tracker sees errors
320
330
  raised while the response was being written (in `:ractor` mode, build
321
331
  the handler with `Ractor.shareable_proc`).
322
332
 
333
+ ## Lifecycle hooks
334
+
335
+ Kino fires four lifecycle hooks alongside `on_error`, split by firing context.
336
+
337
+ **Worker-context hooks** run inside the worker and are available to all workers:
338
+ - `after_worker_boot { |worker_id| }`: runs once before the worker begins serving, with its slot id. In `:ractor` mode it runs inside the worker ractor and must be `Ractor.shareable_proc`.
339
+ - `after_request_complete { |env, status| }`: fires inside the worker after each successful response. This is the hot path—leave it unset for zero cost. In `:ractor` mode it must be `Ractor.shareable_proc`.
340
+
341
+ **Main-context hooks** run on the main thread, outside workers, and are plain procs:
342
+ - `after_boot { }`: fires once after the worker pool is up. Wire readiness here—sd_notify, a "server ready" metric, and so on.
343
+ - `on_worker_exit { |worker_index, error| }`: fires when a worker exits, with its index and the crash cause (or nil on a clean exit).
344
+
345
+ `after_worker_boot`'s argument is the worker's slot id, while in `:ractor` mode `on_worker_exit`'s argument identifies the exited ractor (`0`..`workers - 1`)—a different number space—so don't correlate boot and exit by that number in `:ractor` mode.
346
+
347
+ A raising hook is logged and never kills a worker.
348
+
349
+ ## Stuck-worker quarantine
350
+
351
+ `quarantine_timeout: seconds` (or `quarantine_timeout 60` in `kino.rb`)
352
+ quarantines a dispatch slot whose request has run longer than the deadline
353
+ and spawns a replacement worker to restore capacity—distinct from
354
+ `request_timeout`, which gives the client a 504 but leaves the slot
355
+ occupied. `quarantine_max` (default: the worker count in `:ractor` mode,
356
+ workers × threads in `:threaded`) caps the total number of replacement
357
+ events over the process lifetime—past it the monitor stops replacing and
358
+ the server runs at reduced capacity.
359
+
360
+ The wedged worker is never interrupted or force-killed, and its slot stays
361
+ quarantined for good. In `:threaded` mode, if the blocked thread
362
+ eventually returns, it keeps serving requests on that same slot—but the
363
+ slot itself stays flagged quarantined (busy_ms reported as 0) for the rest
364
+ of the process; in `:ractor` mode the wedged ractor (and its supervisor
365
+ thread) leaks until the process exits, since a wedged ractor cannot be
366
+ safely interrupted. Monitor quarantine activity via `server.stats`
367
+ (top-level `quarantined` count and per-slot `worker_status[].quarantined`
368
+ flag), `GET /stats` (same), and `GET /metrics` (`kino_quarantined_workers`
369
+ gauge and `kino_quarantine_replacements_total` counter).
370
+
323
371
  ## Stats
324
372
 
325
373
  `server.stats` returns a live snapshot: the configuration plus counters
@@ -330,17 +378,50 @@ cost):
330
378
  server.stats
331
379
  # => {mode: :ractor, lanes: false, workers: 8, threads: 1, batch: 1,
332
380
  # respawns: 0, queued: 0, in_flight: 2, served: 1041, rejected: 0,
333
- # timeouts: 0}
381
+ # timeouts: 0, worker_status: [...]}
334
382
  # plus lane_depths: [...] when lane dispatch is on
335
383
  ```
336
384
 
337
- From the outside, `kill -USR1 <pid>` prints the same snapshot as one line
385
+ From the outside, `kill -USR1 <pid>` logs the same snapshot as one line
338
386
  (pair it with `pidfile` to find the pid):
339
387
 
340
388
  ```
341
- Kino stats: mode=:ractor lanes=false workers=8 threads=1 batch=1 respawns=0 queued=0 in_flight=2 served=1041 rejected=0 timeouts=0
389
+ kino[4213] main: stats mode=:ractor lanes=false workers=8 threads=1 batch=1 respawns=0 queued=0 in_flight=2 served=1041 rejected=0 timeouts=0
342
390
  ```
343
391
 
392
+ For pull-based monitoring, `control_bind "127.0.0.1:9293"` (or a
393
+ `unix://` path) serves a read-only **control plane** from the native
394
+ layer on its own thread—it keeps answering even while every Ruby worker
395
+ is busy or stuck, and reports `draining` through a graceful shutdown:
396
+
397
+ - `GET /stats`—the same snapshot as `server.stats`, as JSON (plus
398
+ `state` and `version`).
399
+ - `GET /metrics`—Prometheus text format (`kino_requests_served_total`,
400
+ `kino_queue_depth`, `kino_ready`, …).
401
+
402
+ Both `/stats` and `/metrics` also break the counters down per dispatch
403
+ slot: `/stats` carries a `worker_status` array (`index`, `served`,
404
+ `in_flight`, `busy_ms`) and `/metrics` emits `kino_worker_*{worker="N"}`
405
+ series, one entry per execution slot (`workers × threads`)—a crashed
406
+ worker's slot is never reused, so it stays in the list with its counters
407
+ frozen where they stopped, meaning the array (and its `worker="N"` metric
408
+ series) grows by one across every respawn. `busy_ms` is how long the
409
+ slot's current request has been running (0 when idle), so a single slot
410
+ climbing while the rest sit at 0 is your stuck worker.
411
+
412
+ The `/stats` response and `server.stats` carry `queue_time` (count and
413
+ summed seconds), and `/metrics` exposes `kino_request_queue_seconds`—a
414
+ Prometheus histogram of queue-wait time, the worker-saturation signal.
415
+ Counts admitted requests only; a 503 after queue wait goes to `rejected`,
416
+ not `queue_time`.
417
+
418
+ - `GET /ready`—`200` when serving, `503` while booting or draining:
419
+ wire it to your load balancer or Kubernetes readiness probe.
420
+ - `GET /live`—`200` whenever the process is alive: the liveness probe.
421
+
422
+ `control_token "..."` puts `/stats` and `/metrics` behind
423
+ `Authorization: Bearer`; the probes stay open.
424
+
344
425
  ## Logging
345
426
 
346
427
  With one log line per request, `Kino::Logger` sustained **2.4× the
@@ -349,16 +430,28 @@ box). There are two native pieces. Both write through a lock-free
349
430
  channel to a Rust flusher thread, so request threads never take a log
350
431
  mutex and never make a write syscall:
351
432
 
352
- - **Access log** (`log_requests true`): one line per request to stdout,
353
- including the 503s that never reach your app. Recommended in
354
- development; cheap enough for production. On color terminals the
355
- lines are tinted by status class: 2xx green, 3xx yellow, 4xx maroon,
356
- 5xx bright red:
433
+ - **Access log** (`log_requests true`): two records per request to
434
+ stdout, including the 503s that never reach your app. The arrival line
435
+ is queued before the app runs, so a request that hangs shows as an
436
+ arrow with no answer; the completion line carries the status, the
437
+ total, and a timing breakdown: `ruby` is the time the request spent in
438
+ Ruby (with the GC pause and the objects allocated during it), `kino`
439
+ the server's own overhead, `wait` the queue time before a worker took
440
+ it. Recommended in development; cheap enough for production. On color
441
+ terminals the completion line is tinted by status class: 2xx green,
442
+ 3xx yellow, 4xx maroon, 5xx bright red:
357
443
 
358
444
  ```
359
- 127.0.0.1 [Tue, 10 Jun 2026 13:39:56 GMT] "GET / HTTP/1.1" 200 0.1ms
445
+ 2026-08-22 14:03:11 +0300 GET /users?q=1 from 127.0.0.1
446
+ 2026-08-22 14:03:11 +0300 ← 200 GET /users?q=1 12.4ms (ruby 9.1ms [gc 0.8ms; 1.5k obj]; kino 3.2ms; wait 0.1ms)
360
447
  ```
361
448
 
449
+ The GC and allocation figures come from the VM's process-wide
450
+ counters, so they appear only where one request at a time can own
451
+ them: in `:threaded` mode, or in `:ractor` mode with `workers 1`.
452
+ Parallel ractors would bill each other's work, so there the breakdown
453
+ is `(ruby; kino; wait)` alone.
454
+
362
455
  - **`Kino::Logger`**: a `::Logger` over the same async sink, for your
363
456
  app's own logging (`Kino::Logger.new("log/production.log")`, or no
364
457
  argument for stdout). The raw IO-like device is `Kino::Logger::Device`,
@@ -400,6 +493,27 @@ lines/s), the sink drops lines instead of blocking request threads.
400
493
  These trade-offs are measured in
401
494
  [doc/benchmarks.md](doc/benchmarks.md#logging-costs).
402
495
 
496
+ **Server lines.** Everything Kino says about itself (draining, a crash
497
+ and its respawn, a hook that raised, quarantine, the stats line,
498
+ `rack.errors`) reads `kino[<pid>] <source>: message`, the source being
499
+ the worker that spoke, `worker-3` (or `worker-3/thread-2` in a
500
+ multi-threaded ractor), or `main`. On color terminals the label is dim
501
+ for notes, yellow for warnings, red for errors; the message stays plain.
502
+ A failed request gets a report instead of a bare backtrace: the request
503
+ line, the error, and where it raised in your code, then the trace with
504
+ your frames first, relative to the working directory, and the rest
505
+ folded:
506
+
507
+ ```
508
+ kino[4213] worker-2: 500 GET /boom · RuntimeError: kaboom (app.rb:12:in 'explode')
509
+ app.rb:12:in 'explode'
510
+ /usr/lib/ruby/gems/4.0.0/gems/rack-3.2.7/lib/rack/builder.rb:...
511
+ … 38 more
512
+ ```
513
+
514
+ Hooks can log through the same channel with `Kino::Log.info`, `.warn`,
515
+ and `.error`; it is safe inside worker ractors.
516
+
403
517
  ## Timer waits
404
518
 
405
519
  `Kino.sleep(seconds)` is a high-resolution sleep on the OS clock with
@@ -418,8 +532,9 @@ optional in Rack 3.
418
532
 
419
533
  ## Rails
420
534
 
421
- Rails (edge) runs on Kino today in `:threaded` mode; see
422
- `examples/rails-hello`. Ractor-mode Rails is blocked upstream. The exact
535
+ Rails (edge) runs on Kino today in `:threaded` mode (`rails server -u
536
+ kino`, or the `kino` CLI); see `examples/rails-hello`. Ractor-mode Rails
537
+ is blocked upstream. The exact
423
538
  blockers, the `Ruby::Box` findings, and what would unlock it are written
424
539
  up in [doc/rails-on-ractors.md](doc/rails-on-ractors.md). The example
425
540
  ships a probe script that re-tests against whatever Rails you bundle.
@@ -442,7 +557,7 @@ For the Rust network stack, thanks to [Sean McArthur](https://github.com/seanmon
442
557
 
443
558
  ## Assisted by
444
559
 
445
- Claude Code (Mythos, Opus).
560
+ Claude Code (Fable 5, Opus 4.8).
446
561
 
447
562
  ## Contributing
448
563
 
data/exe/kino CHANGED
@@ -9,10 +9,6 @@
9
9
  # process-global bits an executable owns.
10
10
 
11
11
  Warning[:experimental] = false
12
- # Startup output must land immediately even when stdout is a pipe or file
13
- # (process supervisors, `kino > server.log`); block buffering would hold
14
- # the banner back until exit.
15
- $stdout.sync = true
16
12
 
17
13
  # Running from a git checkout (no installed gem, no bundler context):
18
14
  # prefer the checkout's own lib so `require "kino"` resolves.
data/ext/kino/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kino"
3
- version = "0.2.1"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -26,7 +26,9 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1"] }
26
26
  http = "1"
27
27
  http-body-util = "0.1"
28
28
  bytes = "1"
29
- httpdate = "1"
29
+ # Local-time stamps for the access log: the system zone only (no bundled
30
+ # tzdb, no serde), which keeps the dependency tree to jiff itself.
31
+ jiff = { version = "0.2", default-features = false, features = ["std", "tz-system", "tzdb-zoneinfo"] }
30
32
  # ring (not the default aws-lc-rs) as the rustls crypto provider: it
31
33
  # cross-compiles cleanly in rb-sys-dock containers, while aws-lc-sys wants
32
34
  # cmake and a per-target C toolchain.
@@ -0,0 +1,245 @@
1
+ //! The access log: two records per request on the async sink, so neither
2
+ //! costs the request path a write syscall. An arrival line is queued as
3
+ //! soon as the request head is parsed, before the app sees it, so a hang
4
+ //! shows as an arrow with no answer; a status-tinted completion line
5
+ //! follows the response head with the total and a timing breakdown:
6
+ //!
7
+ //! ```text
8
+ //! 2026-08-22 14:03:11 +0300 → GET /users?q=1 from 127.0.0.1
9
+ //! 2026-08-22 14:03:11 +0300 ← 200 GET /users?q=1 12.4ms (ruby 9.1ms [gc 0.8ms; 1.5k obj]; kino 3.2ms; wait 0.1ms)
10
+ //! ```
11
+ //!
12
+ //! `ruby` is the time the request spent in a Ruby worker (admit to
13
+ //! response head), with the GC pause and objects allocated during the app
14
+ //! call when the worker measured them; `kino` is the server's own
15
+ //! overhead (total minus ruby minus wait); `wait` is the queue time before
16
+ //! a worker took the request. A blank line sets one request apart from
17
+ //! the next.
18
+
19
+ use std::fmt::Write as _;
20
+ use std::net::IpAddr;
21
+ use std::time::Duration;
22
+
23
+ use crate::style::{self, Stream};
24
+
25
+ /// Per-request timing, measured on the way through and riding the
26
+ /// response as an extension so the intake side can log it.
27
+ #[derive(Clone, Copy, Debug)]
28
+ pub struct Timing {
29
+ /// Queue wait before a worker took the request.
30
+ pub wait: Duration,
31
+ /// Admit to response head: the request's time in Ruby.
32
+ pub ruby: Duration,
33
+ /// GC pause and objects allocated during the app call, when measured.
34
+ /// Left out where the VM's process-wide counters cannot be attributed
35
+ /// to one request (parallel ractors).
36
+ pub gc: Option<(Duration, u64)>,
37
+ }
38
+
39
+ /// The arrival record, stamped and styled for stdout.
40
+ pub fn arrival(method: &str, target: &str, ip: IpAddr) -> String {
41
+ let color = style::enabled(Stream::Stdout);
42
+ let record = style::sgr(style::BOLD_WHITE, &arrival_line(method, target, ip), color);
43
+ stamped(&record, color)
44
+ }
45
+
46
+ /// The completion record, stamped, tinted by status class, followed by the
47
+ /// dimmed breakdown when timing was measured (a 503 or 504 never reached a
48
+ /// worker, so it has none). Ends with an extra newline: with the sink's
49
+ /// own, that leaves the blank line after the record.
50
+ pub fn completion(
51
+ status: u16,
52
+ method: &str,
53
+ target: &str,
54
+ total: Duration,
55
+ timing: Option<Timing>,
56
+ ) -> String {
57
+ let color = style::enabled(Stream::Stdout);
58
+ let record = completion_line(status, method, target, total);
59
+ let record = match style::status_sgr(status) {
60
+ Some(code) => style::sgr(code, &record, color),
61
+ None => record,
62
+ };
63
+ let mut line = stamped(&record, color);
64
+ if let Some(timing) = timing {
65
+ line.push(' ');
66
+ if color {
67
+ let _ = write!(line, "\x1b[{}m", style::DIM);
68
+ }
69
+ write_breakdown(&mut line, total, &timing);
70
+ if color {
71
+ line.push_str("\x1b[0m");
72
+ }
73
+ }
74
+ line.push('\n');
75
+ line
76
+ }
77
+
78
+ /// Prefix a record with the local timestamp, dimmed when coloring so it
79
+ /// recedes behind the arrow and status.
80
+ fn stamped(record: &str, color: bool) -> String {
81
+ format!("{} {record}", style::sgr(style::DIM, &now_stamp(), color))
82
+ }
83
+
84
+ /// The local wall clock as `2026-08-22 14:03:11 +0300`: date, time, and
85
+ /// the numeric UTC offset, so a log file is unambiguous across machines.
86
+ fn now_stamp() -> String {
87
+ jiff::Zoned::now()
88
+ .strftime("%Y-%m-%d %H:%M:%S %z")
89
+ .to_string()
90
+ }
91
+
92
+ /// `→ METHOD target from IP`.
93
+ fn arrival_line(method: &str, target: &str, ip: IpAddr) -> String {
94
+ format!("\u{2192} {method} {target} from {ip}")
95
+ }
96
+
97
+ /// `← STATUS METHOD target N.Nms`.
98
+ fn completion_line(status: u16, method: &str, target: &str, total: Duration) -> String {
99
+ format!(
100
+ "\u{2190} {status} {method} {target} {:.1}ms",
101
+ millis(total)
102
+ )
103
+ }
104
+
105
+ /// Append the breakdown straight into the line buffer (no string of its
106
+ /// own): `(ruby N.Nms [gc N.Nms; N obj]; kino N.Nms; wait N.Nms)`, the gc
107
+ /// bracket only when measured. `kino` floors at zero: at sub-millisecond
108
+ /// totals clock granularity can make the parts exceed the whole.
109
+ fn write_breakdown(out: &mut String, total: Duration, timing: &Timing) {
110
+ let kino = total
111
+ .saturating_sub(timing.ruby)
112
+ .saturating_sub(timing.wait);
113
+ let _ = write!(out, "(ruby {:.1}ms", millis(timing.ruby));
114
+ if let Some((gc, allocs)) = timing.gc {
115
+ let _ = write!(out, " [gc {:.1}ms; ", millis(gc));
116
+ write_count(out, allocs);
117
+ out.push_str(" obj]");
118
+ }
119
+ let _ = write!(
120
+ out,
121
+ "; kino {:.1}ms; wait {:.1}ms)",
122
+ millis(kino),
123
+ millis(timing.wait)
124
+ );
125
+ }
126
+
127
+ fn millis(d: Duration) -> f64 {
128
+ d.as_secs_f64() * 1000.0
129
+ }
130
+
131
+ /// A humanized count: `523`, `1.5k`, `52k`.
132
+ fn write_count(out: &mut String, n: u64) {
133
+ if n < 1000 {
134
+ let _ = write!(out, "{n}");
135
+ } else if n < 10_000 {
136
+ let _ = write!(out, "{:.1}k", n as f64 / 1000.0);
137
+ } else {
138
+ let _ = write!(out, "{}k", n / 1000);
139
+ }
140
+ }
141
+
142
+ #[cfg(test)]
143
+ mod tests {
144
+ use super::{arrival_line, completion_line, now_stamp, write_breakdown, write_count, Timing};
145
+ use std::net::{IpAddr, Ipv4Addr};
146
+ use std::time::Duration;
147
+
148
+ fn breakdown(total: Duration, timing: &Timing) -> String {
149
+ let mut out = String::new();
150
+ write_breakdown(&mut out, total, timing);
151
+ out
152
+ }
153
+
154
+ fn count(n: u64) -> String {
155
+ let mut out = String::new();
156
+ write_count(&mut out, n);
157
+ out
158
+ }
159
+
160
+ #[test]
161
+ fn arrival_line_carries_method_target_and_ip() {
162
+ let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
163
+ assert_eq!(
164
+ arrival_line("GET", "/users?q=1", ip),
165
+ "\u{2192} GET /users?q=1 from 203.0.113.5"
166
+ );
167
+ }
168
+
169
+ #[test]
170
+ fn completion_line_carries_status_target_and_total() {
171
+ assert_eq!(
172
+ completion_line(200, "GET", "/users", Duration::from_millis(12)),
173
+ "\u{2190} 200 GET /users 12.0ms"
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn breakdown_splits_the_total_into_ruby_kino_and_wait() {
179
+ let timing = Timing {
180
+ wait: Duration::from_micros(100),
181
+ ruby: Duration::from_millis(41),
182
+ gc: Some((Duration::from_micros(9_700), 52_000)),
183
+ };
184
+ // kino = total - ruby - wait = 45.2 - 41.0 - 0.1 = 4.1ms.
185
+ assert_eq!(
186
+ breakdown(Duration::from_micros(45_200), &timing),
187
+ "(ruby 41.0ms [gc 9.7ms; 52k obj]; kino 4.1ms; wait 0.1ms)"
188
+ );
189
+ }
190
+
191
+ #[test]
192
+ fn breakdown_leaves_out_the_gc_bracket_when_not_measured() {
193
+ let timing = Timing {
194
+ wait: Duration::from_millis(1),
195
+ ruby: Duration::from_millis(2),
196
+ gc: None,
197
+ };
198
+ assert_eq!(
199
+ breakdown(Duration::from_millis(4), &timing),
200
+ "(ruby 2.0ms; kino 1.0ms; wait 1.0ms)"
201
+ );
202
+ }
203
+
204
+ #[test]
205
+ fn breakdown_floors_kino_at_zero() {
206
+ let timing = Timing {
207
+ wait: Duration::from_millis(1),
208
+ ruby: Duration::from_millis(40),
209
+ gc: Some((Duration::ZERO, 10)),
210
+ };
211
+ assert_eq!(
212
+ breakdown(Duration::from_millis(40), &timing),
213
+ "(ruby 40.0ms [gc 0.0ms; 10 obj]; kino 0.0ms; wait 1.0ms)"
214
+ );
215
+ }
216
+
217
+ #[test]
218
+ fn count_humanizes_allocation_counts() {
219
+ assert_eq!(count(0), "0");
220
+ assert_eq!(count(523), "523");
221
+ assert_eq!(count(1500), "1.5k");
222
+ assert_eq!(count(52_000), "52k");
223
+ }
224
+
225
+ #[test]
226
+ fn timestamp_is_date_time_and_numeric_offset() {
227
+ // `YYYY-MM-DD HH:MM:SS +HHMM`, e.g. `2026-08-22 14:03:11 +0300`.
228
+ let ts = now_stamp();
229
+ let (datetime, offset) = ts.rsplit_once(' ').expect("offset field");
230
+ let (date, time) = datetime.split_once(' ').expect("date and time");
231
+ assert_eq!(date.len(), 10, "date {date}");
232
+ assert_eq!(&date[4..5], "-");
233
+ assert_eq!(time.len(), 8, "time {time}");
234
+ assert_eq!(&time[2..3], ":");
235
+ assert_eq!(offset.len(), 5, "offset {offset}");
236
+ assert!(matches!(&offset[0..1], "+" | "-"));
237
+ assert!(offset[1..].bytes().all(|b| b.is_ascii_digit()));
238
+ }
239
+
240
+ #[test]
241
+ fn completion_ends_with_the_blank_line_newline() {
242
+ let line = super::completion(200, "GET", "/", Duration::from_millis(1), None);
243
+ assert!(line.ends_with("\u{2190} 200 GET / 1.0ms\n") || line.ends_with("1.0ms\x1b[0m\n"));
244
+ }
245
+ }