kino 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/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 (what the SIGUSR1 handler logs). 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
+ "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
@@ -116,15 +119,27 @@ module Kino
116
119
  puts dim("\nKino #{VERSION} presents:")
117
120
  end
118
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.
119
124
  # @param server [Kino::Server] a started server
120
125
  # @return [void]
121
126
  def action!(server)
122
- puts dim("- mode: #{server.mode}")
123
- 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
124
134
  puts dim("- Ctrl-C to drain and stop")
125
135
  puts "\n#{bold("Action!")}\n\n"
126
136
  end
127
137
 
138
+ # "1 worker", "8 workers".
139
+ def count(number, noun)
140
+ "#{number} #{noun}#{"s" unless number == 1}"
141
+ end
142
+
128
143
  # Roll credits when the process ends: normal exit or crash (at_exit
129
144
  # also runs after an uncaught exception; only a force-exit skips it).
130
145
  # @return [void]
@@ -197,7 +212,7 @@ module Kino
197
212
  def option_parser(options)
198
213
  OptionParser.new do |opts|
199
214
  opts.banner = "Usage: kino [options] [rackup file (default: config.ru)]"
200
- 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 }
201
216
  opts.on("--init [PATH]", "Write a commented sample config (default: kino.rb) and exit") do |v|
202
217
  options[:init_path] = v || "kino.rb"
203
218
  end
@@ -233,14 +248,13 @@ module Kino
233
248
  require "kino"
234
249
  require "rack"
235
250
 
236
- config_file = options[:config_file]
237
- config_file ||= ("kino.rb" if File.exist?("kino.rb"))
251
+ config_file = options[:config_file] || Configuration.default_path
238
252
 
239
253
  config = Configuration.new
240
254
  config.load_file(config_file) if config_file
241
255
  config.merge!(options[:overrides])
242
256
  # Default port 9292 when neither the file nor a flag chose one.
243
- config.set(:port, 9292) unless config.set?(:port)
257
+ config.set(:port, Configuration::DEFAULT_SERVING_PORT) unless config.set?(:port)
244
258
  config
245
259
  end
246
260
 
@@ -249,6 +263,6 @@ module Kino
249
263
  end
250
264
 
251
265
  private_class_method :print_help, :option_parser, :write_sample,
252
- :resolve_config, :serve
266
+ :resolve_config, :serve, :count
253
267
  end
254
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,
@@ -23,11 +21,19 @@ module Kino
23
21
  lanes: false,
24
22
  log_requests: false,
25
23
  on_error: nil,
24
+ after_boot: nil,
25
+ after_worker_boot: nil,
26
+ after_request_complete: nil,
27
+ on_worker_exit: nil,
26
28
  shutdown_timeout: 30,
27
29
  tokio_threads: nil,
28
30
  tls: nil,
29
31
  environment: nil,
30
32
  pidfile: nil,
33
+ control_bind: nil,
34
+ control_token: nil,
35
+ quarantine_timeout: nil,
36
+ quarantine_max: nil,
31
37
  rackup: nil
32
38
  }.freeze
33
39
 
@@ -37,6 +43,21 @@ module Kino
37
43
  # Source template for {.sample}.
38
44
  SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
39
45
 
46
+ # Where the `kino` CLI and the Rack handler look for a config file when
47
+ # none is named: the project root first, then the Rails-style config/.
48
+ DEFAULT_PATHS = %w[kino.rb config/kino.rb].freeze
49
+
50
+ # The port the CLI and the Rack handler serve on when neither a flag
51
+ # nor the file chose one (Server.new itself defaults to an ephemeral
52
+ # port, for embedding).
53
+ DEFAULT_SERVING_PORT = 9292
54
+
55
+ # The first of {DEFAULT_PATHS} that exists in the working directory.
56
+ # @return [String, nil]
57
+ def self.default_path
58
+ DEFAULT_PATHS.find { |path| File.exist?(path) }
59
+ end
60
+
40
61
  # The fully-commented sample config (see `kino --init`).
41
62
  # @return [String]
42
63
  def self.sample
@@ -105,7 +126,7 @@ module Kino
105
126
  # @return [Hash{Symbol => Object}] every setting, defaults filled in
106
127
  def to_h
107
128
  SETTINGS.to_h { |key| [key, self[key]] }.tap do |h|
108
- h[:workers] ||= Etc.nprocessors
129
+ h[:workers] ||= Kino.available_parallelism
109
130
  end
