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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/Cargo.lock +192 -52
- data/README.md +131 -16
- data/exe/kino +0 -4
- data/ext/kino/Cargo.toml +4 -2
- data/ext/kino/src/access_log.rs +245 -0
- data/ext/kino/src/control.rs +701 -0
- data/ext/kino/src/cpus.rs +37 -0
- data/ext/kino/src/lib.rs +22 -1
- data/ext/kino/src/listen.rs +134 -0
- data/ext/kino/src/log.rs +144 -0
- data/ext/kino/src/mono.rs +26 -0
- data/ext/kino/src/queue.rs +11 -0
- data/ext/kino/src/registry.rs +158 -0
- data/ext/kino/src/request.rs +52 -2
- data/ext/kino/src/server.rs +229 -53
- data/ext/kino/src/style.rs +42 -39
- data/lib/kino/cli.rb +23 -9
- data/lib/kino/configuration.rb +77 -5
- data/lib/kino/errors_stream.rb +4 -3
- data/lib/kino/hook_fire.rb +22 -0
- data/lib/kino/log.rb +104 -0
- data/lib/kino/quarantine_monitor.rb +73 -0
- data/lib/kino/ractor_supervisor.rb +66 -16
- data/lib/kino/server.rb +201 -34
- data/lib/kino/templates/kino.rb.tt +62 -5
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +46 -30
- data/lib/kino/worker_hooks.rb +13 -0
- data/lib/kino.rb +14 -0
- data/lib/rackup/handler/kino.rb +88 -0
- data/sig/kino.rbs +71 -1
- metadata +26 -1
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:
|
|
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.
|
|
257
|
-
|
|
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>`
|
|
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
|
-
|
|
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`):
|
|
353
|
-
including the 503s that never reach your app.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
|
422
|
-
`examples/rails-hello`. Ractor-mode Rails
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|