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.
- checksums.yaml +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/doc/README.md +6 -0
- data/doc/architecture.md +161 -0
- data/doc/benchmarks.md +321 -0
- data/doc/rails-on-ractors.md +50 -0
- data/doc/why-kino.md +91 -0
- data/exe/kino +26 -0
- data/lib/kino/check.rb +199 -0
- data/lib/kino/cli.rb +254 -0
- data/lib/kino/configuration.rb +190 -0
- data/lib/kino/errors_stream.rb +25 -0
- data/lib/kino/input.rb +77 -0
- data/lib/kino/kino.so +0 -0
- data/lib/kino/logger.rb +56 -0
- data/lib/kino/null_input.rb +37 -0
- data/lib/kino/ractor_supervisor.rb +103 -0
- data/lib/kino/server.rb +271 -0
- data/lib/kino/stream.rb +61 -0
- data/lib/kino/templates/kino.rb.tt +141 -0
- data/lib/kino/version.rb +6 -0
- data/lib/kino/worker.rb +124 -0
- data/lib/kino.rb +53 -0
- data/sig/kino.rbs +178 -0
- metadata +193 -0
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
|