kino 0.1.2 → 0.2.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 +4 -4
- data/CHANGELOG.md +33 -0
- data/Cargo.lock +1 -1
- data/Cargo.toml +6 -2
- data/README.md +18 -1
- data/ext/kino/Cargo.toml +1 -1
- data/ext/kino/src/gvl.rs +41 -8
- data/ext/kino/src/lib.rs +5 -0
- data/ext/kino/src/pin.rs +223 -0
- data/ext/kino/src/queue.rs +19 -17
- data/ext/kino/src/registry.rs +4 -0
- data/ext/kino/src/request.rs +37 -10
- data/ext/kino/src/server.rs +12 -0
- data/ext/kino/src/test_support.rs +13 -1
- data/ext/kino/src/timer.rs +1 -1
- data/lib/kino/configuration.rb +6 -0
- data/lib/kino/ractor_supervisor.rb +4 -3
- data/lib/kino/server.rb +111 -17
- data/lib/kino/templates/kino.rb.tt +8 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +35 -11
- data/sig/kino.rbs +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f66c88f09e6d65c5b9ca4b0ed4f1a10f735343286dfed32fbd54d13216faf886
|
|
4
|
+
data.tar.gz: 871d3eeaed7cf34ed8fc83a53fba42d5dfe28b916e9b6c695b332fcf17be5ab8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22b189c9301618dfe411965cfea6f91cd7511b79265f5826e7e158110752104ec3c8c9da55c0e1dcfa4ad4f883bcca5e392505661244c030e3fac000de06fc18
|
|
7
|
+
data.tar.gz: 3ef719d98e7fcc5b5570d9dc606f24c51df1651abc4814007e48cdfa46eaf43e49cbc9680b08e344002670a51e2f9d0d29ee6f3ace2327f27cdbe4c15fc6fe6c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
## [0.2.0] - 2026-07-13
|
|
2
|
+
|
|
3
|
+
- Strip debug info from release builds.
|
|
4
|
+
- A panic in the native layer now raises a RuntimeError on the affected
|
|
5
|
+
worker (visible to `on_error` and the error log) instead of killing the
|
|
6
|
+
server process.
|
|
7
|
+
- The pidfile is claimed exclusively: starting refuses (instead of silently
|
|
8
|
+
overwriting) while the pidfile's owner is alive, a leftover file from a
|
|
9
|
+
dead process is replaced, symlinks are never followed, and shutdown
|
|
10
|
+
removes the file only while it still holds our pid.
|
|
11
|
+
- Zero-copy response bodies: bodies of 4 KB and up ride to the network
|
|
12
|
+
layer by reference instead of being copied at the FFI boundary, in both
|
|
13
|
+
dispatch modes. A 10 KB-body endpoint now serves at plaintext speed.
|
|
14
|
+
|
|
15
|
+
## [0.1.3] - 2026-07-04
|
|
16
|
+
|
|
17
|
+
- Non-String response header names and values (booleans, numbers, symbols)
|
|
18
|
+
are now serialized with `to_s`, matching Puma, instead of failing the
|
|
19
|
+
request with a `TypeError`. (Fixes [#3], reported by Max Erkin @rus-max)
|
|
20
|
+
- New `on_error` directive: a callable invoked with `(exception, env)` when
|
|
21
|
+
a worker catches an app or delivery error, after the client got its 500.
|
|
22
|
+
Delivery errors (unserializable header, a body that raised mid-stream)
|
|
23
|
+
happen after the middleware stack returned, so this hook is the only
|
|
24
|
+
place an error tracker can see them. Handler failures are logged and
|
|
25
|
+
swallowed; in :ractor mode the handler must be Ractor-shareable. (Fixes [#3], reported by Max Erkin @rus-max)
|
|
26
|
+
- Worker error log lines now include the exception backtrace (first 12
|
|
27
|
+
frames), not just the exception class and message.
|
|
28
|
+
- A streaming body that raises mid-stream now aborts the connection
|
|
29
|
+
instead of finishing the chunked response cleanly, so a client can no
|
|
30
|
+
longer mistake a truncated response for a complete one.
|
|
31
|
+
|
|
1
32
|
## [0.1.2] - 2026-06-22
|
|
2
33
|
|
|
3
34
|
- Drop a connection that has not sent its complete request headers
|
|
@@ -89,3 +120,5 @@ Initial release.
|
|
|
89
120
|
- Request timeouts: `request_timeout: seconds` (off by default) returns an
|
|
90
121
|
immediate 504 when the app misses the deadline; the late response is
|
|
91
122
|
dropped and the handler is never killed. Counted as `stats[:timeouts]`.
|
|
123
|
+
|
|
124
|
+
[#3]: https://github.com/yaroslav/kino/issues/3
|
data/Cargo.lock
CHANGED
data/Cargo.toml
CHANGED
|
@@ -7,8 +7,12 @@ members = ["./ext/kino"]
|
|
|
7
7
|
resolver = "2"
|
|
8
8
|
|
|
9
9
|
[profile.release]
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# Ship lean artifacts: no debug info, and leftover debug sections (e.g.
|
|
11
|
+
# from the precompiled std) are stripped. The symbol table stays, so
|
|
12
|
+
# crash backtraces keep function names. For a debuggable local build,
|
|
13
|
+
# compile with RB_SYS_CARGO_PROFILE=dev.
|
|
14
|
+
debug = false
|
|
15
|
+
strip = "debuginfo"
|
|
12
16
|
opt-level = 3
|
|
13
17
|
lto = "fat"
|
|
14
18
|
codegen-units = 1
|
data/README.md
CHANGED
|
@@ -14,13 +14,15 @@ and a threaded fallback mode runs everything else, Rails included.
|
|
|
14
14
|
* **Fast.** On a real 8-core server, every Kino mode is **1.5-2×**
|
|
15
15
|
ahead of a Puma fork cluster on I/O-light endpoints. Ractor mode also
|
|
16
16
|
wins on pure CPU, **30%+**. [Benchmarks](#benchmarks) below.
|
|
17
|
-
* **A fraction of the memory.**
|
|
17
|
+
* **A fraction of the memory.** About **~7×** on the simplistic bench
|
|
18
18
|
Ractor app, and about **4× less memory** than a Puma cluster serving Rails in fallback threaded mode.
|
|
19
19
|
* **Parallel without forking.** Ractor mode runs CPU work **more than
|
|
20
20
|
5× faster** than Kino's own GVL-bound threaded mode, in the same
|
|
21
21
|
small process.
|
|
22
22
|
* **Production plumbing included.** Graceful drain, crash supervision
|
|
23
23
|
and respawn, bounded queues with 503 backpressure, request timeouts,
|
|
24
|
+
hardened intake (slowloris and TLS-handshake deadlines, connection
|
|
25
|
+
and body-size caps), an `on_error` hook for your error tracker,
|
|
24
26
|
TLS (rustls), live stats, async access and app logging.
|
|
25
27
|
* **Tells you why.** `kino --check` lists exactly what blocks your app
|
|
26
28
|
from ractor mode, finding by finding, so you do not have to decode
|
|
@@ -224,6 +226,9 @@ server = Kino::Server.new(app,
|
|
|
224
226
|
queue_depth: 1024, # bounded queue; overflow → 503
|
|
225
227
|
queue_timeout: 5.0, # seconds before 503 on a full queue
|
|
226
228
|
request_timeout: nil, # seconds before a slow response becomes a 504 (nil = off)
|
|
229
|
+
max_connections: 8192, # cap concurrent connections; default: most of ulimit -n
|
|
230
|
+
max_body_size: 50 * 1024 * 1024, # bytes before a 413; nil = let a proxy handle it
|
|
231
|
+
on_error: ->(e, env) { ErrorTracker.capture(e) }, # after the client got its 500
|
|
227
232
|
shutdown_timeout: 30, # drain deadline
|
|
228
233
|
tls: { cert: "cert.pem", key: "key.pem" }, # file paths or inline PEM
|
|
229
234
|
)
|
|
@@ -303,6 +308,18 @@ is unsafe. A stuck handler still occupies its worker slot until it
|
|
|
303
308
|
returns, so set the deadline above your slowest legitimate endpoint and
|
|
304
309
|
watch `stats[:timeouts]`.
|
|
305
310
|
|
|
311
|
+
Timeouts guard your app; the network intake guards itself. New
|
|
312
|
+
connections past `max_connections` (default: most of `ulimit -n`) wait
|
|
313
|
+
in the kernel backlog; request bodies past `max_body_size` (default
|
|
314
|
+
50 MB, `nil` delegates to a fronting proxy) get a **413**; and fixed
|
|
315
|
+
deadlines drop slow-header clients (15 s), stalled TLS handshakes
|
|
316
|
+
(10 s), and uploads stalled mid-body (30 s). When a worker catches an
|
|
317
|
+
app or delivery error,
|
|
318
|
+
`on_error ->(error, env) { ErrorTracker.capture(error) }` is called
|
|
319
|
+
after the client got its 500—the only place a tracker sees errors
|
|
320
|
+
raised while the response was being written (in `:ractor` mode, build
|
|
321
|
+
the handler with `Ractor.shareable_proc`).
|
|
322
|
+
|
|
306
323
|
## Stats
|
|
307
324
|
|
|
308
325
|
`server.stats` returns a live snapshot: the configuration plus counters
|
data/ext/kino/Cargo.toml
CHANGED
data/ext/kino/src/gvl.rs
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
//! body reads/writes) must go through `without_gvl` so other Ruby threads
|
|
6
6
|
//! in the same ractor keep running and the VM can interrupt us via the UBF.
|
|
7
7
|
|
|
8
|
+
use std::any::Any;
|
|
8
9
|
use std::ffi::c_void;
|
|
10
|
+
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
11
|
+
|
|
12
|
+
use magnus::{Error, Ruby};
|
|
9
13
|
|
|
10
14
|
/// An unblock function: called by the Ruby VM from another thread when it
|
|
11
15
|
/// needs to interrupt the blocking region (Thread#kill, VM shutdown, ...).
|
|
@@ -20,23 +24,48 @@ unsafe extern "C" fn trampoline<F, R>(arg: *mut c_void) -> *mut c_void
|
|
|
20
24
|
where
|
|
21
25
|
F: FnOnce() -> R,
|
|
22
26
|
{
|
|
23
|
-
let slot = unsafe { &mut *(arg as *mut (Option<F>, Option<R
|
|
27
|
+
let slot = unsafe { &mut *(arg as *mut (Option<F>, Option<std::thread::Result<R>>)) };
|
|
24
28
|
let f = slot.0.take().expect("without_gvl trampoline called twice");
|
|
25
|
-
|
|
29
|
+
// A panic must not unwind into the VM's C frame beneath us (that
|
|
30
|
+
// aborts the process); catch it here and let without_gvl rethrow it
|
|
31
|
+
// as a Ruby exception once the GVL is held again.
|
|
32
|
+
slot.1 = Some(catch_unwind(AssertUnwindSafe(f)));
|
|
26
33
|
std::ptr::null_mut()
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/// The caught panic payload as a plain RuntimeError, built with the GVL
|
|
37
|
+
/// held. Deliberately not magnus's own panic conversion: that raises
|
|
38
|
+
/// `fatal`, which still ends the process; a RuntimeError flows through
|
|
39
|
+
/// the worker's normal rescue path (500, on_error, error log, respawn).
|
|
40
|
+
fn panic_error(payload: Box<dyn Any + Send>) -> Error {
|
|
41
|
+
let msg = if let Some(m) = payload.downcast_ref::<&'static str>() {
|
|
42
|
+
(*m).to_string()
|
|
43
|
+
} else if let Some(m) = payload.downcast_ref::<String>() {
|
|
44
|
+
m.clone()
|
|
45
|
+
} else {
|
|
46
|
+
"opaque panic payload".to_string()
|
|
47
|
+
};
|
|
48
|
+
let ruby = Ruby::get().expect("without_gvl requires a Ruby thread");
|
|
49
|
+
Error::new(
|
|
50
|
+
ruby.exception_runtime_error(),
|
|
51
|
+
format!("Kino: panic in native blocking call: {msg}"),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
/// Run `f` with the GVL released. Blocks the current Ruby thread but lets
|
|
30
56
|
/// every other Ruby thread (in this ractor) run in parallel.
|
|
31
57
|
///
|
|
32
58
|
/// `f` MUST NOT touch any Ruby API. If `ubf` fires, `f` is responsible for
|
|
33
59
|
/// noticing (e.g. a message on an interrupt channel) and returning promptly;
|
|
34
60
|
/// pending VM interrupts are then delivered once we're back in Ruby.
|
|
35
|
-
|
|
61
|
+
///
|
|
62
|
+
/// A panic in `f` comes back as `Err` (RuntimeError with the panic
|
|
63
|
+
/// message); the default panic hook still reports it to stderr first.
|
|
64
|
+
pub fn without_gvl<F, R>(f: F, ubf: Option<Ubf>) -> Result<R, Error>
|
|
36
65
|
where
|
|
37
66
|
F: FnOnce() -> R,
|
|
38
67
|
{
|
|
39
|
-
let mut slot: (Option<F>, Option<R
|
|
68
|
+
let mut slot: (Option<F>, Option<std::thread::Result<R>>) = (Some(f), None);
|
|
40
69
|
let (ubf_func, ubf_data) = match ubf {
|
|
41
70
|
Some(u) => (Some(u.func), u.data),
|
|
42
71
|
None => (None, std::ptr::null_mut()),
|
|
@@ -49,7 +78,10 @@ where
|
|
|
49
78
|
ubf_data,
|
|
50
79
|
);
|
|
51
80
|
}
|
|
52
|
-
slot.1.expect("without_gvl block did not run")
|
|
81
|
+
match slot.1.expect("without_gvl block did not run") {
|
|
82
|
+
Ok(value) => Ok(value),
|
|
83
|
+
Err(payload) => Err(panic_error(payload)),
|
|
84
|
+
}
|
|
53
85
|
}
|
|
54
86
|
|
|
55
87
|
/// The standard UBF used across this crate: `data` points at an `AtomicBool`
|
|
@@ -64,12 +96,13 @@ pub unsafe extern "C" fn ubf_interrupt(data: *mut c_void) {
|
|
|
64
96
|
/// The crate's one blocking idiom: release the GVL and run `attempt` in a
|
|
65
97
|
/// loop (`Some(T)` finishes, `None` means "tick elapsed, go around"), and
|
|
66
98
|
/// wake within a tick when `flag` is raised (by the VM's UBF or by
|
|
67
|
-
/// interrupt_all_workers). Returns None on interruption
|
|
68
|
-
/// bound each wait (recv_timeout-style)
|
|
99
|
+
/// interrupt_all_workers). Returns Ok(None) on interruption, Err when
|
|
100
|
+
/// `attempt` panicked. `attempt` must bound each wait (recv_timeout-style)
|
|
101
|
+
/// and must not touch the Ruby API.
|
|
69
102
|
pub fn interruptible<T>(
|
|
70
103
|
flag: &std::sync::atomic::AtomicBool,
|
|
71
104
|
mut attempt: impl FnMut() -> Option<T>,
|
|
72
|
-
) -> Option<T> {
|
|
105
|
+
) -> Result<Option<T>, Error> {
|
|
73
106
|
use std::sync::atomic::Ordering;
|
|
74
107
|
|
|
75
108
|
without_gvl(
|
data/ext/kino/src/lib.rs
CHANGED
|
@@ -7,6 +7,7 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
|
|
7
7
|
mod env_strings;
|
|
8
8
|
mod gvl;
|
|
9
9
|
mod logsink;
|
|
10
|
+
mod pin;
|
|
10
11
|
mod queue;
|
|
11
12
|
mod registry;
|
|
12
13
|
mod request;
|
|
@@ -59,6 +60,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
59
60
|
function!(env_strings::register_defaults, 2),
|
|
60
61
|
)?;
|
|
61
62
|
|
|
63
|
+
native.define_class("PinKeeper", ruby.class_object())?;
|
|
64
|
+
native.define_singleton_method("pin_keeper", function!(server::pin_keeper, 1))?;
|
|
65
|
+
|
|
62
66
|
let request = native.define_class("Request", ruby.class_object())?;
|
|
63
67
|
request.define_method("respond_and_take", method!(queue::respond_and_take, 6))?;
|
|
64
68
|
request.define_method(
|
|
@@ -85,6 +89,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
85
89
|
native.define_singleton_method("_test_push", function!(test_support::push, 2))?;
|
|
86
90
|
native.define_singleton_method("_test_take", function!(test_support::take, 1))?;
|
|
87
91
|
native.define_singleton_method("_test_close", function!(test_support::close, 1))?;
|
|
92
|
+
native.define_singleton_method("_test_panic", function!(test_support::panic_in_release, 0))?;
|
|
88
93
|
|
|
89
94
|
Ok(())
|
|
90
95
|
}
|
data/ext/kino/src/pin.rs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
//! Zero-copy response bodies: a large body's bytes ride to hyper in
|
|
2
|
+
//! place instead of being copied at the FFI boundary, with the backing
|
|
3
|
+
//! Ruby string kept alive until hyper drops the buffer.
|
|
4
|
+
//!
|
|
5
|
+
//! Buffer stability is the same mechanism io.c uses to hold a string
|
|
6
|
+
//! across a GVL-released write: `rb_str_tmp_frozen_acquire` returns a
|
|
7
|
+
//! frozen string sharing the original's byte buffer (a frozen original
|
|
8
|
+
//! is returned as-is). Mutating the original afterwards copy-on-writes
|
|
9
|
+
//! the ORIGINAL side, so the shared buffer stays byte-stable for as
|
|
10
|
+
//! long as the acquired string lives.
|
|
11
|
+
//!
|
|
12
|
+
//! Liveness is a fixed slab of atomic VALUE slots per server, marked
|
|
13
|
+
//! from a `PinKeeper` TypedData object that the Ruby `Kino::Server`
|
|
14
|
+
//! holds for the server's lifetime (pin.rs never registers global GC
|
|
15
|
+
//! roots: `rb_gc_register_address` is not ractor-safe in Ruby 4.0).
|
|
16
|
+
//! The mark hook uses the pinning `rb_gc_mark`, so compaction never
|
|
17
|
+
//! moves a pinned string either. Because the keeper lives on the main
|
|
18
|
+
//! ractor, pinned buffers survive worker-ractor crashes while hyper is
|
|
19
|
+
//! still flushing them.
|
|
20
|
+
//!
|
|
21
|
+
//! Concurrency: inserts happen on worker threads (any ractor, its own
|
|
22
|
+
//! GVL held), release is a single atomic store from whatever tokio
|
|
23
|
+
//! thread drops the buffer, and the mark hook only loads atomics, so
|
|
24
|
+
//! no path takes a lock. A full slab degrades to the copy path.
|
|
25
|
+
|
|
26
|
+
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
|
27
|
+
use std::sync::Arc;
|
|
28
|
+
|
|
29
|
+
use bytes::Bytes;
|
|
30
|
+
use magnus::rb_sys::AsRawValue;
|
|
31
|
+
use magnus::RString;
|
|
32
|
+
|
|
33
|
+
/// Floor for the zero-copy path. Below it the pin machinery costs more
|
|
34
|
+
/// than the memcpy it saves. It is also a SOUNDNESS bound: it must
|
|
35
|
+
/// exceed the largest embeddable string (a 640-byte GC slot in Ruby
|
|
36
|
+
/// 4.0), so the acquired string's bytes are guaranteed to live in a
|
|
37
|
+
/// stable malloc'd buffer rather than inside a movable RVALUE, and the
|
|
38
|
+
/// string can never be re-embedded by compaction.
|
|
39
|
+
pub const ZERO_COPY_MIN: usize = 4096;
|
|
40
|
+
|
|
41
|
+
/// In-flight pins per server. Bounds slab memory (8 bytes per slot) and
|
|
42
|
+
/// GC mark work; overflow falls back to copying, so the cap is a
|
|
43
|
+
/// throughput heuristic, not a limit on concurrency.
|
|
44
|
+
const SLAB_CAPACITY: usize = 4096;
|
|
45
|
+
|
|
46
|
+
extern "C" {
|
|
47
|
+
// Exported by libruby but absent from the public headers: io.c's
|
|
48
|
+
// buffer-stabilizing primitive (see module docs). Signature per
|
|
49
|
+
// string.c: VALUE rb_str_tmp_frozen_acquire(VALUE orig).
|
|
50
|
+
fn rb_str_tmp_frozen_acquire(orig: rb_sys::VALUE) -> rb_sys::VALUE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// GC roots for in-flight response buffers. Slot value 0 = empty (a
|
|
54
|
+
/// VALUE of 0 is Qfalse, which can never be a pinned string).
|
|
55
|
+
pub struct PinSlab {
|
|
56
|
+
slots: Box<[AtomicU64]>,
|
|
57
|
+
/// Rotating claim cursor: keeps the free-slot scan O(1) amortized.
|
|
58
|
+
cursor: AtomicUsize,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl PinSlab {
|
|
62
|
+
pub fn new() -> Self {
|
|
63
|
+
PinSlab {
|
|
64
|
+
slots: (0..SLAB_CAPACITY).map(|_| AtomicU64::new(0)).collect(),
|
|
65
|
+
cursor: AtomicUsize::new(0),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Root `value`; None when the slab is full (caller copies instead).
|
|
70
|
+
fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
|
|
71
|
+
let start = self.cursor.fetch_add(1, Ordering::Relaxed);
|
|
72
|
+
for offset in 0..self.slots.len() {
|
|
73
|
+
let index = (start + offset) % self.slots.len();
|
|
74
|
+
if self.slots[index]
|
|
75
|
+
.compare_exchange(0, value, Ordering::SeqCst, Ordering::Relaxed)
|
|
76
|
+
.is_ok()
|
|
77
|
+
{
|
|
78
|
+
return Some(index);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
None
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Drop the root. Called from tokio threads: a plain atomic store,
|
|
85
|
+
/// no Ruby API. The string stays alive until the next GC sweep.
|
|
86
|
+
fn release(&self, index: usize) {
|
|
87
|
+
self.slots[index].store(0, Ordering::SeqCst);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// GC mark hook (PinKeeper). Runs during stop-the-world, so it must
|
|
91
|
+
/// not allocate, lock, or panic: it only loads atomics. A slot
|
|
92
|
+
/// concurrently released by a tokio thread is seen as either the
|
|
93
|
+
/// live value (marked one cycle longer, harmless) or 0 (skipped).
|
|
94
|
+
pub fn mark_all(&self) {
|
|
95
|
+
for slot in self.slots.iter() {
|
|
96
|
+
let value = slot.load(Ordering::SeqCst);
|
|
97
|
+
if value != 0 {
|
|
98
|
+
// SAFETY: non-zero slots hold VALUEs inserted by
|
|
99
|
+
// pinned_bytes and not yet released, i.e. live objects;
|
|
100
|
+
// rb_gc_mark is the pinning mark, callable from a mark
|
|
101
|
+
// hook by design.
|
|
102
|
+
unsafe { rb_sys::rb_gc_mark(value) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[cfg(test)]
|
|
108
|
+
fn occupied(&self) -> usize {
|
|
109
|
+
self.slots
|
|
110
|
+
.iter()
|
|
111
|
+
.filter(|s| s.load(Ordering::SeqCst) != 0)
|
|
112
|
+
.count()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// The GC-visible face of a server's PinSlab: `Kino::Server` holds one
|
|
117
|
+
/// for its lifetime (surviving worker-ractor crashes), and Ruby's GC
|
|
118
|
+
/// marks every pinned string through it.
|
|
119
|
+
#[derive(magnus::TypedData)]
|
|
120
|
+
#[magnus(class = "Kino::Native::PinKeeper", free_immediately, mark)]
|
|
121
|
+
pub struct PinKeeper(pub Arc<PinSlab>);
|
|
122
|
+
|
|
123
|
+
impl magnus::DataTypeFunctions for PinKeeper {
|
|
124
|
+
fn mark(&self, _marker: &magnus::gc::Marker) {
|
|
125
|
+
self.0.mark_all();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Owns one in-flight buffer: the raw view plus the slab slot rooting
|
|
130
|
+
/// the acquired string.
|
|
131
|
+
struct PinnedBuf {
|
|
132
|
+
slab: Arc<PinSlab>,
|
|
133
|
+
index: usize,
|
|
134
|
+
ptr: *const u8,
|
|
135
|
+
len: usize,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// SAFETY: the only operations performed off the Ruby thread are reading
|
|
139
|
+
// the byte buffer (the acquired string is frozen and slab-rooted: no
|
|
140
|
+
// writer exists and the buffer neither moves nor frees while the slot
|
|
141
|
+
// holds it) and Drop's atomic store. No Ruby API is called off-thread.
|
|
142
|
+
unsafe impl Send for PinnedBuf {}
|
|
143
|
+
|
|
144
|
+
impl AsRef<[u8]> for PinnedBuf {
|
|
145
|
+
fn as_ref(&self) -> &[u8] {
|
|
146
|
+
// SAFETY: ptr/len were captured under the GVL from the acquired
|
|
147
|
+
// frozen string, which stays alive and byte-stable until this
|
|
148
|
+
// owner releases its slot (see module docs).
|
|
149
|
+
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl Drop for PinnedBuf {
|
|
154
|
+
fn drop(&mut self) {
|
|
155
|
+
self.slab.release(self.index);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Bytes borrowing `body`'s buffer, with the string rooted until hyper
|
|
160
|
+
/// drops it; None when the body is below ZERO_COPY_MIN or the slab is
|
|
161
|
+
/// full (caller copies). Requires the calling worker's GVL; safe from
|
|
162
|
+
/// any ractor.
|
|
163
|
+
pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
|
|
164
|
+
if body.len() < ZERO_COPY_MIN {
|
|
165
|
+
return None;
|
|
166
|
+
}
|
|
167
|
+
// SAFETY: this worker's GVL is held; body is a live RString rooted
|
|
168
|
+
// by the caller. The acquired tmp is rooted by this thread's machine
|
|
169
|
+
// stack (conservatively scanned) until the slab insert publishes it.
|
|
170
|
+
let tmp = unsafe { rb_str_tmp_frozen_acquire(body.as_raw()) };
|
|
171
|
+
let index = slab.insert(tmp)?;
|
|
172
|
+
// SAFETY: tmp is frozen, alive, and slab-rooted; len >= ZERO_COPY_MIN
|
|
173
|
+
// rules out an embedded buffer, so ptr is a stable heap allocation.
|
|
174
|
+
let (ptr, len) = unsafe {
|
|
175
|
+
(
|
|
176
|
+
rb_sys::macros::RSTRING_PTR(tmp) as *const u8,
|
|
177
|
+
rb_sys::macros::RSTRING_LEN(tmp) as usize,
|
|
178
|
+
)
|
|
179
|
+
};
|
|
180
|
+
Some(Bytes::from_owner(PinnedBuf {
|
|
181
|
+
slab: slab.clone(),
|
|
182
|
+
index,
|
|
183
|
+
ptr,
|
|
184
|
+
len,
|
|
185
|
+
}))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[cfg(test)]
|
|
189
|
+
mod tests {
|
|
190
|
+
use super::*;
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn insert_release_reuse_cycle() {
|
|
194
|
+
let slab = PinSlab::new();
|
|
195
|
+
let a = slab.insert(0x1000).expect("slot free");
|
|
196
|
+
let b = slab.insert(0x2000).expect("slot free");
|
|
197
|
+
assert_ne!(a, b);
|
|
198
|
+
assert_eq!(slab.occupied(), 2);
|
|
199
|
+
|
|
200
|
+
slab.release(a);
|
|
201
|
+
assert_eq!(slab.occupied(), 1);
|
|
202
|
+
|
|
203
|
+
// The freed slot is claimable again.
|
|
204
|
+
let c = slab.insert(0x3000).expect("slot free");
|
|
205
|
+
assert_eq!(slab.occupied(), 2);
|
|
206
|
+
slab.release(b);
|
|
207
|
+
slab.release(c);
|
|
208
|
+
assert_eq!(slab.occupied(), 0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn full_slab_refuses_instead_of_evicting() {
|
|
213
|
+
let slab = PinSlab::new();
|
|
214
|
+
let indexes: Vec<usize> = (0..SLAB_CAPACITY)
|
|
215
|
+
.map(|i| slab.insert(0x1000 + i as rb_sys::VALUE).expect("capacity"))
|
|
216
|
+
.collect();
|
|
217
|
+
assert_eq!(slab.occupied(), SLAB_CAPACITY);
|
|
218
|
+
assert!(slab.insert(0x9999).is_none());
|
|
219
|
+
|
|
220
|
+
slab.release(indexes[7]);
|
|
221
|
+
assert_eq!(slab.insert(0x9999), Some(indexes[7]));
|
|
222
|
+
}
|
|
223
|
+
}
|
data/ext/kino/src/queue.rs
CHANGED
|
@@ -30,7 +30,7 @@ type Taken = Option<BoxedCtx>;
|
|
|
30
30
|
/// cost is real (~20% of cycles at saturation, per perf), but a measured
|
|
31
31
|
/// 20µs spin made things WORSE on oversubscribed cores: spinners steal
|
|
32
32
|
/// exactly the CPU the tokio threads need.
|
|
33
|
-
fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
|
|
33
|
+
fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Result<Taken, Error> {
|
|
34
34
|
if slot.lane_rx.is_some() {
|
|
35
35
|
return lane_take(server, slot);
|
|
36
36
|
}
|
|
@@ -40,15 +40,16 @@ fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
|
|
|
40
40
|
// try_recv never blocks, so the whole GVL release/reacquire (two
|
|
41
41
|
// scheduler round-trips per request) is skipped entirely.
|
|
42
42
|
match req_rx.try_recv() {
|
|
43
|
-
Ok(ctx) => Some(ctx),
|
|
44
|
-
Err(flume::TryRecvError::Disconnected) => None,
|
|
43
|
+
Ok(ctx) => Ok(Some(ctx)),
|
|
44
|
+
Err(flume::TryRecvError::Disconnected) => Ok(None),
|
|
45
45
|
Err(flume::TryRecvError::Empty) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
let taken =
|
|
47
|
+
gvl::interruptible(&slot.interrupted, || match req_rx.recv_timeout(TICK) {
|
|
48
|
+
Ok(ctx) => Some(Some(ctx)),
|
|
49
|
+
Err(flume::RecvTimeoutError::Timeout) => None,
|
|
50
|
+
Err(flume::RecvTimeoutError::Disconnected) => Some(None),
|
|
51
|
+
})?;
|
|
52
|
+
Ok(taken.flatten())
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -56,7 +57,7 @@ fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
|
|
|
56
57
|
/// Lane-mode take: own lane first (no wake needed while the dispatcher
|
|
57
58
|
/// keeps feeding an awake lane), then steal from siblings, then park on
|
|
58
59
|
/// the own lane with the parked flag raised so the dispatcher avoids it.
|
|
59
|
-
fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
|
|
60
|
+
fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Result<Taken, Error> {
|
|
60
61
|
let lane_rx = slot.lane_rx.as_ref().expect("lane_take without lane");
|
|
61
62
|
|
|
62
63
|
let steal = || -> Option<BoxedCtx> {
|
|
@@ -76,12 +77,12 @@ fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
|
|
|
76
77
|
|
|
77
78
|
// Hot path, GVL still held: own lane, then a steal sweep.
|
|
78
79
|
match lane_rx.try_recv() {
|
|
79
|
-
Ok(ctx) => return Some(ctx),
|
|
80
|
-
Err(flume::TryRecvError::Disconnected) => return None,
|
|
80
|
+
Ok(ctx) => return Ok(Some(ctx)),
|
|
81
|
+
Err(flume::TryRecvError::Disconnected) => return Ok(None),
|
|
81
82
|
Err(flume::TryRecvError::Empty) => {}
|
|
82
83
|
}
|
|
83
84
|
if let Some(ctx) = steal() {
|
|
84
|
-
return Some(ctx);
|
|
85
|
+
return Ok(Some(ctx));
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
// Park. The flag-then-recheck order closes the race with a dispatcher
|
|
@@ -96,10 +97,11 @@ fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Taken {
|
|
|
96
97
|
Err(flume::RecvTimeoutError::Timeout) => steal().map(Some),
|
|
97
98
|
Err(flume::RecvTimeoutError::Disconnected) => Some(None),
|
|
98
99
|
}
|
|
99
|
-
})
|
|
100
|
-
|
|
100
|
+
});
|
|
101
|
+
// Unpark before propagating any panic, or the dispatcher shuns this
|
|
102
|
+
// lane for good.
|
|
101
103
|
slot.parked.store(false, Ordering::SeqCst);
|
|
102
|
-
taken
|
|
104
|
+
Ok(taken?.flatten())
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/// Wrap a ctx into its env Hash, with the Ruby request handle embedded
|
|
@@ -137,7 +139,7 @@ fn checkout(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Chec
|
|
|
137
139
|
slot.current.lock().clear();
|
|
138
140
|
slot.interrupted.store(false, Ordering::SeqCst);
|
|
139
141
|
|
|
140
|
-
Ok(block_take(&server, &slot)
|
|
142
|
+
Ok(block_take(&server, &slot)?.map(|ctx| (server, slot, ctx)))
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
/// Take one request; returns its env Hash (request handle inside under
|
data/ext/kino/src/registry.rs
CHANGED
|
@@ -52,6 +52,9 @@ pub struct ServerInner {
|
|
|
52
52
|
pub lanes: bool,
|
|
53
53
|
/// Round-robin cursor for lane dispatch.
|
|
54
54
|
pub lane_cursor: AtomicUsize,
|
|
55
|
+
/// GC roots for zero-copy response buffers (pin.rs). The Ruby Server
|
|
56
|
+
/// object holds the marking PinKeeper for this slab.
|
|
57
|
+
pub pin_slab: Arc<crate::pin::PinSlab>,
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
/// One per worker *thread* (slot count = workers × threads). The interrupt
|
|
@@ -189,6 +192,7 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
|
|
|
189
192
|
access_log: None,
|
|
190
193
|
lanes,
|
|
191
194
|
lane_cursor: AtomicUsize::new(0),
|
|
195
|
+
pin_slab: Arc::new(crate::pin::PinSlab::new()),
|
|
192
196
|
})
|
|
193
197
|
}
|
|
194
198
|
|
data/ext/kino/src/request.rs
CHANGED
|
@@ -9,7 +9,8 @@ use std::sync::Arc;
|
|
|
9
9
|
|
|
10
10
|
use bytes::Bytes;
|
|
11
11
|
use magnus::r_hash::ForEach;
|
|
12
|
-
use magnus::
|
|
12
|
+
use magnus::value::ReprValue;
|
|
13
|
+
use magnus::{Error, RArray, RHash, RString, Ruby, Value};
|
|
13
14
|
|
|
14
15
|
use crate::gvl;
|
|
15
16
|
use crate::response::{full_body, BodyFrame, Responder};
|
|
@@ -36,6 +37,9 @@ pub struct RequestCtx {
|
|
|
36
37
|
/// The owning worker slot (set at admit time, queue.rs); its interrupt
|
|
37
38
|
/// flag makes blocked body reads/writes interruptible like the queue pop.
|
|
38
39
|
pub slot: Option<Arc<crate::registry::WorkerSlot>>,
|
|
40
|
+
/// The server's zero-copy roots: large response bodies are pinned
|
|
41
|
+
/// here and ride to hyper without a copy (pin.rs).
|
|
42
|
+
pub pin_slab: Arc<crate::pin::PinSlab>,
|
|
39
43
|
pub responder: Arc<Responder>,
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -51,12 +55,12 @@ impl Drop for RequestCtx {
|
|
|
51
55
|
|
|
52
56
|
/// Block on a channel operation with the GVL released, waking on the
|
|
53
57
|
/// slot's interrupt flag. `attempt` performs one bounded tick and returns
|
|
54
|
-
/// Some when done; None on timeout.
|
|
58
|
+
/// Some when done; None on timeout. Ok(None) = interrupted. Every Ruby
|
|
55
59
|
/// handle is created by `admit` (queue.rs), which always sets the slot.
|
|
56
60
|
fn block_on<T>(
|
|
57
61
|
slot: &Option<Arc<crate::registry::WorkerSlot>>,
|
|
58
62
|
attempt: impl FnMut() -> Option<T>,
|
|
59
|
-
) -> Option<T> {
|
|
63
|
+
) -> Result<Option<T>, Error> {
|
|
60
64
|
let slot = slot.as_ref().expect("slot set by admit");
|
|
61
65
|
gvl::interruptible(&slot.interrupted, attempt)
|
|
62
66
|
}
|
|
@@ -227,13 +231,23 @@ pub fn respond_simple(
|
|
|
227
231
|
) -> Result<bool, Error> {
|
|
228
232
|
let ctx = request.0.borrow();
|
|
229
233
|
let builder = build_head(status, headers)?;
|
|
230
|
-
let bytes =
|
|
234
|
+
let bytes = body_bytes(&ctx, body);
|
|
231
235
|
let response = builder
|
|
232
236
|
.body(full_body(bytes))
|
|
233
237
|
.map_err(|e| invalid_response(ruby, e))?;
|
|
234
238
|
Ok(ctx.responder.send_response(response))
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
/// Body bytes for hyper: large bodies are pinned in place (zero-copy),
|
|
242
|
+
/// everything else is copied out of the Ruby string, which may be
|
|
243
|
+
/// mutated or collected the moment we return.
|
|
244
|
+
fn body_bytes(ctx: &RequestCtx, body: RString) -> Bytes {
|
|
245
|
+
if let Some(bytes) = crate::pin::pinned_bytes(&ctx.pin_slab, body) {
|
|
246
|
+
return bytes;
|
|
247
|
+
}
|
|
248
|
+
Bytes::copy_from_slice(unsafe { body.as_slice() })
|
|
249
|
+
}
|
|
250
|
+
|
|
237
251
|
impl Request {
|
|
238
252
|
/// Next chunk of the request body, at most `max_len` bytes; nil at EOF.
|
|
239
253
|
/// Blocks (GVL released) until the client sends more.
|
|
@@ -255,7 +269,7 @@ impl Request {
|
|
|
255
269
|
Err(flume::RecvTimeoutError::Timeout) => None,
|
|
256
270
|
Err(flume::RecvTimeoutError::Disconnected) => Some(None),
|
|
257
271
|
}
|
|
258
|
-
})
|
|
272
|
+
})?;
|
|
259
273
|
match outcome {
|
|
260
274
|
Some(Some(bytes)) => bytes,
|
|
261
275
|
Some(None) => {
|
|
@@ -305,7 +319,7 @@ impl Request {
|
|
|
305
319
|
"Kino: response stream not started or already finished",
|
|
306
320
|
));
|
|
307
321
|
};
|
|
308
|
-
let bytes =
|
|
322
|
+
let bytes = body_bytes(&ctx, chunk);
|
|
309
323
|
let mut pending = Some(Ok(BodyFrame::data(bytes)));
|
|
310
324
|
|
|
311
325
|
let outcome = block_on(&ctx.slot, || {
|
|
@@ -318,7 +332,7 @@ impl Request {
|
|
|
318
332
|
}
|
|
319
333
|
Err(flume::SendTimeoutError::Disconnected(_)) => Some(false),
|
|
320
334
|
}
|
|
321
|
-
})
|
|
335
|
+
})?;
|
|
322
336
|
match outcome {
|
|
323
337
|
// Receiver dropped = client went away; the app keeps writing
|
|
324
338
|
// into the void harmlessly (Rack has no error contract here).
|
|
@@ -348,7 +362,8 @@ impl Request {
|
|
|
348
362
|
/// copies the bytes immediately.
|
|
349
363
|
fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Builder, Error> {
|
|
350
364
|
let mut builder = Some(hyper::Response::builder().status(status));
|
|
351
|
-
headers.foreach(|name:
|
|
365
|
+
headers.foreach(|name: Value, value: Value| {
|
|
366
|
+
let name = coerce_str(name)?;
|
|
352
367
|
let take = |b: &mut Option<hyper::http::response::Builder>, v: RString| {
|
|
353
368
|
let next = b
|
|
354
369
|
.take()
|
|
@@ -358,16 +373,27 @@ fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Buil
|
|
|
358
373
|
};
|
|
359
374
|
if let Some(values) = RArray::from_value(value) {
|
|
360
375
|
for i in 0..values.len() {
|
|
361
|
-
take(&mut builder, values.entry
|
|
376
|
+
take(&mut builder, coerce_str(values.entry(i as isize)?)?);
|
|
362
377
|
}
|
|
363
378
|
} else {
|
|
364
|
-
take(&mut builder,
|
|
379
|
+
take(&mut builder, coerce_str(value)?);
|
|
365
380
|
}
|
|
366
381
|
Ok(ForEach::Continue)
|
|
367
382
|
})?;
|
|
368
383
|
Ok(builder.expect("builder always present"))
|
|
369
384
|
}
|
|
370
385
|
|
|
386
|
+
/// Rack SPEC says header names and values are Strings, but real apps set
|
|
387
|
+
/// booleans, numbers, and symbols, and Puma serves them via to_s. Match
|
|
388
|
+
/// that: Strings pass through untouched (the hot path), anything else pays
|
|
389
|
+
/// one to_s call back into Ruby.
|
|
390
|
+
fn coerce_str(value: Value) -> Result<RString, Error> {
|
|
391
|
+
match RString::from_value(value) {
|
|
392
|
+
Some(s) => Ok(s),
|
|
393
|
+
None => value.funcall("to_s", ()),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
371
397
|
fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
|
|
372
398
|
match host.rsplit_once(':') {
|
|
373
399
|
Some((name, port)) if !name.is_empty() => match port.parse() {
|
|
@@ -397,6 +423,7 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
|
|
|
397
423
|
body_timeout: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
398
424
|
leftover: None,
|
|
399
425
|
slot: None,
|
|
426
|
+
pin_slab: Arc::new(crate::pin::PinSlab::new()),
|
|
400
427
|
responder: Arc::new(Responder::new(head_tx)),
|
|
401
428
|
})
|
|
402
429
|
}
|
data/ext/kino/src/server.rs
CHANGED
|
@@ -112,6 +112,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
112
112
|
access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
|
|
113
113
|
lanes,
|
|
114
114
|
lane_cursor: std::sync::atomic::AtomicUsize::new(0),
|
|
115
|
+
pin_slab: Arc::new(crate::pin::PinSlab::new()),
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
let tokio_listener = {
|
|
@@ -350,6 +351,7 @@ async fn handle_request(
|
|
|
350
351
|
body_timeout,
|
|
351
352
|
leftover: None,
|
|
352
353
|
slot: None,
|
|
354
|
+
pin_slab: server.pin_slab.clone(),
|
|
353
355
|
responder,
|
|
354
356
|
});
|
|
355
357
|
|
|
@@ -589,6 +591,16 @@ pub fn shutdown_runtime(_ruby: &Ruby, server_id: u64, timeout_ms: u64) -> Result
|
|
|
589
591
|
Ok(())
|
|
590
592
|
}
|
|
591
593
|
|
|
594
|
+
/// The GC anchor for this server's zero-copy pins: the Ruby Server holds
|
|
595
|
+
/// it for its lifetime, so pinned buffers survive worker-ractor crashes.
|
|
596
|
+
pub fn pin_keeper(
|
|
597
|
+
ruby: &Ruby,
|
|
598
|
+
server_id: u64,
|
|
599
|
+
) -> Result<magnus::typed_data::Obj<crate::pin::PinKeeper>, Error> {
|
|
600
|
+
let server = registry::get(ruby, server_id)?;
|
|
601
|
+
Ok(ruby.obj_wrap(crate::pin::PinKeeper(server.pin_slab.clone())))
|
|
602
|
+
}
|
|
603
|
+
|
|
592
604
|
/// Errors print in red on color terminals. Covers worker errors,
|
|
593
605
|
/// supervisor crash reports, and everything apps write to rack.errors.
|
|
594
606
|
pub fn log_error(message: String) {
|
|
@@ -71,10 +71,22 @@ pub fn take(ruby: &magnus::Ruby, id: u64) -> Result<Option<String>, magnus::Erro
|
|
|
71
71
|
Err(flume::RecvTimeoutError::Timeout) => None,
|
|
72
72
|
Err(flume::RecvTimeoutError::Disconnected) => Some(None),
|
|
73
73
|
}
|
|
74
|
-
})
|
|
74
|
+
})?;
|
|
75
75
|
Ok(taken.flatten())
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/// Panic inside a GVL-released block: proves it surfaces as a Ruby
|
|
79
|
+
/// exception instead of unwinding into the VM and killing the process.
|
|
80
|
+
/// The default panic hook is silenced around the intentional panic so
|
|
81
|
+
/// spec output stays clean.
|
|
82
|
+
pub fn panic_in_release() -> Result<(), magnus::Error> {
|
|
83
|
+
let hook = std::panic::take_hook();
|
|
84
|
+
std::panic::set_hook(Box::new(|_| {}));
|
|
85
|
+
let result = gvl::without_gvl(|| panic!("intentional test panic"), None);
|
|
86
|
+
std::panic::set_hook(hook);
|
|
87
|
+
result
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
pub fn close(ruby: &magnus::Ruby, id: u64) -> Result<(), magnus::Error> {
|
|
79
91
|
let chan = lookup(ruby, id)?;
|
|
80
92
|
chan.tx.lock().take();
|
data/ext/kino/src/timer.rs
CHANGED
data/lib/kino/configuration.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Kino
|
|
|
22
22
|
batch: 1,
|
|
23
23
|
lanes: false,
|
|
24
24
|
log_requests: false,
|
|
25
|
+
on_error: nil,
|
|
25
26
|
shutdown_timeout: 30,
|
|
26
27
|
tokio_threads: nil,
|
|
27
28
|
tls: nil,
|
|
@@ -179,6 +180,11 @@ module Kino
|
|
|
179
180
|
# Native access log: one status-colored line per request to stdout.
|
|
180
181
|
def log_requests(enabled) = @config.set(:log_requests, !!enabled)
|
|
181
182
|
|
|
183
|
+
# Called with (exception, env) when a worker catches an app or
|
|
184
|
+
# delivery error; wire your error tracker here. Takes a callable
|
|
185
|
+
# or a block. Must be Ractor-shareable in :ractor mode.
|
|
186
|
+
def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
|
|
187
|
+
|
|
182
188
|
# Graceful-shutdown drain deadline in seconds.
|
|
183
189
|
def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
|
|
184
190
|
|
|
@@ -9,12 +9,13 @@ module Kino
|
|
|
9
9
|
class RactorSupervisor
|
|
10
10
|
attr_reader :respawns
|
|
11
11
|
|
|
12
|
-
def initialize(server_id, app, workers:, threads:, batch: 1)
|
|
12
|
+
def initialize(server_id, app, workers:, threads:, batch: 1, on_error: nil)
|
|
13
13
|
@server_id = server_id
|
|
14
14
|
@app = app
|
|
15
15
|
@workers = workers
|
|
16
16
|
@threads = threads
|
|
17
17
|
@batch = batch
|
|
18
|
+
@on_error = on_error
|
|
18
19
|
@respawns = 0
|
|
19
20
|
@draining = false
|
|
20
21
|
@lock = Mutex.new
|
|
@@ -83,13 +84,13 @@ module Kino
|
|
|
83
84
|
# old slot.
|
|
84
85
|
def spawn_worker
|
|
85
86
|
worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
|
|
86
|
-
ractor = Ractor.new(@server_id, worker_ids, @app, @batch) do |server_id, ids, app, batch|
|
|
87
|
+
ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @on_error) do |server_id, ids, app, batch, on_error|
|
|
87
88
|
ids.map do |id|
|
|
88
89
|
Thread.new do
|
|
89
90
|
# Crashes surface via Ractor#value in the supervisor; don't also
|
|
90
91
|
# spray the backtrace to stderr from inside the dying ractor.
|
|
91
92
|
Thread.current.report_on_exception = false
|
|
92
|
-
Kino::Worker.run(server_id, id, app, batch)
|
|
93
|
+
Kino::Worker.run(server_id, id, app, batch, on_error)
|
|
93
94
|
end
|
|
94
95
|
end.each(&:join)
|
|
95
96
|
end
|
data/lib/kino/server.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Kino
|
|
|
41
41
|
@bind = settings[:bind]
|
|
42
42
|
@requested_port = settings[:port]
|
|
43
43
|
@workers = Integer(settings[:workers])
|
|
44
|
+
@on_error = validate_on_error(settings[:on_error])
|
|
44
45
|
@mode = resolve_mode(settings[:mode])
|
|
45
46
|
# Default threads per mode: 1 in :ractor (threads inside a ractor
|
|
46
47
|
# share its lock; a measured +17% on fast handlers; raise `workers`
|
|
@@ -72,23 +73,35 @@ module Kino
|
|
|
72
73
|
def start
|
|
73
74
|
raise Error, "server already started" if @started
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
# Claim the pidfile before binding: refusing to start (another
|
|
77
|
+
# instance is alive) must not leave a booted native runtime behind.
|
|
78
|
+
write_pidfile if @pidfile
|
|
79
|
+
booted = false
|
|
80
|
+
begin
|
|
81
|
+
@id, @port = Native.server_start(
|
|
82
|
+
bind: @bind, port: @requested_port,
|
|
83
|
+
queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
|
|
84
|
+
request_timeout_ms: @request_timeout_ms,
|
|
85
|
+
max_connections: @max_connections,
|
|
86
|
+
max_body_size: @max_body_size,
|
|
87
|
+
tokio_threads: @tokio_threads,
|
|
88
|
+
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
89
|
+
lanes: @lanes, log_requests: @log_requests
|
|
90
|
+
)
|
|
91
|
+
booted = true
|
|
92
|
+
ensure
|
|
93
|
+
remove_pidfile if @pidfile && !booted
|
|
94
|
+
end
|
|
95
|
+
# GC anchor for zero-copy response buffers: held for the server's
|
|
96
|
+
# lifetime so in-flight buffers survive even a worker ractor crash.
|
|
97
|
+
@pin_keeper = Native.pin_keeper(@id)
|
|
86
98
|
if @mode == :ractor
|
|
87
|
-
@supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
|
|
99
|
+
@supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
|
|
100
|
+
batch: @batch, on_error: @on_error).start
|
|
88
101
|
else
|
|
89
102
|
@worker_threads = (@workers * @threads).times.map do
|
|
90
103
|
worker_id = Native.register_worker(@id)
|
|
91
|
-
Thread.new { Worker.run(@id, worker_id, @app, @batch) }
|
|
104
|
+
Thread.new { Worker.run(@id, worker_id, @app, @batch, @on_error) }
|
|
92
105
|
end
|
|
93
106
|
end
|
|
94
107
|
@started = true
|
|
@@ -131,9 +144,12 @@ module Kino
|
|
|
131
144
|
end
|
|
132
145
|
|
|
133
146
|
Native.shutdown_runtime(@id, 1_000)
|
|
147
|
+
# The runtime is gone, so hyper has dropped every pinned buffer;
|
|
148
|
+
# the keeper (and the strings it marked) may now be collected.
|
|
149
|
+
@pin_keeper = nil
|
|
134
150
|
@worker_threads.clear
|
|
135
151
|
@started = false
|
|
136
|
-
|
|
152
|
+
remove_pidfile if @pidfile
|
|
137
153
|
nil
|
|
138
154
|
end
|
|
139
155
|
|
|
@@ -214,6 +230,15 @@ module Kino
|
|
|
214
230
|
{cert: String(tls[:cert]), key: String(tls[:key])}
|
|
215
231
|
end
|
|
216
232
|
|
|
233
|
+
def validate_on_error(handler)
|
|
234
|
+
return nil if handler.nil?
|
|
235
|
+
unless handler.respond_to?(:call)
|
|
236
|
+
raise ArgumentError, "on_error must respond to #call (got #{handler.class})"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
handler
|
|
240
|
+
end
|
|
241
|
+
|
|
217
242
|
def monotonic_now
|
|
218
243
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
244
|
end
|
|
@@ -230,6 +255,67 @@ module Kino
|
|
|
230
255
|
[soft * 8 / 10, 64].max
|
|
231
256
|
end
|
|
232
257
|
|
|
258
|
+
# Claim the pidfile for this process. O_EXCL creation fails on ANY
|
|
259
|
+
# existing directory entry (regular file, symlink, even a dangling
|
|
260
|
+
# one), so a live instance's pidfile is never overwritten and a
|
|
261
|
+
# symlink is never followed. A leftover entry whose owner is gone is
|
|
262
|
+
# replaced; one that does not hold a pid is refused, not clobbered.
|
|
263
|
+
def write_pidfile
|
|
264
|
+
claim_pidfile
|
|
265
|
+
rescue Errno::EEXIST
|
|
266
|
+
refuse_unless_stale
|
|
267
|
+
begin
|
|
268
|
+
# Unlink removes the entry itself; a symlink's target is untouched.
|
|
269
|
+
File.unlink(@pidfile)
|
|
270
|
+
rescue Errno::ENOENT
|
|
271
|
+
# Vanished on its own; the claim below settles any remaining race.
|
|
272
|
+
end
|
|
273
|
+
begin
|
|
274
|
+
claim_pidfile
|
|
275
|
+
rescue Errno::EEXIST
|
|
276
|
+
raise Error, "lost the race for #{@pidfile}: another instance is starting"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def claim_pidfile
|
|
281
|
+
File.open(@pidfile, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
282
|
+
file.write("#{Process.pid}\n")
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @raise [Kino::Error] when the pidfile's owner is still alive, or the
|
|
287
|
+
# file does not look like a pidfile at all
|
|
288
|
+
def refuse_unless_stale
|
|
289
|
+
content = begin
|
|
290
|
+
File.read(@pidfile)
|
|
291
|
+
rescue Errno::ENOENT
|
|
292
|
+
return # already gone; nothing to refuse
|
|
293
|
+
end
|
|
294
|
+
pid = Integer(content.strip, exception: false)
|
|
295
|
+
unless pid&.positive?
|
|
296
|
+
raise Error, "refusing to overwrite #{@pidfile}: does not hold a pid"
|
|
297
|
+
end
|
|
298
|
+
raise Error, "already running (pid #{pid}, per #{@pidfile})" if process_alive?(pid)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def process_alive?(pid)
|
|
302
|
+
Process.kill(0, pid)
|
|
303
|
+
true
|
|
304
|
+
rescue Errno::ESRCH
|
|
305
|
+
false
|
|
306
|
+
rescue Errno::EPERM
|
|
307
|
+
true # exists, just not ours to signal
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Delete only a pidfile that is still ours: by shutdown time the path
|
|
311
|
+
# may belong to a replacement instance, or an operator may have
|
|
312
|
+
# repointed it at something that is not a pidfile at all.
|
|
313
|
+
def remove_pidfile
|
|
314
|
+
File.unlink(@pidfile) if File.read(@pidfile) == "#{Process.pid}\n"
|
|
315
|
+
rescue Errno::ENOENT
|
|
316
|
+
nil
|
|
317
|
+
end
|
|
318
|
+
|
|
233
319
|
def join_workers(deadline)
|
|
234
320
|
if @supervisor
|
|
235
321
|
@supervisor.shutdown([deadline - monotonic_now, 0].max)
|
|
@@ -275,13 +361,21 @@ module Kino
|
|
|
275
361
|
"Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
|
|
276
362
|
"or mode: :threaded"
|
|
277
363
|
end
|
|
364
|
+
unless @on_error.nil? || Ractor.shareable?(@on_error)
|
|
365
|
+
raise Error,
|
|
366
|
+
"mode: :ractor requires a Ractor-shareable on_error handler " \
|
|
367
|
+
"(build it with Ractor.shareable_proc, or use mode: :threaded)"
|
|
368
|
+
end
|
|
278
369
|
:ractor
|
|
279
370
|
when :auto
|
|
280
|
-
if Ractor.shareable?(@app)
|
|
281
|
-
:ractor
|
|
282
|
-
else
|
|
371
|
+
if !Ractor.shareable?(@app)
|
|
283
372
|
warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
|
|
284
373
|
:threaded
|
|
374
|
+
elsif !(@on_error.nil? || Ractor.shareable?(@on_error))
|
|
375
|
+
warn "Kino: on_error handler is not Ractor-shareable; falling back to mode: :threaded"
|
|
376
|
+
:threaded
|
|
377
|
+
else
|
|
378
|
+
:ractor
|
|
285
379
|
end
|
|
286
380
|
else
|
|
287
381
|
raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
|
|
@@ -79,6 +79,14 @@
|
|
|
79
79
|
# never saw, such as 503s. Recommended in development.
|
|
80
80
|
# log_requests false
|
|
81
81
|
|
|
82
|
+
# Called when a worker catches an app or delivery error, after the client
|
|
83
|
+
# got its 500. This is the only place that sees errors raised while the
|
|
84
|
+
# response was being written (bad header, body that died mid-stream):
|
|
85
|
+
# they happen after your middleware returned, so only the server can
|
|
86
|
+
# report them. Wire your error tracker here. In :ractor mode the handler
|
|
87
|
+
# must be Ractor-shareable (build it with Ractor.shareable_proc).
|
|
88
|
+
# on_error ->(error, env) { ExceptionService.capture(error) }
|
|
89
|
+
|
|
82
90
|
## Lifecycle
|
|
83
91
|
|
|
84
92
|
# On shutdown, give in-flight requests this many seconds to finish.
|
data/lib/kino/version.rb
CHANGED
data/lib/kino/worker.rb
CHANGED
|
@@ -18,13 +18,13 @@ module Kino
|
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
21
|
-
def run(server_id, worker_id, app, batch_size = 1)
|
|
21
|
+
def run(server_id, worker_id, app, batch_size = 1, on_error = nil)
|
|
22
22
|
if batch_size <= 1
|
|
23
23
|
env = Native.take_one(server_id, worker_id)
|
|
24
|
-
env = handle_one(env, server_id, worker_id, app) while env
|
|
24
|
+
env = handle_one(env, server_id, worker_id, app, on_error) while env
|
|
25
25
|
else
|
|
26
26
|
batch = Native.take_batch(server_id, worker_id, batch_size)
|
|
27
|
-
batch = process(batch, server_id, worker_id, app, batch_size) while batch
|
|
27
|
+
batch = process(batch, server_id, worker_id, app, batch_size, on_error) while batch
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -34,8 +34,8 @@ module Kino
|
|
|
34
34
|
NOT_FUSED = Object.new.freeze
|
|
35
35
|
|
|
36
36
|
# Handle one request; returns the next env (fused take) or nil.
|
|
37
|
-
def handle_one(env, server_id, worker_id, app)
|
|
38
|
-
result = serve(env, app) do |request, status, headers, chunks|
|
|
37
|
+
def handle_one(env, server_id, worker_id, app, on_error)
|
|
38
|
+
result = serve(env, app, on_error) do |request, status, headers, chunks|
|
|
39
39
|
request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
|
|
40
40
|
end
|
|
41
41
|
result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
|
|
@@ -43,10 +43,10 @@ module Kino
|
|
|
43
43
|
|
|
44
44
|
# Handle every env in the batch; returns the next batch (the last
|
|
45
45
|
# simple response rides the fused respond_and_take) or nil on shutdown.
|
|
46
|
-
def process(batch, server_id, worker_id, app, batch_size)
|
|
46
|
+
def process(batch, server_id, worker_id, app, batch_size, on_error)
|
|
47
47
|
last = batch.size - 1
|
|
48
48
|
batch.each_with_index do |env, index|
|
|
49
|
-
result = serve(env, app) do |request, status, headers, chunks|
|
|
49
|
+
result = serve(env, app, on_error) do |request, status, headers, chunks|
|
|
50
50
|
if index == last
|
|
51
51
|
request.respond_and_take(server_id, worker_id, batch_size,
|
|
52
52
|
status, headers, chunks)
|
|
@@ -66,7 +66,7 @@ module Kino
|
|
|
66
66
|
# here and return NOT_FUSED. App errors must never kill the worker;
|
|
67
67
|
# hard crashes (Exception) are the supervisor's job; and `abort` does
|
|
68
68
|
# the right thing whether or not the response head already went out.
|
|
69
|
-
def serve(env, app)
|
|
69
|
+
def serve(env, app, on_error)
|
|
70
70
|
request = env[KINO_REQUEST]
|
|
71
71
|
env[RACK_INPUT] ||= Input.new(request)
|
|
72
72
|
status, headers, body = app.call(env)
|
|
@@ -80,11 +80,32 @@ module Kino
|
|
|
80
80
|
NOT_FUSED
|
|
81
81
|
end
|
|
82
82
|
rescue => e
|
|
83
|
-
|
|
83
|
+
# Abort before the hook: the client's 500 must never wait on a
|
|
84
|
+
# reporting round-trip. The hook is the app's only window onto
|
|
85
|
+
# delivery errors (they happen after app.call returned, so no
|
|
86
|
+
# middleware can see them); its own failures are logged, not raised,
|
|
87
|
+
# because nothing may escape this block and kill the worker.
|
|
88
|
+
Native.log_error(error_log_line(e))
|
|
84
89
|
request.abort
|
|
90
|
+
if on_error
|
|
91
|
+
begin
|
|
92
|
+
on_error.call(e, env)
|
|
93
|
+
rescue => hook_error
|
|
94
|
+
Native.log_error("on_error hook raised #{hook_error.class}: #{hook_error.message}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
85
97
|
NOT_FUSED
|
|
86
98
|
end
|
|
87
99
|
|
|
100
|
+
# First frames only: the raise site is at the top, and Rails stacks
|
|
101
|
+
# run hundreds of middleware frames deep. Hooks get the full exception.
|
|
102
|
+
BACKTRACE_FRAMES = 12
|
|
103
|
+
|
|
104
|
+
def error_log_line(error)
|
|
105
|
+
["#{error.class}: #{error.message}",
|
|
106
|
+
*(error.backtrace || []).first(BACKTRACE_FRAMES)].join("\n ")
|
|
107
|
+
end
|
|
108
|
+
|
|
88
109
|
def deliver_streaming(request, status, headers, body, input)
|
|
89
110
|
request.send_headers(status, headers)
|
|
90
111
|
if body.respond_to?(:call) && !body.respond_to?(:each)
|
|
@@ -99,10 +120,13 @@ module Kino
|
|
|
99
120
|
end
|
|
100
121
|
else
|
|
101
122
|
# Enumerable body: chunked transfer unless the app set content-length.
|
|
123
|
+
# finish only on success: a body that raised must abort the
|
|
124
|
+
# connection (serve's rescue), not fake a clean end of stream that
|
|
125
|
+
# the client cannot tell from a complete response.
|
|
102
126
|
begin
|
|
103
127
|
body.each { |chunk| request.write_chunk(chunk) }
|
|
104
|
-
ensure
|
|
105
128
|
request.finish
|
|
129
|
+
ensure
|
|
106
130
|
body.close if body.respond_to?(:close)
|
|
107
131
|
end
|
|
108
132
|
end
|
|
@@ -119,6 +143,6 @@ module Kino
|
|
|
119
143
|
end
|
|
120
144
|
|
|
121
145
|
private_class_method :handle_one, :process, :serve, :deliver_streaming,
|
|
122
|
-
:join_chunks
|
|
146
|
+
:join_chunks, :error_log_line
|
|
123
147
|
end
|
|
124
148
|
end
|
data/sig/kino.rbs
CHANGED
|
@@ -97,6 +97,7 @@ module Kino
|
|
|
97
97
|
def batch: (int count) -> untyped
|
|
98
98
|
def lanes: (boolish enabled) -> untyped
|
|
99
99
|
def log_requests: (boolish enabled) -> untyped
|
|
100
|
+
def on_error: (?^(Exception, Hash[String, untyped]) -> void handler) ?{ (Exception, Hash[String, untyped]) -> void } -> untyped
|
|
100
101
|
def shutdown_timeout: (Numeric seconds) -> untyped
|
|
101
102
|
def tokio_threads: (int count) -> untyped
|
|
102
103
|
def tls: (cert: String, key: String) -> untyped
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kino
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -165,6 +165,7 @@ files:
|
|
|
165
165
|
- ext/kino/src/gvl.rs
|
|
166
166
|
- ext/kino/src/lib.rs
|
|
167
167
|
- ext/kino/src/logsink.rs
|
|
168
|
+
- ext/kino/src/pin.rs
|
|
168
169
|
- ext/kino/src/queue.rs
|
|
169
170
|
- ext/kino/src/registry.rs
|
|
170
171
|
- ext/kino/src/request.rs
|