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,318 @@
1
+ //! One-time cache of frozen Ruby strings for the per-request env hash.
2
+ //!
3
+ //! Frozen strings are Ractor-shareable, so a single cache built on the main
4
+ //! ractor at init serves every worker ractor forever. Frozen Hash keys are
5
+ //! also stored without the dup `Hash#[]=` does for unfrozen string keys.
6
+ //! This removes the bulk of per-request allocations: every static key, the
7
+ //! method/protocol/scheme values, and the `HTTP_*` names of all common
8
+ //! request headers.
9
+ //!
10
+ //! `Opaque<RString>` is magnus's sanctioned way to keep Ruby values in
11
+ //! statics (it is Send + Sync); GC registration makes them immortal. Built
12
+ //! once, with the GVL held, on the main ractor; read-only afterwards.
13
+
14
+ use std::net::IpAddr;
15
+ use std::sync::OnceLock;
16
+
17
+ use magnus::value::Opaque;
18
+ use magnus::{gc, prelude::*, RString, Ruby, Value};
19
+ use parking_lot::{Mutex, RwLock};
20
+
21
+ /// These maps are probed several times per request; ahash beats the
22
+ /// DoS-resistant default since every key here is our own static data.
23
+ type HashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
24
+
25
+ pub struct EnvStrings {
26
+ // keys
27
+ pub request_method: Opaque<RString>,
28
+ pub script_name: Opaque<RString>,
29
+ pub path_info: Opaque<RString>,
30
+ pub query_string: Opaque<RString>,
31
+ pub server_protocol: Opaque<RString>,
32
+ pub server_name: Opaque<RString>,
33
+ pub server_port: Opaque<RString>,
34
+ pub remote_addr: Opaque<RString>,
35
+ pub content_type: Opaque<RString>,
36
+ pub content_length: Opaque<RString>,
37
+ pub rack_url_scheme: Opaque<RString>,
38
+ pub rack_input: Opaque<RString>,
39
+ pub rack_errors: Opaque<RString>,
40
+ pub kino_request: Opaque<RString>,
41
+ // values
42
+ pub empty: Opaque<RString>,
43
+ pub http: Opaque<RString>,
44
+ pub https: Opaque<RString>,
45
+ pub http10: Opaque<RString>,
46
+ pub http11: Opaque<RString>,
47
+ pub methods: HashMap<&'static str, Opaque<RString>>,
48
+ /// lowercase header name -> frozen "HTTP_<UPPER>" key
49
+ pub header_names: HashMap<&'static str, Opaque<RString>>,
50
+ /// Host-header bytes -> frozen (SERVER_NAME, SERVER_PORT) values, and
51
+ /// peer IP -> frozen REMOTE_ADDR value. Real traffic has low
52
+ /// cardinality on both, so these kill 3 string allocations per request.
53
+ /// LRU-bounded: entries are BoxValue-rooted (registered with the GC on
54
+ /// insert, UNregistered on eviction-drop), so a rotating-host attack
55
+ /// recycles cache slots instead of leaking immortal strings.
56
+ pub hosts: Mutex<lru::LruCache<Vec<u8>, (CachedStr, CachedStr), ahash::RandomState>>,
57
+ pub addrs: Mutex<lru::LruCache<IpAddr, CachedStr, ahash::RandomState>>,
58
+ /// Ractor-shareable defaults provided by the Ruby layer at boot:
59
+ /// the frozen rack.errors writer and the frozen null rack.input.
60
+ pub errors_stream: RwLock<Option<Opaque<Value>>>,
61
+ pub null_input: RwLock<Option<Opaque<Value>>>,
62
+ }
63
+
64
+ const HOST_CACHE_CAP: usize = 256;
65
+ const ADDR_CACHE_CAP: usize = 1024;
66
+
67
+ /// A frozen RString rooted via BoxValue (GC-registered address; unregisters
68
+ /// on Drop, so LRU eviction actually frees the string).
69
+ ///
70
+ /// SAFETY of Send + Sync: the contents are frozen Ruby strings (therefore
71
+ /// Ractor-shareable), and creation, reads, and drops all happen while the
72
+ /// calling thread holds its GVL (native method context) AND the owning
73
+ /// cache Mutex; readers root the value into a live env Hash before
74
+ /// releasing the lock, so an eviction on another ractor can never free a
75
+ /// string that a reader still holds unrooted.
76
+ pub struct CachedStr(magnus::value::BoxValue<RString>);
77
+ unsafe impl Send for CachedStr {}
78
+ unsafe impl Sync for CachedStr {}
79
+
80
+ impl CachedStr {
81
+ fn new(ruby: &Ruby, s: &str) -> Self {
82
+ let string = ruby.str_new(s);
83
+ string.freeze();
84
+ CachedStr(magnus::value::BoxValue::new(string))
85
+ }
86
+
87
+ fn get(&self) -> RString {
88
+ *self.0
89
+ }
90
+ }
91
+
92
+ static ENV_STRINGS: OnceLock<EnvStrings> = OnceLock::new();
93
+
94
+ const COMMON_METHODS: &[&str] = &[
95
+ "GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE", "CONNECT",
96
+ ];
97
+
98
+ /// Headers worth pre-caching: hyper lowercases names, so these match the
99
+ /// wire form of essentially all browser/proxy/SDK traffic.
100
+ const COMMON_HEADERS: &[&str] = &[
101
+ "host",
102
+ "connection",
103
+ "user-agent",
104
+ "accept",
105
+ "accept-encoding",
106
+ "accept-language",
107
+ "accept-charset",
108
+ "cookie",
109
+ "referer",
110
+ "origin",
111
+ "authorization",
112
+ "cache-control",
113
+ "pragma",
114
+ "expect",
115
+ "forwarded",
116
+ "via",
117
+ "range",
118
+ "te",
119
+ "dnt",
120
+ "upgrade-insecure-requests",
121
+ "if-none-match",
122
+ "if-modified-since",
123
+ "if-match",
124
+ "if-unmodified-since",
125
+ "if-range",
126
+ "x-forwarded-for",
127
+ "x-forwarded-proto",
128
+ "x-forwarded-host",
129
+ "x-forwarded-port",
130
+ "x-real-ip",
131
+ "x-request-id",
132
+ "x-requested-with",
133
+ "x-csrf-token",
134
+ "x-api-key",
135
+ "content-encoding",
136
+ "content-language",
137
+ "sec-fetch-dest",
138
+ "sec-fetch-mode",
139
+ "sec-fetch-site",
140
+ "sec-fetch-user",
141
+ "sec-ch-ua",
142
+ "sec-ch-ua-mobile",
143
+ "sec-ch-ua-platform",
144
+ "keep-alive",
145
+ ];
146
+
147
+ pub fn cgi_name(lower: &str) -> String {
148
+ let mut key = String::with_capacity(5 + lower.len());
149
+ key.push_str("HTTP_");
150
+ for ch in lower.chars() {
151
+ key.push(match ch {
152
+ '-' => '_',
153
+ c => c.to_ascii_uppercase(),
154
+ });
155
+ }
156
+ key
157
+ }
158
+
159
+ fn frozen(ruby: &Ruby, s: &str) -> Opaque<RString> {
160
+ let string = ruby.str_new(s);
161
+ string.freeze();
162
+ gc::register_mark_object(string);
163
+ Opaque::from(string)
164
+ }
165
+
166
+ /// Build the cache. Main ractor, GVL held, before any worker exists.
167
+ pub fn init(ruby: &Ruby) {
168
+ let methods = COMMON_METHODS
169
+ .iter()
170
+ .map(|m| (*m, frozen(ruby, m)))
171
+ .collect::<HashMap<_, _>>();
172
+ let header_names = COMMON_HEADERS
173
+ .iter()
174
+ .map(|h| (*h, frozen(ruby, &cgi_name(h))))
175
+ .collect::<HashMap<_, _>>();
176
+
177
+ let strings = EnvStrings {
178
+ request_method: frozen(ruby, "REQUEST_METHOD"),
179
+ script_name: frozen(ruby, "SCRIPT_NAME"),
180
+ path_info: frozen(ruby, "PATH_INFO"),
181
+ query_string: frozen(ruby, "QUERY_STRING"),
182
+ server_protocol: frozen(ruby, "SERVER_PROTOCOL"),
183
+ server_name: frozen(ruby, "SERVER_NAME"),
184
+ server_port: frozen(ruby, "SERVER_PORT"),
185
+ remote_addr: frozen(ruby, "REMOTE_ADDR"),
186
+ content_type: frozen(ruby, "CONTENT_TYPE"),
187
+ content_length: frozen(ruby, "CONTENT_LENGTH"),
188
+ rack_url_scheme: frozen(ruby, "rack.url_scheme"),
189
+ rack_input: frozen(ruby, "rack.input"),
190
+ rack_errors: frozen(ruby, "rack.errors"),
191
+ kino_request: frozen(ruby, "kino.request"),
192
+ empty: frozen(ruby, ""),
193
+ http: frozen(ruby, "http"),
194
+ https: frozen(ruby, "https"),
195
+ http10: frozen(ruby, "HTTP/1.0"),
196
+ http11: frozen(ruby, "HTTP/1.1"),
197
+ methods,
198
+ header_names,
199
+ hosts: Mutex::new(lru::LruCache::with_hasher(
200
+ std::num::NonZeroUsize::new(HOST_CACHE_CAP).unwrap(),
201
+ ahash::RandomState::new(),
202
+ )),
203
+ addrs: Mutex::new(lru::LruCache::with_hasher(
204
+ std::num::NonZeroUsize::new(ADDR_CACHE_CAP).unwrap(),
205
+ ahash::RandomState::new(),
206
+ )),
207
+ errors_stream: RwLock::new(None),
208
+ null_input: RwLock::new(None),
209
+ };
210
+ let _ = ENV_STRINGS.set(strings);
211
+ }
212
+
213
+ pub fn get() -> &'static EnvStrings {
214
+ ENV_STRINGS.get().expect("env_strings::init not called")
215
+ }
216
+
217
+ /// Called once from lib/kino.rb (main ractor) with the frozen,
218
+ /// Ractor-shareable singletons the Ruby layer owns.
219
+ pub fn register_defaults(
220
+ ruby: &Ruby,
221
+ errors: Value,
222
+ null_input: Value,
223
+ ) -> Result<(), magnus::Error> {
224
+ for value in [errors, null_input] {
225
+ if !value.is_frozen() {
226
+ return Err(magnus::Error::new(
227
+ ruby.exception_arg_error(),
228
+ "register_defaults expects frozen objects",
229
+ ));
230
+ }
231
+ }
232
+ gc::register_mark_object(errors);
233
+ gc::register_mark_object(null_input);
234
+ let s = get();
235
+ *s.errors_stream.write() = Some(Opaque::from(errors));
236
+ *s.null_input.write() = Some(Opaque::from(null_input));
237
+ Ok(())
238
+ }
239
+
240
+ /// Set SERVER_NAME/SERVER_PORT on `env` from the LRU host cache, building
241
+ /// (and caching) frozen values on miss. The aset happens UNDER the cache
242
+ /// lock; see CachedStr's safety contract.
243
+ pub fn set_host_env(
244
+ ruby: &Ruby,
245
+ env: magnus::RHash,
246
+ host: &[u8],
247
+ make: impl FnOnce() -> (String, u16),
248
+ ) -> Result<(), magnus::Error> {
249
+ let s = get();
250
+ let mut hosts = s.hosts.lock();
251
+ let (name, port) = match hosts.get(host) {
252
+ Some((name, port)) => (name.get(), port.get()),
253
+ None => {
254
+ let (name_s, port_n) = make();
255
+ let entry = (
256
+ CachedStr::new(ruby, &name_s),
257
+ CachedStr::new(ruby, &port_n.to_string()),
258
+ );
259
+ let values = (entry.0.get(), entry.1.get());
260
+ hosts.put(host.to_vec(), entry); // may evict + free an old pair
261
+ values
262
+ }
263
+ };
264
+ env.aset(ruby.get_inner(s.server_name), name)?;
265
+ env.aset(ruby.get_inner(s.server_port), port)?;
266
+ Ok(())
267
+ }
268
+
269
+ /// Set REMOTE_ADDR on `env` from the LRU peer-IP cache; same locking
270
+ /// contract as set_host_env.
271
+ pub fn set_addr_env(ruby: &Ruby, env: magnus::RHash, ip: IpAddr) -> Result<(), magnus::Error> {
272
+ let s = get();
273
+ let mut addrs = s.addrs.lock();
274
+ let value = match addrs.get(&ip) {
275
+ Some(cached) => cached.get(),
276
+ None => {
277
+ let entry = CachedStr::new(ruby, &ip.to_string());
278
+ let value = entry.get();
279
+ addrs.put(ip, entry);
280
+ value
281
+ }
282
+ };
283
+ env.aset(ruby.get_inner(s.remote_addr), value)?;
284
+ Ok(())
285
+ }
286
+
287
+ #[cfg(test)]
288
+ mod tests {
289
+ use super::{cgi_name, COMMON_HEADERS, COMMON_METHODS};
290
+
291
+ #[test]
292
+ fn cgi_names() {
293
+ assert_eq!(cgi_name("x-request-id"), "HTTP_X_REQUEST_ID");
294
+ assert_eq!(cgi_name("host"), "HTTP_HOST");
295
+ assert_eq!(cgi_name(""), "HTTP_");
296
+ assert_eq!(cgi_name("sec-ch-ua"), "HTTP_SEC_CH_UA");
297
+ }
298
+
299
+ // The cache is keyed by hyper's lowercase wire form; one uppercase
300
+ // entry would silently never hit.
301
+ #[test]
302
+ fn common_headers_are_lowercase_and_unique() {
303
+ let mut seen = std::collections::HashSet::new();
304
+ for header in COMMON_HEADERS {
305
+ assert_eq!(*header, header.to_ascii_lowercase(), "{header} must be lowercase");
306
+ assert!(seen.insert(*header), "{header} listed twice");
307
+ }
308
+ }
309
+
310
+ #[test]
311
+ fn common_methods_are_uppercase_and_unique() {
312
+ let mut seen = std::collections::HashSet::new();
313
+ for method in COMMON_METHODS {
314
+ assert_eq!(*method, method.to_ascii_uppercase(), "{method} must be uppercase");
315
+ assert!(seen.insert(*method), "{method} listed twice");
316
+ }
317
+ }
318
+ }
@@ -0,0 +1,103 @@
1
+ //! Releasing the GVL (per-ractor VM lock) around blocking Rust calls.
2
+ //!
3
+ //! magnus does not wrap `rb_thread_call_without_gvl`, so this is the one
4
+ //! module that talks to rb-sys directly. Everything blocking (queue pops,
5
+ //! body reads/writes) must go through `without_gvl` so other Ruby threads
6
+ //! in the same ractor keep running and the VM can interrupt us via the UBF.
7
+
8
+ use std::ffi::c_void;
9
+
10
+ /// An unblock function: called by the Ruby VM from another thread when it
11
+ /// needs to interrupt the blocking region (Thread#kill, VM shutdown, ...).
12
+ /// Implementations must be async-signal-safe in spirit: no locks, no Ruby
13
+ /// API. A single lock-free channel send is the intended use.
14
+ pub struct Ubf {
15
+ pub func: unsafe extern "C" fn(*mut c_void),
16
+ pub data: *mut c_void,
17
+ }
18
+
19
+ unsafe extern "C" fn trampoline<F, R>(arg: *mut c_void) -> *mut c_void
20
+ where
21
+ F: FnOnce() -> R,
22
+ {
23
+ let slot = unsafe { &mut *(arg as *mut (Option<F>, Option<R>)) };
24
+ let f = slot.0.take().expect("without_gvl trampoline called twice");
25
+ slot.1 = Some(f());
26
+ std::ptr::null_mut()
27
+ }
28
+
29
+ /// Run `f` with the GVL released. Blocks the current Ruby thread but lets
30
+ /// every other Ruby thread (in this ractor) run in parallel.
31
+ ///
32
+ /// `f` MUST NOT touch any Ruby API. If `ubf` fires, `f` is responsible for
33
+ /// noticing (e.g. a message on an interrupt channel) and returning promptly;
34
+ /// pending VM interrupts are then delivered once we're back in Ruby.
35
+ pub fn without_gvl<F, R>(f: F, ubf: Option<Ubf>) -> R
36
+ where
37
+ F: FnOnce() -> R,
38
+ {
39
+ let mut slot: (Option<F>, Option<R>) = (Some(f), None);
40
+ let (ubf_func, ubf_data) = match ubf {
41
+ Some(u) => (Some(u.func), u.data),
42
+ None => (None, std::ptr::null_mut()),
43
+ };
44
+ unsafe {
45
+ rb_sys::rb_thread_call_without_gvl(
46
+ Some(trampoline::<F, R>),
47
+ &mut slot as *mut _ as *mut c_void,
48
+ ubf_func,
49
+ ubf_data,
50
+ );
51
+ }
52
+ slot.1.expect("without_gvl block did not run")
53
+ }
54
+
55
+ /// The standard UBF used across this crate: `data` points at an `AtomicBool`
56
+ /// owned by a `WorkerSlot` kept alive by the caller's stack frame (an Arc
57
+ /// clone held across the blocking call). The blocked region polls the flag
58
+ /// between bounded waits, so a store here unblocks it within one tick.
59
+ pub unsafe extern "C" fn ubf_interrupt(data: *mut c_void) {
60
+ let flag = unsafe { &*(data as *const std::sync::atomic::AtomicBool) };
61
+ flag.store(true, std::sync::atomic::Ordering::SeqCst);
62
+ }
63
+
64
+ /// The crate's one blocking idiom: release the GVL and run `attempt` in a
65
+ /// loop (`Some(T)` finishes, `None` means "tick elapsed, go around"), and
66
+ /// wake within a tick when `flag` is raised (by the VM's UBF or by
67
+ /// interrupt_all_workers). Returns None on interruption. `attempt` must
68
+ /// bound each wait (recv_timeout-style) and must not touch the Ruby API.
69
+ pub fn interruptible<T>(
70
+ flag: &std::sync::atomic::AtomicBool,
71
+ mut attempt: impl FnMut() -> Option<T>,
72
+ ) -> Option<T> {
73
+ use std::sync::atomic::Ordering;
74
+
75
+ without_gvl(
76
+ || loop {
77
+ if flag.load(Ordering::SeqCst) {
78
+ return None;
79
+ }
80
+ if let Some(result) = attempt() {
81
+ return Some(result);
82
+ }
83
+ },
84
+ Some(Ubf {
85
+ func: ubf_interrupt,
86
+ data: flag as *const _ as *mut c_void,
87
+ }),
88
+ )
89
+ }
90
+
91
+ #[cfg(test)]
92
+ mod tests {
93
+ use std::sync::atomic::{AtomicBool, Ordering};
94
+
95
+ #[test]
96
+ fn ubf_sets_the_interrupt_flag() {
97
+ let flag = AtomicBool::new(false);
98
+ unsafe {
99
+ super::ubf_interrupt(&flag as *const _ as *mut std::ffi::c_void);
100
+ }
101
+ assert!(flag.load(Ordering::SeqCst));
102
+ }
103
+ }
@@ -0,0 +1,90 @@
1
+ // All Rust-side allocations (request/response buffers, hyper, tokio,
2
+ // channels) go through mimalloc: ~+10% plaintext throughput on Linux over
3
+ // glibc malloc, no downside measured elsewhere.
4
+ #[global_allocator]
5
+ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
6
+
7
+ mod env_strings;
8
+ mod gvl;
9
+ mod logsink;
10
+ mod queue;
11
+ mod registry;
12
+ mod request;
13
+ mod response;
14
+ mod server;
15
+ mod style;
16
+ mod test_support;
17
+ mod timer;
18
+ mod tls;
19
+
20
+ use magnus::{function, method, prelude::*, Error, Ruby};
21
+
22
+ use crate::request::Request;
23
+
24
+ #[magnus::init]
25
+ fn init(ruby: &Ruby) -> Result<(), Error> {
26
+ // Must come before any method definition: methods defined while this
27
+ // flag is unset raise Ractor::UnsafeError when called from a non-main
28
+ // ractor, and worker ractors are this gem's entire reason to exist.
29
+ unsafe { rb_sys::rb_ext_ractor_safe(true) };
30
+
31
+ let module = ruby.define_module("Kino")?;
32
+
33
+ let native = module.define_module("Native")?;
34
+ native.define_singleton_method("server_start", function!(server::server_start, 1))?;
35
+ native.define_singleton_method("register_worker", function!(server::register_worker, 1))?;
36
+ native.define_singleton_method("take_one", function!(queue::take_one, 2))?;
37
+ native.define_singleton_method("take_batch", function!(queue::take_batch, 3))?;
38
+ native.define_singleton_method("stop_accepting", function!(server::stop_accepting, 1))?;
39
+ native.define_singleton_method("close_queue", function!(server::close_queue, 1))?;
40
+ native.define_singleton_method("queue_stats", function!(server::queue_stats, 1))?;
41
+ native.define_singleton_method("server_stats", function!(server::server_stats, 1))?;
42
+ native.define_singleton_method("abort_inflight", function!(server::abort_inflight, 2))?;
43
+ native.define_singleton_method(
44
+ "abort_all_inflight",
45
+ function!(server::abort_all_inflight, 1),
46
+ )?;
47
+ native.define_singleton_method(
48
+ "interrupt_all_workers",
49
+ function!(server::interrupt_all_workers, 1),
50
+ )?;
51
+ native.define_singleton_method("shutdown_runtime", function!(server::shutdown_runtime, 2))?;
52
+ native.define_singleton_method("log_error", function!(server::log_error, 1))?;
53
+ native.define_singleton_method("sleep_chunk", function!(timer::sleep_chunk, 1))?;
54
+ native.define_singleton_method("log_device_open", function!(logsink::device_open, 1))?;
55
+ native.define_singleton_method("log_device_write", function!(logsink::device_write, 2))?;
56
+ native.define_singleton_method("log_device_close", function!(logsink::device_close, 1))?;
57
+ native.define_singleton_method(
58
+ "register_defaults",
59
+ function!(env_strings::register_defaults, 2),
60
+ )?;
61
+
62
+ let request = native.define_class("Request", ruby.class_object())?;
63
+ request.define_method("respond_and_take", method!(queue::respond_and_take, 6))?;
64
+ request.define_method(
65
+ "respond_and_take_one",
66
+ method!(queue::respond_and_take_one, 5),
67
+ )?;
68
+ request.define_method("read_body", method!(Request::read_body, 1))?;
69
+ request.define_method("send_simple", method!(crate::request::respond_simple, 3))?;
70
+ request.define_method("send_headers", method!(Request::send_headers, 2))?;
71
+ request.define_method("write_chunk", method!(Request::write_chunk, 1))?;
72
+ request.define_method("finish", method!(Request::finish, 0))?;
73
+ request.define_method("abort", method!(Request::abort, 0))?;
74
+
75
+ // Force-resolve the TypedData class cache on the main ractor: magnus
76
+ // resolves it lazily on first wrap, and a racy first resolution from two
77
+ // worker ractors is the failure mode we must rule out.
78
+ let _ = <Request as magnus::TypedData>::class(ruby);
79
+
80
+ // Frozen env key/value cache: built once here (main ractor, GVL held),
81
+ // shared by every worker ractor afterwards.
82
+ env_strings::init(ruby);
83
+
84
+ native.define_singleton_method("_test_channel_create", function!(test_support::create, 1))?;
85
+ native.define_singleton_method("_test_push", function!(test_support::push, 2))?;
86
+ native.define_singleton_method("_test_take", function!(test_support::take, 1))?;
87
+ native.define_singleton_method("_test_close", function!(test_support::close, 1))?;
88
+
89
+ Ok(())
90
+ }
@@ -0,0 +1,155 @@
1
+ //! Asynchronous log sink: callers push complete lines onto a lock-free
2
+ //! channel and return immediately; one dedicated flusher thread batches
3
+ //! them into the output. This removes the two per-line costs of a classic
4
+ //! Ruby Logger device (the cross-thread mutex and the write syscall)
5
+ //! from request threads. Shared by the native access log and
6
+ //! Kino::Logger::Device.
7
+ //!
8
+ //! Durability: the flusher flushes after each drained batch, and dropping
9
+ //! the last Sender makes it drain everything and exit; a graceful
10
+ //! shutdown loses nothing. A hard crash can lose the tail of the buffer,
11
+ //! the standard async-logging trade-off.
12
+
13
+ use std::io::Write;
14
+
15
+ pub struct Sink {
16
+ tx: flume::Sender<String>,
17
+ }
18
+
19
+ impl Sink {
20
+ /// `out` is taken by the flusher thread (stdout lock, File, ...).
21
+ pub fn new<W: Write + Send + 'static>(mut out: W) -> Sink {
22
+ let (tx, rx) = flume::bounded::<String>(8192);
23
+ std::thread::Builder::new()
24
+ .name("kino-log".to_string())
25
+ .spawn(move || {
26
+ fn put<W: Write>(out: &mut W, line: &str) {
27
+ let _ = out.write_all(line.as_bytes());
28
+ let _ = out.write_all(b"\n");
29
+ }
30
+ // Block for the first line, then drain whatever else is
31
+ // queued before flushing once: batching under load,
32
+ // prompt output when quiet.
33
+ while let Ok(line) = rx.recv() {
34
+ put(&mut out, &line);
35
+ while let Ok(line) = rx.try_recv() {
36
+ put(&mut out, &line);
37
+ }
38
+ let _ = out.flush();
39
+ }
40
+ let _ = out.flush();
41
+ })
42
+ .expect("failed to spawn log flusher thread");
43
+ Sink { tx }
44
+ }
45
+
46
+ /// Queue one line (without trailing newline). Never blocks the caller:
47
+ /// when the channel is full the line is dropped; backpressure on the
48
+ /// request path is exactly what an async log must not create.
49
+ pub fn write_line(&self, line: String) {
50
+ let _ = self.tx.try_send(line);
51
+ }
52
+ }
53
+
54
+ // --- Ruby-facing log devices (Kino::Logger::Device) -------------------
55
+ //
56
+ // A device is just a Sink in a registry, addressed by id from Ruby. The
57
+ // flusher thread exits when the device is closed (sender dropped).
58
+
59
+ use std::sync::OnceLock;
60
+
61
+ type DeviceMap = parking_lot::Mutex<std::collections::HashMap<u64, Sink, ahash::RandomState>>;
62
+
63
+ static DEVICES: OnceLock<DeviceMap> = OnceLock::new();
64
+ static NEXT_DEVICE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
65
+
66
+ fn devices() -> &'static DeviceMap {
67
+ DEVICES.get_or_init(|| parking_lot::Mutex::new(std::collections::HashMap::default()))
68
+ }
69
+
70
+ /// Open a device: nil/empty path = stdout, otherwise append-create the
71
+ /// file. Returns the device id.
72
+ pub fn device_open(ruby: &magnus::Ruby, path: Option<String>) -> Result<u64, magnus::Error> {
73
+ let sink = match path.as_deref() {
74
+ None | Some("") => Sink::new(std::io::stdout()),
75
+ Some(p) => {
76
+ let file = std::fs::OpenOptions::new()
77
+ .create(true)
78
+ .append(true)
79
+ .open(p)
80
+ .map_err(|e| {
81
+ magnus::Error::new(
82
+ ruby.exception_runtime_error(),
83
+ format!("Kino::Logger::Device: cannot open {p}: {e}"),
84
+ )
85
+ })?;
86
+ Sink::new(file)
87
+ }
88
+ };
89
+ let id = NEXT_DEVICE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
90
+ devices().lock().insert(id, sink);
91
+ Ok(id)
92
+ }
93
+
94
+ /// Queue a message on a device. The trailing newline (Logger adds one) is
95
+ /// trimmed because the sink writes line-wise.
96
+ pub fn device_write(
97
+ _ruby: &magnus::Ruby,
98
+ id: u64,
99
+ mut message: String,
100
+ ) -> Result<(), magnus::Error> {
101
+ if let Some(sink) = devices().lock().get(&id) {
102
+ // Trim in place: no second allocation per line.
103
+ message.truncate(message.trim_end_matches('\n').len());
104
+ sink.write_line(message);
105
+ }
106
+ Ok(())
107
+ }
108
+
109
+ /// Close a device: drops the sink, which makes the flusher drain its
110
+ /// queue and exit. Writes after close are silently ignored.
111
+ pub fn device_close(_ruby: &magnus::Ruby, id: u64) -> Result<(), magnus::Error> {
112
+ devices().lock().remove(&id);
113
+ Ok(())
114
+ }
115
+
116
+ #[cfg(test)]
117
+ mod tests {
118
+ use super::Sink;
119
+ use std::io::Write;
120
+ use std::sync::Arc;
121
+ use std::time::{Duration, Instant};
122
+
123
+ #[derive(Clone, Default)]
124
+ struct SharedBuf(Arc<parking_lot::Mutex<Vec<u8>>>);
125
+
126
+ impl Write for SharedBuf {
127
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
128
+ self.0.lock().extend_from_slice(buf);
129
+ Ok(buf.len())
130
+ }
131
+
132
+ fn flush(&mut self) -> std::io::Result<()> {
133
+ Ok(())
134
+ }
135
+ }
136
+
137
+ #[test]
138
+ fn sink_writes_lines_and_drains_on_drop() {
139
+ let buf = SharedBuf::default();
140
+ let sink = Sink::new(buf.clone());
141
+
142
+ sink.write_line("first".to_string());
143
+ sink.write_line("second".to_string());
144
+ drop(sink); // sender gone: the flusher drains everything and exits
145
+
146
+ let deadline = Instant::now() + Duration::from_secs(2);
147
+ loop {
148
+ if *buf.0.lock() == b"first\nsecond\n" {
149
+ break;
150
+ }
151
+ assert!(Instant::now() < deadline, "flusher never drained");
152
+ std::thread::sleep(Duration::from_millis(5));
153
+ }
154
+ }
155
+ }