110
131
  end
111
132
 
@@ -138,7 +159,9 @@ module Kino
138
159
  @config = config
139
160
  end
140
161
 
141
- # Address to listen on ("0.0.0.0" accepts non-local connections).
162
+ # Address to listen on: a host ("0.0.0.0" accepts non-local
163
+ # connections), or "unix:///path/to.sock" for a unix domain socket
164
+ # (then `port` is unused).
142
165
  def bind(host) = @config.set(:bind, host)
143
166
 
144
167
  # Port to listen on; 0 picks an ephemeral port.
@@ -185,6 +208,24 @@ module Kino
185
208
  # or a block. Must be Ractor-shareable in :ractor mode.
186
209
  def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
187
210
 
211
+ # Called once on the main thread after the worker pool is up. The
212
+ # readiness seam (wire sd_notify or a "server ready" metric here).
213
+ def after_boot(handler = nil, &block) = @config.set(:after_boot, handler || block)
214
+
215
+ # Called once inside each worker (a ractor in :ractor mode) before it
216
+ # serves, with the worker's slot id. Must be Ractor-shareable in
217
+ # :ractor mode (build it with Ractor.shareable_proc).
218
+ def after_worker_boot(handler = nil, &block) = @config.set(:after_worker_boot, handler || block)
219
+
220
+ # Called inside the worker after each successful response with
221
+ # (env, status). Hot path: leave unset for zero cost. Must be
222
+ # Ractor-shareable in :ractor mode.
223
+ def after_request_complete(handler = nil, &block) = @config.set(:after_request_complete, handler || block)
224
+
225
+ # Called on the main thread when a worker exits, with (worker_index,
226
+ # error_or_nil). error is the crash cause, or nil on a clean exit.
227
+ def on_worker_exit(handler = nil, &block) = @config.set(:on_worker_exit, handler || block)
228
+
188
229
  # Graceful-shutdown drain deadline in seconds.
189
230
  def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
190
231
 
@@ -200,6 +241,37 @@ module Kino
200
241
  # Write the master PID here on start.
201
242
  def pidfile(path) = @config.set(:pidfile, path.to_s)
202
243
 
244
+ # Serve the read-only control plane (live stats as JSON at /stats,
245
+ # Prometheus text at /metrics, /ready and /live probes) on this
246
+ # address: "host:port" or "unix://path". Off unless set.
247
+ def control_bind(addr) = @config.set(:control_bind, addr.to_s)
248
+
249
+ # When set, /stats and /metrics require "Authorization: Bearer <token>".
250
+ # The probes stay open; they carry no data.
251
+ def control_token(token) = @config.set(:control_token, token.to_s)
252
+
253
+ # Quarantine a dispatch slot whose current request has run longer
254
+ # than this many seconds, spawning a replacement to restore capacity.
255
+ # Off unless set. Set it above your slowest legitimate endpoint (and
256
+ # typically above request_timeout).
257
+ def quarantine_timeout(seconds)
258
+ seconds &&= Float(seconds)
259
+ if seconds && seconds <= 0
260
+ raise ArgumentError, "quarantine_timeout must be greater than 0 (got #{seconds})"
261
+ end
262
+ @config.set(:quarantine_timeout, seconds)
263
+ end
264
+
265
+ # Cap on the total number of replacement events over the process
266
+ # lifetime. Past the cap the monitor stops replacing and the server
267
+ # runs at reduced capacity. Default: the worker count in :ractor
268
+ # mode, workers x threads in :threaded.
269
+ def quarantine_max(count)
270
+ count = Integer(count)
271
+ raise ArgumentError, "quarantine_max must be >= 1 (got #{count})" if count < 1
272
+ @config.set(:quarantine_max, count)
273
+ end
274
+
203
275
  # Rackup file the `kino` CLI loads (positional argument wins).
204
276
  def rackup(path) = @config.set(:rackup, path.to_s)
205
277
  end
@@ -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
 
