kino 0.3.0-aarch64-linux → 0.5.0-aarch64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18bb12fa02179c4c2e164cf83e605ac893094650fcaee119132b48b2eea0ae2e
4
- data.tar.gz: 56c6652d6671f2872e0399f82a789e6b4cc5b0ea018f5503bb91ed575f187985
3
+ metadata.gz: d9cb260754fbbffdbf123d4f654ae41c92a001fcf14acb5c2bcc75275a51687a
4
+ data.tar.gz: 17e23ec4d2cccce26863df15c275d6e300e8d4327c89e340f22c47e6e7f707ae
5
5
  SHA512:
6
- metadata.gz: 1c3f92ba0bd339f2415ae94024f4afc5666a356c69b7721bb36deeccc492521d7f75a5ef064f994306a771924a42ccb00ff6e3ce138754b797ef5e2263144622
7
- data.tar.gz: ae5f7535f0fc9ef4b9398a1fdd57723aae445d49ef7705fb1cab907d6b8ab01bb25338394509467e7cc755b37105b306ab08f89f755d4496e186e63f32cd6ace
6
+ metadata.gz: fa65e8268d3e0f8c295f42b5875b75d2dc9cbdb6d768228ee12ee7c155af2a3bfbda5bc451c13ee1ad937ad7ac2d48b0dff1529e32586c9ca23f1784e2bb885c
7
+ data.tar.gz: fbad5b1bbc64478792677b472b4120e73ed25d731c3a2524c4ba6601a059a5ac08b028e78b7f1e5071fd6e29ac28ad437644da02ec867f5a6b0b912cdff4f982
data/CHANGELOG.md CHANGED
@@ -1,3 +1,58 @@
1
+ ## [0.5.0] - 2026-08-29
2
+
3
+ - Add opt-in `io_shards true`: accepted HTTP connections can run on
4
+ current-thread Tokio I/O shards instead of Tokio's shared multi-thread
5
+ runtime, reducing scheduler contention on very fast handlers. `io_threads`
6
+ sets the shard count; otherwise Kino uses half the available CPUs
7
+ ([Patrik Wenger](https://github.com/paddor)).
8
+ - Update Rust dependencies: hyper 1.11.1, rb-sys 0.9.130,
9
+ rustls-webpki 0.103.15, among others.
10
+
11
+ ## [0.4.0] - 2026-08-22
12
+
13
+ - Rack handler: `rails server -u kino` and `rackup -s kino` boot Kino
14
+ through `Rackup::Handler::Kino`, reading the same config file as the
15
+ `kino` CLI with the host's flags on top; `rackup -s kino --help` lists
16
+ the `-O` options (Workers, Threads, Mode, Config).
17
+ - The config file is also looked up at `config/kino.rb` (the Rails
18
+ layout) when there is no `kino.rb`, by the CLI and the handler alike.
19
+ - `Kino::Server#run` serves an already built server the way
20
+ `Kino::Server.run` does (banner, signal traps, block until shutdown),
21
+ and syncs stdout there so the banner is never held back by block
22
+ buffering under a pipe, whichever entry point booted the server.
23
+ - `workers` now defaults to `Kino.available_parallelism`, the CPUs the
24
+ process may actually use: the affinity mask and, in a container, the
25
+ cgroup CPU quota (a pod limited to 2 CPUs on a 64-core node gets 2
26
+ workers, not 64). `Etc.nprocessors` only ever saw the mask.
27
+ - `bind "unix:///path/to.sock"` listens on a unix domain socket, the
28
+ usual shape behind nginx: a stale socket file is reclaimed, a live one
29
+ is refused, and the file is removed on shutdown. `port` is unused on
30
+ it and TLS is rejected (terminate TLS at the proxy). Requests arriving
31
+ over the socket report `REMOTE_ADDR` 127.0.0.1.
32
+ - `Kino::Server#url`, `#control_url`, and `#unix?` report where a started
33
+ server and its control plane listen.
34
+ - The access log is two records per request: an arrival line queued
35
+ before the app runs (a hang shows as an arrow with no answer) and a
36
+ status-tinted completion line with a timing breakdown of `ruby`,
37
+ `kino`, and `wait`, the `ruby` part carrying the GC pause and objects
38
+ allocated where one request at a time can own the VM's counters
39
+ (`:threaded`, or `:ractor` with `workers 1`). Local timestamps with
40
+ their UTC offset; a blank line between requests. The former one-line
41
+ format is gone.
42
+ - A failed request is reported as `500 GET /path · Class: message (site)`
43
+ followed by its backtrace relative to the working directory, the app's
44
+ own frames first, the rest folded into `… N more`.
45
+ - Every line Kino logs about itself (draining, a crash and its respawn,
46
+ hook failures, quarantine, the USR1 stats line, `rack.errors`) reads
47
+ `kino[<pid>] <source>: message`, the source naming the worker that
48
+ spoke (`worker-3`, `worker-3/thread-2`) or `main`; worker ractors and
49
+ threads now carry those names. The label is dim, yellow, or red by
50
+ level on color terminals. `Kino::Log.info`, `.warn`, and `.error` are
51
+ public, for hooks.
52
+ - The startup banner lists the Ruby build with its JIT and parser flags,
53
+ the environment, the topology, the pid, and the control-plane address
54
+ when one is bound.
55
+
1
56
  ## [0.3.0] - 2026-08-13
2
57
 
3
58
  - Queue-time histogram: `/metrics` exposes `kino_request_queue_seconds`, a
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
@@ -253,10 +259,27 @@ server.shutdown # graceful: drain → deadline → abort straggler
253
259
  always counts as "shareable" (classes are), even if calling it touches
254
260
  unshareable state. Force `:threaded` for those.
255
261
 
262
+ ### Sharded I/O
263
+
264
+ `io_shards true` (off by default) moves HTTP I/O from Tokio's shared
265
+ multi-thread runtime onto current-thread shards: one thread accepts and
266
+ hands each connection to the least-loaded shard, which then owns it for
267
+ its lifetime—no work-stealing, no cross-thread wakeups on the hot path.
268
+ Fast handlers gain double-digit throughput; Ruby-bound endpoints are
269
+ unchanged. Orthogonal to `mode`: it reshapes the Rust side only.
270
+
271
+ ```ruby
272
+ # kino.rb
273
+ io_shards true
274
+ io_threads 4 # optional; default: half the available CPUs
275
+ ```
276
+
256
277
  ## Config file and CLI
257
278
 
258
- Settings can live in a Puma-style Ruby DSL file. Precedence: explicit
259
- kwargs and CLI flags > config file > defaults.
279
+ Settings can live in a Puma-style Ruby DSL file: `kino.rb` in the
280
+ working directory, or `config/kino.rb` (the Rails layout), is picked up
281
+ automatically; `-C PATH` names any other. Precedence: explicit kwargs
282
+ and CLI flags > config file > defaults.
260
283
 
261
284
  ```ruby
262
285
  # kino.rb
@@ -374,11 +397,11 @@ server.stats
374
397
  # plus lane_depths: [...] when lane dispatch is on
375
398
  ```
376
399
 
377
- From the outside, `kill -USR1 <pid>` prints the same snapshot as one line
400
+ From the outside, `kill -USR1 <pid>` logs the same snapshot as one line
378
401
  (pair it with `pidfile` to find the pid):
379
402
 
380
403
  ```
381
- 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
404
+ 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
405
  ```
383
406
 
384
407
  For pull-based monitoring, `control_bind "127.0.0.1:9293"` (or a
@@ -422,16 +445,28 @@ box). There are two native pieces. Both write through a lock-free
422
445
  channel to a Rust flusher thread, so request threads never take a log
423
446
  mutex and never make a write syscall:
424
447
 
425
- - **Access log** (`log_requests true`): one line per request to stdout,
426
- including the 503s that never reach your app. Recommended in
427
- development; cheap enough for production. On color terminals the
428
- lines are tinted by status class: 2xx green, 3xx yellow, 4xx maroon,
429
- 5xx bright red:
448
+ - **Access log** (`log_requests true`): two records per request to
449
+ stdout, including the 503s that never reach your app. The arrival line
450
+ is queued before the app runs, so a request that hangs shows as an
451
+ arrow with no answer; the completion line carries the status, the
452
+ total, and a timing breakdown: `ruby` is the time the request spent in
453
+ Ruby (with the GC pause and the objects allocated during it), `kino`
454
+ the server's own overhead, `wait` the queue time before a worker took
455
+ it. Recommended in development; cheap enough for production. On color
456
+ terminals the completion line is tinted by status class: 2xx green,
457
+ 3xx yellow, 4xx maroon, 5xx bright red:
430
458
 
431
459
  ```
432
- 127.0.0.1 [Tue, 10 Jun 2026 13:39:56 GMT] "GET / HTTP/1.1" 200 0.1ms
460
+ 2026-08-22 14:03:11 +0300 GET /users?q=1 from 127.0.0.1
461
+ 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
462
  ```
434
463
 
464
+ The GC and allocation figures come from the VM's process-wide
465
+ counters, so they appear only where one request at a time can own
466
+ them: in `:threaded` mode, or in `:ractor` mode with `workers 1`.
467
+ Parallel ractors would bill each other's work, so there the breakdown
468
+ is `(ruby; kino; wait)` alone.
469
+
435
470
  - **`Kino::Logger`**: a `::Logger` over the same async sink, for your
436
471
  app's own logging (`Kino::Logger.new("log/production.log")`, or no
437
472
  argument for stdout). The raw IO-like device is `Kino::Logger::Device`,
@@ -473,6 +508,27 @@ lines/s), the sink drops lines instead of blocking request threads.
473
508
  These trade-offs are measured in
