kino 0.1.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.
data/lib/kino/check.rb ADDED
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # The shareability doctor behind `kino --check`: explains WHY an app
5
+ # can't run in :ractor mode, instead of leaving you to decode
6
+ # Ractor::IsolationError one ivar at a time.
7
+ #
8
+ # The walk is strictly non-mutating: Ractor.make_shareable would freeze
9
+ # the user's object graph, so we never call it. Instead we recurse into
10
+ # whatever Ractor.shareable? rejects and name the leaves: instance
11
+ # variables by path, proc captures by variable name and definition site,
12
+ # and the class-ivar trap that bites class-style apps (a Class is always
13
+ # "shareable", but reading its unshareable ivars from a worker ractor
14
+ # raises on the first request).
15
+ module Check
16
+ # Stop after this many findings: the first few name the problem.
17
+ MAX_FINDINGS = 20
18
+ # Walk budget, so a pathological object graph cannot hang the check.
19
+ MAX_NODES = 5_000
20
+
21
+ # One named blocker: a path into the object graph plus what is wrong
22
+ # there.
23
+ Finding = Struct.new(:path, :message) do
24
+ # @return [String] "path — message", as printed by the CLI
25
+ def to_s
26
+ "#{path} — #{message}"
27
+ end
28
+ end
29
+
30
+ module_function
31
+
32
+ # @param app [#call] a Rack application (or a Class/Module used as one)
33
+ # @return [Hash] +{shareable: Boolean, findings: Array<Finding>}+
34
+ def report(app)
35
+ findings = []
36
+ seen = {}.compare_by_identity
37
+ budget = {nodes: 0}
38
+
39
+ if app.is_a?(Module)
40
+ # Classes/modules pass Ractor.shareable? unconditionally, but their
41
+ # unshareable class-level state is main-ractor-only at runtime.
42
+ scan_module(app, "app (#{app.inspect})", findings)
43
+ {shareable: findings.empty?, findings: findings}
44
+ elsif Ractor.shareable?(app)
45
+ {shareable: true, findings: []}
46
+ else
47
+ walk(app, "app", findings, seen, budget)
48
+ findings << Finding.new(path: "app", message: unshareable_note(app)) if findings.empty?
49
+ {shareable: false, findings: findings}
50
+ end
51
+ end
52
+
53
+ # Pretty-printed report; returns true when the app is ractor-ready.
54
+ # @param app [#call] a Rack application
55
+ # @param io [IO] where to print
56
+ # @return [Boolean]
57
+ def print_report(app, io: $stdout)
58
+ result = report(app)
59
+ if result[:shareable]
60
+ io.puts CLI.paint("32", "check: app is Ractor-shareable — mode :ractor will work", io: io)
61
+ true
62
+ else
63
+ io.puts CLI.red("check: app is NOT Ractor-shareable", io: io)
64
+ result[:findings].each { |finding| io.puts " - #{finding}" }
65
+ io.puts dim_hint(io)
66
+ false
67
+ end
68
+ end
69
+
70
+ def dim_hint(io)
71
+ CLI.dim(
72
+ " hints: freeze config at boot; build endpoints with " \
73
+ "Ractor.shareable_proc; keep per-worker resources in " \
74
+ "Ractor.store_if_absent; or run mode :threaded.",
75
+ io: io
76
+ )
77
+ end
78
+
79
+ # Recurse into an unshareable object and name its blockers. Shareable
80
+ # objects return immediately, so callers never need their own guard.
81
+ def walk(obj, path, findings, seen, budget)
82
+ return if Ractor.shareable?(obj)
83
+ return if findings.size >= MAX_FINDINGS
84
+ return if seen[obj]
85
+ seen[obj] = true
86
+ return if (budget[:nodes] += 1) > MAX_NODES
87
+
88
+ case obj
89
+ when Proc
90
+ scan_proc(obj, path, findings, seen, budget)
91
+ when Hash
92
+ scan_ivars(obj, path, findings, seen, budget)
93
+ obj.each do |key, value|
94
+ walk(key, "#{path} key #{key.inspect}", findings, seen, budget)
95
+ walk(value, "#{path}[#{key.inspect}]", findings, seen, budget)
96
+ end
97
+ report_leaf(obj, path, findings)
98
+ when Array
99
+ scan_ivars(obj, path, findings, seen, budget)
100
+ obj.each_with_index do |value, index|
101
+ walk(value, "#{path}[#{index}]", findings, seen, budget)
102
+ end
103
+ report_leaf(obj, path, findings)
104
+ else
105
+ scan_ivars(obj, path, findings, seen, budget)
106
+ report_leaf(obj, path, findings)
107
+ end
108
+ end
109
+
110
+ # A finding is recorded only for leaves: unshareable objects whose
111
+ # innards gave us nothing more specific to point at.
112
+ def report_leaf(obj, path, findings)
113
+ return if obj.instance_variables.any? || obj.is_a?(Proc)
114
+ return if (obj.is_a?(Hash) || obj.is_a?(Array)) && !obj.frozen?
115
+
116
+ findings << Finding.new(path: path, message: unshareable_note(obj))
117
+ end
118
+
119
+ def scan_ivars(obj, path, findings, seen, budget)
120
+ obj.instance_variables.each do |name|
121
+ value = obj.instance_variable_get(name)
122
+ next if Ractor.shareable?(value)
123
+
124
+ findings << Finding.new(
125
+ path: "#{path}.#{name}",
126
+ message: unshareable_note(value)
127
+ )
128
+ walk(value, "#{path}.#{name}", findings, seen, budget)
129
+ break if findings.size >= MAX_FINDINGS
130
+ end
131
+ unless obj.frozen? || obj.is_a?(Proc) || obj.is_a?(Module)
132
+ findings << Finding.new(path: path, message: "#{obj.class} instance is not frozen")
133
+ end
134
+ end
135
+
136
+ def scan_proc(proc_obj, path, findings, seen, budget)
137
+ where = proc_obj.source_location&.join(":") || "native"
138
+ binding = begin
139
+ proc_obj.binding
140
+ rescue
141
+ nil
142
+ end
143
+ return unless binding
144
+
145
+ receiver = binding.receiver
146
+ unless Ractor.shareable?(receiver)
147
+ findings << Finding.new(
148
+ path: "#{path} (Proc at #{where})",
149
+ message: "self is not shareable: #{brief(receiver)} — use Ractor.shareable_proc"
150
+ )
151
+ end
152
+ binding.local_variables.each do |name|
153
+ value = binding.local_variable_get(name)
154
+ next if Ractor.shareable?(value)
155
+
156
+ findings << Finding.new(
157
+ path: "#{path} (Proc at #{where})",
158
+ message: "captures `#{name}` = #{brief(value)} (unshareable)"
159
+ )
160
+ walk(value, "#{path} capture `#{name}`", findings, seen, budget)
161
+ break if findings.size >= MAX_FINDINGS
162
+ end
163
+ end
164
+
165
+ def scan_module(mod, path, findings)
166
+ mod.instance_variables.each do |name|
167
+ value = mod.instance_variable_get(name)
168
+ next if Ractor.shareable?(value)
169
+
170
+ findings << Finding.new(
171
+ path: "#{path}.#{name}",
172
+ message: "class-level ivar holds #{brief(value)} — classes pass " \
173
+ "Ractor.shareable?, but reading this from a worker ractor " \
174
+ "raises Ractor::IsolationError on the first request"
175
+ )
176
+ break if findings.size >= MAX_FINDINGS
177
+ end
178
+ end
179
+
180
+ def unshareable_note(obj)
181
+ if obj.frozen?
182
+ "#{brief(obj)} is frozen but holds unshareable contents"
183
+ else
184
+ "#{brief(obj)} is not frozen"
185
+ end
186
+ end
187
+
188
+ def brief(obj)
189
+ inspected = obj.inspect
190
+ inspected = "#{inspected[0, 60]}..." if inspected.size > 60
191
+ "#{inspected} (#{obj.class})"
192
+ rescue
193
+ "#<#{obj.class}>"
194
+ end
195
+
196
+ private_class_method :dim_hint, :walk, :report_leaf, :scan_ivars,
197
+ :scan_proc, :scan_module, :unshareable_note, :brief
198
+ end
199
+ end
data/lib/kino/cli.rb ADDED
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "version"
5
+
6
+ module Kino
7
+ # The `kino` executable (CLI.start) plus startup presentation shared with
8
+ # Server.run: the banner, ANSI styling, and the exit credit. Nothing here
9
+ # is part of the serving API. (The native layer has a twin of `paint` in
10
+ # style.rs for the few places Rust writes to the terminal.)
11
+ #
12
+ # This file deliberately loads no native code: `require "kino"` happens
13
+ # inside the actions that need it, so --help and --version stay instant.
14
+ module CLI
15
+ # The plain banner art: "Kino" in TheDraw's Mindbenders font; {motd}
16
+ # adds the original three-tone shading.
17
+ MOTD = <<~BANNER
18
+ ggg .o
19
+ $$$_,o$$P aaa $$$eea,. .,aaa,.
20
+ %$$`4eP' $$$ $$$``$$$% $$$```$$$
21
+ $$$--`$$o ggg $$$---$$$ $$$---$$$
22
+ $$$ ░ $$$ $$$ $$$ ░ $$$ $$$ ░ $$$
23
+ $$$---$$$ $$$ $$$---$$$ $$$---$$$
24
+ $$$ $$$ $$' $$$ $$$ ^$$aaaS$'
25
+ BANNER
26
+
27
+ # Tone stencil aligned with MOTD, character by character: 1 bright
28
+ # white, 2 light gray, 3 dark gray; spaces follow the art.
29
+ MOTD_TONES = <<~BANNER
30
+ 111 11
31
+ 111111111 111 11111111 1111111
32
+ 11111111 111 111111111 111111111
33
+ 111331111 111 111333111 111333111
34
+ 111 3 322 122 111 3 322 112 3 122
35
+ 111333322 223 111333223 123333223
36
+ 112 233 333 222 333 122233332
37
+ BANNER
38
+
39
+ # The basic-palette SGR code for each stencil tone.
40
+ TONE_SGR = {"1" => "97", "2" => "37", "3" => "90"}.freeze
41
+
42
+ private_constant :MOTD_TONES, :TONE_SGR
43
+
44
+ module_function
45
+
46
+ # True when output to `io` may use ANSI styling.
47
+ # @param io [IO]
48
+ # @return [Boolean]
49
+ def color?(io = $stdout)
50
+ io.tty? && ENV["NO_COLOR"].nil? && ENV["TERM"] != "dumb"
51
+ end
52
+
53
+ # Wrap `text` in an SGR code ("1" bold, "31" red, "38;5;N" 256-color),
54
+ # resetting at the end; plain when `io` is not a color terminal.
55
+ #
56
+ # @param code [String] an SGR code
57
+ # @param text [String]
58
+ # @param io [IO] the stream the text is destined for (gates coloring)
59
+ # @return [String]
60
+ def paint(code, text, io: $stdout)
61
+ color?(io) ? "\e[#{code}m#{text}\e[0m" : text
62
+ end
63
+
64
+ # Startup-output styling, same gray family as the banner.
65
+ # @return [String]
66
+ def dim(text, io: $stdout)
67
+ paint("38;5;243", text, io: io)
68
+ end
69
+
70
+ # Bold styling for headings and the Action!/Fin. bookends.
71
+ # @return [String]
72
+ def bold(text, io: $stdout)
73
+ paint("1", text, io: io)
74
+ end
75
+
76
+ # Errors are red (gated on stderr unless another io is given).
77
+ # @return [String]
78
+ def red(text, io: $stderr)
79
+ paint("91", text, io: io)
80
+ end
81
+
82
+ # The banner with its three-tone shading applied per character.
83
+ # @param color [Boolean]
84
+ # @return [String]
85
+ def motd(color: color?)
86
+ return MOTD unless color
87
+
88
+ MOTD.lines.zip(MOTD_TONES.lines).map do |art, tones|
89
+ current = nil
90
+ line = art.chomp.each_char.with_index.map { |char, i|
91
+ sgr = TONE_SGR[tones.to_s[i]]
92
+ if char != " " && sgr && sgr != current
93
+ current = sgr
94
+ "\e[#{sgr}m#{char}"
95
+ else
96
+ char
97
+ end
98
+ }.join
99
+ "#{line}\e[0m\n"
100
+ end.join
101
+ end
102
+
103
+ # One-line stats dump (the SIGUSR1 handler's output).
104
+ # @param stats [Hash{Symbol => Object}] see {Kino::Server#stats}
105
+ # @return [String]
106
+ def stats_line(stats)
107
+ dim("Kino stats: #{stats.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}")
108
+ end
109
+
110
+ # The two banner halves around Server#start: credits before, the ready
111
+ # block plus a bold "Action!" after, once mode and port are known.
112
+ # Server.run is the one caller; the kino CLI funnels into it.
113
+ # @return [void]
114
+ def opening_credits
115
+ puts motd
116
+ puts dim("\nKino #{VERSION} presents:")
117
+ end
118
+
119
+ # @param server [Kino::Server] a started server
120
+ # @return [void]
121
+ def action!(server)
122
+ puts dim("- mode: #{server.mode}")
123
+ puts dim("- listening: http#{"s" if server.tls?}://#{server.bind}:#{server.port}")
124
+ puts dim("- Ctrl-C to drain and stop")
125
+ puts "\n#{bold("Action!")}\n\n"
126
+ end
127
+
128
+ # Roll credits when the process ends: normal exit or crash (at_exit
129
+ # also runs after an uncaught exception; only a force-exit skips it).
130
+ # @return [void]
131
+ def fin_at_exit
132
+ return if @fin_registered
133
+
134
+ @fin_registered = true
135
+ at_exit { $stdout.puts bold("\nFin.\n") }
136
+ end
137
+
138
+ # The `kino` executable: parse flags, then init/check/serve. Returns
139
+ # the process exit status (exe/kino passes it to Kernel#exit), except
140
+ # for -v and -h, which print and exit in place per optparse convention.
141
+ #
142
+ # @param argv [Array<String>] command-line arguments (consumed)
143
+ # @return [Integer] process exit status
144
+ def start(argv)
145
+ options = {overrides: {}}
146
+ parser = option_parser(options)
147
+ parser.parse!(argv)
148
+
149
+ return write_sample(options[:init_path]) if options[:init_path]
150
+
151
+ config = resolve_config(options)
152
+
153
+ # Precedence for the rackup file: positional arg > `rackup` in config > config.ru
154
+ rackup_file = argv.first || config[:rackup] || "config.ru"
155
+ unless File.exist?(rackup_file)
156
+ warn red("Kino: #{rackup_file} not found")
157
+ puts
158
+ print_help(parser)
159
+ return 1
160
+ end
161
+
162
+ ENV["RACK_ENV"] ||= config[:environment] if config[:environment]
163
+
164
+ app = Rack::Builder.parse_file(rackup_file)
165
+ app = app.first if app.is_a?(Array) # rack < 3 compat
166
+
167
+ return Check.print_report(app) ? 0 : 1 if options[:check]
168
+
169
+ serve(app, config)
170
+ 0
171
+ end
172
+
173
+ # Bun-style colored help, generated from the parser's own switch list
174
+ # so it can never drift from the real options.
175
+ def print_help(parser)
176
+ puts "#{bold("Kino")}#{dim(": high-performance Ractor web server for Ruby")}"
177
+ puts
178
+ puts "#{bold("Usage:")} kino #{paint("36",
179
+ "[options]")} #{paint("36",
180
+ "[rackup file]")}#{dim(" (default: config.ru)")}"
181
+ puts
182
+ puts bold("Options:")
183
+ parser.top.list.each do |switch|
184
+ next unless switch.is_a?(OptionParser::Switch) && switch.desc.any?
185
+
186
+ flags = [*switch.short, *switch.long].join(", ")
187
+ flags += " #{switch.arg.strip}" if switch.arg
188
+ puts " #{paint("36", flags.ljust(24))} #{dim(switch.desc.join(" "))}"
189
+ end
190
+ puts
191
+ puts bold("Examples:")
192
+ puts " #{paint("36", "kino --init")}#{dim(" write a documented kino.rb")}"
193
+ puts " #{paint("36", "kino")}#{dim(" serve config.ru on :9292")}"
194
+ puts " #{paint("36", "kino --check app.ru")}#{dim(" explain Ractor-shareability")}"
195
+ end
196
+
197
+ def option_parser(options)
198
+ OptionParser.new do |opts|
199
+ 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 }
201
+ opts.on("--init [PATH]", "Write a commented sample config (default: kino.rb) and exit") do |v|
202
+ options[:init_path] = v || "kino.rb"
203
+ end
204
+ opts.on("--check", "Load the app and report Ractor-shareability, then exit") { options[:check] = true }
205
+ opts.on("-b", "--bind HOST", "Bind address") { |v| options[:overrides][:bind] = v }
206
+ opts.on("-p", "--port PORT", Integer, "Port") { |v| options[:overrides][:port] = v }
207
+ opts.on("-w", "--workers COUNT", Integer, "Worker count") { |v| options[:overrides][:workers] = v }
208
+ opts.on("-t", "--threads COUNT", Integer, "Threads per worker") { |v| options[:overrides][:threads] = v }
209
+ opts.on("-m", "--mode MODE", "auto | ractor | threaded") { |v| options[:overrides][:mode] = v.to_sym }
210
+ opts.on("-v", "--version") do
211
+ puts "kino #{VERSION}"
212
+ exit
213
+ end
214
+ opts.on_tail("-h", "--help", "Show this help") do
215
+ print_help(opts)
216
+ exit
217
+ end
218
+ end
219
+ end
220
+
221
+ def write_sample(path)
222
+ require "kino"
223
+ Configuration.write_sample(path)
224
+ puts "Kino: wrote sample config to #{path}"
225
+ 0
226
+ rescue Kino::Error => e
227
+ warn red("kino: #{e.message}")
228
+ 1
229
+ end
230
+
231
+ # Resolve the full configuration once: file + CLI flag overrides.
232
+ def resolve_config(options)
233
+ require "kino"
234
+ require "rack"
235
+
236
+ config_file = options[:config_file]
237
+ config_file ||= ("kino.rb" if File.exist?("kino.rb"))
238
+
239
+ config = Configuration.new
240
+ config.load_file(config_file) if config_file
241
+ config.merge!(options[:overrides])
242
+ # Default port 9292 when neither the file nor a flag chose one.
243
+ config.set(:port, 9292) unless config.set?(:port)
244
+ config
245
+ end
246
+
247
+ def serve(app, config)
248
+ Server.run(app, **config.server_options)
249
+ end
250
+
251
+ private_class_method :print_help, :option_parser, :write_sample,
252
+ :resolve_config, :serve
253
+ end
254
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Kino
6
+ # Server settings with Puma-style precedence:
7
+ # explicit Server.new kwargs > config file DSL > defaults.
8
+ class Configuration
9
+ # Every setting and its default; the full reference lives in the
10
+ # generated sample config (`kino --init`).
11
+ DEFAULTS = {
12
+ bind: "127.0.0.1",
13
+ port: 0,
14
+ workers: nil, # resolved to Etc.nprocessors in #to_h
15
+ threads: 3,
16
+ mode: :auto,
17
+ queue_depth: 1024,
18
+ queue_timeout: 1.0,
19
+ request_timeout: nil,
20
+ batch: 1,
21
+ lanes: false,
22
+ log_requests: false,
23
+ shutdown_timeout: 30,
24
+ tokio_threads: nil,
25
+ tls: nil,
26
+ environment: nil,
27
+ pidfile: nil,
28
+ rackup: nil
29
+ }.freeze
30
+
31
+ # The known setting names.
32
+ SETTINGS = DEFAULTS.keys.freeze
33
+
34
+ # Source template for {.sample}.
35
+ SAMPLE_TEMPLATE = File.expand_path("templates/kino.rb.tt", __dir__)
36
+
37
+ # The fully-commented sample config (see `kino --init`).
38
+ # @return [String]
39
+ def self.sample
40
+ File.read(SAMPLE_TEMPLATE)
41
+ end
42
+
43
+ # Write the sample config to +path+. Refuses to clobber an existing
44
+ # file unless force: true.
45
+ #
46
+ # @param path [String]
47
+ # @param force [Boolean] overwrite an existing file
48
+ # @return [String] the path written
49
+ # @raise [Kino::Error] when the file exists and force is false
50
+ def self.write_sample(path, force: false)
51
+ if File.exist?(path) && !force
52
+ raise Error, "#{path} already exists (use force: true to overwrite)"
53
+ end
54
+
55
+ File.write(path, sample)
56
+ path
57
+ end
58
+
59
+ def initialize
60
+ @values = {}
61
+ end
62
+
63
+ # @param key [Symbol] a key from DEFAULTS
64
+ # @return [Object] the explicit value, or the default
65
+ def [](key)
66
+ @values.fetch(key) { DEFAULTS.fetch(key) }
67
+ end
68
+
69
+ # @param key [Symbol] a key from DEFAULTS
70
+ # @param value [Object]
71
+ # @raise [ArgumentError] for unknown settings
72
+ def set(key, value)
73
+ raise ArgumentError, "unknown setting #{key.inspect}" unless DEFAULTS.key?(key)
74
+
75
+ @values[key] = value
76
+ end
77
+
78
+ # @return [Boolean] whether the key was explicitly set
79
+ def set?(key)
80
+ @values.key?(key)
81
+ end
82
+
83
+ # Load a config file (Ruby DSL) into this configuration.
84
+ # @param path [String]
85
+ # @return [self]
86
+ # @raise [Kino::Error] when the file does not exist
87
+ def load_file(path)
88
+ raise Error, "config file not found: #{path}" unless File.exist?(path)
89
+
90
+ DSL.new(self).instance_eval(File.read(path), path, 1)
91
+ self
92
+ end
93
+
94
+ # Explicit kwargs win over everything already set.
95
+ # @param options [Hash{Symbol => Object}]
96
+ # @return [self]
97
+ def merge!(options)
98
+ options.each { |key, value| set(key, value) }
99
+ self
100
+ end
101
+
102
+ # @return [Hash{Symbol => Object}] every setting, defaults filled in
103
+ def to_h
104
+ SETTINGS.to_h { |key| [key, self[key]] }.tap do |h|
105
+ h[:workers] ||= Etc.nprocessors
106
+ end
107
+ end
108
+
109
+ # The settings Server.new accepts: everything except the keys only the
110
+ # CLI consumes (rackup file selection, RACK_ENV).
111
+ # @return [Hash{Symbol => Object}]
112
+ def server_options
113
+ to_h.except(:rackup, :environment)
114
+ end
115
+
116
+ # The config-file DSL, deliberately Puma-shaped:
117
+ #
118
+ # # kino.rb
119
+ # bind "0.0.0.0"
120
+ # port 9292
121
+ # workers 8 # ractors (or thread groups in :threaded mode)
122
+ # threads 3 # threads per worker
123
+ # mode :ractor # :auto | :ractor | :threaded
124
+ # queue_depth 2048
125
+ # queue_timeout 0.5
126
+ # shutdown_timeout 15
127
+ # tokio_threads 4
128
+ # tls cert: "cert.pem", key: "key.pem"
129
+ #
130
+ # Every directive is documented in the generated sample config
131
+ # (`kino --init`); the one-liners here only state the value each
132
+ # directive expects.
133
+ class DSL
134
+ def initialize(config)
135
+ @config = config
136
+ end
137
+
138
+ # Address to listen on ("0.0.0.0" accepts non-local connections).
139
+ def bind(host) = @config.set(:bind, host)
140
+
141
+ # Port to listen on; 0 picks an ephemeral port.
142
+ def port(port) = @config.set(:port, Integer(port))
143
+
144
+ # Worker count (ractors in :ractor mode); defaults to CPU cores.
145
+ def workers(count) = @config.set(:workers, Integer(count))
146
+
147
+ # Threads per worker (I/O concurrency inside one ractor).
148
+ def threads(count) = @config.set(:threads, Integer(count))
149
+
150
+ # Dispatch mode: :auto, :ractor, or :threaded.
151
+ def mode(mode) = @config.set(:mode, mode.to_sym)
152
+
153
+ # Bounded request-queue depth; overflow earns clients a 503.
154
+ def queue_depth(depth) = @config.set(:queue_depth, Integer(depth))
155
+
156
+ # Seconds a request may wait for queue space before the 503.
157
+ def queue_timeout(seconds) = @config.set(:queue_timeout, Float(seconds))
158
+
159
+ # Seconds the app gets before the client receives a 504; nil = off.
160
+ def request_timeout(seconds) = @config.set(:request_timeout, seconds && Float(seconds))
161
+
162
+ # Requests a worker may grab per queue visit (default 1).
163
+ def batch(count) = @config.set(:batch, Integer(count))
164
+
165
+ # EXPERIMENTAL per-worker lane dispatch.
166
+ def lanes(enabled) = @config.set(:lanes, !!enabled)
167
+
168
+ # Native access log: one status-colored line per request to stdout.
169
+ def log_requests(enabled) = @config.set(:log_requests, !!enabled)
170
+
171
+ # Graceful-shutdown drain deadline in seconds.
172
+ def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
173
+
174
+ # Threads for the tokio (Rust I/O) runtime; default: one per core.
175
+ def tokio_threads(count) = @config.set(:tokio_threads, Integer(count))
176
+
177
+ # TLS termination; file paths or inline PEM strings.
178
+ def tls(cert:, key:) = @config.set(:tls, {cert: cert, key: key})
179
+
180
+ # Sets RACK_ENV (unless already set) before the CLI loads the app.
181
+ def environment(env) = @config.set(:environment, env.to_s)
182
+
183
+ # Write the master PID here on start.
184
+ def pidfile(path) = @config.set(:pidfile, path.to_s)
185
+
186
+ # Rackup file the `kino` CLI loads (positional argument wins).
187
+ def rackup(path) = @config.set(:rackup, path.to_s)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kino
4
+ # @private
5
+ # rack.errors: stateless writer into the native logger. Frozen singleton,
6
+ # which also makes it Ractor-shareable; one instance serves all workers.
7
+ class ErrorsStream
8
+ def puts(message)
9
+ Native.log_error(message.to_s)
10
+ nil
11
+ end
12
+
13
+ def write(message)
14
+ message = message.to_s
15
+ Native.log_error(message)
16
+ message.bytesize
17
+ end
18
+
19
+ def flush
20
+ self
21
+ end
22
+
23
+ INSTANCE = new.freeze
24
+ end
25
+ end