kino 0.4.0 → 0.6.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.
data/ext/kino/src/lib.rs CHANGED
@@ -9,6 +9,7 @@ mod control;
9
9
  mod cpus;
10
10
  mod env_strings;
11
11
  mod gvl;
12
+ mod io_shards;
12
13
  mod listen;
13
14
  mod log;
14
15
  mod logsink;
@@ -40,8 +41,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
40
41
  let native = module.define_module("Native")?;
41
42
  native.define_singleton_method("server_start", function!(server::server_start, 1))?;
42
43
  native.define_singleton_method("register_worker", function!(server::register_worker, 1))?;
43
- native.define_singleton_method("take_one", function!(queue::take_one, 2))?;
44
- native.define_singleton_method("take_batch", function!(queue::take_batch, 3))?;
44
+ native.define_singleton_method("worker", function!(queue::worker, 2))?;
45
45
  native.define_singleton_method("stop_accepting", function!(server::stop_accepting, 1))?;
46
46
  native.define_singleton_method("close_queue", function!(server::close_queue, 1))?;
47
47
  native.define_singleton_method("queue_stats", function!(server::queue_stats, 1))?;
@@ -83,11 +83,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
83
83
  native.define_class("PinKeeper", ruby.class_object())?;
84
84
  native.define_singleton_method("pin_keeper", function!(server::pin_keeper, 1))?;
85
85
 
86
+ let worker = native.define_class("Worker", ruby.class_object())?;
87
+ worker.define_method("take_one", method!(queue::Worker::take_one, 0))?;
88
+ worker.define_method("take_batch", method!(queue::Worker::take_batch, 1))?;
89
+
86
90
  let request = native.define_class("Request", ruby.class_object())?;
87
- request.define_method("respond_and_take", method!(queue::respond_and_take, 6))?;
91
+ request.define_method("respond_and_take", method!(queue::respond_and_take, 5))?;
88
92
  request.define_method(
89
93
  "respond_and_take_one",
90
- method!(queue::respond_and_take_one, 5),
94
+ method!(queue::respond_and_take_one, 4),
91
95
  )?;
92
96
  request.define_method("read_body", method!(Request::read_body, 1))?;
93
97
  request.define_method("send_simple", method!(crate::request::respond_simple, 3))?;
@@ -97,10 +101,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
97
101
  request.define_method("abort", method!(Request::abort, 0))?;
98
102
  request.define_method("timing", method!(Request::set_timing, 2))?;
99
103
 
100
- // Force-resolve the TypedData class cache on the main ractor: magnus
101
- // resolves it lazily on first wrap, and a racy first resolution from two
102
- // worker ractors is the failure mode we must rule out.
104
+ // Force-resolve the TypedData class caches on the main ractor: magnus
105
+ // resolves them lazily on first wrap, and a racy first resolution from
106
+ // two worker ractors is the failure mode we must rule out.
103
107
  let _ = <Request as magnus::TypedData>::class(ruby);
108
+ let _ = <queue::Worker as magnus::TypedData>::class(ruby);
104
109
 
105
110
  // Frozen env key/value cache: built once here (main ractor, GVL held),
106
111
  // shared by every worker ractor afterwards.
