kino 0.2.0-aarch64-linux → 0.3.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: '04184f3cc709476d0cf35ceb23fbe9cbdd46f13d56f55f9e5f6a3fd261c9a8c9'
4
- data.tar.gz: 261bf88fb0bd9ca45633c2141d189591e2c183a4002cd300e124b3a16e74ae8c
3
+ metadata.gz: 18bb12fa02179c4c2e164cf83e605ac893094650fcaee119132b48b2eea0ae2e
4
+ data.tar.gz: 56c6652d6671f2872e0399f82a789e6b4cc5b0ea018f5503bb91ed575f187985
5
5
  SHA512:
6
- metadata.gz: 52cb0b30513607501b56c01ed343a119ad0fc1277ad17655fa1453b82bfdaa8a345cb52ce4937058a491e67db485560481d4523ca85255d7ff57546e4d1146f5
7
- data.tar.gz: f1fb8a9e78b4c28c2ed3f4e589e10c40775e9605863148cac6edcd1e3f47dc2ab16b7b1e168f481e7e6ff15fc21ad545c680c732a33e5e4a4ccaadf534b0e114
6
+ metadata.gz: 1c3f92ba0bd339f2415ae94024f4afc5666a356c69b7721bb36deeccc492521d7f75a5ef064f994306a771924a42ccb00ff6e3ce138754b797ef5e2263144622
7
+ data.tar.gz: ae5f7535f0fc9ef4b9398a1fdd57723aae445d49ef7705fb1cab907d6b8ab01bb25338394509467e7cc755b37105b306ab08f89f755d4496e186e63f32cd6ace
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.3.0] - 2026-08-13
2
+
3
+ - Queue-time histogram: `/metrics` exposes `kino_request_queue_seconds`, a
4
+ histogram of how long each request waited for a free worker (the
5
+ saturation signal), and `server.stats`/`/stats` gain `queue_time`
6
+ (count and summed seconds). Measured internally with a monotonic clock,
7
+ so it needs no proxy header and is immune to clock skew.
8
+ - Lifecycle hooks: `after_boot`, `after_worker_boot`,
9
+ `after_request_complete`, and `on_worker_exit` join `on_error`, so apps
10
+ can wire their own metrics, readiness, and error tracking. The
11
+ worker-context hooks must be Ractor-shareable in `:ractor` mode; a raising
12
+ hook is logged and never kills a worker.
13
+ - Stuck-worker quarantine: past `quarantine_timeout`, a wedged dispatch
14
+ slot is quarantined and a replacement worker is spawned to restore
15
+ capacity (capped by `quarantine_max`), surfaced via `/stats`, `/metrics`,
16
+ and `server.stats`. The wedged worker is never force-killed.
17
+ - Control plane: a read-only monitoring listener (`control_bind`,
18
+ optional `control_token`) serving live stats as JSON at `/stats`,
19
+ Prometheus metrics at `/metrics`, and `/ready`/`/live` probes, answered
20
+ from the native layer on a dedicated thread so it stays responsive
21
+ while workers are busy, stuck, or draining.
22
+ - Per-worker stats: `/stats`, `/metrics`, and `server.stats` now break the
23
+ counters down per dispatch slot (served, in-flight, and `busy_ms`, the
24
+ age of the slot's current request), so a stuck slot is visible
25
+ individually.
26
+ - Update Rust and Ruby dependencies.
27
+
28
+ ## [0.2.1] - 2026-07-27
29
+
30
+ - Update Rust dependencies for Kino.
31
+ - Update: puma 8 in benchmarks.
32
+
1
33
  ## [0.2.0] - 2026-07-13
2
34
 
3
35
  - Strip debug info from release builds.
data/README.md CHANGED
@@ -183,7 +183,7 @@ bundle add kino # or: gem install kino (outside a bundle)
183
183
  or put it in the `Gemfile` yourself:
184
184
 
185
185
  ```ruby
186
- gem "kino", "~> 0.1"
186
+ gem "kino"
187
187
  ```
188
188
 
189
189
  Then generate a config and serve:
@@ -230,6 +230,8 @@ server = Kino::Server.new(app,
230
230
  max_body_size: 50 * 1024 * 1024, # bytes before a 413; nil = let a proxy handle it
231
231
  on_error: ->(e, env) { ErrorTracker.capture(e) }, # after the client got its 500
232
232
  shutdown_timeout: 30, # drain deadline
233
+ control_bind: "127.0.0.1:9293", # monitoring: /stats /metrics /ready /live; port 0 reads back via server.control_port
234
+ control_token: ENV["KINO_CONTROL_TOKEN"], # optional Bearer auth for /stats + /metrics
233
235
  tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM
234
236
  )
235
237
  server.start
@@ -320,6 +322,44 @@ after the client got its 500—the only place a tracker sees errors
320
322
  raised while the response was being written (in `:ractor` mode, build
321
323
  the handler with `Ractor.shareable_proc`).
322
324
 
325
+ ## Lifecycle hooks
326
+
327
+ Kino fires four lifecycle hooks alongside `on_error`, split by firing context.
328
+
329
+ **Worker-context hooks** run inside the worker and are available to all workers:
330
+ - `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`.
331
+ - `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`.
332
+
333
+ **Main-context hooks** run on the main thread, outside workers, and are plain procs:
334
+ - `after_boot { }`: fires once after the worker pool is up. Wire readiness here—sd_notify, a "server ready" metric, and so on.
335
+ - `on_worker_exit { |worker_index, error| }`: fires when a worker exits, with its index and the crash cause (or nil on a clean exit).
336
+
337
+ `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.
338
+
339
+ A raising hook is logged and never kills a worker.
340
+
341
+ ## Stuck-worker quarantine
342
+
343
+ `quarantine_timeout: seconds` (or `quarantine_timeout 60` in `kino.rb`)
344
+ quarantines a dispatch slot whose request has run longer than the deadline
345
+ and spawns a replacement worker to restore capacity—distinct from
346
+ `request_timeout`, which gives the client a 504 but leaves the slot
347
+ occupied. `quarantine_max` (default: the worker count in `:ractor` mode,
348
+ workers × threads in `:threaded`) caps the total number of replacement
349
+ events over the process lifetime—past it the monitor stops replacing and
350
+ the server runs at reduced capacity.
351
+
352
+ The wedged worker is never interrupted or force-killed, and its slot stays
353
+ quarantined for good. In `:threaded` mode, if the blocked thread
354
+ eventually returns, it keeps serving requests on that same slot—but the
355
+ slot itself stays flagged quarantined (busy_ms reported as 0) for the rest
356
+ of the process; in `:ractor` mode the wedged ractor (and its supervisor
357
+ thread) leaks until the process exits, since a wedged ractor cannot be
358
+ safely interrupted. Monitor quarantine activity via `server.stats`
359
+ (top-level `quarantined` count and per-slot `worker_status[].quarantined`
360
+ flag), `GET /stats` (same), and `GET /metrics` (`kino_quarantined_workers`
361
+ gauge and `kino_quarantine_replacements_total` counter).
362
+
323
363
  ## Stats
324
364
 
325
365
  `server.stats` returns a live snapshot: the configuration plus counters
@@ -330,7 +370,7 @@ cost):
330
370
  server.stats
331
371
  # => {mode: :ractor, lanes: false, workers: 8, threads: 1, batch: 1,
332
372
  # respawns: 0, queued: 0, in_flight: 2, served: 1041, rejected: 0,
333
- # timeouts: 0}
373
+ # timeouts: 0, worker_status: [...]}
334
374
  # plus lane_depths: [...] when lane dispatch is on
335
375
  ```
336
376
 
@@ -341,6 +381,39 @@ From the outside, `kill -USR1 <pid>` prints the same snapshot as one line
341
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
342
382
  ```
343
383
 
384
+ For pull-based monitoring, `control_bind "127.0.0.1:9293"` (or a
385
+ `unix://` path) serves a read-only **control plane** from the native
386
+ layer on its own thread—it keeps answering even while every Ruby worker
387
+ is busy or stuck, and reports `draining` through a graceful shutdown:
388
+
389
+ - `GET /stats`—the same snapshot as `server.stats`, as JSON (plus
390
+ `state` and `version`).
391
+ - `GET /metrics`—Prometheus text format (`kino_requests_served_total`,
392
+ `kino_queue_depth`, `kino_ready`, …).
393
+
394
+ Both `/stats` and `/metrics` also break the counters down per dispatch
395
+ slot: `/stats` carries a `worker_status` array (`index`, `served`,
396
+ `in_flight`, `busy_ms`) and `/metrics` emits `kino_worker_*{worker="N"}`
397
+ series, one entry per execution slot (`workers × threads`)—a crashed
398
+ worker's slot is never reused, so it stays in the list with its counters
399
+ frozen where they stopped, meaning the array (and its `worker="N"` metric
400
+ series) grows by one across every respawn. `busy_ms` is how long the
401
+ slot's current request has been running (0 when idle), so a single slot
402
+ climbing while the rest sit at 0 is your stuck worker.
403
+
404
+ The `/stats` response and `server.stats` carry `queue_time` (count and
405
+ summed seconds), and `/metrics` exposes `kino_request_queue_seconds`—a
406
+ Prometheus histogram of queue-wait time, the worker-saturation signal.
407
+ Counts admitted requests only; a 503 after queue wait goes to `rejected`,
408
+ not `queue_time`.
409
+
410
+ - `GET /ready`—`200` when serving, `503` while booting or draining:
411
+ wire it to your load balancer or Kubernetes readiness probe.
412
+ - `GET /live`—`200` whenever the process is alive: the liveness probe.
413
+
414
+ `control_token "..."` puts `/stats` and `/metrics` behind
415
+ `Authorization: Bearer`; the probes stay open.
416
+
344
417
  ## Logging
345
418
 
346
419
  With one log line per request, `Kino::Logger` sustained **2.4× the
@@ -432,9 +505,17 @@ bundle exec rake # compile, Rust tests, specs, RBS, lint
432
505
  RB_SYS_CARGO_PROFILE=dev bundle exec rake compile # fast dev rebuilds
433
506
  ```
434
507
 
508
+ ## Acknowledgements
509
+
510
+ Thanks to [Mat Sadler](https://github.com/matsadler) for [magnus](https://github.com/matsadler/magnus).
511
+
512
+ For ractors, thanks to [Koichi Sasada](https://github.com/ko1), [John Hawthorn](https://github.com/jhawthorn), [Jean Boussier](https://github.com/byroot), [Luke Gruber](https://github.com/luke-gruber), and other Ruby core contributors.
513
+
514
+ For the Rust network stack, thanks to [Sean McArthur](https://github.com/seanmonstar) for [hyper](https://github.com/hyperium/hyper), and to [Carl Lerche](https://github.com/carllerche), [Alice Ryhl](https://github.com/Darksonn), and the other [Tokio](https://github.com/tokio-rs/tokio) maintainers for the runtime underneath it. Thanks to [Joshua Barretto](https://github.com/zesterer) for [flume](https://github.com/zesterer/flume)—its channels carry every request between the network side and the workers.
515
+
435
516
  ## Assisted by
436
517
 
437
- Claude Code (Mythos, Opus).
518
+ Claude Code (Fable 5, Opus 4.8).
438
519
 
439
520
  ## Contributing
440
521
 
data/lib/kino/cli.rb CHANGED
@@ -100,11 +100,14 @@ module Kino
100
100
  end.join
101
101
  end
102
102
 
103
- # One-line stats dump (the SIGUSR1 handler's output).
103
+ # One-line stats dump (the SIGUSR1 handler's output). Excludes
104
+ # worker_status: it's an array with one entry per execution slot, and
105
+ # printing it inline would break the one-line contract (see /stats for
106
+ # per-worker detail).
104
107
  # @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
105
108
  # @return [String]
106
109
  def stats_line(stats)
107
- dim("Kino stats: #{stats.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
110
+ dim("Kino stats: #{stats.except(:worker_status).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
108
111
  end
109
112
 
110
113
  # The two banner halves around Server#start: credits before, the ready
@@ -23,11 +23,19 @@ module Kino
23
23
  lanes: false,
24
24
  log_requests: false,
25
25
  on_error: nil,
26
+ after_boot: nil,
27
+ after_worker_boot: nil,
28
+ after_request_complete: nil,
29
+ on_worker_exit: nil,
26
30
  shutdown_timeout: 30,
27
31
  tokio_threads: nil,
28
32
  tls: nil,
29
33
  environment: nil,
30
34
  pidfile: nil,
35
+ control_bind: nil,
36
+ control_token: nil,
37
+ quarantine_timeout: nil,
38
+ quarantine_max: nil,
31
39
  rackup: nil
32
40
  }.freeze
33
41
 
@@ -185,6 +193,24 @@ module Kino
185
193
  # or a block. Must be Ractor-shareable in :ractor mode.
186
194
  def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
187
195
 
196
+ # Called once on the main thread after the worker pool is up. The
197
+ # readiness seam (wire sd_notify or a "server ready" metric here).
198
+ def after_boot(handler = nil, &block) = @config.set(:after_boot, handler || block)
199
+
200
+ # Called once inside each worker (a ractor in :ractor mode) before it
201
+ # serves, with the worker's slot id. Must be Ractor-shareable in
202
+ # :ractor mode (build it with Ractor.shareable_proc).
203
+ def after_worker_boot(handler = nil, &block) = @config.set(:after_worker_boot, handler || block)
204
+
205
+ # Called inside the worker after each successful response with
206
+ # (env, status). Hot path: leave unset for zero cost. Must be
207
+ # Ractor-shareable in :ractor mode.
208
+ def after_request_complete(handler = nil, &block) = @config.set(:after_request_complete, handler || block)
209
+
210
+ # Called on the main thread when a worker exits, with (worker_index,
211
+ # error_or_nil). error is the crash cause, or nil on a clean exit.
212
+ def on_worker_exit(handler = nil, &block) = @config.set(:on_worker_exit, handler || block)
213
+
188
214
  # Graceful-shutdown drain deadline in seconds.
189
215
  def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
190
216
 
@@ -200,6 +226,37 @@ module Kino
200
226
  # Write the master PID here on start.
201
227
  def pidfile(path) = @config.set(:pidfile, path.to_s)
202
228
 
229
+ # Serve the read-only control plane (live stats as JSON at /stats,
230
+ # Prometheus text at /metrics, /ready and /live probes) on this
231
+ # address: "host:port" or "unix://path". Off unless set.
232
+ def control_bind(addr) = @config.set(:control_bind, addr.to_s)
233
+
234
+ # When set, /stats and /metrics require "Authorization: Bearer <token>".
235
+ # The probes stay open; they carry no data.
236
+ def control_token(token) = @config.set(:control_token, token.to_s)
237
+
238
+ # Quarantine a dispatch slot whose current request has run longer
239
+ # than this many seconds, spawning a replacement to restore capacity.
240
+ # Off unless set. Set it above your slowest legitimate endpoint (and
241
+ # typically above request_timeout).
242
+ def quarantine_timeout(seconds)
243
+ seconds &&= Float(seconds)
244
+ if seconds && seconds <= 0
245
+ raise ArgumentError, "quarantine_timeout must be greater than 0 (got #{seconds})"
246
+ end
247
+ @config.set(:quarantine_timeout, seconds)
248
+ end
249
+
250
+ # Cap on the total number of replacement events over the process
251
+ # lifetime. Past the cap the monitor stops replacing and the server
252
+ # runs at reduced capacity. Default: the worker count in :ractor
253
+ # mode, workers x threads in :threaded.
254
+ def quarantine_max(count)
255
+ count = Integer(count)
256
+ raise ArgumentError, "quarantine_max must be >= 1 (got #{count})" if count < 1
257
+ @config.set(:quarantine_max, count)
258
+ end
259
+
203
260
  # Rackup file the `kino` CLI loads (positional argument wins).
204
261
  def rackup(path) = @config.set(:rackup, path.to_s)
205
262
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Fires a lifecycle hook and turns a raise into a logged line instead of
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.
10
+ module HookFire
11
+ module_function
12
+
13
+ def fire(hook, name, *args)
14
+ return unless hook
15
+
16
+ begin
17
+ hook.call(*args)
18
+ rescue => e
19
+ Native.log_error("#{name} hook raised #{e.class}: #{e.message}")
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/kino/kino.so CHANGED
Binary file
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # Polls per-slot busy_ms and, past the timeout, quarantines a wedged slot
6
+ # and asks the replacer to spawn a fresh worker. Runs one thread on the
7
+ # main ractor (uncontended by wedged worker ractors, so it stays
8
+ # responsive in :ractor mode). Never interrupts the wedged worker.
9
+ class QuarantineMonitor
10
+ def initialize(server_id:, timeout_ms:, max:, replacer:, tick: 0.5)
11
+ @server_id = server_id
12
+ @timeout_ms = timeout_ms
13
+ @max = max
14
+ @replacer = replacer
15
+ @tick = tick
16
+ @outstanding = 0
17
+ @at_cap_logged = false
18
+ @running = false
19
+ @thread = nil
20
+ end
21
+
22
+ def start
23
+ @running = true
24
+ @thread = Thread.new { run }
25
+ self
26
+ end
27
+
28
+ def stop
29
+ @running = false
30
+ @thread&.join(@tick * 2)
31
+ end
32
+
33
+ private
34
+
35
+ def run
36
+ tick while @running
37
+ rescue => e
38
+ Native.log_error("quarantine monitor crashed: #{e.class}: #{e.message}")
39
+ end
40
+
41
+ def tick
42
+ scan_slots
43
+ rescue => e
44
+ # A bad tick must never kill the monitor.
45
+ Native.log_error("quarantine tick error: #{e.class}: #{e.message}")
46
+ ensure
47
+ sleep @tick
48
+ end
49
+
50
+ def scan_slots
51
+ Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
52
+ next if quarantined || busy_ms <= @timeout_ms
53
+
54
+ if @outstanding >= @max
55
+ unless @at_cap_logged
56
+ Native.log_error("quarantine at cap (#{@max}); serving at reduced capacity")
57
+ @at_cap_logged = true
58
+ end
59
+ next
60
+ end
61
+
62
+ if @replacer.replace(index)
63
+ Native.record_quarantine_replacement(@server_id)
64
+ @outstanding += 1
65
+ @at_cap_logged = false
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -7,19 +7,23 @@ module Kino
7
7
  # ractor, Exception from app code included) wakes it to 500 the in-flight
8
8
  # requests and respawn. Clean exits (queue drained) end supervision.
9
9
  class RactorSupervisor
10
- attr_reader :respawns
11
-
12
- def initialize(server_id, app, workers:, threads:, batch: 1, on_error: nil)
10
+ def initialize(server_id, app, workers:, threads:, batch: 1, hooks: nil, on_worker_exit: nil)
13
11
  @server_id = server_id
14
12
  @app = app
15
13
  @workers = workers
16
14
  @threads = threads
17
15
  @batch = batch
18
- @on_error = on_error
19
- @respawns = 0
16
+ @hooks = hooks
17
+ @on_worker_exit = on_worker_exit
20
18
  @draining = false
21
19
  @lock = Mutex.new
22
20
  @supervisor_threads = []
21
+ @worker_slots = {}
22
+ @slot_to_worker = {}
23
+ @replaced = {}
24
+ # The first replacement's index; `replace` increments before using it,
25
+ # so this starts one below the first free index (@workers).
26
+ @next_worker_index = @workers - 1
23
27
  end
24
28
 
25
29
  def start
@@ -32,20 +36,55 @@ module Kino
32
36
  def shutdown(timeout)
33
37
  @lock.synchronize { @draining = true }
34
38
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
35
- @supervisor_threads.each do |thread|
39
+ @lock.synchronize { @supervisor_threads.dup }.each do |thread|
36
40
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
41
  thread.join([remaining, 0.01].max)
38
42
  end
39
43
  end
40
44
 
41
45
  def done?
42
- @supervisor_threads.none?(&:alive?)
46
+ @lock.synchronize { @supervisor_threads.dup }.none?(&:alive?)
43
47
  end
44
48
 
45
49
  # Block until the workers exit on their own (drain elsewhere): join
46
50
  # without flipping the draining flag.
47
51
  def join
48
- @supervisor_threads.each(&:join)
52
+ @lock.synchronize { @supervisor_threads.dup }.each(&:join)
53
+ end
54
+
55
+ # Replace the ractor owning slot `worker_id`: spawn a fresh supervised
56
+ # ractor, then quarantine the old ractor's slots. The old supervisor
57
+ # thread stays blocked in ractor.value on the wedged ractor (it and the
58
+ # ractor leak until process exit; a wedged ractor cannot be
59
+ # force-killed). Returns true if a replacement was spawned.
60
+ def replace(worker_id)
61
+ worker_index = @lock.synchronize { @slot_to_worker[worker_id] }
62
+ return false unless worker_index
63
+
64
+ # Idempotent per ractor: a stale monitor snapshot can list two sibling
65
+ # slots of the same ractor, only the first replaces it.
66
+ claimed = @lock.synchronize do
67
+ if @replaced.key?(worker_index)
68
+ false
69
+ else
70
+ @replaced[worker_index] = true
71
+ true
72
+ end
73
+ end
74
+ return false unless claimed
75
+
76
+ new_index = @lock.synchronize { @next_worker_index += 1 }
77
+ thread =
78
+ begin
79
+ supervise(new_index) # spawn FIRST, nothing quarantined yet
80
+ rescue
81
+ @lock.synchronize { @replaced.delete(worker_index) } # allow retry next tick
82
+ raise
83
+ end
84
+ slot_ids = @lock.synchronize { @worker_slots[worker_index] } || []
85
+ slot_ids.each { |id| Native.quarantine_slot(@server_id, id) } # quarantine only after success
86
+ @lock.synchronize { @supervisor_threads << thread }
87
+ true
49
88
  end
50
89
 
51
90
  private
@@ -54,20 +93,22 @@ module Kino
54
93
  Thread.new do
55
94
  crashes = 0
56
95
  loop do
57
- ractor, worker_ids = spawn_worker
96
+ ractor, worker_ids = spawn_worker(index)
58
97
  begin
59
98
  ractor.value # blocks until the ractor terminates
99
+ HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: queue drained
60
100
  break # clean exit: queue closed, workers drained
61
101
  rescue Ractor::Error => e
62
102
  # The ractor died mid-flight. Anything it was serving will never
63
103
  # be answered by Ruby: 500 those clients NOW (not when GC gets
64
104
  # around to dropping the dead heap), then decide on respawn.
65
105
  worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
106
+ cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
107
+ HookFire.fire(@on_worker_exit, "on_worker_exit", index, cause)
66
108
  break if draining?
67
109
 
68
110
  crashes += 1
69
- @lock.synchronize { @respawns += 1 }
70
- cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
111
+ Native.record_respawn(@server_id)
71
112
  Native.log_error("worker ractor #{index} crashed (#{cause.class}: #{cause.message}); respawning")
72
113
  # Policy (crash recovery): unlimited respawn
73
114
  # keeps the server up under rare crashes but turns a
@@ -82,15 +123,19 @@ module Kino
82
123
  # Fresh ractor + fresh native slots. Slots are never reused across
83
124
  # respawns: stale interrupt kicks and dead weak refs go down with the
84
125
  # old slot.
85
- def spawn_worker
126
+ def spawn_worker(worker_index)
86
127
  worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
87
- ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @on_error) do |server_id, ids, app, batch, on_error|
128
+ @lock.synchronize do
129
+ @worker_slots[worker_index] = worker_ids
130
+ worker_ids.each { |id| @slot_to_worker[id] = worker_index }
131
+ end
132
+ ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @hooks) do |server_id, ids, app, batch, hooks|
88
133
  ids.map do |id|
89
134
  Thread.new do
90
135
  # Crashes surface via Ractor#value in the supervisor; don't also
91
136
  # spray the backtrace to stderr from inside the dying ractor.
92
137
  Thread.current.report_on_exception = false
93
- Kino::Worker.run(server_id, id, app, batch, on_error)
138
+ Kino::Worker.run(server_id, id, app, batch, hooks)
94
139
  end
95
140
  end.each(&:join)
96
141
  end
data/lib/kino/server.rb CHANGED
@@ -12,6 +12,10 @@ module Kino
12
12
  # port when configured with port 0)
13
13
  attr_reader :port
14
14
 
15
+ # @return [Integer, nil] the control plane's TCP port (nil until #start,
16
+ # when the control plane is off, or for a unix-socket bind)
17
+ attr_reader :control_port
18
+
15
19
  # @return [Symbol] the resolved dispatch mode, :ractor or :threaded
16
20
  attr_reader :mode
17
21
 
@@ -41,8 +45,17 @@ module Kino
41
45
  @bind = settings[:bind]
42
46
  @requested_port = settings[:port]
43
47
  @workers = Integer(settings[:workers])
44
- @on_error = validate_on_error(settings[:on_error])
48
+ @on_error = validate_hook(settings[:on_error], :on_error)
49
+ @after_worker_boot = validate_hook(settings[:after_worker_boot], :after_worker_boot)
50
+ @after_request_complete = validate_hook(settings[:after_request_complete], :after_request_complete)
51
+ @after_boot = validate_hook(settings[:after_boot], :after_boot)
52
+ @on_worker_exit = validate_hook(settings[:on_worker_exit], :on_worker_exit)
45
53
  @mode = resolve_mode(settings[:mode])
54
+ @worker_hooks = WorkerHooks.new(
55
+ on_error: @on_error,
56
+ after_worker_boot: @after_worker_boot,
57
+ after_request_complete: @after_request_complete
58
+ )
46
59
  # Default threads per mode: 1 in :ractor (threads inside a ractor
47
60
  # share its lock; a measured +17% on fast handlers; raise `workers`
48
61
  # for I/O concurrency instead), 3 in :threaded (threads ARE the
@@ -60,8 +73,25 @@ module Kino
60
73
  @tokio_threads = settings[:tokio_threads]
61
74
  @tls = validate_tls(settings[:tls])
62
75
  @pidfile = settings[:pidfile]
76
+ @control_bind = settings[:control_bind]&.to_s
77
+ @control_token = settings[:control_token]&.to_s
78
+ # An empty token (e.g. control_token ENV["KINO_CONTROL_TOKEN"] with the
79
+ # var unset) must not half-disable auth: treat it as auth off, not as
80
+ # "require a zero-length Bearer token".
81
+ @control_token = nil if @control_token && @control_token.empty?
82
+ @quarantine_timeout_ms = settings[:quarantine_timeout] ? (Float(settings[:quarantine_timeout]) * 1000).round : nil
83
+ @quarantine_max =
84
+ if settings[:quarantine_max]
85
+ Integer(settings[:quarantine_max])
86
+ elsif @mode == :ractor
87
+ @workers
88
+ else
89
+ @workers * @threads
90
+ end
63
91
  @worker_threads = []
92
+ @worker_threads_lock = Mutex.new
64
93
  @supervisor = nil
94
+ @quarantine_monitor = nil
65
95
  @started = false
66
96
  end
67
97
 
@@ -78,7 +108,7 @@ module Kino
78
108
  write_pidfile if @pidfile
79
109
  booted = false
80
110
  begin
81
- @id, @port = Native.server_start(
111
+ @id, @port, @control_port = Native.server_start(
82
112
  bind: @bind, port: @requested_port,
83
113
  queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
84
114
  request_timeout_ms: @request_timeout_ms,
@@ -86,7 +116,9 @@ module Kino
86
116
  max_body_size: @max_body_size,
87
117
  tokio_threads: @tokio_threads,
88
118
  tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
89
- lanes: @lanes, log_requests: @log_requests
119
+ lanes: @lanes, log_requests: @log_requests,
120
+ mode: @mode.to_s, workers: @workers, threads: @threads, batch: @batch,
121
+ control_bind: @control_bind, control_token: @control_token
90
122
  )
91
123
  booted = true
92
124
  ensure
@@ -97,13 +129,13 @@ module Kino
97
129
  @pin_keeper = Native.pin_keeper(@id)
98
130
  if @mode == :ractor
99
131
  @supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
100
- batch: @batch, on_error: @on_error).start
132
+ batch: @batch, hooks: @worker_hooks, on_worker_exit: @on_worker_exit).start
101
133
  else
102
- @worker_threads = (@workers * @threads).times.map do
103
- worker_id = Native.register_worker(@id)
104
- Thread.new { Worker.run(@id, worker_id, @app, @batch, @on_error) }
105
- end
134
+ @worker_threads = (@workers * @threads).times.map { spawn_worker_thread }
106
135
  end
136
+ start_quarantine_monitor if @quarantine_timeout_ms
137
+ Native.control_ready(@id)
138
+ HookFire.fire(@after_boot, "after_boot")
107
139
  @started = true
108
140
  self
109
141
  end
@@ -119,6 +151,7 @@ module Kino
119
151
  def shutdown(timeout: nil)
120
152
  return unless @started
121
153
 
154
+ @quarantine_monitor&.stop
122
155
  deadline = monotonic_now + (timeout || @shutdown_timeout)
123
156
  Native.stop_accepting(@id)
124
157
 
@@ -144,6 +177,9 @@ module Kino
144
177
  end
145
178
 
146
179
  Native.shutdown_runtime(@id, 1_000)
180
+ # The control thread reports "draining" for the whole drain and stops
181
+ # only now, once there is nothing left to report.
182
+ Native.control_stop(@id)
147
183
  # The runtime is gone, so hyper has dropped every pinned buffer;
148
184
  # the keeper (and the strings it marked) may now be collected.
149
185
  @pin_keeper = nil
@@ -205,22 +241,88 @@ module Kino
205
241
  #
206
242
  # @return [Hash{Symbol => Object}] mode, lanes, workers, threads,
207
243
  # batch, respawns; plus queued, in_flight, served, rejected,
208
- # timeouts (and lane_depths in lanes mode) once started
244
+ # timeouts, worker_status, quarantined, queue_time (and lane_depths in
245
+ # lanes mode) once started
209
246
  def stats
210
247
  base = {
211
248
  mode: @mode, lanes: @lanes, workers: @workers, threads: @threads,
212
- batch: @batch, respawns: @supervisor ? @supervisor.respawns : 0
249
+ batch: @batch, respawns: 0
213
250
  }
214
251
  return base unless @started
215
252
 
216
- queued, in_flight, served, rejected, timeouts, lane_depths = Native.server_stats(@id)
217
- base.merge!(queued:, in_flight:, served:, rejected:, timeouts:)
253
+ queued, in_flight, served, rejected, timeouts, respawns, lane_depths = Native.server_stats(@id)
254
+ base.merge!(queued:, in_flight:, served:, rejected:, timeouts:, respawns:)
218
255
  base[:lane_depths] = lane_depths if lane_depths
256
+ rows = Native.worker_stats(@id)
257
+ base[:worker_status] = rows.map do |index, served, in_flight, busy_ms, quarantined|
258
+ {index:, served:, in_flight:, busy_ms:, quarantined:}
259
+ end
260
+ base[:quarantined] = rows.count { |_index, _served, _in_flight, _busy_ms, quarantined| quarantined }
261
+ count, sum_seconds = Native.queue_time(@id)
262
+ base[:queue_time] = {count:, sum_seconds:}
219
263
  base
220
264
  end
221
265
 
222
266
  private
223
267
 
268
+ # Register a fresh dispatch slot and run a worker thread on it; returns
269
+ # the thread. Used at boot and by the quarantine replacer.
270
+ def spawn_worker_thread
271
+ worker_id = Native.register_worker(@id)
272
+ Thread.new do
273
+ error = nil
274
+ begin
275
+ Worker.run(@id, worker_id, @app, @batch, @worker_hooks)
276
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a hard crash in a threaded worker thread
277
+ error = e
278
+ raise
279
+ ensure
280
+ HookFire.fire(@on_worker_exit, "on_worker_exit", worker_id, error)
281
+ end
282
+ end
283
+ end
284
+
285
+ # Track a replacement thread spawned outside the initial pool assignment
286
+ # (the quarantine replacer) so shutdown's join/done?/kill sweeps see it.
287
+ def track_replacement_thread(thread)
288
+ @worker_threads_lock.synchronize { @worker_threads << thread }
289
+ end
290
+
291
+ # @private
292
+ # The :threaded-mode quarantine replacer: spawns a replacement worker
293
+ # thread, quarantines the wedged slot, then tracks the new thread so
294
+ # shutdown's join/done?/kill sweeps see it. Built from bound Method
295
+ # objects instead of a server reference, so it drives the server
296
+ # through those methods without send or instance_variable_get.
297
+ class ThreadedReplacer
298
+ def initialize(server_id:, spawner:, tracker:)
299
+ @server_id = server_id
300
+ @spawner = spawner
301
+ @tracker = tracker
302
+ end
303
+
304
+ def replace(worker_id)
305
+ thread = @spawner.call # spawn FIRST (may raise ThreadError)
306
+ Native.quarantine_slot(@server_id, worker_id) # quarantine after success
307
+ @tracker.call(thread)
308
+ true
309
+ end
310
+ end
311
+ private_constant :ThreadedReplacer
312
+
313
+ # A replacer.replace(worker_id) spawns a replacement worker, then
314
+ # quarantines the wedged slot, mode-appropriately. In :ractor the
315
+ # supervisor is the replacer; in :threaded a small object over
316
+ # spawn_worker_thread.
317
+ def start_quarantine_monitor
318
+ replacer = @supervisor || ThreadedReplacer.new(server_id: @id, spawner: method(:spawn_worker_thread),
319
+ tracker: method(:track_replacement_thread))
320
+ @quarantine_monitor = QuarantineMonitor.new(
321
+ server_id: @id, timeout_ms: @quarantine_timeout_ms,
322
+ max: @quarantine_max, replacer: replacer
323
+ ).start
324
+ end
325
+
224
326
  def validate_tls(tls)
225
327
  return nil if tls.nil?
226
328
  unless tls.is_a?(Hash) && tls[:cert] && tls[:key]
@@ -230,10 +332,10 @@ module Kino
230
332
  {cert: String(tls[:cert]), key: String(tls[:key])}
231
333
  end
232
334
 
233
- def validate_on_error(handler)
335
+ def validate_hook(handler, name)
234
336
  return nil if handler.nil?
235
337
  unless handler.respond_to?(:call)
236
- raise ArgumentError, "on_error must respond to #call (got #{handler.class})"
338
+ raise ArgumentError, "#{name} must respond to #call (got #{handler.class})"
237
339
  end
238
340
 
239
341
  handler
@@ -320,7 +422,8 @@ module Kino
320
422
  if @supervisor
321
423
  @supervisor.shutdown([deadline - monotonic_now, 0].max)
322
424
  else
323
- @worker_threads.each do |thread|
425
+ threads = @worker_threads_lock.synchronize { @worker_threads.dup }
426
+ threads.each do |thread|
324
427
  thread.join([deadline - monotonic_now, 0.01].max)
325
428
  end
326
429
  end
@@ -330,7 +433,8 @@ module Kino
330
433
  if @supervisor
331
434
  @supervisor.done?
332
435
  else
333
- @worker_threads.none?(&:alive?)
436
+ threads = @worker_threads_lock.synchronize { @worker_threads.dup }
437
+ threads.none?(&:alive?)
334
438
  end
335
439
  end
336
440
 
@@ -340,7 +444,8 @@ module Kino
340
444
  # by abort_all_inflight. The stuck ractor leaks until process exit.
341
445
  Native.log_error("shutdown deadline passed with stuck ractor workers") unless @supervisor.done?
342
446
  else
343
- @worker_threads.each { |thread| thread.kill if thread.alive? }
447
+ threads = @worker_threads_lock.synchronize { @worker_threads.dup }
448
+ threads.each { |thread| thread.kill if thread.alive? }
344
449
  end
345
450
  end
346
451
 
@@ -361,9 +466,9 @@ module Kino
361
466
  "Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
362
467
  "or mode: :threaded"
363
468
  end
364
- unless @on_error.nil? || Ractor.shareable?(@on_error)
469
+ if (name = unshareable_worker_hook_name)
365
470
  raise Error,
366
- "mode: :ractor requires a Ractor-shareable on_error handler " \
471
+ "mode: :ractor requires a Ractor-shareable #{name} hook " \
367
472
  "(build it with Ractor.shareable_proc, or use mode: :threaded)"
368
473
  end
369
474
  :ractor
@@ -371,8 +476,8 @@ module Kino
371
476
  if !Ractor.shareable?(@app)
372
477
  warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
373
478
  :threaded
374
- elsif !(@on_error.nil? || Ractor.shareable?(@on_error))
375
- warn "Kino: on_error handler is not Ractor-shareable; falling back to mode: :threaded"
479
+ elsif (name = unshareable_worker_hook_name)
480
+ warn "Kino: #{name} hook is not Ractor-shareable; falling back to mode: :threaded"
376
481
  :threaded
377
482
  else
378
483
  :ractor
@@ -381,5 +486,22 @@ module Kino
381
486
  raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
382
487
  end
383
488
  end
489
+
490
+ # The hooks that ride into worker context (a ractor in :ractor mode)
491
+ # and so must be Ractor-shareable there. after_boot and on_worker_exit
492
+ # run on the main thread and are exempt.
493
+ def worker_context_hooks
494
+ [[:on_error, @on_error], [:after_worker_boot, @after_worker_boot],
495
+ [:after_request_complete, @after_request_complete]]
496
+ end
497
+
498
+ # The name of the first worker-context hook that is set but not
499
+ # Ractor-shareable, or nil if all set ones are. Used by both the
500
+ # :ractor raise and the :auto warn/fallback branches, so "first
501
+ # offender" is defined once.
502
+ def unshareable_worker_hook_name
503
+ bad = worker_context_hooks.find { |_name, hook| !(hook.nil? || Ractor.shareable?(hook)) }
504
+ bad&.first
505
+ end
384
506
  end
385
507
  end
@@ -87,6 +87,27 @@
87
87
  # must be Ractor-shareable (build it with Ractor.shareable_proc).
88
88
  # on_error ->(error, env) { ExceptionService.capture(error) }
89
89
 
90
+ # Called once on the main thread after the worker pool is up. Wire
91
+ # readiness here (sd_notify, a "server ready" metric).
92
+ # after_boot { }
93
+
94
+ # Called once inside each worker before it serves, with the worker's slot
95
+ # id. In :ractor mode it runs inside the worker ractor, so it must be
96
+ # Ractor-shareable (build it with Ractor.shareable_proc).
97
+ # after_worker_boot { |worker_id| }
98
+
99
+ # Called inside the worker after each successful response, with (env,
100
+ # status). This is the hot path: leave it unset for zero cost. In :ractor
101
+ # mode it must be Ractor-shareable.
102
+ # after_request_complete { |env, status| }
103
+
104
+ # Called on the main thread when a worker exits, with (worker_index,
105
+ # error). error is the crash cause, or nil on a clean exit. In :ractor
106
+ # mode worker_index identifies the exited ractor (0..workers - 1), a
107
+ # different number space than after_worker_boot's slot id: do not
108
+ # correlate boot and exit by that number in :ractor mode.
109
+ # on_worker_exit { |worker_index, error| }
110
+
90
111
  ## Lifecycle
91
112
 
92
113
  # On shutdown, give in-flight requests this many seconds to finish.
@@ -102,6 +123,36 @@
102
123
  # heavily CPU-bound apps, try 1 to leave more cores for Ruby.
103
124
  # tokio_threads 4
104
125
 
126
+ ## Control plane
127
+
128
+ # Serve read-only monitoring on a separate address: live stats as JSON
129
+ # at /stats, Prometheus text at /metrics, and /ready and /live probes
130
+ # for an orchestrator. Answered by the native layer on its own thread,
131
+ # so it stays live even when every Ruby worker is busy or stuck.
132
+ # Accepts "host:port" or "unix://path". Off unless set.
133
+ # control_bind "127.0.0.1:9293"
134
+
135
+ # When set, /stats and /metrics require "Authorization: Bearer <token>".
136
+ # The probes stay open; they carry no data.
137
+ # control_token ENV["KINO_CONTROL_TOKEN"]
138
+
139
+ ## Quarantine
140
+
141
+ # Quarantine a dispatch slot whose current request has run longer than
142
+ # this many seconds and spawn a replacement to restore capacity. The
143
+ # wedged worker is never interrupted or force-killed; its slot stays
144
+ # quarantined for good (in :ractor mode the ractor itself leaks until
145
+ # process exit, since a wedged ractor cannot be safely interrupted). Off
146
+ # unless set; set it above your slowest legitimate endpoint, and above
147
+ # request_timeout.
148
+ # quarantine_timeout 60
149
+
150
+ # Cap on the total number of replacement events over the process
151
+ # lifetime. Past the cap the monitor stops replacing and the server runs
152
+ # at reduced capacity. Default: the worker count in :ractor mode, workers
153
+ # x threads in :threaded.
154
+ # quarantine_max 8
155
+
105
156
  ## App
106
157
 
107
158
  # Rackup file to load (a command-line argument wins).
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.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
data/lib/kino/worker.rb CHANGED
@@ -18,13 +18,14 @@ module Kino
18
18
 
19
19
  module_function
20
20
 
21
- def run(server_id, worker_id, app, batch_size = 1, on_error = nil)
21
+ def run(server_id, worker_id, app, batch_size = 1, hooks = nil)
22
+ fire_after_worker_boot(hooks, worker_id)
22
23
  if batch_size <= 1
23
24
  env = Native.take_one(server_id, worker_id)
24
- env = handle_one(env, server_id, worker_id, app, on_error) while env
25
+ env = handle_one(env, server_id, worker_id, app, hooks) while env
25
26
  else
26
27
  batch = Native.take_batch(server_id, worker_id, batch_size)
27
- batch = process(batch, server_id, worker_id, app, batch_size, on_error) while batch
28
+ batch = process(batch, server_id, worker_id, app, batch_size, hooks) while batch
28
29
  end
29
30
  end
30
31
 
@@ -34,8 +35,8 @@ module Kino
34
35
  NOT_FUSED = Object.new.freeze
35
36
 
36
37
  # Handle one request; returns the next env (fused take) or nil.
37
- def handle_one(env, server_id, worker_id, app, on_error)
38
- result = serve(env, app, on_error) do |request, status, headers, chunks|
38
+ def handle_one(env, server_id, worker_id, app, hooks)
39
+ result = serve(env, app, hooks) do |request, status, headers, chunks|
39
40
  request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
40
41
  end
41
42
  result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
@@ -43,10 +44,10 @@ module Kino
43
44
 
44
45
  # Handle every env in the batch; returns the next batch (the last
45
46
  # simple response rides the fused respond_and_take) or nil on shutdown.
46
- def process(batch, server_id, worker_id, app, batch_size, on_error)
47
+ def process(batch, server_id, worker_id, app, batch_size, hooks)
47
48
  last = batch.size - 1
48
49
  batch.each_with_index do |env, index|
49
- result = serve(env, app, on_error) do |request, status, headers, chunks|
50
+ result = serve(env, app, hooks) do |request, status, headers, chunks|
50
51
  if index == last
51
52
  request.respond_and_take(server_id, worker_id, batch_size,
52
53
  status, headers, chunks)
@@ -66,17 +67,30 @@ module Kino
66
67
  # here and return NOT_FUSED. App errors must never kill the worker;
67
68
  # hard crashes (Exception) are the supervisor's job; and `abort` does
68
69
  # the right thing whether or not the response head already went out.
69
- def serve(env, app, on_error)
70
+ def serve(env, app, hooks)
70
71
  request = env[KINO_REQUEST]
71
72
  env[RACK_INPUT] ||= Input.new(request)
72
73
  status, headers, body = app.call(env)
73
74
 
74
75
  if body.respond_to?(:to_ary)
75
- result = yield(request, status.to_i, headers, join_chunks(body.to_ary))
76
- body.close if body.respond_to?(:close)
77
- result
76
+ chunks = join_chunks(body.to_ary)
77
+ if hooks&.after_request_complete
78
+ # Hook set: do not fuse. Send the complete response, fire the hook
79
+ # after it is out, then signal the caller to take the next request
80
+ # separately (so the hook never waits on the next request).
81
+ request.send_simple(status.to_i, headers, chunks)
82
+ body.close if body.respond_to?(:close)
83
+ fire_after_request_complete(hooks, env, status.to_i)
84
+ NOT_FUSED
85
+ else
86
+ # No hook: fused fast path, unchanged, zero cost.
87
+ result = yield(request, status.to_i, headers, chunks)
88
+ body.close if body.respond_to?(:close)
89
+ result
90
+ end
78
91
  else
79
92
  deliver_streaming(request, status.to_i, headers, body, env[RACK_INPUT])
93
+ fire_after_request_complete(hooks, env, status.to_i)
80
94
  NOT_FUSED
81
95
  end
82
96
  rescue => e
@@ -87,13 +101,7 @@ module Kino
87
101
  # because nothing may escape this block and kill the worker.
88
102
  Native.log_error(error_log_line(e))
89
103
  request.abort
90
- if on_error
91
- begin
92
- on_error.call(e, env)
93
- rescue => hook_error
94
- Native.log_error("on_error hook raised #{hook_error.class}: #{hook_error.message}")
95
- end
96
- end
104
+ HookFire.fire(hooks&.on_error, "on_error", e, env)
97
105
  NOT_FUSED
98
106
  end
99
107
 
@@ -142,7 +150,16 @@ module Kino
142
150
  joined
143
151
  end
144
152
 
153
+ def fire_after_worker_boot(hooks, worker_id)
154
+ HookFire.fire(hooks&.after_worker_boot, "after_worker_boot", worker_id)
155
+ end
156
+
157
+ def fire_after_request_complete(hooks, env, status)
158
+ HookFire.fire(hooks&.after_request_complete, "after_request_complete", env, status)
159
+ end
160
+
145
161
  private_class_method :handle_one, :process, :serve, :deliver_streaming,
146
- :join_chunks, :error_log_line
162
+ :join_chunks, :error_log_line, :fire_after_worker_boot,
163
+ :fire_after_request_complete
147
164
  end
148
165
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # The worker-context lifecycle hooks, bundled so one frozen, shareable
6
+ # value crosses into each worker (a ractor in :ractor mode) instead of
7
+ # several bare procs. Any member may be nil. A Data instance is frozen,
8
+ # so it is Ractor.shareable? exactly when its members are (nil, or a
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)
11
+ end
data/lib/kino.rb CHANGED
@@ -43,8 +43,11 @@ require_relative "kino/null_input"
43
43
  require_relative "kino/errors_stream"
44
44
  require_relative "kino/stream"
45
45
  require_relative "kino/configuration"
46
+ require_relative "kino/hook_fire"
47
+ require_relative "kino/worker_hooks"
46
48
  require_relative "kino/worker"
47
49
  require_relative "kino/ractor_supervisor"
50
+ require_relative "kino/quarantine_monitor"
48
51
  require_relative "kino/server"
49
52
 
50
53
  # Hand the frozen shareable singletons to the native layer: it sets them
data/sig/kino.rbs CHANGED
@@ -22,6 +22,7 @@ module Kino
22
22
 
23
23
  class Server
24
24
  attr_reader port: Integer?
25
+ attr_reader control_port: Integer?
25
26
  attr_reader mode: Symbol
26
27
  attr_reader bind: String
27
28
 
@@ -98,11 +99,19 @@ module Kino
98
99
  def lanes: (boolish enabled) -> untyped
99
100
  def log_requests: (boolish enabled) -> untyped
100
101
  def on_error: (?^(Exception, Hash[String, untyped]) -> void handler) ?{ (Exception, Hash[String, untyped]) -> void } -> untyped
102
+ def after_boot: (?^() -> void handler) ?{ () -> void } -> untyped
103
+ def after_worker_boot: (?^(Integer) -> void handler) ?{ (Integer) -> void } -> untyped
104
+ def after_request_complete: (?^(Hash[String, untyped], Integer) -> void handler) ?{ (Hash[String, untyped], Integer) -> void } -> untyped
105
+ def on_worker_exit: (?^(Integer, Exception?) -> void handler) ?{ (Integer, Exception?) -> void } -> untyped
101
106
  def shutdown_timeout: (Numeric seconds) -> untyped
102
107
  def tokio_threads: (int count) -> untyped
103
108
  def tls: (cert: String, key: String) -> untyped
104
109
  def environment: (String | Symbol env) -> untyped
105
110
  def pidfile: (String path) -> untyped
111
+ def control_bind: (String addr) -> String
112
+ def control_token: (String token) -> String
113
+ def quarantine_timeout: (Numeric? seconds) -> untyped
114
+ def quarantine_max: (int count) -> untyped
106
115
  def rackup: (String path) -> untyped
107
116
  end
108
117
  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.2.0
4
+ version: 0.3.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-07-13 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -147,16 +147,19 @@ files:
147
147
  - lib/kino/cli.rb
148
148
  - lib/kino/configuration.rb
149
149
  - lib/kino/errors_stream.rb
150
+ - lib/kino/hook_fire.rb
150
151
  - lib/kino/input.rb
151
152
  - lib/kino/kino.so
152
153
  - lib/kino/logger.rb
153
154
  - lib/kino/null_input.rb
155
+ - lib/kino/quarantine_monitor.rb
154
156
  - lib/kino/ractor_supervisor.rb
155
157
  - lib/kino/server.rb
156
158
  - lib/kino/stream.rb
157
159
  - lib/kino/templates/kino.rb.tt
158
160
  - lib/kino/version.rb
159
161
  - lib/kino/worker.rb
162
+ - lib/kino/worker_hooks.rb
160
163
  - sig/kino.rbs
161
164
  homepage: https://github.com/yaroslav/kino
162
165
  licenses: