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.
- checksums.yaml +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/Cargo.lock +993 -0
- data/Cargo.toml +15 -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/ext/kino/Cargo.toml +49 -0
- data/ext/kino/build.rs +5 -0
- data/ext/kino/extconf.rb +6 -0
- data/ext/kino/src/env_strings.rs +318 -0
- data/ext/kino/src/gvl.rs +103 -0
- data/ext/kino/src/lib.rs +90 -0
- data/ext/kino/src/logsink.rs +155 -0
- data/ext/kino/src/queue.rs +207 -0
- data/ext/kino/src/registry.rs +268 -0
- data/ext/kino/src/request.rs +432 -0
- data/ext/kino/src/response.rs +214 -0
- data/ext/kino/src/server.rs +621 -0
- data/ext/kino/src/style.rs +87 -0
- data/ext/kino/src/test_support.rs +82 -0
- data/ext/kino/src/timer.rs +57 -0
- data/ext/kino/src/tls.rs +96 -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/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 +219 -0
|
@@ -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
|
+
}
|
data/ext/kino/src/gvl.rs
ADDED
|
@@ -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
|
+
}
|
data/ext/kino/src/lib.rs
ADDED
|
@@ -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
|
+
}
|