kino 0.3.0 → 0.5.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,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/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