@@ -86,7 +86,10 @@ mod tests {
86
86
 
87
87
  #[test]
88
88
  fn unix_path_recognises_only_the_unix_scheme() {
89
- assert_eq!(unix_path("unix:///run/kino.sock").unwrap().to_str(), Some("/run/kino.sock"));
89
+ assert_eq!(
90
+ unix_path("unix:///run/kino.sock").unwrap().to_str(),
91
+ Some("/run/kino.sock")
92
+ );
90
93
  assert!(unix_path("127.0.0.1").is_none());
91
94
  assert!(unix_path("unix.example.com").is_none());
92
95
  }
data/ext/kino/src/log.rs CHANGED
@@ -99,7 +99,10 @@ mod tests {
99
99
  #[test]
100
100
  fn label_is_a_syslog_tag_plus_the_source() {
101
101
  assert_eq!(label(4213, "main"), "kino[4213] main:");
102
- assert_eq!(label(4213, "worker-3/thread-2"), "kino[4213] worker-3/thread-2:");
102
+ assert_eq!(
103
+ label(4213, "worker-3/thread-2"),
104
+ "kino[4213] worker-3/thread-2:"
105
+ );
103
106
  }
104
107
 
105
108
  #[test]
@@ -129,7 +132,12 @@ mod tests {
129
132
  #[test]
130
133
  fn a_report_is_labelled_on_its_first_line_only() {
131
134
  assert_eq!(
132
- format_line(Level::Error, "kino[1] main:", "500 GET / · X: y\n a.rb:1", false),
135
+ format_line(
136
+ Level::Error,
137
+ "kino[1] main:",
138
+ "500 GET / · X: y\n a.rb:1",
139
+ false
140
+ ),
133
141
  "kino[1] main: 500 GET / · X: y\n a.rb:1"
134
142
  );
135
143
  }
@@ -117,7 +117,8 @@ fn admit(
117
117
  ) -> Result<RHash, Error> {
118
118
  server.served.fetch_add(1, Ordering::Relaxed);
119
119
  slot.served.fetch_add(1, Ordering::Relaxed);
120
- slot.last_started_ms.store(crate::mono::mono_ms(), Ordering::Relaxed);
120
+ slot.last_started_ms
121
+ .store(crate::mono::mono_ms(), Ordering::Relaxed);
121
122
  slot.in_flight.fetch_add(1, Ordering::Relaxed);
122
123
  // One clock read serves the histogram and, for the access log, the
123
124
  // request's queue wait and the start of its time in Ruby.
@@ -137,54 +138,73 @@ fn admit(
137
138
  Ok(env)
138
139
  }
139
140
 
140
- type Checkout = (Arc<ServerInner>, Arc<WorkerSlot>, BoxedCtx);
141
+ /// Per-worker native handle: the registry lookup and slot resolution are
142
+ /// paid once at worker boot instead of on every take. Created inside the
143
+ /// worker thread/ractor that uses it (like Request handles), so ownership
144
+ /// is correct by construction. Holds the server weakly: after teardown the
145
+ /// upgrade fails, which is the same clean shutdown signal the per-take
146
+ /// registry lookup used to give.
147
+ #[magnus::wrap(class = "Kino::Native::Worker", free_immediately)]
148
+ pub struct Worker {
149
+ server: std::sync::Weak<ServerInner>,
150
+ slot: Arc<WorkerSlot>,
151
+ }
141
152
 
142
- fn checkout(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Checkout>, Error> {
153
+ /// Resolve a (server, worker) pair into a Worker handle; nil when the
154
+ /// server is already gone (the caller treats that as shutdown).
155
+ pub fn worker(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Worker>, Error> {
143
156
  let Some(server) = registry::try_get(server_id) else {
144
- return Ok(None); // server torn down → clean shutdown signal
157
+ return Ok(None);
145
158
  };
146
159
  let slot = server.slot(ruby, worker_id)?;
160
+ Ok(Some(Worker {
161
+ server: Arc::downgrade(&server),
162
+ slot,
163
+ }))
164
+ }
147
165
 
148
- // The previous batch is fully answered once the worker comes back.
149
- slot.current.lock().clear();
150
- slot.in_flight.store(0, Ordering::Relaxed);
151
- slot.interrupted.store(false, Ordering::SeqCst);
166
+ impl Worker {
167
+ fn checkout(&self) -> Result<Option<(Arc<ServerInner>, BoxedCtx)>, Error> {
168
+ let Some(server) = self.server.upgrade() else {
169
+ return Ok(None); // server torn down → clean shutdown signal
170
+ };
152
171
 
153
- Ok(block_take(&server, &slot)?.map(|ctx| (server, slot, ctx)))
154
- }
172
+ // The previous batch is fully answered once the worker comes back.
173
+ self.slot.current.lock().clear();
174
+ self.slot.in_flight.store(0, Ordering::Relaxed);
175
+ self.slot.interrupted.store(false, Ordering::SeqCst);
155
176
 
156
- /// Take one request; returns its env Hash (request handle inside under
157
- /// "kino.request") or nil on shutdown. The batch-of-one hot path: no
158
- /// arrays allocated at all.
159
- pub fn take_one(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<RHash>, Error> {
160
- match checkout(ruby, server_id, worker_id)? {
161
- Some((server, slot, ctx)) => Ok(Some(admit(ruby, &server, &slot, ctx)?)),
162
- None => Ok(None),
177
+ Ok(block_take(&server, &self.slot)?.map(|ctx| (server, ctx)))
163
178
  }
164
- }
165
179
 
166
- /// Take up to `max` requests: block for the first, drain the rest
167
- /// non-blocking (they only batch when the queue is already deep).
168
- /// Returns nil on shutdown; otherwise an Array of env Hashes.
169
- pub fn take_batch(
170
- ruby: &Ruby,
171
- server_id: u64,
172
- worker_id: usize,
173
- max: usize,
174
- ) -> Result<Option<RArray>, Error> {
175
- let Some((server, slot, first)) = checkout(ruby, server_id, worker_id)? else {
176
- return Ok(None);
177
- };
180
+ /// Take one request; returns its env Hash (request handle inside under
181
+ /// "kino.request") or nil on shutdown. The batch-of-one hot path: no
182
+ /// arrays allocated at all.
183
+ pub fn take_one(ruby: &Ruby, rb_self: &Worker) -> Result<Option<RHash>, Error> {
184
+ match rb_self.checkout()? {
185
+ Some((server, ctx)) => Ok(Some(admit(ruby, &server, &rb_self.slot, ctx)?)),
186
+ None => Ok(None),
187
+ }
188
+ }
178
189
 
179
- let batch = ruby.ary_new_capa(max.max(1));
180
- batch.push(admit(ruby, &server, &slot, first)?)?;
181
- for _ in 1..max {
182
- match server.req_rx.try_recv() {
183
- Ok(ctx) => batch.push(admit(ruby, &server, &slot, ctx)?)?,
184
- Err(_) => break,
190
+ /// Take up to `max` requests: block for the first, drain the rest
191
+ /// non-blocking (they only batch when the queue is already deep).
192
+ /// Returns nil on shutdown; otherwise an Array of env Hashes.
193
+ pub fn take_batch(ruby: &Ruby, rb_self: &Worker, max: usize) -> Result<Option<RArray>, Error> {
194
+ let Some((server, first)) = rb_self.checkout()? else {
195
+ return Ok(None);
196
+ };
197
+
198
+ let batch = ruby.ary_new_capa(max.max(1));
199
+ batch.push(admit(ruby, &server, &rb_self.slot, first)?)?;
200
+ for _ in 1..max {
201
+ match server.req_rx.try_recv() {
202
+ Ok(ctx) => batch.push(admit(ruby, &server, &rb_self.slot, ctx)?)?,
203
+ Err(_) => break,
204
+ }
185
205
  }
206
+ Ok(Some(batch))
186
207
  }
187
- Ok(Some(batch))
188
208
  }
189
209
 
190
210
  /// The fused hot path: answer `request` (complete response in one shot)
@@ -193,28 +213,25 @@ pub fn take_batch(
193
213
  pub fn respond_and_take_one(
194
214
  ruby: &Ruby,
195
215
  request: &Request,
196
- server_id: u64,
197
- worker_id: usize,
216
+ worker: magnus::typed_data::Obj<Worker>,
198
217
  status: u16,
199
218
  headers: RHash,
200
219
  body: RString,
201
220
  ) -> Result<Option<RHash>, Error> {
202
221
  crate::request::respond_simple(ruby, request, status, headers, body)?;
203
- take_one(ruby, server_id, worker_id)
222
+ Worker::take_one(ruby, &worker)
204
223
  }
205
224
 
206
225
  /// Batch variant of the fused call.
207
- #[allow(clippy::too_many_arguments)]
208
226
  pub fn respond_and_take(
209
227
  ruby: &Ruby,
210
228
  request: &Request,
211
- server_id: u64,
212
- worker_id: usize,
229
+ worker: magnus::typed_data::Obj<Worker>,
213
230
  max: usize,
214
231
  status: u16,
215
232
  headers: RHash,
216
233
  body: RString,
217
234
  ) -> Result<Option<RArray>, Error> {
218
235
  crate::request::respond_simple(ruby, request, status, headers, body)?;
219
- take_batch(ruby, server_id, worker_id, max)
236
+ Worker::take_batch(ruby, &worker, max)
220
237
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7
7
  use std::sync::{Arc, OnceLock, Weak};
8
+ use std::time::{Duration, Instant};
8
9
 
9
10
  use parking_lot::{Mutex, RwLock};
10
11
 
@@ -32,6 +33,51 @@ pub type BoxedCtx = Box<RequestCtx>;
32
33
  /// Probed on every take; keys are our own ids, so ahash over SipHash.
33
34
  type HashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
34
35
 
36
+ /// What runs the server's I/O, taken out at shutdown. The default
37
+ /// multi-thread runtime and sharded I/O carry different teardown state,
38
+ /// so the shape stays explicit instead of parallel optional fields.
39
+ #[derive(Default)]
40
+ pub enum RuntimeHandle {
41
+ /// Not started yet, or already shut down.
42
+ #[default]
43
+ None,
44
+ MultiThread(tokio::runtime::Runtime),
45
+ /// The shards' final-teardown signal and every I/O thread (shards plus
46
+ /// the acceptor).
47
+ Shards {
48
+ shutdown_tx: tokio::sync::watch::Sender<bool>,
49
+ threads: Vec<std::thread::JoinHandle<()>>,
50
+ },
51
+ }
52
+
53
+ impl RuntimeHandle {
54
+ /// Stop the I/O side, giving in-flight work `timeout` to finish. A
55
+ /// thread that misses the deadline is abandoned rather than joined:
56
+ /// a wedged shard must not hang `Server#shutdown`, which promises to
57
+ /// return by its deadline.
58
+ pub fn shutdown(self, timeout: Duration) {
59
+ match self {
60
+ RuntimeHandle::None => {}
61
+ RuntimeHandle::MultiThread(runtime) => runtime.shutdown_timeout(timeout),
62
+ RuntimeHandle::Shards {
63
+ shutdown_tx,
64
+ threads,
65
+ } => {
66
+ let _ = shutdown_tx.send(true);
67
+ let deadline = Instant::now() + timeout;
68
+ for thread in threads {
69
+ while !thread.is_finished() && Instant::now() < deadline {
70
+ std::thread::sleep(Duration::from_millis(1));
71
+ }
72
+ if thread.is_finished() {
73
+ let _ = thread.join();
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+
35
81
  /// One per `Kino::Server`. Owns the tokio runtime, the request queue and the
36
82
  /// worker slots.
37
83
  pub struct ServerInner {
@@ -42,9 +88,9 @@ pub struct ServerInner {
42
88
  pub req_rx: flume::Receiver<BoxedCtx>,
43
89
  /// Signals the accept loop to stop. Watch channel: `true` = draining.
44
90
  pub shutdown_tx: tokio::sync::watch::Sender<bool>,
45
- /// Runtime is kept so we can shut it down explicitly; in an Option so
46
- /// `shutdown_runtime` can take ownership out of the Arc.
47
- pub runtime: Mutex<Option<tokio::runtime::Runtime>>,
91
+ /// Runtime is kept so we can shut it down explicitly; `shutdown_runtime`
92
+ /// takes ownership out of the Arc.
93
+ pub runtime: Mutex<RuntimeHandle>,
48
94
  pub slots: RwLock<Vec<Arc<WorkerSlot>>>,
49
95
  pub in_flight: AtomicUsize,
50
96
  /// Requests handed to Ruby workers (admitted), and requests rejected
@@ -69,6 +115,9 @@ pub struct ServerInner {
69
115
  pub quarantine_replacements: AtomicU64,
70
116
  pub topology: Topology,
71
117
  pub https: bool,
118
+ /// HTTP/2 serving (ALPN over TLS, prior-knowledge h2c on plaintext);
119
+ /// false pins every connection to HTTP/1.
120
+ pub http2: bool,
72
121
  /// The socket file of a `unix://` bind, removed at shutdown.
73
122
  pub unix_path: Option<std::path::PathBuf>,
74
123
  /// Native access log sink (None unless log_requests is on).
@@ -118,8 +167,8 @@ pub const LANE_DEPTH: usize = 4;
118
167
  /// Fixed queue-wait bucket boundaries in microseconds (0.5ms .. 10s),
119
168
  /// ascending. Emitted in seconds. Not a knob (YAGNI).
120
169
  pub const QUEUE_BOUNDS_US: [u64; 14] = [
121
- 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
122
- 250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, 10_000_000,
170
+ 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
171
+ 2_500_000, 5_000_000, 10_000_000,
123
172
  ];
124
173
 
125
174
  /// Queue-wait histogram: per-bucket counts plus an overflow (the implicit
@@ -289,7 +338,7 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
289
338
  req_tx: Mutex::new(Some(req_tx)),
290
339
  req_rx,
291
340
  shutdown_tx,
292
- runtime: Mutex::new(None),
341
+ runtime: Mutex::new(RuntimeHandle::None),
293
342
  slots: RwLock::new(Vec::new()),
294
343
  in_flight: AtomicUsize::new(0),
295
344
  served: AtomicU64::new(0),
@@ -301,8 +350,14 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
301
350
  state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
302
351
  respawns: AtomicU64::new(0),
303
352
  quarantine_replacements: AtomicU64::new(0),
304
- topology: Topology { mode: "threaded".to_string(), workers: 0, threads: 0, batch: 1 },
353
+ topology: Topology {
354
+ mode: "threaded".to_string(),
355
+ workers: 0,
356
+ threads: 0,
357
+ batch: 1,
358
+ },
305
359
  https: false,
360
+ http2: true,
306
361
  unix_path: None,
307
362
  access_log: None,
308
363
  lanes,
@@ -317,6 +372,44 @@ mod tests {
317
372
  use super::*;
318
373
  use crate::request::test_ctx;
319
374
 
375
+ #[test]
376
+ fn shards_shutdown_joins_threads_that_observe_the_signal() {
377
+ let (shutdown_tx, rx) = tokio::sync::watch::channel(false);
378
+ let thread = std::thread::spawn(move || {
379
+ while !*rx.borrow() {
380
+ std::thread::sleep(Duration::from_millis(1));
381
+ }
382
+ });
383
+
384
+ let start = Instant::now();
385
+ RuntimeHandle::Shards {
386
+ shutdown_tx,
387
+ threads: vec![thread],
388
+ }
389
+ .shutdown(Duration::from_secs(5));
390
+
391
+ // The thread exits on the signal, so the join comes nowhere near
392
+ // the deadline.
393
+ assert!(start.elapsed() < Duration::from_secs(1));
394
+ }
395
+
396
+ #[test]
397
+ fn shards_shutdown_abandons_a_wedged_thread_at_the_deadline() {
398
+ let (shutdown_tx, _rx) = tokio::sync::watch::channel(false);
399
+ let wedged = std::thread::spawn(|| std::thread::sleep(Duration::from_secs(30)));
400
+
401
+ let start = Instant::now();
402
+ RuntimeHandle::Shards {
403
+ shutdown_tx,
404
+ threads: vec![wedged],
405
+ }
406
+ .shutdown(Duration::from_millis(50));
407
+
408
+ let elapsed = start.elapsed();
409
+ assert!(elapsed >= Duration::from_millis(50));
410
+ assert!(elapsed < Duration::from_secs(5));
411
+ }
412
+
320
413
  #[test]
321
414
  fn worker_registration_hands_out_sequential_slot_ids() {
322
415
  let server = test_server(false, 4);
@@ -420,9 +513,9 @@ mod tests {
420
513
  #[test]
421
514
  fn queue_histogram_buckets_by_wait() {
422
515
  let h = QueueHistogram::new();
423
- h.record(400); // <= 500 -> bucket 0
424
- h.record(500); // == 500 -> bucket 0 (inclusive)
425
- h.record(600); // (500, 1000] -> bucket 1
516
+ h.record(400); // <= 500 -> bucket 0
517
+ h.record(500); // == 500 -> bucket 0 (inclusive)
518
+ h.record(600); // (500, 1000] -> bucket 1
426
519
  h.record(20_000_000); // > last bound -> overflow
427
520
  let s = h.snapshot();
428
521
  assert_eq!(s.buckets[0], 2);