@@ -0,0 +1,22 @@
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
+ # Kino::Log (safe inside worker ractors), so it is safe to call from
8
+ # worker context: no main-ractor state is captured.
9
+ module HookFire
10
+ module_function
11
+
12
+ def fire(hook, name, *args)
13
+ return unless hook
14
+
15
+ begin
16
+ hook.call(*args)
17
+ rescue => e
18
+ Log.error("#{name} hook raised #{e.class}: #{e.message}")
19
+ end
20
+ end
21
+ end
22
+ end
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
@@ -0,0 +1,73 @@
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 do
25
+ Thread.current.name = "quarantine"
26
+ run
27
+ end
28
+ self
29
+ end
30
+
31
+ def stop
32
+ @running = false
33
+ @thread&.join(@tick * 2)
34
+ end
35
+
36
+ private
37
+
38
+ def run
39
+ tick while @running
40
+ rescue => e
41
+ Log.error("quarantine monitor crashed: #{e.class}: #{e.message}")
42
+ end
43
+
44
+ def tick
45
+ scan_slots
46
+ rescue => e
47
+ # A bad tick must never kill the monitor.
48
+ Log.error("quarantine tick error: #{e.class}: #{e.message}")
49
+ ensure
50
+ sleep @tick
51
+ end
52
+
53
+ def scan_slots
54
+ Native.worker_stats(@server_id).each do |index, _served, _in_flight, busy_ms, quarantined|
55
+ next if quarantined || busy_ms <= @timeout_ms
56
+
57
+ if @outstanding >= @max
58
+ unless @at_cap_logged
59
+ Log.warn("quarantine at cap (#{@max}); serving at reduced capacity")
60
+ @at_cap_logged = true
61
+ end
62
+ next
63
+ end
64
+
65
+ if @replacer.replace(index)
66
+ Native.record_quarantine_replacement(@server_id)
67
+ @outstanding += 1
68
+ @at_cap_logged = false
69
+ end
70
+ end
71
+ end
72
+ end
73
+ 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,43 +36,81 @@ 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
52
91
 
53
92
  def supervise(index)
54
93
  Thread.new do
94
+ Thread.current.name = "supervisor-#{index}"
55
95
  crashes = 0
56
96
  loop do
57
- ractor, worker_ids = spawn_worker
97
+ ractor, worker_ids = spawn_worker(index)
58
98
  begin
59
99
  ractor.value # blocks until the ractor terminates
100
+ HookFire.fire(@on_worker_exit, "on_worker_exit", index, nil) # clean exit: queue drained
60
101
  break # clean exit: queue closed, workers drained
61
102
  rescue Ractor::Error => e
62
103
  # The ractor died mid-flight. Anything it was serving will never
63
104
  # be answered by Ruby: 500 those clients NOW (not when GC gets
64
105
  # around to dropping the dead heap), then decide on respawn.
65
106
  worker_ids.each { |id| Native.abort_inflight(@server_id, id) }
107
+ cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
108
+ HookFire.fire(@on_worker_exit, "on_worker_exit", index, cause)
66
109
  break if draining?
67
110
 
68
111
  crashes += 1
69
- @lock.synchronize { @respawns += 1 }
70
- cause = (e.respond_to?(:cause) && e.cause) ? e.cause : e
71
- Native.log_error("worker ractor #{index} crashed (#{cause.class}: #{cause.message}); respawning")
112
+ Native.record_respawn(@server_id)
113
+ Log.error("worker-#{index} crashed (#{cause.class}: #{cause.message}); respawning")
72
114
  # Policy (crash recovery): unlimited respawn
73
115
  # keeps the server up under rare crashes but turns a
74
116
  # crash-on-every-request bug into a busy loop. A circuit breaker
@@ -82,15 +124,23 @@ module Kino
82
124
  # Fresh ractor + fresh native slots. Slots are never reused across
83
125
  # respawns: stale interrupt kicks and dead weak refs go down with the
84
126
  # old slot.
85
- def spawn_worker
127
+ def spawn_worker(worker_index)
86
128
  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|
88
- ids.map do |id|
129
+ @lock.synchronize do
130
+ @worker_slots[worker_index] = worker_ids
131
+ worker_ids.each { |id| @slot_to_worker[id] = worker_index }
132
+ end
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|
89
138
  Thread.new do
90
139
  # Crashes surface via Ractor#value in the supervisor; don't also
91
140
  # spray the backtrace to stderr from inside the dying ractor.
92
141
  Thread.current.report_on_exception = false
93
- Kino::Worker.run(server_id, id, app, batch, on_error)
142
+ Thread.current.name = "thread-#{position + 1}" if ids.size > 1
143
+ Kino::Worker.run(server_id, id, app, batch, hooks)
94
144
  end
95
145
  end.each(&:join)
96
146
  end