kino 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ //! Current-thread Tokio runtimes for HTTP I/O.
2
+ //!
3
+ //! One accept thread owns the listener and assigns accepted connections to
4
+ //! the least-loaded shard. Each shard then owns that connection for its
5
+ //! lifetime, avoiding the shared Tokio worker pool on hot HTTP paths.
6
+
7
+ use std::net::SocketAddr;
8
+ use std::sync::atomic::{AtomicUsize, Ordering};
9
+ use std::sync::Arc;
10
+ use std::thread::JoinHandle;
11
+
12
+ use crate::listen::Listener;
13
+ use crate::log::{self, Level};
14
+ use crate::registry::{ServerInner, STATE_DRAINING};
15
+ use crate::server::{serve_conn, AsyncListener, Conn};
16
+
17
+ /// A connection in transit from the acceptor to its shard. Tokio streams
18
+ /// are bound to the runtime that registered them, so the handoff carries
19
+ /// the std stream and the shard re-registers it on arrival.
20
+ enum StdConn {
21
+ Tcp(std::net::TcpStream),
22
+ Unix(std::os::unix::net::UnixStream),
23
+ }
24
+
25
+ impl StdConn {
26
+ /// Register with the calling (shard) runtime.
27
+ fn into_tokio(self) -> std::io::Result<Conn> {
28
+ Ok(match self {
29
+ StdConn::Tcp(stream) => Conn::Tcp(tokio::net::TcpStream::from_std(stream)?),
30
+ StdConn::Unix(stream) => Conn::Unix(tokio::net::UnixStream::from_std(stream)?),
31
+ })
32
+ }
33
+ }
34
+
35
+ /// Detach an accepted stream from the acceptor's runtime for the handoff.
36
+ fn into_std(conn: Conn) -> std::io::Result<StdConn> {
37
+ Ok(match conn {
38
+ Conn::Tcp(stream) => StdConn::Tcp(stream.into_std()?),
39
+ Conn::Unix(stream) => StdConn::Unix(stream.into_std()?),
40
+ })
41
+ }
42
+
43
+ /// One accepted connection en route to a shard: the detached stream, the
44
+ /// addresses hyper reports, and the slot it holds against max_connections.
45
+ struct Accepted {
46
+ conn: StdConn,
47
+ remote_addr: SocketAddr,
48
+ local_addr: SocketAddr,
49
+ permit: tokio::sync::OwnedSemaphorePermit,
50
+ }
51
+
52
+ /// Shard count: an explicit `io_threads` wins; the default is half the
53
+ /// available CPUs. Framing requests is cheap next to running the app, so
54
+ /// the I/O plane gets the smaller share and Ruby workers keep the rest.
55
+ pub(crate) fn thread_count(io_threads: usize) -> usize {
56
+ if io_threads > 0 {
57
+ return io_threads;
58
+ }
59
+ default_thread_count(std::thread::available_parallelism().map_or(1, usize::from))
60
+ }
61
+
62
+ fn default_thread_count(cpus: usize) -> usize {
63
+ cpus.div_ceil(2)
64
+ }
65
+
66
+ /// Boot the shard threads, then the acceptor. Any thread that fails to
67
+ /// come up fails the whole boot: the already started threads are drained
68
+ /// (their senders drop) and joined before the error reaches Ruby.
69
+ pub(crate) fn spawn(
70
+ listener: Listener,
71
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
72
+ server: Arc<ServerInner>,
73
+ max_connections: usize,
74
+ accept_shutdown_rx: tokio::sync::watch::Receiver<bool>,
75
+ runtime_shutdown_rx: tokio::sync::watch::Receiver<bool>,
76
+ shard_count: usize,
77
+ ) -> std::io::Result<Vec<JoinHandle<()>>> {
78
+ let shard_count = shard_count.max(1);
79
+ let mut handles = Vec::with_capacity(shard_count + 1);
80
+ let mut shard_txs = Vec::with_capacity(shard_count);
81
+ let mut loads = Vec::with_capacity(shard_count);
82
+
83
+ for i in 0..shard_count {
84
+ let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
85
+ let load = Arc::new(AtomicUsize::new(0));
86
+ let spawned = spawn_shard(
87
+ i,
88
+ rx,
89
+ acceptor.clone(),
90
+ server.clone(),
91
+ load.clone(),
92
+ runtime_shutdown_rx.clone(),
93
+ );
94
+ match spawned {
95
+ Ok(handle) => {
96
+ shard_txs.push(tx);
97
+ loads.push(load);
98
+ handles.push(handle);
99
+ }
100
+ Err(error) => {
101
+ drop(tx);
102
+ drop(shard_txs);
103
+ join_all(handles);
104
+ return Err(error);
105
+ }
106
+ }
107
+ }
108
+
109
+ match spawn_acceptor(
110
+ listener,
111
+ server,
112
+ max_connections,
113
+ accept_shutdown_rx,
114
+ shard_txs,
115
+ loads,
116
+ ) {
117
+ Ok(handle) => handles.push(handle),
118
+ Err(error) => {
119
+ join_all(handles);
120
+ return Err(error);
121
+ }
122
+ }
123
+ Ok(handles)
124
+ }
125
+
126
+ fn join_all(handles: Vec<JoinHandle<()>>) {
127
+ for handle in handles {
128
+ let _ = handle.join();
129
+ }
130
+ }
131
+
132
+ fn current_thread_runtime() -> std::io::Result<tokio::runtime::Runtime> {
133
+ tokio::runtime::Builder::new_current_thread()
134
+ .enable_all()
135
+ .build()
136
+ }
137
+
138
+ /// Wait for a just spawned I/O thread to report its runtime up, so a
139
+ /// startup failure becomes the boot error Ruby sees instead of a silently
140
+ /// dead thread.
141
+ fn await_ready(
142
+ handle: JoinHandle<()>,
143
+ ready_rx: std::sync::mpsc::Receiver<std::io::Result<()>>,
144
+ what: &str,
145
+ ) -> std::io::Result<JoinHandle<()>> {
146
+ match ready_rx.recv() {
147
+ Ok(Ok(())) => Ok(handle),
148
+ Ok(Err(error)) => {
149
+ let _ = handle.join();
150
+ Err(error)
151
+ }
152
+ Err(_) => Err(std::io::Error::other(format!(
153
+ "{what} thread exited during startup"
154
+ ))),
155
+ }
156
+ }
157
+
158
+ fn spawn_shard(
159
+ index: usize,
160
+ rx: tokio::sync::mpsc::UnboundedReceiver<Accepted>,
161
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
162
+ server: Arc<ServerInner>,
163
+ load: Arc<AtomicUsize>,
164
+ mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
165
+ ) -> std::io::Result<JoinHandle<()>> {
166
+ let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
167
+ let handle = std::thread::Builder::new()
168
+ .name(format!("kino-io-{index}"))
169
+ .spawn(move || {
170
+ let runtime = match current_thread_runtime() {
171
+ Ok(runtime) => runtime,
172
+ Err(error) => {
173
+ let _ = ready_tx.send(Err(error));
174
+ return;
175
+ }
176
+ };
177
+ let _ = ready_tx.send(Ok(()));
178
+ runtime.block_on(shard_loop(rx, acceptor, server, load, &mut shutdown_rx));
179
+ })?;
180
+ await_ready(handle, ready_rx, "shard")
181
+ }
182
+
183
+ fn spawn_acceptor(
184
+ listener: Listener,
185
+ server: Arc<ServerInner>,
186
+ max_connections: usize,
187
+ shutdown_rx: tokio::sync::watch::Receiver<bool>,
188
+ shard_txs: Vec<tokio::sync::mpsc::UnboundedSender<Accepted>>,
189
+ loads: Vec<Arc<AtomicUsize>>,
190
+ ) -> std::io::Result<JoinHandle<()>> {
191
+ let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
192
+ let handle = std::thread::Builder::new()
193
+ .name("kino-accept".to_string())
194
+ .spawn(move || {
195
+ let runtime = match current_thread_runtime() {
196
+ Ok(runtime) => runtime,
197
+ Err(error) => {
198
+ let _ = ready_tx.send(Err(error));
199
+ return;
200
+ }
201
+ };
202
+ runtime.block_on(async move {
203
+ // Registration must happen on this runtime; a failure is
204
+ // routed through the same ready channel as a build error.
205
+ let listener = match AsyncListener::from_std(listener) {
206
+ Ok(listener) => listener,
207
+ Err(error) => {
208
+ let _ = ready_tx.send(Err(error));
209
+ return;
210
+ }
211
+ };
212
+ accept_loop(
213
+ listener,
214
+ server,
215
+ max_connections,
216
+ shutdown_rx,
217
+ shard_txs,
218
+ loads,
219
+ ready_tx,
220
+ )
221
+ .await;
222
+ });
223
+ })?;
224
+ await_ready(handle, ready_rx, "accept")
225
+ }
226
+
227
+ /// Serve handed-over connections until the acceptor is gone, then let the
228
+ /// remaining ones finish. The final teardown signal cuts either phase
229
+ /// short: dropping the runtime cancels connection tasks at their next
230
+ /// await point.
231
+ async fn shard_loop(
232
+ mut rx: tokio::sync::mpsc::UnboundedReceiver<Accepted>,
233
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
234
+ server: Arc<ServerInner>,
235
+ load: Arc<AtomicUsize>,
236
+ shutdown_rx: &mut tokio::sync::watch::Receiver<bool>,
237
+ ) {
238
+ let mut connections = tokio::task::JoinSet::new();
239
+ loop {
240
+ tokio::select! {
241
+ _ = shutdown_rx.changed() => return,
242
+ accepted = rx.recv() => {
243
+ let Some(accepted) = accepted else { break };
244
+ let acceptor = acceptor.clone();
245
+ let server = server.clone();
246
+ let guard = LoadGuard(load.clone());
247
+ connections.spawn(async move {
248
+ let _guard = guard;
249
+ serve_accepted(accepted, acceptor, server).await;
250
+ });
251
+ }
252
+ // Reap closed connections so the set stays small.
253
+ Some(_) = connections.join_next(), if !connections.is_empty() => {}
254
+ }
255
+ }
256
+ // The acceptor is gone: drain. Every join is one connection closing.
257
+ while !connections.is_empty() {
258
+ tokio::select! {
259
+ _ = shutdown_rx.changed() => return,
260
+ _ = connections.join_next() => {}
261
+ }
262
+ }
263
+ }
264
+
265
+ /// Keeps the shard's connection count honest whichever way the task ends:
266
+ /// return, panic, or cancellation at teardown. A plain decrement after the
267
+ /// await would never run on the last two.
268
+ struct LoadGuard(Arc<AtomicUsize>);
269
+
270
+ impl Drop for LoadGuard {
271
+ fn drop(&mut self) {
272
+ self.0.fetch_sub(1, Ordering::Relaxed);
273
+ }
274
+ }
275
+
276
+ /// The shard's half of the handoff: re-register the stream on this
277
+ /// runtime, then run the shared connection pipeline (TLS handshake,
278
+ /// protocol layer) exactly as the default runtime would.
279
+ async fn serve_accepted(
280
+ accepted: Accepted,
281
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
282
+ server: Arc<ServerInner>,
283
+ ) {
284
+ // Held for the connection's lifetime; dropping it frees a slot.
285
+ let _permit = accepted.permit;
286
+ let conn = match accepted.conn.into_tokio() {
287
+ Ok(conn) => conn,
288
+ Err(_) => {
289
+ log::emit(
290
+ Level::Warn,
291
+ "tokio",
292
+ "failed to register a stream on an I/O shard",
293
+ );
294
+ return;
295
+ }
296
+ };
297
+ serve_conn(
298
+ conn,
299
+ acceptor,
300
+ server,
301
+ accepted.remote_addr,
302
+ accepted.local_addr,
303
+ )
304
+ .await;
305
+ }
306
+
307
+ /// The sharded accept loop. Same backpressure as the default loop: the
308
+ /// permit is acquired before accept, so past max_connections the excess
309
+ /// waits in the kernel backlog instead of being accepted and dropped.
310
+ /// A shard whose channel is gone is marked dead and routed around; with
311
+ /// no shard left the loop stops accepting and flips the server to
312
+ /// draining, so the control plane stops reporting ready.
313
+ async fn accept_loop(
314
+ listener: AsyncListener,
315
+ server: Arc<ServerInner>,
316
+ max_connections: usize,
317
+ mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
318
+ shard_txs: Vec<tokio::sync::mpsc::UnboundedSender<Accepted>>,
319
+ loads: Vec<Arc<AtomicUsize>>,
320
+ ready_tx: std::sync::mpsc::SyncSender<std::io::Result<()>>,
321
+ ) {
322
+ let conn_limit = Arc::new(tokio::sync::Semaphore::new(max_connections));
323
+ let mut live = vec![true; shard_txs.len()];
324
+ let _ = ready_tx.send(Ok(()));
325
+ 'accept: loop {
326
+ let permit = tokio::select! {
327
+ _ = shutdown_rx.changed() => break,
328
+ permit = conn_limit.clone().acquire_owned() => match permit {
329
+ Ok(permit) => permit,
330
+ Err(_) => break,
331
+ },
332
+ };
333
+ let (conn, remote_addr, local_addr) = tokio::select! {
334
+ _ = shutdown_rx.changed() => break,
335
+ accepted = listener.accept() => match accepted {
336
+ Ok(accepted) => accepted,
337
+ Err(_) => continue,
338
+ },
339
+ };
340
+ let conn = match into_std(conn) {
341
+ Ok(conn) => conn,
342
+ Err(_) => {
343
+ log::emit(
344
+ Level::Warn,
345
+ "tokio",
346
+ "failed to detach an accepted stream; connection dropped",
347
+ );
348
+ continue;
349
+ }
350
+ };
351
+ let mut accepted = Accepted {
352
+ conn,
353
+ remote_addr,
354
+ local_addr,
355
+ permit,
356
+ };
357
+ loop {
358
+ let Some(index) = least_loaded(&loads, &live) else {
359
+ log::emit(
360
+ Level::Error,
361
+ "tokio",
362
+ "all I/O shards are down; not accepting connections",
363
+ );
364
+ server.state.store(STATE_DRAINING, Ordering::Relaxed);
365
+ break 'accept;
366
+ };
367
+ loads[index].fetch_add(1, Ordering::Relaxed);
368
+ match shard_txs[index].send(accepted) {
369
+ Ok(()) => break,
370
+ Err(returned) => {
371
+ loads[index].fetch_sub(1, Ordering::Relaxed);
372
+ live[index] = false;
373
+ log::emit(Level::Warn, "tokio", "I/O shard is down; routing around it");
374
+ accepted = returned.0;
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ /// The live shard with the fewest open connections.
382
+ fn least_loaded(loads: &[Arc<AtomicUsize>], live: &[bool]) -> Option<usize> {
383
+ loads
384
+ .iter()
385
+ .enumerate()
386
+ .filter(|(index, _)| live[*index])
387
+ .min_by_key(|(_, load)| load.load(Ordering::Relaxed))
388
+ .map(|(index, _)| index)
389
+ }
390
+
391
+ #[cfg(test)]
392
+ mod tests {
393
+ use super::{default_thread_count, least_loaded, thread_count, Arc, AtomicUsize};
394
+
395
+ fn loads(counts: &[usize]) -> Vec<Arc<AtomicUsize>> {
396
+ counts
397
+ .iter()
398
+ .map(|&n| Arc::new(AtomicUsize::new(n)))
399
+ .collect()
400
+ }
401
+
402
+ #[test]
403
+ fn explicit_io_threads_win() {
404
+ assert_eq!(thread_count(3), 3);
405
+ }
406
+
407
+ #[test]
408
+ fn least_loaded_picks_the_emptiest_live_shard() {
409
+ assert_eq!(
410
+ least_loaded(&loads(&[3, 0, 1]), &[true, true, true]),
411
+ Some(1)
412
+ );
413
+ }
414
+
415
+ #[test]
416
+ fn least_loaded_routes_around_dead_shards() {
417
+ // The dead shard's count is frozen at 0; it must not win anyway.
418
+ assert_eq!(
419
+ least_loaded(&loads(&[3, 0, 1]), &[true, false, true]),
420
+ Some(2)
421
+ );
422
+ }
423
+
424
+ #[test]
425
+ fn least_loaded_reports_when_no_shard_is_left() {
426
+ assert_eq!(least_loaded(&loads(&[0, 0]), &[false, false]), None);
427
+ }
428
+
429
+ #[test]
430
+ fn default_is_half_the_cpus() {
431
+ assert_eq!(default_thread_count(1), 1);
432
+ assert_eq!(default_thread_count(2), 1);
433
+ assert_eq!(default_thread_count(3), 2);
434
+ assert_eq!(default_thread_count(12), 6);
435
+ assert_eq!(default_thread_count(128), 64);
436
+ }
437
+ }
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;
@@ -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.
@@ -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
@@ -118,8 +164,8 @@ pub const LANE_DEPTH: usize = 4;
118
164
  /// Fixed queue-wait bucket boundaries in microseconds (0.5ms .. 10s),
119
165
  /// ascending. Emitted in seconds. Not a knob (YAGNI).
120
166
  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,
167
+ 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
168
+ 2_500_000, 5_000_000, 10_000_000,
123
169
  ];
124
170
 
125
171
  /// Queue-wait histogram: per-bucket counts plus an overflow (the implicit
@@ -289,7 +335,7 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
289
335
  req_tx: Mutex::new(Some(req_tx)),
290
336
  req_rx,
291
337
  shutdown_tx,
292
- runtime: Mutex::new(None),
338
+ runtime: Mutex::new(RuntimeHandle::None),
293
339
  slots: RwLock::new(Vec::new()),
294
340
  in_flight: AtomicUsize::new(0),
295
341
  served: AtomicU64::new(0),
@@ -301,7 +347,12 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
301
347
  state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
302
348
  respawns: AtomicU64::new(0),
303
349
  quarantine_replacements: AtomicU64::new(0),
304
- topology: Topology { mode: "threaded".to_string(), workers: 0, threads: 0, batch: 1 },
350
+ topology: Topology {
351
+ mode: "threaded".to_string(),
352
+ workers: 0,
353
+ threads: 0,
354
+ batch: 1,
355
+ },
305
356
  https: false,
306
357
  unix_path: None,
307
358
  access_log: None,
@@ -317,6 +368,44 @@ mod tests {
317
368
  use super::*;
318
369
  use crate::request::test_ctx;
319
370
 
371
+ #[test]
372
+ fn shards_shutdown_joins_threads_that_observe_the_signal() {
373
+ let (shutdown_tx, rx) = tokio::sync::watch::channel(false);
374
+ let thread = std::thread::spawn(move || {
375
+ while !*rx.borrow() {
376
+ std::thread::sleep(Duration::from_millis(1));
377
+ }
378
+ });
379
+
380
+ let start = Instant::now();
381
+ RuntimeHandle::Shards {
382
+ shutdown_tx,
383
+ threads: vec![thread],
384
+ }
385
+ .shutdown(Duration::from_secs(5));
386
+
387
+ // The thread exits on the signal, so the join comes nowhere near
388
+ // the deadline.
389
+ assert!(start.elapsed() < Duration::from_secs(1));
390
+ }
391
+
392
+ #[test]
393
+ fn shards_shutdown_abandons_a_wedged_thread_at_the_deadline() {
394
+ let (shutdown_tx, _rx) = tokio::sync::watch::channel(false);
395
+ let wedged = std::thread::spawn(|| std::thread::sleep(Duration::from_secs(30)));
396
+
397
+ let start = Instant::now();
398
+ RuntimeHandle::Shards {
399
+ shutdown_tx,
400
+ threads: vec![wedged],
401
+ }
402
+ .shutdown(Duration::from_millis(50));
403
+
404
+ let elapsed = start.elapsed();
405
+ assert!(elapsed >= Duration::from_millis(50));
406
+ assert!(elapsed < Duration::from_secs(5));
407
+ }
408
+
320
409
  #[test]
321
410
  fn worker_registration_hands_out_sequential_slot_ids() {
322
411
  let server = test_server(false, 4);
@@ -420,9 +509,9 @@ mod tests {
420
509
  #[test]
421
510
  fn queue_histogram_buckets_by_wait() {
422
511
  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
512
+ h.record(400); // <= 500 -> bucket 0
513
+ h.record(500); // == 500 -> bucket 0 (inclusive)
514
+ h.record(600); // (500, 1000] -> bucket 1
426
515
  h.record(20_000_000); // > last bound -> overflow
427
516
  let s = h.snapshot();
428
517
  assert_eq!(s.buckets[0], 2);
@@ -115,10 +115,7 @@ pub fn plain_response(status: u16, message: &'static str) -> HyperResponse {
115
115
  mod tests {
116
116
  use super::*;
117
117
 
118
- fn pair() -> (
119
- Responder,
120
- tokio::sync::oneshot::Receiver<HyperResponse>,
121
- ) {
118
+ fn pair() -> (Responder, tokio::sync::oneshot::Receiver<HyperResponse>) {
122
119
  let (head_tx, head_rx) = tokio::sync::oneshot::channel();
123
120
  (Responder::new(head_tx), head_rx)
124
121
  }