474
509
  [doc/benchmarks.md](doc/benchmarks.md#logging-costs).
475
510
 
511
+ **Server lines.** Everything Kino says about itself (draining, a crash
512
+ and its respawn, a hook that raised, quarantine, the stats line,
513
+ `rack.errors`) reads `kino[<pid>] <source>: message`, the source being
514
+ the worker that spoke, `worker-3` (or `worker-3/thread-2` in a
515
+ multi-threaded ractor), or `main`. On color terminals the label is dim
516
+ for notes, yellow for warnings, red for errors; the message stays plain.
517
+ A failed request gets a report instead of a bare backtrace: the request
518
+ line, the error, and where it raised in your code, then the trace with
519
+ your frames first, relative to the working directory, and the rest
520
+ folded:
521
+
522
+ ```
523
+ kino[4213] worker-2: 500 GET /boom · RuntimeError: kaboom (app.rb:12:in 'explode')
524
+ app.rb:12:in 'explode'
525
+ /usr/lib/ruby/gems/4.0.0/gems/rack-3.2.7/lib/rack/builder.rb:...
526
+ … 38 more
527
+ ```
528
+
529
+ Hooks can log through the same channel with `Kino::Log.info`, `.warn`,
530
+ and `.error`; it is safe inside worker ractors.
531
+
476
532
  ## Timer waits
477
533
 
478
534
  `Kino.sleep(seconds)` is a high-resolution sleep on the OS clock with
@@ -491,8 +547,9 @@ optional in Rack 3.
491
547
 
492
548
  ## Rails
493
549
 
494
- Rails (edge) runs on Kino today in `:threaded` mode; see
495
- `examples/rails-hello`. Ractor-mode Rails is blocked upstream. The exact
550
+ Rails (edge) runs on Kino today in `:threaded` mode (`rails server -u
551
+ kino`, or the `kino` CLI); see `examples/rails-hello`. Ractor-mode Rails
552
+ is blocked upstream. The exact
496
553
  blockers, the `Ruby::Box` findings, and what would unlock it are written
497
554
  up in [doc/rails-on-ractors.md](doc/rails-on-ractors.md). The example
498
555
  ships a probe script that re-tests against whatever Rails you bundle.
data/doc/architecture.md CHANGED
@@ -11,10 +11,13 @@ tokio (Rust threads) Ruby
11
11
  └──────────────────────────┘ └────────────────────────────┘
12
12
  ```
13
13
 
14
- All network I/O lives in Rust on a tokio multi-threaded runtime; hyper
15
- parses HTTP/1.1 and handles keep-alive; rustls terminates TLS. Ruby never
16
- touches a socket. Each request becomes a Rust-side `RequestCtx` pushed to a
17
- bounded flume MPMC queue; Ruby workers pull from it.
14
+ All network I/O lives in Rust on tokio runtimes; hyper parses HTTP/1.1 and
15
+ handles keep-alive; rustls terminates TLS. The default is Tokio's
16
+ multi-thread runtime. With `io_shards true`, one current-thread runtime
17
+ accepts connections and assigns them to current-thread I/O shards, where
18
+ each connection stays for its lifetime. Ruby never touches a socket. Each
19
+ request becomes a Rust-side `RequestCtx` pushed to a bounded flume MPMC
20
+ queue; Ruby workers pull from it.
18
21
 
19
22
  ## Topology
20
23
 
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/lib/kino/cli.rb CHANGED
@@ -100,14 +100,14 @@ module Kino
100
100
  end.join
101
101
  end
102
102
 
103
- # One-line stats dump (the SIGUSR1 handler's output). Excludes
103
+ # One-line stats dump (what the SIGUSR1 handler logs). Excludes
104
104
  # worker_status: it's an array with one entry per execution slot, and
105
105
  # printing it inline would break the one-line contract (see /stats for
106
106
  # per-worker detail).
107
107
  # @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
108
108
  # @return [String]
109
109
  def stats_line(stats)
110
- dim("Kino stats: #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
110
+ "stats #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}"
111
111
  end
112
112
 
113
113
  # The two banner halves around Server#start: credits before, the ready
@@ -119,15 +119,27 @@ module Kino
119
119
  puts dim("\nKino #{VERSION} presents:")
120
120
  end
121
121
 
122
+ # The ready block: what this process is (Ruby build with its JIT and
123
+ # parser flags, environment, topology, pid) and where it listens.
122
124
  # @param server [Kino::Server] a started server
123
125
  # @return [void]
124
126
  def action!(server)
125
- puts dim("- mode: #{server.mode}")
126
- puts dim("- listening: http#{"s" if server.tls?}://#{server.bind}:#{server.port}")
127
+ stats = server.stats
128
+ puts dim("- ruby: #{RUBY_DESCRIPTION}")
129
+ puts dim("- env: #{ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"}")
130
+ puts dim("- mode: #{server.mode}, #{count(stats[:workers], "worker")} × #{count(stats[:threads], "thread")}")
131
+ puts dim("- pid: #{Process.pid}")
132
+ puts dim("- listening: #{server.url}")
133
+ puts dim("- control: #{server.control_url}") if server.control_url
127
134
  puts dim("- Ctrl-C to drain and stop")
128
135
  puts "\n#{bold("Action!")}\n\n"
129
136
  end
130
137
 
138
+ # "1 worker", "8 workers".
139
+ def count(number, noun)
140
+ "#{number} #{noun}#{"s" unless number == 1}"
141
+ end
142
+
131
143
  # Roll credits when the process ends: normal exit or crash (at_exit
132
144
  # also runs after an uncaught exception; only a force-exit skips it).
133
145
  # @return [void]
@@ -200,7 +212,7 @@ module Kino
200
212
  def option_parser(options)
201
213
  OptionParser.new do |opts|
202
214
  opts.banner = "Usage: kino [options] [rackup file (default: config.ru)]"
203
- opts.on("-C", "--config FILE", "Config file (default: kino.rb if present)") { |v| options[:config_file] = v }
215
+ opts.on("-C", "--config FILE", "Config file (default: kino.rb, then config/kino.rb)") { |v| options[:config_file] = v }
204
216
  opts.on("--init [PATH]", "Write a commented sample config (default: kino.rb) and exit") do |v|
205
217
  options[:init_path] = v || "kino.rb"
206
218
  end
@@ -236,14 +248,13 @@ module Kino
236
248
  require "kino"
237
249
  require "rack"
238
250
 
239
- config_file = options[:config_file]
240
- config_file ||= ("kino.rb" if File.exist?("kino.rb"))
251
+ config_file = options[:config_file] || Configuration.default_path
241
252
 
242
253
  config = Configuration.new
243
254
  config.load_file(config_file) if config_file
244
255
  config.merge!(options[:overrides])
245
256
  # Default port 9292 when neither the file nor a flag chose one.
246
- config.set(:port, 9292) unless config.set?(:port)
257
+ config.set(:port, Configuration::DEFAULT_SERVING_PORT) unless config.set?(:port)
247
258
  config
248
259
  end
249
260
 
@@ -252,6 +263,6 @@ module Kino
252
263
  end
253
264
 
254
265
  private_class_method :print_help, :option_parser, :write_sample,
255
- :resolve_config, :serve
266
+ :resolve_config, :serve, :count
256
267
  end
257
268
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "etc"
4
-
5
3
  module Kino
6
4
  # Server settings with Puma-style precedence:
7
5
  # explicit Server.new kwargs > config file DSL > defaults.
@@ -11,7 +9,7 @@ module Kino
11
9
  DEFAULTS = {
12
10
  bind: "127.0.0.1",
13
11
  port: 0,
14
- workers: nil, # resolved to Etc.nprocessors in #to_h
12
+ workers: nil, # resolved to Kino.available_parallelism in #to_h
15
13
  threads: nil, # resolved per mode in Server: 1 in :ractor, 3 in :threaded
16
14
  mode: :auto,
17
15
  queue_depth: 1024,
@@ -28,6 +26,8 @@ module Kino
28
26
  after_request_complete: nil,
29
27
  on_worker_exit: nil,
30
28
  shutdown_timeout: 30,
29
+ io_shards: false,
30
+ io_threads: nil,
31
31
  tokio_threads: nil,
32
32
  tls: nil,
33
33
  environment: nil,
@@ -45,6 +45,21 @@ module Kino
45
45
  # Source template for {.sample}.
46
46
  SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
47
47
 
48
+ # Where the `kino` CLI and the Rack handler look for a config file when
49
+ # none is named: the project root first, then the Rails-style config/.
50
+ DEFAULT_PATHS = %w[kino.rb config/kino.rb].freeze
51
+
52
+ # The port the CLI and the Rack handler serve on when neither a flag
53
+ # nor the file chose one (Server.new itself defaults to an ephemeral
54
+ # port, for embedding).
55
+ DEFAULT_SERVING_PORT = 9292
56
+
57
+ # The first of {DEFAULT_PATHS} that exists in the working directory.
58
+ # @return [String, nil]
59
+ def self.default_path
60
+ DEFAULT_PATHS.find { |path| File.exist?(path) }
61
+ end
62
+
48
63
  # The fully-commented sample config (see `kino --init`).
49
64
  # @return [String]
50
65
  def self.sample
@@ -113,7 +128,7 @@ module Kino
113
128
  # @return [Hash{Symbol => Object}] every setting, defaults filled in
114
129
  def to_h
115
130
  SETTINGS.to_h { |key| [key, self[key]] }.tap do |h|
116
- h[:workers] ||= Etc.nprocessors
131
+ h[:workers] ||= Kino.available_parallelism
117
132
  end
118
133
  end
119
134
 
@@ -135,6 +150,8 @@ module Kino
135
150
  # queue_depth 2048
136
151
  # queue_timeout 0.5
137
152
  # shutdown_timeout 15
153
+ # io_shards true
154
+ # io_threads 6
138
155
  # tokio_threads 4
139
156
  # tls cert: "cert.pem", key: "key.pem"
140
157
  #
@@ -146,7 +163,9 @@ module Kino
146
163
  @config = config
147
164
  end
148
165
 
149
- # Address to listen on ("0.0.0.0" accepts non-local connections).
166
+ # Address to listen on: a host ("0.0.0.0" accepts non-local
167
+ # connections), or "unix:///path/to.sock" for a unix domain socket
168
+ # (then `port` is unused).
150
169
  def bind(host) = @config.set(:bind, host)
151
170
 
152
171
  # Port to listen on; 0 picks an ephemeral port.
@@ -214,6 +233,12 @@ module Kino
214
233
  # Graceful-shutdown drain deadline in seconds.
215
234
  def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
216
235
 
236
+ # Run native HTTP I/O on current-thread shards instead of Tokio's shared pool.
237
+ def io_shards(enabled = true) = @config.set(:io_shards, !!enabled)
238
+
239
+ # Native HTTP I/O shard count; default with io_shards: half available CPUs.
240
+ def io_threads(count) = @config.set(:io_threads, Integer(count))
241
+
217
242
  # Threads for the tokio (Rust I/O) runtime; default: one per core.
218
243
  def tokio_threads(count) = @config.set(:tokio_threads, Integer(count))
219
244
 
@@ -2,17 +2,18 @@
2
2
 
3
3
  module Kino
4
4
  # @private
5
- # rack.errors: stateless writer into the native logger. Frozen singleton,
5
+ # rack.errors: stateless writer into the server log (one line per
6
+ # call, labelled like every other line Kino writes). Frozen singleton,
6
7
  # which also makes it Ractor-shareable; one instance serves all workers.
7
8
  class ErrorsStream
8
9
  def puts(message)
9
- Native.log_error(message.to_s)
10
+ Log.error(message.to_s.chomp)
10
11
  nil
11
12
  end
12
13
 
13
14
  def write(message)
14
15
  message = message.to_s
15
- Native.log_error(message)
16
+ Log.error(message.chomp)
16
17
  message.bytesize
17
18
  end
18
19
 
@@ -4,9 +4,8 @@ module Kino
4
4
  # @private
5
5
  # Fires a lifecycle hook and turns a raise into a logged line instead of
6
6
  # letting it escape. Stateless and touches only its arguments plus
7
- # Native.log_error (already called from inside worker ractors today), so
8
- # it is safe to call from worker context: no main-ractor state is
9
- # captured.
7
+ # Kino::Log (safe inside worker ractors), so it is safe to call from
8
+ # worker context: no main-ractor state is captured.
10
9
  module HookFire
11
10
  module_function
12
11
 
@@ -16,7 +15,7 @@ module Kino
16
15
  begin
17
16
  hook.call(*args)
18
17
  rescue => e
19
- Native.log_error("#{name} hook raised #{e.class}: #{e.message}")
18
+ Log.error("#{name} hook raised #{e.class}: #{e.message}")
20
19
  end
21
20
  end
22
21
  end
data/lib/kino/kino.so CHANGED
Binary file
data/lib/kino/log.rb ADDED
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # Server log lines: the lifecycle notices, crash and respawn reports,
5
+ # hook failures, the failed-request report, and whatever apps write to
6
+ # `rack.errors`, all in one shape:
7
+ #
8
+ # kino[4213] worker-3: after_worker_boot hook raised RuntimeError: boom
9
+ #
10
+ # The label is syslog's `ident[pid]` tag plus the source that spoke: the
11
+ # worker ractor and/or thread by name, `main` for neither. On color
12
+ # terminals the label is dim, yellow, or red by level; the message stays
13
+ # plain. Notes go to stdout, warnings and errors to stderr.
14
+ #
15
+ # Hooks may log through here too (`Kino::Log.info "cache warm"`). Every
16
+ # method is safe inside a worker ractor: the line is handed to the
17
+ # native layer, which owns the streams, so no ractor touches `$stdout`
18
+ # or `$stderr` itself.
19
+ module Log
20
+ # Frames shown in a failed-request report before the rest are folded.
21
+ FRAMES = 12
22
+
23
+ # The working directory at boot, stripped from backtrace frames so the
24
+ # app's own code reads `app/controllers/x.rb:9` rather than an
25
+ # absolute path (frozen: worker ractors read it).
26
+ WORKING_DIR = File.join(Dir.pwd, "").freeze
27
+
28
+ module_function
29
+
30
+ # @param message [#to_s]
31
+ # @return [void]
32
+ def info(message)
33
+ Native.log_line("info", source, message.to_s)
34
+ end
35
+
36
+ # @param message [#to_s]
37
+ # @return [void]
38
+ def warn(message)
39
+ Native.log_line("warn", source, message.to_s)
40
+ end
41
+
42
+ # @param message [#to_s]
43
+ # @return [void]
44
+ def error(message)
45
+ Native.log_line("error", source, message.to_s)
46
+ end
47
+
48
+ # The failed-request report: the request line, the error, and where it
49
+ # raised in the app, then the backtrace with the app's own frames
50
+ # first (relative to the working directory) and the rest folded.
51
+ #
52
+ # 500 GET /boom · RuntimeError: kaboom (app.rb:12:in 'explode')
53
+ # app.rb:12:in 'explode'
54
+ # /gems/rack-3.2.7/lib/rack/builder.rb:...
55
+ # … 38 more
56
+ #
57
+ # @param error [Exception]
58
+ # @param env [Hash] the Rack env of the failed request
59
+ # @param status [Integer] the status the client got
60
+ # @return [void]
61
+ def exception(error, env, status: 500)
62
+ frames, depth = trace(error)
63
+ site = frames.first ? " (#{frames.first})" : ""
64
+ lines = ["#{status} #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} · #{error.class}: #{error.message}#{site}"]
65
+ frames.each { |frame| lines << " #{frame}" }
66
+ lines << " … #{depth - frames.size} more" if depth > frames.size
67
+ error(lines.join("\n"))
68
+ end
69
+
70
+ # The `kino[<pid>] <source>:` tag a line from here carries.
71
+ # @return [String]
72
+ def label
73
+ "kino[#{Process.pid}] #{source}:"
74
+ end
75
+
76
+ # Who is speaking: the ractor's name, the thread's name, both joined
77
+ # with a slash, or `main` when neither is named. Kino names its
78
+ # worker ractors and threads `worker-N`.
79
+ # @return [String]
80
+ def source
81
+ parts = [Ractor.current.name, Thread.current.name].compact
82
+ parts.empty? ? "main" : parts.join("/")
83
+ end
84
+
85
+ # The backtrace as [frames, depth]: each frame relativized to the
86
+ # working directory, the app's own frames floated to the front (the
87
+ # raise site in your code reads first; gem and stdlib frames keep
88
+ # their order below), capped at FRAMES; depth is the real length.
89
+ def trace(error)
90
+ raw = error.backtrace || []
91
+ app, rest = raw.map { |frame| frame.delete_prefix(WORKING_DIR) }.partition { |frame| app_frame?(frame) }
92
+ [(app + rest).first(FRAMES), raw.size]
93
+ end
94
+
95
+ # A project-relative path (the working-directory prefix came off, so
96
+ # it does not start with `/`) that is not a synthetic frame (`(eval)`,
97
+ # `<internal:...>`). Gem and stdlib frames stay absolute.
98
+ def app_frame?(frame)
99
+ !frame.start_with?("/", "<", "(")
100
+ end
101
+
102
+ private_class_method :trace, :app_frame?
103
+ end
104
+ end
@@ -21,7 +21,10 @@ module Kino
21
21
 
22
22
  def start
23
23
  @running = true
24
- @thread = Thread.new { run }
24
+ @thread = Thread.new do
25
+ Thread.current.name = "quarantine"
26
+ run
27
+ end
25
28
  self
26
29
  end
27
30
 
@@ -35,14 +38,14 @@ module Kino
35
38
  def run
36
39
  tick while @running
37
40
  rescue => e
38
- Native.log_error("quarantine monitor crashed: #{e.class}: #{e.message}")
41
+ Log.error("quarantine monitor crashed: #{e.class}: #{e.message}")
39
42
  end
40
43
 
41
44
  def tick
42
45
  scan_slots
43
46
  rescue => e
44
47
  # A bad tick must never kill the monitor.
45
- Native.log_error("quarantine tick error: #{e.class}: #{e.message}")
48
+ Log.error("quarantine tick error: #{e.class}: #{e.message}")
46
49
  ensure
47
50
  sleep @tick
48
51
  end
@@ -53,7 +56,7 @@ module Kino
53
56
 
54
57
  if @outstanding >= @max
55
58
  unless @at_cap_logged
56
- Native.log_error("quarantine at cap (#{@max}); serving at reduced capacity")
59
+ Log.warn("quarantine at cap (#{@max}); serving at reduced capacity")
57
60
  @at_cap_logged = true
58
61
  end
59
62
  next
@@ -91,6 +91,7 @@ module Kino
91
91
 
92
92
  def supervise(index)
93
93
  Thread.new do
94
+ Thread.current.name = "supervisor-#{index}"
94
95
  crashes = 0
95
96
  loop do
96
97
  ractor, worker_ids = spawn_worker(index)
@@ -109,7 +110,7 @@ module Kino
109
110
 
110
111
  crashes += 1
111
112
  Native.record_respawn(@server_id)
112
- Native.log_error("worker ractor #{index} crashed (#{cause.class}: #{cause.message}); respawning")
113
+ Log.error("worker-#{index} crashed (#{cause.class}: #{cause.message}); respawning")
113
114
  # Policy (crash recovery): unlimited respawn
114
115
  # keeps the server up under rare crashes but turns a
115
116
  # crash-on-every-request bug into a busy loop. A circuit breaker
@@ -129,12 +130,16 @@ module Kino
129
130
  @worker_slots[worker_index] = worker_ids
130
131
  worker_ids.each { |id| @slot_to_worker[id] = worker_index }
131
132
  end
132
- ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @hooks) do |server_id, ids, app, batch, hooks|
133
- ids.map do |id|
133
+ # Named so log lines from inside say which worker spoke: the ractor
134
+ # alone for a single thread, `worker-N/thread-M` for more.
135
+ ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @hooks,
136
+ name: "worker-#{worker_index}") do |server_id, ids, app, batch, hooks|
137
+ ids.each_with_index.map do |id, position|
134
138
  Thread.new do
135
139
  # Crashes surface via Ractor#value in the supervisor; don't also
136
140
  # spray the backtrace to stderr from inside the dying ractor.
137
141
  Thread.current.report_on_exception = false
142
+ Thread.current.name = "thread-#{position + 1}" if ids.size > 1
138
143
  Kino::Worker.run(server_id, id, app, batch, hooks)
139
144
  end
140
145
  end.each(&:join)
data/lib/kino/server.rb CHANGED
@@ -27,6 +27,29 @@ module Kino
27
27
  !@tls.nil?
28
28
  end
29
29
 
30
+ # @return [Boolean] whether the bind is a unix domain socket
31
+ # ("unix:///path/to.sock")
32
+ def unix?
33
+ @bind.start_with?("unix://")
34
+ end
35
+
36
+ # Where the server listens, once started: `http://host:port`
37
+ # (`https` under TLS), or the `unix://` socket path.
38
+ # @return [String]
39
+ def url
40
+ unix? ? @bind : "http#{"s" if tls?}://#{@bind}:#{@port}"
41
+ end
42
+
43
+ # Where the control plane listens, once started, or nil when it is
44
+ # off: `http://host:port`, or its `unix://` socket path.
45
+ # @return [String, nil]
46
+ def control_url
47
+ return nil unless @control_bind
48
+ return @control_bind if @control_bind.start_with?("unix://")
49
+
50
+ "http://#{@control_bind.rpartition(":").first}:#{@control_port}"
51
+ end
52
+
30
53
  # Settings precedence: explicit kwargs > config_file DSL > defaults.
31
54
  #
32
55
  # @param app [#call] a Rack 3 application
@@ -54,7 +77,12 @@ module Kino
54
77
  @worker_hooks = WorkerHooks.new(
55
78
  on_error: @on_error,
56
79
  after_worker_boot: @after_worker_boot,
57
- after_request_complete: @after_request_complete
80
+ after_request_complete: @after_request_complete,
81
+ # The access log's GC and allocation figures come from the VM's
82
+ # process-wide counters, so they are measured only where one
83
+ # request at a time can own them: the GVL serializes :threaded
84
+ # mode, and a single ractor has nothing to race.
85
+ access_timing: !!settings[:log_requests] && (@mode == :threaded || @workers == 1)
58
86
  )
59
87
  # Default threads per mode: 1 in :ractor (threads inside a ractor
60
88
  # share its lock; a measured +17% on fast handlers; raise `workers`
@@ -70,8 +98,17 @@ module Kino
70
98
  @lanes = !!settings[:lanes]
71
99
  @log_requests = !!settings[:log_requests]
72
100
  @shutdown_timeout = settings[:shutdown_timeout]
101
+ @io_shards = !!settings[:io_shards]
102
+ @io_threads = Integer(settings[:io_threads]) unless settings[:io_threads].nil?
103
+ if @io_threads && @io_threads < 1
104
+ raise ArgumentError, "io_threads must be >= 1"
105
+ end
106
+ Log.warn("io_threads has no effect unless io_shards is true") if @io_threads && !@io_shards
73
107
  @tokio_threads = settings[:tokio_threads]
74
108
  @tls = validate_tls(settings[:tls])
109
+ if @tls && unix?
110
+ raise ArgumentError, "TLS is not supported on a unix socket bind; terminate TLS at the proxy in front"
111
+ end
75
112
  @pidfile = settings[:pidfile]
76
113
  @control_bind = settings[:control_bind]&.to_s
77
114
  @control_token = settings[:control_token]&.to_s
@@ -114,6 +151,8 @@ module Kino
114
151
  request_timeout_ms: @request_timeout_ms,
115
152
  max_connections: @max_connections,
116
153
  max_body_size: @max_body_size,
154
+ io_shards: @io_shards,
155
+ io_threads: @io_threads,
117
156
  tokio_threads: @tokio_threads,
118
157
  tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
119
158
  lanes: @lanes, log_requests: @log_requests,
@@ -195,22 +234,34 @@ module Kino
195
234
  @supervisor ? @supervisor.join : @worker_threads.each(&:join)
196
235
  end
197
236
 
198
- # Production entry point: start, print the banner, trap INT/TERM for
199
- # graceful shutdown (second signal force-exits), block until done.
200
- # The `kino` CLI funnels into this too (CLI#serve).
237
+ # Production entry point: build the server and {#run} it. The `kino`
238
+ # CLI funnels into this too (CLI#serve).
201
239
  #
202
240
  # @param app [#call] a Rack 3 application
203
241
  # @param opts [Hash] see #initialize
204
242
  # @return [Kino::Server] the (stopped) server, after shutdown
205
243
  def self.run(app, **opts)
206
- server = new(app, **opts)
244
+ new(app, **opts).run
245
+ end
246
+
247
+ # Serve until shut down: start, print the banner, trap INT/TERM for
248
+ # graceful shutdown (second signal force-exits), block until done.
249
+ # The Rack handler calls this on a server it built itself.
250
+ #
251
+ # @return [self] after shutdown
252
+ def run
253
+ # Startup output must land immediately even when stdout is a pipe or
254
+ # file (process supervisors, `kino > server.log`, `rails server`
255
+ # under Docker); block buffering would hold the banner back until
256
+ # exit.
257
+ $stdout.sync = true
207
258
  CLI.opening_credits
208
- server.start
209
- CLI.action!(server)
259
+ start
260
+ CLI.action!(self)
210
261
  CLI.fin_at_exit
211
- trap_signals(server)
212
- server.wait
213
- server
262
+ self.class.trap_signals(self)
263
+ wait
264
+ self
214
265
  end
215
266
 
216
267
  # Signal handling shared by Server.run and the kino CLI: INT/TERM drain
@@ -222,14 +273,14 @@ module Kino
222
273
  # kill -USR1 <pid> prints a one-line stats snapshot (find the pid in
223
274
  # the pidfile when configured).
224
275
  trap("USR1") do
225
- Thread.new { $stdout.puts Kino::CLI.stats_line(server.stats) }
276
+ Thread.new { Log.info(CLI.stats_line(server.stats)) }
226
277
  end
227
278
  signaled = false
228
279
  %w[INT TERM].each do |signal|
229
280
  trap(signal) do
230
281
  Process.exit!(1) if signaled
231
282
  signaled = true
232
- $stderr.write("Kino: draining (signal again to force exit)\n")
283
+ Log.warn("draining (signal again to force exit)")
233
284
  # Trap context forbids mutexes; do the real work on a thread.
234
285
  Thread.new { server.shutdown }
235
286
  end
@@ -270,6 +321,8 @@ module Kino
270
321
  def spawn_worker_thread
271
322
  worker_id = Native.register_worker(@id)
272
323
  Thread.new do
324
+ # Named so log lines from inside say which worker spoke.
325
+ Thread.current.name = "worker-#{worker_id}"
273
326
  error = nil
274
327
  begin
275
328
  Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
@@ -442,7 +495,7 @@ module Kino
442
495
  if @supervisor
443
496
  # Ractors cannot be force-killed; their clients were already freed
444
497
  # by abort_all_inflight. The stuck ractor leaks until process exit.
445
- Native.log_error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
498
+ Log.error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
446
499
  else
447
500
  threads = @worker_threads_lock.synchronize { @worker_threads.dup }
448
501
  threads.each { |thread| thread.kill if thread.alive? }
@@ -474,10 +527,10 @@ module Kino
474
527
  :ractor
475
528
  when :auto
476
529
  if !Ractor.shareable?(@app)
477
- warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
530
+ Log.warn("app is not Ractor-shareable; falling back to mode: :threaded")
478
531
  :threaded
479
532
  elsif (name = unshareable_worker_hook_name)
480
- warn "Kino: #{name} hook is not Ractor-shareable; falling back to mode: :threaded"
533
+ Log.warn("#{name} hook is not Ractor-shareable; falling back to mode: :threaded")
481
534
  :threaded
482
535
  else
483
536
  :ractor
@@ -8,7 +8,9 @@
8
8
  ## Network
9
9
 
10
10
  # Address to listen on. Use "0.0.0.0" to accept connections from other
11
- # machines.
11
+ # machines, or "unix:///run/kino.sock" to listen on a unix domain socket
12
+ # behind a proxy such as nginx (the port below is then unused; a stale
13
+ # socket file is reclaimed, a live one refused).
12
14
  # bind "127.0.0.1"
13
15
 
14
16
  # Port to listen on.
@@ -22,7 +24,8 @@
22
24
 
23
25
  # How many workers to run. Each worker handles requests independently;
24
26
  # in :ractor mode every worker runs Ruby in parallel on its own core.
25
- # Default: one per CPU core.
27
+ # Default: the CPUs this process may use (Kino.available_parallelism:
28
+ # the affinity mask and, in a container, the cgroup CPU quota).
26
29
  # workers 8
27
30
 
28
31
  # Threads inside each worker. More threads help when your app spends
@@ -74,9 +77,12 @@
74
77
  # for quick handlers; behavior under heavy overload differs slightly.
75
78
  # lanes false
76
79
 
77
- # Print one line per request to stdout, colored by status on a
78
- # terminal. This is the server's view: it includes requests your app
79
- # never saw, such as 503s. Recommended in development.
80
+ # Log every request to stdout: an arrival line before the app runs and a
81
+ # completion line after it, colored by status on a terminal, with a
82
+ # timing breakdown (time in Ruby with its GC pause and allocations, the
83
+ # server's own overhead, and queue wait). This is the server's view: it
84
+ # includes requests your app never saw, such as 503s. Recommended in
85
+ # development; cheap enough for production.
80
86
  # log_requests false
81
87
 
82
88
  # Called when a worker catches an app or delivery error, after the client
@@ -119,8 +125,14 @@
119
125
 
120
126
  ## Runtime
121
127
 
122
- # Threads for the Rust I/O engine. The default suits most apps; for
123
- # heavily CPU-bound apps, try 1 to leave more cores for Ruby.
128
+ # Run native HTTP I/O on current-thread shards instead of Tokio's shared
129
+ # worker pool, reducing scheduler contention on very fast handlers.
130
+ # io_shards true
131
+
132
+ # I/O shard count. Default with io_shards: half available CPUs.
133
+ # io_threads 6
134
+
135
+ # Threads for the Tokio multi-thread runtime. Default: one per available CPU.
124
136
  # tokio_threads 4
125
137
 
126
138
  ## Control plane
data/lib/kino/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Kino
4
4
  # The gem version (single source of truth; ext/kino/Cargo.toml syncs).
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/lib/kino/worker.rb CHANGED
@@ -70,7 +70,16 @@ module Kino
70
70
  def serve(env, app, hooks)
71
71
  request = env[KINO_REQUEST]
72
72
  env[RACK_INPUT] ||= Input.new(request)
73
- status, headers, body = app.call(env)
73
+ if hooks&.access_timing
74
+ # The access log's breakdown: the VM's cumulative GC time and
75
+ # allocation count, differenced around the app call.
76
+ gc_before = GC.total_time
77
+ allocated_before = GC.stat(:total_allocated_objects)
78
+ status, headers, body = app.call(env)
79
+ request.timing(GC.total_time - gc_before, GC.stat(:total_allocated_objects) - allocated_before)
80
+ else
81
+ status, headers, body = app.call(env)
82
+ end
74
83
 
75
84
  if body.respond_to?(:to_ary)
76
85
  chunks = join_chunks(body.to_ary)
@@ -99,21 +108,12 @@ module Kino
99
108
  # delivery errors (they happen after app.call returned, so no
100
109
  # middleware can see them); its own failures are logged, not raised,
101
110
  # because nothing may escape this block and kill the worker.
102
- Native.log_error(error_log_line(e))
111
+ Log.exception(e, env)
103
112
  request.abort
104
113
  HookFire.fire(hooks&.on_error, "on_error", e, env)
105
114
  NOT_FUSED
106
115
  end
107
116
 
108
- # First frames only: the raise site is at the top, and Rails stacks
109
- # run hundreds of middleware frames deep. Hooks get the full exception.
110
- BACKTRACE_FRAMES = 12
111
-
112
- def error_log_line(error)
113
- ["#{error.class}: #{error.message}",
114
- *(error.backtrace || []).first(BACKTRACE_FRAMES)].join("\n ")
115
- end
116
-
117
117
  def deliver_streaming(request, status, headers, body, input)
118
118
  request.send_headers(status, headers)
119
119
  if body.respond_to?(:call) && !body.respond_to?(:each)
@@ -159,7 +159,6 @@ module Kino
159
159
  end
160
160
 
161
161
  private_class_method :handle_one, :process, :serve, :deliver_streaming,
162
- :join_chunks, :error_log_line, :fire_after_worker_boot,
163
- :fire_after_request_complete
162
+ :join_chunks, :fire_after_worker_boot, :fire_after_request_complete
164
163
  end
165
164
  end
@@ -7,5 +7,7 @@ module Kino
7
7
  # several bare procs. Any member may be nil. A Data instance is frozen,
8
8
  # so it is Ractor.shareable? exactly when its members are (nil, or a
9
9
  # Ractor.shareable_proc), letting it ride the ractor boundary like the app.
10
- WorkerHooks = Data.define(:on_error, :after_worker_boot, :after_request_complete)
10
+ # `access_timing` rides along: whether the worker measures the GC pause
11
+ # and allocations around each app call for the access log's breakdown.
12
+ WorkerHooks = Data.define(:on_error, :after_worker_boot, :after_request_complete, :access_timing)
11
13
  end
data/lib/kino.rb CHANGED
@@ -33,9 +33,20 @@ module Kino
33
33
  remaining = Native.sleep_chunk(remaining) while remaining.positive?
34
34
  nil
35
35
  end
36
+
37
+ # How many CPUs this process may actually use: the `workers` default.
38
+ # Unlike `Etc.nprocessors`, this honours a cgroup CPU quota (a container
39
+ # limited to 2 CPUs on a 64-core host gets 2, not 64) as well as the
40
+ # affinity mask; a fractional quota rounds up. Never below 1.
41
+ #
42
+ # @return [Integer]
43
+ def self.available_parallelism
44
+ Native.available_parallelism
45
+ end
36
46
  end
37
47
 
38
48
  require_relative "kino/cli"
49
+ require_relative "kino/log"
39
50
  require_relative "kino/logger"
40
51
  require_relative "kino/check"
41
52
  require_relative "kino/input"
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rackup
4
+ module Handler
5
+ # The Rack handler: lets any host that speaks the rackup protocol boot
6
+ # Kino, which is what `rackup -s kino` and `rails server -u kino` do.
7
+ # Loaded on demand by Rackup::Handler.get(:kino), so Rackup itself is
8
+ # already defined here; Kino is required only when the host calls in.
9
+ module Kino
10
+ # Host option name => Kino setting plus the coercion it needs: rackup
11
+ # hands `-O NAME=VALUE` values (and its own -p) over as strings.
12
+ OPTION_MAP = {
13
+ Host: [:bind, ->(value) { value.to_s }],
14
+ Port: [:port, ->(value) { Integer(value) }],
15
+ Workers: [:workers, ->(value) { Integer(value) }],
16
+ Threads: [:threads, ->(value) { Integer(value) }],
17
+ Mode: [:mode, ->(value) { value.to_sym }]
18
+ }.freeze
19
+ private_constant :OPTION_MAP
20
+
21
+ # Boot a server for `app` and block until it shuts down, the way the
22
+ # `kino` executable does (banner, INT/TERM drain, stats on USR1).
23
+ #
24
+ # @param app [#call] the Rack application the host built
25
+ # @param options [Hash] the host's options (see {.server_options})
26
+ # @yield [server] the built, not yet started server, for hosts that
27
+ # want a handle on it
28
+ # @return [::Kino::Server] the stopped server, after shutdown
29
+ def self.run(app, **options)
30
+ require "kino"
31
+ server = ::Kino::Server.new(app, **server_options(options))
32
+ yield server if block_given?
33
+ server.run
34
+ end
35
+
36
+ # The `-O NAME=VALUE` options `rackup -s kino --help` lists (rackup
37
+ # shows its own -o/-p in place of Host and Port).
38
+ # @return [Hash{String => String}]
39
+ def self.valid_options
40
+ {
41
+ "Host=HOST" => "Address to bind (default: 127.0.0.1)",
42
+ "Port=PORT" => "Port to listen on (default: 9292)",
43
+ "Workers=COUNT" => "Workers: ractors in :ractor mode, thread groups in :threaded (default: one per CPU)",
44
+ "Threads=COUNT" => "Threads per worker (default: 1 in :ractor, 3 in :threaded)",
45
+ "Mode=MODE" => "auto | ractor | threaded (default: auto)",
46
+ "Config=PATH" => "Kino config file (default: kino.rb, then config/kino.rb)"
47
+ }
48
+ end
49
+
50
+ # Translate host options into {::Kino::Server#initialize} kwargs.
51
+ # Precedence: options the user typed > the config file > defaults the
52
+ # host supplied (rackup's and Rails' own Host and Port) > Kino's
53
+ # defaults. Hosts that say which options were typed pass
54
+ # `user_supplied_options`; when that list is absent every option
55
+ # counts as typed. Keys outside OPTION_MAP (the host's bookkeeping:
56
+ # environment, pid, config, ...) are ignored.
57
+ #
58
+ # @param options [Hash{Symbol => Object}]
59
+ # @return [Hash{Symbol => Object}]
60
+ def self.server_options(options)
61
+ require "kino"
62
+ options = options.dup
63
+ host_defaults = {}
64
+ if (typed = options.delete(:user_supplied_options))
65
+ (options.keys - typed).each { |key| host_defaults[key] = options.delete(key) }
66
+ end
67
+
68
+ config = ::Kino::Configuration.new
69
+ path = options.delete(:Config) || host_defaults.delete(:Config) || ::Kino::Configuration.default_path
70
+ config.load_file(path) if path
71
+ translate(host_defaults).each { |key, value| config.set(key, value) unless config.set?(key) }
72
+ config.merge!(translate(options))
73
+ config.set(:port, ::Kino::Configuration::DEFAULT_SERVING_PORT) unless config.set?(:port)
74
+ config.server_options
75
+ end
76
+
77
+ def self.translate(options)
78
+ options.filter_map do |key, value|
79
+ setting, coerce = OPTION_MAP[key]
80
+ [setting, coerce.call(value)] if setting
81
+ end.to_h
82
+ end
83
+ private_class_method :translate
84
+ end
85
+
86
+ register :kino, Kino
87
+ end
88
+ end
data/sig/kino.rbs CHANGED
@@ -20,6 +20,9 @@ module Kino
20
20
  # High-resolution sleep on the OS clock with the GVL released.
21
21
  def self.sleep: (Numeric seconds) -> nil
22
22
 
23
+ # CPUs this process may use (affinity mask and cgroup quota); never below 1.
24
+ def self.available_parallelism: () -> Integer
25
+
23
26
  class Server
24
27
  attr_reader port: Integer?
25
28
  attr_reader control_port: Integer?
@@ -28,6 +31,16 @@ module Kino
28
31
 
29
32
  def tls?: () -> bool
30
33
 
34
+ # Whether the bind is a unix domain socket ("unix:///path/to.sock").
35
+ def unix?: () -> bool
36
+
37
+ # Where the server listens once started: http(s)://host:port or the
38
+ # unix:// socket path.
39
+ def url: () -> String
40
+
41
+ # Where the control plane listens once started, or nil when it is off.
42
+ def control_url: () -> String?
43
+
31
44
  # Settings precedence: explicit kwargs > config_file DSL > defaults.
32
45
  def initialize: (rack_app app, ?config_file: String?, **untyped options) -> void
33
46
 
@@ -42,9 +55,12 @@ module Kino
42
55
  # served, rejected, timeouts, respawns, lane_depths when lanes are on).
43
56
  def stats: () -> stats_hash
44
57
 
45
- # Production entry point: start, banner, signal traps, block until done.
58
+ # Production entry point: build the server and #run it.
46
59
  def self.run: (rack_app app, **untyped opts) -> Server
47
60
 
61
+ # Serve until shut down: start, banner, signal traps, block until done.
62
+ def run: () -> self
63
+
48
64
  # INT/TERM drain gracefully (second signal force-exits); USR1 prints stats.
49
65
  def self.trap_signals: (Server server) -> void
50
66
  end
@@ -53,6 +69,12 @@ module Kino
53
69
  DEFAULTS: Hash[Symbol, untyped]
54
70
  SETTINGS: Array[Symbol]
55
71
  SAMPLE_TEMPLATE: String
72
+ DEFAULT_PATHS: Array[String]
73
+ DEFAULT_SERVING_PORT: Integer
74
+
75
+ # The first of DEFAULT_PATHS (kino.rb, config/kino.rb) present in the
76
+ # working directory.
77
+ def self.default_path: () -> String?
56
78
 
57
79
  # The fully commented sample config (see `kino --init`).
58
80
  def self.sample: () -> String
@@ -104,6 +126,8 @@ module Kino
104
126
  def after_request_complete: (?^(Hash[String, untyped], Integer) -> void handler) ?{ (Hash[String, untyped], Integer) -> void } -> untyped
105
127
  def on_worker_exit: (?^(Integer, Exception?) -> void handler) ?{ (Integer, Exception?) -> void } -> untyped
106
128
  def shutdown_timeout: (Numeric seconds) -> untyped
129
+ def io_shards: (?boolish enabled) -> untyped
130
+ def io_threads: (int? count) -> untyped
107
131
  def tokio_threads: (int count) -> untyped
108
132
  def tls: (cert: String, key: String) -> untyped
109
133
  def environment: (String | Symbol env) -> untyped
@@ -168,6 +192,27 @@ module Kino
168
192
  def self.print_report: (rack_app app, ?io: IO) -> bool
169
193
  end
170
194
 
195
+ # Server log lines, `kino[<pid>] <source>: message`; safe inside worker
196
+ # ractors, so hooks may log through it.
197
+ module Log
198
+ FRAMES: Integer
199
+ WORKING_DIR: String
200
+
201
+ def self.info: (untyped message) -> void
202
+ def self.warn: (untyped message) -> void
203
+ def self.error: (untyped message) -> void
204
+
205
+ # The failed-request report: request line, error, and an app-first
206
+ # backtrace relative to the working directory.
207
+ def self.exception: (Exception error, Hash[String, untyped] env, ?status: Integer) -> void
208
+
209
+ # The `kino[<pid>] <source>:` tag.
210
+ def self.label: () -> String
211
+
212
+ # The ractor and/or thread name, or `main`.
213
+ def self.source: () -> String
214
+ end
215
+
171
216
  # A ::Logger writing through the native async sink.
172
217
  class Logger < ::Logger
173
218
  # path: a file (created/appended) or nil for stdout.
@@ -188,3 +233,21 @@ module Kino
188
233
  end
189
234
  end
190
235
  end
236
+
237
+ module Rackup
238
+ module Handler
239
+ # The Rack handler behind `rackup -s kino` and `rails server -u kino`.
240
+ module Kino
241
+ # Boot a server for app and block until shutdown; yields the built,
242
+ # not yet started server to hosts that want a handle on it.
243
+ def self.run: (::Kino::rack_app app, **untyped options) ?{ (::Kino::Server) -> void } -> ::Kino::Server
244
+
245
+ # The -O options rackup lists for this handler.
246
+ def self.valid_options: () -> Hash[String, String]
247
+
248
+ # Host options translated into Kino::Server kwargs, with precedence
249
+ # typed > config file > host defaults > Kino defaults.
250
+ def self.server_options: (Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
251
+ end
252
+ end
253
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kino
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Yaroslav Markin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rackup
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.2'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.2'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rake
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -150,6 +164,7 @@ files:
150
164
  - lib/kino/hook_fire.rb
151
165
  - lib/kino/input.rb
152
166
  - lib/kino/kino.so
167
+ - lib/kino/log.rb
153
168
  - lib/kino/logger.rb
154
169
  - lib/kino/null_input.rb
155
170
  - lib/kino/quarantine_monitor.rb
@@ -160,6 +175,7 @@ files:
160
175
  - lib/kino/version.rb
161
176
  - lib/kino/worker.rb
162
177
  - lib/kino/worker_hooks.rb
178
+ - lib/rackup/handler/kino.rb
163
179
  - sig/kino.rbs
164
180
  homepage: https://github.com/yaroslav/kino
165
181
  licenses: