kino 0.3.0 → 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 +45 -0
- data/Cargo.lock +133 -4
- data/README.md +56 -14
- 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 +12 -29
- data/ext/kino/src/cpus.rs +37 -0
- data/ext/kino/src/lib.rs +10 -1
- data/ext/kino/src/listen.rs +134 -0
- data/ext/kino/src/log.rs +144 -0
- data/ext/kino/src/queue.rs +7 -3
- data/ext/kino/src/registry.rs +3 -0
- data/ext/kino/src/request.rs +48 -2
- data/ext/kino/src/server.rs +114 -48
- data/ext/kino/src/style.rs +42 -39
- data/lib/kino/cli.rb +20 -9
- data/lib/kino/configuration.rb +20 -5
- data/lib/kino/errors_stream.rb +4 -3
- data/lib/kino/hook_fire.rb +3 -4
- data/lib/kino/log.rb +104 -0
- data/lib/kino/quarantine_monitor.rb +7 -4
- data/lib/kino/ractor_supervisor.rb +8 -3
- data/lib/kino/server.rb +60 -15
- data/lib/kino/templates/kino.rb.tt +11 -5
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +12 -13
- data/lib/kino/worker_hooks.rb +3 -1
- data/lib/kino.rb +11 -0
- data/lib/rackup/handler/kino.rb +88 -0
- data/sig/kino.rbs +62 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96ea3372ae6524dd1243f75354485613d0e49c38005f870bc83c57a46034e579
|
|
4
|
+
data.tar.gz: c71ac26d45838d6d5ad573e929ad72612f970799a50686b55324304e65a8daae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f918bd194f00189487a4229f9cffd6005a4b60c6357b21acb6d06e496b9d27ba4487002f8a7fea28f8e2f21324353d3e2fc175e9fcf04906b581d4f2c5309f9
|
|
7
|
+
data.tar.gz: 30b42185d524fc97c4f16f13e27573215ff25d5af6f91881ae91a3c8450945c3d380130aec7acf897d19e2707df5a387f62cdcca9513aa34a148960dff5f420b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
## [0.4.0] - 2026-08-22
|
|
2
|
+
|
|
3
|
+
- Rack handler: `rails server -u kino` and `rackup -s kino` boot Kino
|
|
4
|
+
through `Rackup::Handler::Kino`, reading the same config file as the
|
|
5
|
+
`kino` CLI with the host's flags on top; `rackup -s kino --help` lists
|
|
6
|
+
the `-O` options (Workers, Threads, Mode, Config).
|
|
7
|
+
- The config file is also looked up at `config/kino.rb` (the Rails
|
|
8
|
+
layout) when there is no `kino.rb`, by the CLI and the handler alike.
|
|
9
|
+
- `Kino::Server#run` serves an already built server the way
|
|
10
|
+
`Kino::Server.run` does (banner, signal traps, block until shutdown),
|
|
11
|
+
and syncs stdout there so the banner is never held back by block
|
|
12
|
+
buffering under a pipe, whichever entry point booted the server.
|
|
13
|
+
- `workers` now defaults to `Kino.available_parallelism`, the CPUs the
|
|
14
|
+
process may actually use: the affinity mask and, in a container, the
|
|
15
|
+
cgroup CPU quota (a pod limited to 2 CPUs on a 64-core node gets 2
|
|
16
|
+
workers, not 64). `Etc.nprocessors` only ever saw the mask.
|
|
17
|
+
- `bind "unix:///path/to.sock"` listens on a unix domain socket, the
|
|
18
|
+
usual shape behind nginx: a stale socket file is reclaimed, a live one
|
|
19
|
+
is refused, and the file is removed on shutdown. `port` is unused on
|
|
20
|
+
it and TLS is rejected (terminate TLS at the proxy). Requests arriving
|
|
21
|
+
over the socket report `REMOTE_ADDR` 127.0.0.1.
|
|
22
|
+
- `Kino::Server#url`, `#control_url`, and `#unix?` report where a started
|
|
23
|
+
server and its control plane listen.
|
|
24
|
+
- The access log is two records per request: an arrival line queued
|
|
25
|
+
before the app runs (a hang shows as an arrow with no answer) and a
|
|
26
|
+
status-tinted completion line with a timing breakdown of `ruby`,
|
|
27
|
+
`kino`, and `wait`, the `ruby` part carrying the GC pause and objects
|
|
28
|
+
allocated where one request at a time can own the VM's counters
|
|
29
|
+
(`:threaded`, or `:ractor` with `workers 1`). Local timestamps with
|
|
30
|
+
their UTC offset; a blank line between requests. The former one-line
|
|
31
|
+
format is gone.
|
|
32
|
+
- A failed request is reported as `500 GET /path · Class: message (site)`
|
|
33
|
+
followed by its backtrace relative to the working directory, the app's
|
|
34
|
+
own frames first, the rest folded into `… N more`.
|
|
35
|
+
- Every line Kino logs about itself (draining, a crash and its respawn,
|
|
36
|
+
hook failures, quarantine, the USR1 stats line, `rack.errors`) reads
|
|
37
|
+
`kino[<pid>] <source>: message`, the source naming the worker that
|
|
38
|
+
spoke (`worker-3`, `worker-3/thread-2`) or `main`; worker ractors and
|
|
39
|
+
threads now carry those names. The label is dim, yellow, or red by
|
|
40
|
+
level on color terminals. `Kino::Log.info`, `.warn`, and `.error` are
|
|
41
|
+
public, for hooks.
|
|
42
|
+
- The startup banner lists the Ruby build with its JIT and parser flags,
|
|
43
|
+
the environment, the topology, the pid, and the control-plane address
|
|
44
|
+
when one is bound.
|
|
45
|
+
|
|
1
46
|
## [0.3.0] - 2026-08-13
|
|
2
47
|
|
|
3
48
|
- Queue-time histogram: `/metrics` exposes `kino_request_queue_seconds`, a
|
data/Cargo.lock
CHANGED
|
@@ -42,7 +42,7 @@ version = "0.72.1"
|
|
|
42
42
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
43
43
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
|
44
44
|
dependencies = [
|
|
45
|
-
"bitflags",
|
|
45
|
+
"bitflags 2.13.1",
|
|
46
46
|
"cexpr",
|
|
47
47
|
"clang-sys",
|
|
48
48
|
"itertools",
|
|
@@ -54,6 +54,12 @@ dependencies = [
|
|
|
54
54
|
"syn 2.0.119",
|
|
55
55
|
]
|
|
56
56
|
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "bitflags"
|
|
59
|
+
version = "1.3.2"
|
|
60
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
61
|
+
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|
62
|
+
|
|
57
63
|
[[package]]
|
|
58
64
|
name = "bitflags"
|
|
59
65
|
version = "2.13.1"
|
|
@@ -108,6 +114,37 @@ dependencies = [
|
|
|
108
114
|
"libloading",
|
|
109
115
|
]
|
|
110
116
|
|
|
117
|
+
[[package]]
|
|
118
|
+
name = "defmt"
|
|
119
|
+
version = "1.1.1"
|
|
120
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
121
|
+
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
|
122
|
+
dependencies = [
|
|
123
|
+
"bitflags 1.3.2",
|
|
124
|
+
"defmt-macros",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[[package]]
|
|
128
|
+
name = "defmt-macros"
|
|
129
|
+
version = "1.1.1"
|
|
130
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
131
|
+
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
|
132
|
+
dependencies = [
|
|
133
|
+
"defmt-parser",
|
|
134
|
+
"proc-macro2",
|
|
135
|
+
"quote",
|
|
136
|
+
"syn 2.0.119",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
[[package]]
|
|
140
|
+
name = "defmt-parser"
|
|
141
|
+
version = "1.0.0"
|
|
142
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
143
|
+
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
|
144
|
+
dependencies = [
|
|
145
|
+
"thiserror",
|
|
146
|
+
]
|
|
147
|
+
|
|
111
148
|
[[package]]
|
|
112
149
|
name = "either"
|
|
113
150
|
version = "1.17.0"
|
|
@@ -321,6 +358,43 @@ version = "1.0.18"
|
|
|
321
358
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
322
359
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
323
360
|
|
|
361
|
+
[[package]]
|
|
362
|
+
name = "jiff"
|
|
363
|
+
version = "0.2.35"
|
|
364
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
365
|
+
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
|
366
|
+
dependencies = [
|
|
367
|
+
"defmt",
|
|
368
|
+
"jiff-core",
|
|
369
|
+
"jiff-static",
|
|
370
|
+
"log",
|
|
371
|
+
"portable-atomic",
|
|
372
|
+
"portable-atomic-util",
|
|
373
|
+
"serde_core",
|
|
374
|
+
"windows-link",
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
[[package]]
|
|
378
|
+
name = "jiff-core"
|
|
379
|
+
version = "0.1.0"
|
|
380
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
381
|
+
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
|
382
|
+
dependencies = [
|
|
383
|
+
"defmt",
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
[[package]]
|
|
387
|
+
name = "jiff-static"
|
|
388
|
+
version = "0.2.35"
|
|
389
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
390
|
+
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
|
391
|
+
dependencies = [
|
|
392
|
+
"jiff-core",
|
|
393
|
+
"proc-macro2",
|
|
394
|
+
"quote",
|
|
395
|
+
"syn 2.0.119",
|
|
396
|
+
]
|
|
397
|
+
|
|
324
398
|
[[package]]
|
|
325
399
|
name = "js-sys"
|
|
326
400
|
version = "0.3.104"
|
|
@@ -333,16 +407,16 @@ dependencies = [
|
|
|
333
407
|
|
|
334
408
|
[[package]]
|
|
335
409
|
name = "kino"
|
|
336
|
-
version = "0.
|
|
410
|
+
version = "0.4.0"
|
|
337
411
|
dependencies = [
|
|
338
412
|
"ahash",
|
|
339
413
|
"bytes",
|
|
340
414
|
"flume",
|
|
341
415
|
"http",
|
|
342
416
|
"http-body-util",
|
|
343
|
-
"httpdate",
|
|
344
417
|
"hyper",
|
|
345
418
|
"hyper-util",
|
|
419
|
+
"jiff",
|
|
346
420
|
"lru",
|
|
347
421
|
"magnus",
|
|
348
422
|
"mimalloc",
|
|
@@ -510,6 +584,21 @@ version = "0.2.17"
|
|
|
510
584
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
511
585
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
|
512
586
|
|
|
587
|
+
[[package]]
|
|
588
|
+
name = "portable-atomic"
|
|
589
|
+
version = "1.15.0"
|
|
590
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
591
|
+
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|
592
|
+
|
|
593
|
+
[[package]]
|
|
594
|
+
name = "portable-atomic-util"
|
|
595
|
+
version = "0.2.7"
|
|
596
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
597
|
+
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
|
598
|
+
dependencies = [
|
|
599
|
+
"portable-atomic",
|
|
600
|
+
]
|
|
601
|
+
|
|
513
602
|
[[package]]
|
|
514
603
|
name = "proc-macro2"
|
|
515
604
|
version = "1.0.107"
|
|
@@ -576,7 +665,7 @@ version = "0.5.18"
|
|
|
576
665
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
577
666
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
|
578
667
|
dependencies = [
|
|
579
|
-
"bitflags",
|
|
668
|
+
"bitflags 2.13.1",
|
|
580
669
|
]
|
|
581
670
|
|
|
582
671
|
[[package]]
|
|
@@ -690,6 +779,26 @@ version = "0.3.6"
|
|
|
690
779
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
691
780
|
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
|
|
692
781
|
|
|
782
|
+
[[package]]
|
|
783
|
+
name = "serde_core"
|
|
784
|
+
version = "1.0.229"
|
|
785
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
786
|
+
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
|
787
|
+
dependencies = [
|
|
788
|
+
"serde_derive",
|
|
789
|
+
]
|
|
790
|
+
|
|
791
|
+
[[package]]
|
|
792
|
+
name = "serde_derive"
|
|
793
|
+
version = "1.0.229"
|
|
794
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
795
|
+
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|
796
|
+
dependencies = [
|
|
797
|
+
"proc-macro2",
|
|
798
|
+
"quote",
|
|
799
|
+
"syn 3.0.3",
|
|
800
|
+
]
|
|
801
|
+
|
|
693
802
|
[[package]]
|
|
694
803
|
name = "shell-words"
|
|
695
804
|
version = "1.1.1"
|
|
@@ -761,6 +870,26 @@ dependencies = [
|
|
|
761
870
|
"unicode-ident",
|
|
762
871
|
]
|
|
763
872
|
|
|
873
|
+
[[package]]
|
|
874
|
+
name = "thiserror"
|
|
875
|
+
version = "2.0.20"
|
|
876
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
877
|
+
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
|
878
|
+
dependencies = [
|
|
879
|
+
"thiserror-impl",
|
|
880
|
+
]
|
|
881
|
+
|
|
882
|
+
[[package]]
|
|
883
|
+
name = "thiserror-impl"
|
|
884
|
+
version = "2.0.20"
|
|
885
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
886
|
+
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
|
887
|
+
dependencies = [
|
|
888
|
+
"proc-macro2",
|
|
889
|
+
"quote",
|
|
890
|
+
"syn 3.0.3",
|
|
891
|
+
]
|
|
892
|
+
|
|
764
893
|
[[package]]
|
|
765
894
|
name = "tokio"
|
|
766
895
|
version = "1.53.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
|
|
@@ -255,8 +261,10 @@ server.shutdown # graceful: drain → deadline → abort straggler
|
|
|
255
261
|
|
|
256
262
|
## Config file and CLI
|
|
257
263
|
|
|
258
|
-
Settings can live in a Puma-style Ruby DSL file.
|
|
259
|
-
|
|
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.
|
|
260
268
|
|
|
261
269
|
```ruby
|
|
262
270
|
# kino.rb
|
|
@@ -374,11 +382,11 @@ server.stats
|
|
|
374
382
|
# plus lane_depths: [...] when lane dispatch is on
|
|
375
383
|
```
|
|
376
384
|
|
|
377
|
-
From the outside, `kill -USR1 <pid>`
|
|
385
|
+
From the outside, `kill -USR1 <pid>` logs the same snapshot as one line
|
|
378
386
|
(pair it with `pidfile` to find the pid):
|
|
379
387
|
|
|
380
388
|
```
|
|
381
|
-
|
|
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
|
|
382
390
|
```
|
|
383
391
|
|
|
384
392
|
For pull-based monitoring, `control_bind "127.0.0.1:9293"` (or a
|
|
@@ -422,16 +430,28 @@ box). There are two native pieces. Both write through a lock-free
|
|
|
422
430
|
channel to a Rust flusher thread, so request threads never take a log
|
|
423
431
|
mutex and never make a write syscall:
|
|
424
432
|
|
|
425
|
-
- **Access log** (`log_requests true`):
|
|
426
|
-
including the 503s that never reach your app.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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:
|
|
430
443
|
|
|
431
444
|
```
|
|
432
|
-
|
|
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)
|
|
433
447
|
```
|
|
434
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
|
+
|
|
435
455
|
- **`Kino::Logger`**: a `::Logger` over the same async sink, for your
|
|
436
456
|
app's own logging (`Kino::Logger.new("log/production.log")`, or no
|
|
437
457
|
argument for stdout). The raw IO-like device is `Kino::Logger::Device`,
|
|
@@ -473,6 +493,27 @@ lines/s), the sink drops lines instead of blocking request threads.
|
|
|
473
493
|
These trade-offs are measured in
|
|
474
494
|
[doc/benchmarks.md](doc/benchmarks.md#logging-costs).
|
|
475
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
|
+
|
|
476
517
|
## Timer waits
|
|
477
518
|
|
|
478
519
|
`Kino.sleep(seconds)` is a high-resolution sleep on the OS clock with
|
|
@@ -491,8 +532,9 @@ optional in Rack 3.
|
|
|
491
532
|
|
|
492
533
|
## Rails
|
|
493
534
|
|
|
494
|
-
Rails (edge) runs on Kino today in `:threaded` mode
|
|
495
|
-
`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
|
|
496
538
|
blockers, the `Ruby::Box` findings, and what would unlock it are written
|
|
497
539
|
up in [doc/rails-on-ractors.md](doc/rails-on-ractors.md). The example
|
|
498
540
|
ships a probe script that re-tests against whatever Rails you bundle.
|
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
|
+
}
|