kino 0.1.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.
@@ -0,0 +1,82 @@
1
+ //! Test-only natives proving the Phase 0 primitives: blocking channel takes
2
+ //! under `without_gvl`, UBF interruptibility, and ractor-safe method calls.
3
+ //! Exposed as `Kino::Native::_test_*`; not part of the public API.
4
+
5
+ use std::collections::HashMap;
6
+
7
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8
+ use std::sync::OnceLock;
9
+
10
+ use parking_lot::Mutex;
11
+
12
+ use crate::gvl;
13
+
14
+ struct TestChannel {
15
+ tx: Mutex<Option<flume::Sender<String>>>,
16
+ rx: flume::Receiver<String>,
17
+ interrupted: AtomicBool,
18
+ }
19
+
20
+ static CHANNELS: OnceLock<Mutex<HashMap<u64, &'static TestChannel>>> = OnceLock::new();
21
+ static NEXT_ID: AtomicU64 = AtomicU64::new(1);
22
+
23
+ fn channels() -> &'static Mutex<HashMap<u64, &'static TestChannel>> {
24
+ CHANNELS.get_or_init(|| Mutex::new(HashMap::new()))
25
+ }
26
+
27
+ fn lookup(ruby: &magnus::Ruby, id: u64) -> Result<&'static TestChannel, magnus::Error> {
28
+ channels().lock().get(&id).copied().ok_or_else(|| {
29
+ magnus::Error::new(
30
+ ruby.exception_arg_error(),
31
+ format!("unknown test channel {id}"),
32
+ )
33
+ })
34
+ }
35
+
36
+ pub fn create(depth: usize) -> u64 {
37
+ let (tx, rx) = flume::bounded(depth);
38
+ let chan = Box::leak(Box::new(TestChannel {
39
+ tx: Mutex::new(Some(tx)),
40
+ rx,
41
+ interrupted: AtomicBool::new(false),
42
+ }));
43
+ let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
44
+ channels().lock().insert(id, chan);
45
+ id
46
+ }
47
+
48
+ pub fn push(ruby: &magnus::Ruby, id: u64, value: String) -> Result<(), magnus::Error> {
49
+ let chan = lookup(ruby, id)?;
50
+ let guard = chan.tx.lock();
51
+ let Some(tx) = guard.as_ref() else {
52
+ return Err(magnus::Error::new(
53
+ ruby.exception_runtime_error(),
54
+ "test channel is closed",
55
+ ));
56
+ };
57
+ tx.try_send(value).map_err(|e| {
58
+ magnus::Error::new(ruby.exception_runtime_error(), format!("push failed: {e}"))
59
+ })
60
+ }
61
+
62
+ /// Blocking take, GVL released, interruptible. nil = closed or interrupted
63
+ /// (the VM delivers any pending interrupt right after we return to Ruby).
64
+ pub fn take(ruby: &magnus::Ruby, id: u64) -> Result<Option<String>, magnus::Error> {
65
+ let chan = lookup(ruby, id)?;
66
+ chan.interrupted.store(false, Ordering::SeqCst);
67
+
68
+ let taken = gvl::interruptible(&chan.interrupted, || {
69
+ match chan.rx.recv_timeout(crate::queue::TICK) {
70
+ Ok(v) => Some(Some(v)),
71
+ Err(flume::RecvTimeoutError::Timeout) => None,
72
+ Err(flume::RecvTimeoutError::Disconnected) => Some(None),
73
+ }
74
+ });
75
+ Ok(taken.flatten())
76
+ }
77
+
78
+ pub fn close(ruby: &magnus::Ruby, id: u64) -> Result<(), magnus::Error> {
79
+ let chan = lookup(ruby, id)?;
80
+ chan.tx.lock().take();
81
+ Ok(())
82
+ }
@@ -0,0 +1,57 @@
1
+ //! High-resolution sleep for worker code. MRI's own `sleep` parks the
2
+ //! thread on the VM timer, whose wakeups inside non-main ractors are
3
+ //! observably coarse (≈+2.5 ms on Linux). This path instead releases the
4
+ //! GVL and uses the OS timer directly (std::thread::sleep → nanosleep),
5
+ //! which is microsecond-accurate regardless of which ractor calls it.
6
+ //!
7
+ //! Only one bounded chunk is slept per call: the caller (Kino.sleep in
8
+ //! Ruby) loops, so pending VM interrupts are processed between chunks and
9
+ //! Thread#kill / shutdown stay responsive within one tick.
10
+
11
+ use std::ffi::c_void;
12
+ use std::sync::atomic::{AtomicBool, Ordering};
13
+ use std::time::{Duration, Instant};
14
+
15
+ use magnus::{Error, Ruby};
16
+
17
+ use crate::gvl::{self, Ubf};
18
+
19
+ /// Sleep up to `seconds` (capped at one interrupt tick), GVL released.
20
+ /// Returns the seconds actually remaining after this chunk (0.0 = done),
21
+ /// so the Ruby loop knows when to stop.
22
+ pub fn sleep_chunk(ruby: &Ruby, seconds: f64) -> Result<f64, Error> {
23
+ if !seconds.is_finite() || seconds < 0.0 {
24
+ return Err(Error::new(
25
+ ruby.exception_arg_error(),
26
+ "sleep duration must be a non-negative number",
27
+ ));
28
+ }
29
+
30
+ let requested = Duration::from_secs_f64(seconds);
31
+ let chunk = requested.min(crate::queue::TICK);
32
+ let deadline = Instant::now() + chunk;
33
+
34
+ let interrupted = AtomicBool::new(false);
35
+ gvl::without_gvl(
36
+ || {
37
+ // std::thread::sleep retries on EINTR, so the UBF can't cut a
38
+ // sleep short: the flag is only observed once the chunk ends.
39
+ // That bounds interrupt latency at one chunk, which is why the
40
+ // chunk is capped at TICK above.
41
+ while !interrupted.load(Ordering::Relaxed) {
42
+ let now = Instant::now();
43
+ if now >= deadline {
44
+ break;
45
+ }
46
+ std::thread::sleep(deadline - now);
47
+ }
48
+ },
49
+ Some(Ubf {
50
+ func: gvl::ubf_interrupt,
51
+ data: &interrupted as *const _ as *mut c_void,
52
+ }),
53
+ );
54
+
55
+ let remaining = requested.saturating_sub(chunk);
56
+ Ok(remaining.as_secs_f64())
57
+ }
@@ -0,0 +1,96 @@
1
+ //! rustls acceptor construction. Cert/key inputs are either file paths or
2
+ //! inline PEM (detected by the `-----BEGIN` marker), so test fixtures never
3
+ //! need temp files.
4
+
5
+ use std::io::BufReader;
6
+ use std::sync::Arc;
7
+
8
+ use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
9
+ use tokio_rustls::rustls::ServerConfig;
10
+ use tokio_rustls::TlsAcceptor;
11
+
12
+ fn pem_bytes(input: &str) -> Result<Vec<u8>, String> {
13
+ if input.contains("-----BEGIN") {
14
+ Ok(input.as_bytes().to_vec())
15
+ } else {
16
+ std::fs::read(input).map_err(|e| format!("cannot read {input}: {e}"))
17
+ }
18
+ }
19
+
20
+ pub fn build_acceptor(cert: &str, key: &str) -> Result<TlsAcceptor, String> {
21
+ let cert_pem = pem_bytes(cert)?;
22
+ let key_pem = pem_bytes(key)?;
23
+
24
+ let certs: Vec<CertificateDer> = rustls_pemfile::certs(&mut BufReader::new(&cert_pem[..]))
25
+ .collect::<Result<_, _>>()
26
+ .map_err(|e| format!("invalid certificate PEM: {e}"))?;
27
+ if certs.is_empty() {
28
+ return Err("no certificates found in PEM".to_string());
29
+ }
30
+
31
+ let key: PrivateKeyDer = rustls_pemfile::private_key(&mut BufReader::new(&key_pem[..]))
32
+ .map_err(|e| format!("invalid private key PEM: {e}"))?
33
+ .ok_or_else(|| "no private key found in PEM".to_string())?;
34
+
35
+ let mut config = ServerConfig::builder()
36
+ .with_no_client_auth()
37
+ .with_single_cert(certs, key)
38
+ .map_err(|e| format!("TLS config rejected: {e}"))?;
39
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
40
+
41
+ Ok(TlsAcceptor::from(Arc::new(config)))
42
+ }
43
+
44
+ #[cfg(test)]
45
+ mod tests {
46
+ use super::build_acceptor;
47
+
48
+ const CERT: &str = "-----BEGIN CERTIFICATE-----
49
+ MIIBfjCCASWgAwIBAgIUTs9+cVJSzJjy4TSi9YLEd+i4KiIwCgYIKoZIzj0EAwIw
50
+ FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDYxMDE3MjUzNVoYDzIxMjYwNTE3
51
+ MTcyNTM1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO
52
+ PQMBBwNCAAQCgKc2l0PwJW5CAWzp8uW8iIIaGaPkWJ2lRijROyX9v7f8aSQlb6kE
53
+ wKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAao1MwUTAdBgNVHQ4EFgQUsHZa6iIl6Xho
54
+ +EWK6t2Fy9sWAnYwHwYDVR0jBBgwFoAUsHZa6iIl6Xho+EWK6t2Fy9sWAnYwDwYD
55
+ VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiBZM27gueoioJ9YPb+310NI
56
+ vdrY8C5A0QufP/Y1Bm0OrgIgQz+pJX47iTdoINM49gX/6ekLZgmfjwzilJK37E4z
57
+ gw8=
58
+ -----END CERTIFICATE-----";
59
+
60
+ const KEY: &str = "-----BEGIN PRIVATE KEY-----
61
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgSlcfB39H6lv5IqYe
62
+ Q+daLGVt9Bf/i5bf+OnLUKcaZYahRANCAAQCgKc2l0PwJW5CAWzp8uW8iIIaGaPk
63
+ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
64
+ -----END PRIVATE KEY-----";
65
+
66
+ #[test]
67
+ fn inline_pem_builds_an_acceptor() {
68
+ assert!(build_acceptor(CERT, KEY).is_ok());
69
+ }
70
+
71
+ #[test]
72
+ fn missing_file_paths_error_without_panicking() {
73
+ let err = build_acceptor("/nonexistent/cert.pem", "/nonexistent/key.pem")
74
+ .err().expect("missing files");
75
+ assert!(err.contains("cannot read"));
76
+ }
77
+
78
+ #[test]
79
+ fn pem_without_certificates_is_rejected() {
80
+ let err = build_acceptor(KEY, KEY).err().expect("a key is not a cert");
81
+ assert!(err.contains("no certificates found"));
82
+ }
83
+
84
+ #[test]
85
+ fn pem_without_a_key_is_rejected() {
86
+ let err = build_acceptor(CERT, CERT).err().expect("a cert is not a key");
87
+ assert!(err.contains("no private key found"));
88
+ }
89
+
90
+ #[test]
91
+ fn mismatched_cert_and_garbage_key_are_rejected() {
92
+ let err = build_acceptor(CERT, "-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----")
93
+ .err().expect("garbage key");
94
+ assert!(!err.is_empty());
95
+ }
96
+ }
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