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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bd8e6e3b295832fa1d87743b4c9a121cdb5687287011b29f1140814fdae0575
4
- data.tar.gz: f21305459e857d366159ee258d873e2109b7a7715bb0cbe8d23c47e458ae2b33
3
+ metadata.gz: f66c88f09e6d65c5b9ca4b0ed4f1a10f735343286dfed32fbd54d13216faf886
4
+ data.tar.gz: 871d3eeaed7cf34ed8fc83a53fba42d5dfe28b916e9b6c695b332fcf17be5ab8
5
5
  SHA512:
6
- metadata.gz: 04e95f9ee2133b4d15bdd2069977e73791a16b033e57a3bdb88478d0048b0bb5d8f56eff1963813bbf8d9399461bb80bf9c33a2039f98805e4f129b3e42b28ef
7
- data.tar.gz: 8eb131cdbbe5bdbd29d188ab4ff43dbbec2f8c791aacf85586ba69c7208b2f74cc05d4007c469a4de61c4578ef08214e56a4fd080b9ad655af3a01d208b4c752
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
@@ -332,7 +332,7 @@ dependencies = [
332
332
 
333
333
  [[package]]
334
334
  name = "kino"
335
- version = "0.1.2"
335
+ version = "0.2.0"
336
336
  dependencies = [
337
337
  "ahash",
338
338
  "bytes",
data/Cargo.toml CHANGED
@@ -7,8 +7,12 @@ members = ["./ext/kino"]
7
7
  resolver = "2"
8
8
 
9
9
  [profile.release]
10
- # Keep debug symbols in release builds so the final binary stays debuggable.
11
- debug = true
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.** Aabout **~7×** on the simplistic bench
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kino"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
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
- slot.1 = Some(f());
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
- pub fn without_gvl<F, R>(f: F, ubf: Option<Ubf>) -> R
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>) = (Some(f), None);
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. `attempt` must
68
- /// bound each wait (recv_timeout-style) and must not touch the Ruby API.
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
  }
@@ -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
+ }
@@ -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
- gvl::interruptible(&slot.interrupted, || match req_rx.recv_timeout(TICK) {
47
- Ok(ctx) => Some(Some(ctx)),
48
- Err(flume::RecvTimeoutError::Timeout) => None,
49
- Err(flume::RecvTimeoutError::Disconnected) => Some(None),
50
- })
51
- .flatten()
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
- .flatten();
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).map(|ctx| (server, slot, ctx)))
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
@@ -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
 
@@ -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::{Error, RArray, RHash, RString, Ruby, TryConvert, Value};
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. Outer None = interrupted. Every Ruby
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 = Bytes::copy_from_slice(unsafe { body.as_slice() });
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 = Bytes::copy_from_slice(unsafe { chunk.as_slice() });
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: RString, value: Value| {
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::<RString>(i as isize)?);
376
+ take(&mut builder, coerce_str(values.entry(i as isize)?)?);
362
377
  }
363
378
  } else {
364
- take(&mut builder, RString::try_convert(value)?);
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
  }
@@ -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();
@@ -50,7 +50,7 @@ pub fn sleep_chunk(ruby: &Ruby, seconds: f64) -> Result<f64, Error> {
50
50
  func: gvl::ubf_interrupt,
51
51
  data: &interrupted as *const _ as *mut c_void,
52
52
  }),
53
- );
53
+ )?;
54
54
 
55
55
  let remaining = requested.saturating_sub(chunk);
56
56
  Ok(remaining.as_secs_f64())
@@ -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
- @id, @port = Native.server_start(
76
- bind: @bind, port: @requested_port,
77
- queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
78
- request_timeout_ms: @request_timeout_ms,
79
- max_connections: @max_connections,
80
- max_body_size: @max_body_size,
81
- tokio_threads: @tokio_threads,
82
- tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
83
- lanes: @lanes, log_requests: @log_requests
84
- )
85
- File.write(@pidfile, "#{Process.pid}\n") if @pidfile
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, batch: @batch).start
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
- File.delete(@pidfile) if @pidfile && File.exist?(@pidfile)
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Kino
4
4
  # The gem version (single source of truth; ext/kino/Cargo.toml syncs).
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.0"
6
6
  end
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
- Native.log_error("#{e.class}: #{e.message}")
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.1.2
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