kino 0.6.0 → 0.7.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.
@@ -25,6 +25,21 @@ pub const TICK: Duration = Duration::from_millis(50);
25
25
  /// the difference and doesn't need to.
26
26
  type Taken = Option<BoxedCtx>;
27
27
 
28
+ /// What a take does when its bounded wait times out: keep waiting (None),
29
+ /// or end the loop because the pool scaler retired this slot. A retired
30
+ /// lane worker first empties its own lane, one item per timeout: the
31
+ /// dispatcher stopped feeding it when the flag went up (see
32
+ /// registry::WorkerSlot::retire for the ordering), so whatever is
33
+ /// still there is the last of it, and nothing already assigned is
34
+ /// orphaned. Only reached with the queue momentarily empty, so the fast
35
+ /// path pays nothing for it.
36
+ fn after_timeout(slot: &WorkerSlot) -> Option<Taken> {
37
+ if !slot.retired.load(Ordering::SeqCst) {
38
+ return None;
39
+ }
40
+ Some(slot.lane_rx.as_ref().and_then(|rx| rx.try_recv().ok()))
41
+ }
42
+
28
43
  /// Block until one request arrives (GVL released, interruptible).
29
44
  /// No busy-poll before parking, deliberately: the wake-per-request futex
30
45
  /// cost is real (~20% of cycles at saturation, per perf), but a measured
@@ -46,7 +61,7 @@ fn block_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Result<Taken, Err
46
61
  let taken =
47
62
  gvl::interruptible(&slot.interrupted, || match req_rx.recv_timeout(TICK) {
48
63
  Ok(ctx) => Some(Some(ctx)),
49
- Err(flume::RecvTimeoutError::Timeout) => None,
64
+ Err(flume::RecvTimeoutError::Timeout) => after_timeout(slot),
50
65
  Err(flume::RecvTimeoutError::Disconnected) => Some(None),
51
66
  })?;
52
67
  Ok(taken.flatten())
@@ -93,8 +108,10 @@ fn lane_take(server: &ServerInner, slot: &Arc<WorkerSlot>) -> Result<Taken, Erro
93
108
  match lane_rx.recv_timeout(TICK) {
94
109
  Ok(ctx) => Some(Some(ctx)),
95
110
  // Periodic steal so a backlog behind a slow sibling can't
96
- // outlive a tick.
97
- Err(flume::RecvTimeoutError::Timeout) => steal().map(Some),
111
+ // outlive a tick; a retired worker leaves instead.
112
+ Err(flume::RecvTimeoutError::Timeout) => {
113
+ after_timeout(slot).or_else(|| steal().map(Some))
114
+ }
98
115
  Err(flume::RecvTimeoutError::Disconnected) => Some(None),
99
116
  }
100
117
  });
@@ -235,3 +252,82 @@ pub fn respond_and_take(
235
252
  crate::request::respond_simple(ruby, request, status, headers, body)?;
236
253
  Worker::take_batch(ruby, &worker, max)
237
254
  }
255
+
256
+ #[cfg(test)]
257
+ mod tests {
258
+ use super::*;
259
+ use crate::registry::test_server;
260
+ use crate::request::test_ctx;
261
+
262
+ #[test]
263
+ fn a_take_keeps_waiting_after_a_timeout_unless_the_slot_is_retired() {
264
+ let server = test_server(false, 4);
265
+ server.register_worker();
266
+ let slot = server.slots.read()[0].clone();
267
+
268
+ assert!(after_timeout(&slot).is_none());
269
+ }
270
+
271
+ #[test]
272
+ fn a_retired_shared_queue_worker_ends_its_loop_at_the_next_timeout() {
273
+ let server = test_server(false, 4);
274
+ server.register_worker();
275
+ server.slots.read()[0].retire();
276
+ let slot = server.slots.read()[0].clone();
277
+
278
+ assert!(matches!(after_timeout(&slot), Some(None)));
279
+ }
280
+
281
+ #[test]
282
+ fn a_retired_lane_worker_drains_its_own_lane_before_leaving() {
283
+ let server = test_server(true, 4);
284
+ server.register_worker();
285
+ let slot = server.slots.read()[0].clone();
286
+ slot.lane_tx
287
+ .lock()
288
+ .as_ref()
289
+ .expect("lane open")
290
+ .send(test_ctx())
291
+ .expect("lane has room");
292
+ server.slots.read()[0].retire();
293
+
294
+ // One item still assigned to this lane: serve it, not orphan it.
295
+ assert!(matches!(after_timeout(&slot), Some(Some(_))));
296
+ // Lane empty now: leave.
297
+ assert!(matches!(after_timeout(&slot), Some(None)));
298
+ }
299
+
300
+ #[test]
301
+ fn a_retired_lane_worker_drains_every_item_one_per_timeout() {
302
+ let server = test_server(true, 4);
303
+ server.register_worker();
304
+ let slot = server.slots.read()[0].clone();
305
+ for _ in 0..3 {
306
+ slot.lane_tx
307
+ .lock()
308
+ .as_ref()
309
+ .expect("lane open")
310
+ .send(test_ctx())
311
+ .expect("lane has room");
312
+ }
313
+ server.slots.read()[0].retire();
314
+
315
+ for _ in 0..3 {
316
+ assert!(matches!(after_timeout(&slot), Some(Some(_))));
317
+ }
318
+ assert!(matches!(after_timeout(&slot), Some(None)));
319
+ }
320
+
321
+ #[test]
322
+ fn a_reset_slot_keeps_waiting_again() {
323
+ let server = test_server(false, 4);
324
+ server.register_worker();
325
+ let slot = server.slots.read()[0].clone();
326
+ slot.retire();
327
+ assert!(matches!(after_timeout(&slot), Some(None)));
328
+
329
+ slot.reset();
330
+
331
+ assert!(after_timeout(&slot).is_none());
332
+ }
333
+ }
@@ -21,7 +21,11 @@ pub const STATE_DRAINING: u8 = 2;
21
21
  /// "ractor" or "threaded", never "auto".
22
22
  pub struct Topology {
23
23
  pub mode: String,
24
+ /// The pool floor: the configured worker count.
24
25
  pub workers: usize,
26
+ /// The pool ceiling: equal to `workers` for a fixed pool. HTTP/2
27
+ /// admission (SETTINGS_MAX_CONCURRENT_STREAMS) is advertised from it.
28
+ pub max_workers: usize,
25
29
  pub threads: usize,
26
30
  pub batch: usize,
27
31
  }
@@ -113,6 +117,13 @@ pub struct ServerInner {
113
117
  pub respawns: AtomicU64,
114
118
  /// Replacements spawned by the quarantine monitor (Relaxed, advisory).
115
119
  pub quarantine_replacements: AtomicU64,
120
+ /// Workers staying in the pool (a worker told to retire no longer
121
+ /// counts), reported by the Ruby pool as it grows and shrinks; starts
122
+ /// at the floor. Relaxed, advisory.
123
+ pub active_workers: AtomicUsize,
124
+ /// Pool scaler events (Relaxed, advisory).
125
+ pub scale_ups: AtomicU64,
126
+ pub scale_downs: AtomicU64,
116
127
  pub topology: Topology,
117
128
  pub https: bool,
118
129
  /// HTTP/2 serving (ALPN over TLS, prior-knowledge h2c on plaintext);
@@ -158,6 +169,11 @@ pub struct WorkerSlot {
158
169
  /// Set by the quarantine monitor when this slot is abandoned as wedged:
159
170
  /// excluded from wedge detection, and its busy_ms is reported as 0.
160
171
  pub quarantined: std::sync::atomic::AtomicBool,
172
+ /// Set by the pool scaler to send this slot's worker home: the lane
173
+ /// dispatcher skips it, and the take loop ends at its next idle tick
174
+ /// (a request already taken finishes first). Cleared by `reset` when
175
+ /// the slot is handed to a new worker.
176
+ pub retired: std::sync::atomic::AtomicBool,
161
177
  }
162
178
 
163
179
  /// Per-lane depth cap: small, so a slow handler can only ever delay this
@@ -251,8 +267,35 @@ impl WorkerSlot {
251
267
  in_flight: AtomicUsize::new(0),
252
268
  last_started_ms: AtomicU64::new(0),
253
269
  quarantined: std::sync::atomic::AtomicBool::new(false),
270
+ retired: std::sync::atomic::AtomicBool::new(false),
254
271
  }
255
272
  }
273
+
274
+ /// Send this slot's worker home once it is idle. The flag is raised
275
+ /// under the lane lock, the same lock the lane dispatcher holds while
276
+ /// it checks the flag and sends: any dispatch that saw "not retired"
277
+ /// has landed in the lane before the worker can see the flag and
278
+ /// drain, so no request is orphaned.
279
+ pub fn retire(&self) {
280
+ let _lane = self.lane_tx.lock();
281
+ self.retired.store(true, Ordering::SeqCst);
282
+ }
283
+
284
+ /// Return a retired slot to its fresh state so a new worker can take
285
+ /// it over (slots are never removed; recycling keeps the registry
286
+ /// from growing with every scale-up). The lane channel stays, the new
287
+ /// occupant simply starts taking from it; the quarantine mark stays
288
+ /// too, a quarantined slot is never handed out.
289
+ pub fn reset(&self) {
290
+ let _lane = self.lane_tx.lock();
291
+ self.current.lock().clear();
292
+ self.retired.store(false, Ordering::SeqCst);
293
+ self.parked.store(false, Ordering::SeqCst);
294
+ self.interrupted.store(false, Ordering::SeqCst);
295
+ self.served.store(0, Ordering::Relaxed);
296
+ self.in_flight.store(0, Ordering::Relaxed);
297
+ self.last_started_ms.store(0, Ordering::Relaxed);
298
+ }
256
299
  }
257
300
 
258
301
  static REGISTRY: OnceLock<RwLock<HashMap<u64, Arc<ServerInner>>>> = OnceLock::new();
@@ -350,9 +393,13 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
350
393
  state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
351
394
  respawns: AtomicU64::new(0),
352
395
  quarantine_replacements: AtomicU64::new(0),
396
+ active_workers: AtomicUsize::new(0),
397
+ scale_ups: AtomicU64::new(0),
398
+ scale_downs: AtomicU64::new(0),
353
399
  topology: Topology {
354
400
  mode: "threaded".to_string(),
355
401
  workers: 0,
402
+ max_workers: 0,
356
403
  threads: 0,
357
404
  batch: 1,
358
405
  },
@@ -491,6 +538,18 @@ mod tests {
491
538
  assert_eq!(server.topology.batch, 1);
492
539
  }
493
540
 
541
+ #[test]
542
+ fn pool_counters_start_at_the_configured_floor() {
543
+ let server = test_server(false, 4);
544
+ assert_eq!(
545
+ server.active_workers.load(Ordering::Relaxed),
546
+ server.topology.workers
547
+ );
548
+ assert_eq!(server.topology.max_workers, server.topology.workers);
549
+ assert_eq!(server.scale_ups.load(Ordering::Relaxed), 0);
550
+ assert_eq!(server.scale_downs.load(Ordering::Relaxed), 0);
551
+ }
552
+
494
553
  #[test]
495
554
  fn fresh_slot_has_zeroed_per_worker_sensors() {
496
555
  let server = test_server(false, 4);
@@ -510,6 +569,88 @@ mod tests {
510
569
  assert_eq!(server.quarantine_replacements.load(Ordering::Relaxed), 0);
511
570
  }
512
571
 
572
+ #[test]
573
+ fn fresh_slot_is_not_retired() {
574
+ let server = test_server(false, 4);
575
+ server.register_worker();
576
+ assert!(!server.slots.read()[0].retired.load(Ordering::Relaxed));
577
+ }
578
+
579
+ #[test]
580
+ fn retire_then_reset_returns_a_slot_to_its_fresh_state() {
581
+ let server = test_server(true, 4);
582
+ server.register_worker();
583
+ let slot = server.slots.read()[0].clone();
584
+ slot.parked.store(true, Ordering::Relaxed);
585
+ slot.interrupted.store(true, Ordering::Relaxed);
586
+ slot.served.store(5, Ordering::Relaxed);
587
+ slot.in_flight.store(1, Ordering::Relaxed);
588
+ slot.last_started_ms.store(9, Ordering::Relaxed);
589
+ slot.current.lock().push(std::sync::Weak::new());
590
+
591
+ slot.retire();
592
+ assert!(slot.retired.load(Ordering::Relaxed));
593
+
594
+ slot.reset();
595
+ assert!(!slot.retired.load(Ordering::Relaxed));
596
+ assert!(!slot.parked.load(Ordering::Relaxed));
597
+ assert!(!slot.interrupted.load(Ordering::Relaxed));
598
+ assert_eq!(slot.served.load(Ordering::Relaxed), 0);
599
+ assert_eq!(slot.in_flight.load(Ordering::Relaxed), 0);
600
+ assert_eq!(slot.last_started_ms.load(Ordering::Relaxed), 0);
601
+ assert!(slot.current.lock().is_empty());
602
+ // The lane survives reuse: the next occupant takes from it.
603
+ assert!(slot.lane_tx.lock().is_some());
604
+ }
605
+
606
+ #[test]
607
+ fn reset_keeps_a_quarantine_mark() {
608
+ let server = test_server(false, 4);
609
+ server.register_worker();
610
+ let slot = server.slots.read()[0].clone();
611
+ slot.quarantined.store(true, Ordering::Relaxed);
612
+
613
+ slot.retire();
614
+ slot.reset();
615
+
616
+ // A wedged slot stays flagged even if it ever came back around.
617
+ assert!(slot.quarantined.load(Ordering::Relaxed));
618
+ }
619
+
620
+ #[test]
621
+ fn retire_releases_the_lane_lock() {
622
+ let server = test_server(true, 4);
623
+ server.register_worker();
624
+ let slot = server.slots.read()[0].clone();
625
+
626
+ slot.retire();
627
+
628
+ assert!(slot.lane_tx.try_lock().is_some());
629
+ }
630
+
631
+ #[test]
632
+ fn retire_waits_for_a_dispatcher_holding_the_lane_lock() {
633
+ let server = test_server(true, 4);
634
+ server.register_worker();
635
+ let slot = server.slots.read()[0].clone();
636
+
637
+ // A dispatcher that has already read "not retired" and is sending.
638
+ let sending = slot.lane_tx.lock();
639
+ let retiring = std::thread::spawn({
640
+ let slot = slot.clone();
641
+ move || slot.retire()
642
+ });
643
+ std::thread::sleep(Duration::from_millis(30));
644
+ assert!(
645
+ !slot.retired.load(Ordering::SeqCst),
646
+ "the flag must not go up under a dispatcher's lock"
647
+ );
648
+
649
+ drop(sending);
650
+ retiring.join().expect("retire thread");
651
+ assert!(slot.retired.load(Ordering::SeqCst));
652
+ }
653
+
513
654
  #[test]
514
655
  fn queue_histogram_buckets_by_wait() {
515
656
  let h = QueueHistogram::new();
@@ -492,7 +492,7 @@ fn coerce_str(value: Value) -> Result<RString, Error> {
492
492
  }
493
493
  }
494
494
 
495
- fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
495
+ pub(crate) fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
496
496
  match host.rsplit_once(':') {
497
497
  Some((name, port)) if !name.is_empty() => match port.parse() {
498
498
  Ok(p) => (name.to_string(), p),
@@ -68,6 +68,10 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
68
68
  let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
69
69
  let mode: String = cfg_opt(ruby, config, "mode")?.unwrap_or_else(|| "threaded".to_string());
70
70
  let workers: usize = cfg_opt(ruby, config, "workers")?.unwrap_or(0);
71
+ // Absent or below the floor means a fixed pool.
72
+ let max_workers: usize = cfg_opt::<usize>(ruby, config, "max_workers")?
73
+ .unwrap_or(workers)
74
+ .max(workers);
71
75
  let threads: usize = cfg_opt(ruby, config, "threads")?.unwrap_or(0);
72
76
  let batch: usize = cfg_opt(ruby, config, "batch")?.unwrap_or(1);
73
77
  let acceptor = match (&tls_cert, &tls_key) {
@@ -130,9 +134,13 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
130
134
  state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
131
135
  respawns: std::sync::atomic::AtomicU64::new(0),
132
136
  quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
137
+ active_workers: std::sync::atomic::AtomicUsize::new(workers),
138
+ scale_ups: std::sync::atomic::AtomicU64::new(0),
139
+ scale_downs: std::sync::atomic::AtomicU64::new(0),
133
140
  topology: registry::Topology {
134
141
  mode,
135
142
  workers,
143
+ max_workers,
136
144
  threads,
137
145
  batch,
138
146
  },
@@ -383,6 +391,12 @@ fn advertised_streams(workers: usize, threads: usize) -> u32 {
383
391
  slots.clamp(8, 1024) as u32
384
392
  }
385
393
 
394
+ /// The pool can grow to its ceiling to meet what an h2 balancer sends, so
395
+ /// the ceiling is the admission to advertise (the floor for a fixed pool).
396
+ fn stream_capacity(topology: &registry::Topology) -> u32 {
397
+ advertised_streams(topology.max_workers, topology.threads)
398
+ }
399
+
386
400
  fn conn_builder(http2: bool, max_streams: u32) -> auto::Builder<TokioExecutor> {
387
401
  let mut builder = auto::Builder::new(TokioExecutor::new());
388
402
  builder
@@ -418,7 +432,7 @@ async fn serve_connection<I>(
418
432
  I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
419
433
  {
420
434
  let http2 = server.http2;
421
- let max_streams = advertised_streams(server.topology.workers, server.topology.threads);
435
+ let max_streams = stream_capacity(&server.topology);
422
436
  let mut drain = server.shutdown_tx.subscribe();
423
437
  let service =
424
438
  service_fn(move |req| handle_request(server.clone(), remote_addr, local_addr, req));
@@ -712,6 +726,11 @@ fn try_dispatch(server: &ServerInner, mut ctx: BoxedCtx) -> Dispatch {
712
726
  let guard = slot.lane_tx.lock();
713
727
  let Some(tx) = guard.as_ref() else { continue };
714
728
  any_open = true;
729
+ // Checked under the lane lock, which WorkerSlot::retire also
730
+ // takes: see there for the ordering.
731
+ if slot.retired.load(Ordering::SeqCst) {
732
+ continue;
733
+ }
715
734
  match tx.try_send(ctx) {
716
735
  Ok(()) => return Dispatch::Sent,
717
736
  Err(flume::TrySendError::Full(c)) | Err(flume::TrySendError::Disconnected(c)) => {
@@ -900,7 +919,7 @@ pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
900
919
  }
901
920
 
902
921
  /// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
903
- pub type WorkerStatRow = (usize, u64, usize, u64, bool);
922
+ pub type WorkerStatRow = (usize, u64, usize, u64, bool, bool);
904
923
 
905
924
  /// Per-slot rows for Server#stats parity: [index, served, in_flight,
906
925
  /// busy_ms, quarantined] each. Empty when the server is gone.
@@ -910,7 +929,16 @@ pub fn worker_stats(_ruby: &Ruby, server_id: u64) -> Result<Vec<WorkerStatRow>,
910
929
  };
911
930
  Ok(crate::control::collect_worker_status(&server)
912
931
  .into_iter()
913
- .map(|w| (w.index, w.served, w.in_flight, w.busy_ms, w.quarantined))
932
+ .map(|w| {
933
+ (
934
+ w.index,
935
+ w.served,
936
+ w.in_flight,
937
+ w.busy_ms,
938
+ w.quarantined,
939
+ w.retired,
940
+ )
941
+ })
914
942
  .collect())
915
943
  }
916
944
 
@@ -923,6 +951,61 @@ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<
923
951
  Ok(())
924
952
  }
925
953
 
954
+ /// Send a slot's worker home once idle (pool scale-down); see
955
+ /// registry::WorkerSlot::retire. An unknown id is a caller bug: raise.
956
+ pub fn retire_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
957
+ if let Some(server) = registry::try_get(server_id) {
958
+ server.slot(ruby, worker_id)?.retire();
959
+ }
960
+ Ok(())
961
+ }
962
+
963
+ /// Hand a retired slot to a new worker (pool scale-up reusing a slot).
964
+ pub fn reset_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
965
+ if let Some(server) = registry::try_get(server_id) {
966
+ server.slot(ruby, worker_id)?.reset();
967
+ }
968
+ Ok(())
969
+ }
970
+
971
+ /// The Ruby pool reports how many workers are alive and serving.
972
+ pub fn set_active_workers(_ruby: &Ruby, server_id: u64, count: usize) -> Result<(), Error> {
973
+ if let Some(server) = registry::try_get(server_id) {
974
+ server.active_workers.store(count, Ordering::Relaxed);
975
+ }
976
+ Ok(())
977
+ }
978
+
979
+ /// One worker added by the pool scaler.
980
+ pub fn record_scale_up(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
981
+ if let Some(server) = registry::try_get(server_id) {
982
+ server.scale_ups.fetch_add(1, Ordering::Relaxed);
983
+ }
984
+ Ok(())
985
+ }
986
+
987
+ /// One worker retired by the pool scaler.
988
+ pub fn record_scale_down(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
989
+ if let Some(server) = registry::try_get(server_id) {
990
+ server.scale_downs.fetch_add(1, Ordering::Relaxed);
991
+ }
992
+ Ok(())
993
+ }
994
+
995
+ /// [active_workers, max_workers, scale_ups, scale_downs] for Server#stats.
996
+ /// Zeros when the server is gone.
997
+ pub fn pool_stats(_ruby: &Ruby, server_id: u64) -> Result<(usize, usize, u64, u64), Error> {
998
+ let Some(server) = registry::try_get(server_id) else {
999
+ return Ok((0, 0, 0, 0));
1000
+ };
1001
+ Ok((
1002
+ server.active_workers.load(Ordering::Relaxed),
1003
+ server.topology.max_workers,
1004
+ server.scale_ups.load(Ordering::Relaxed),
1005
+ server.scale_downs.load(Ordering::Relaxed),
1006
+ ))
1007
+ }
1008
+
926
1009
  /// One replacement spawned by the quarantine monitor.
927
1010
  pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
928
1011
  if let Some(server) = registry::try_get(server_id) {
@@ -1355,6 +1438,32 @@ mod tests {
1355
1438
  assert_eq!(advertised_streams(8, 0), 200);
1356
1439
  }
1357
1440
 
1441
+ #[test]
1442
+ fn stream_capacity_is_advertised_from_the_pool_ceiling() {
1443
+ // An elastic pool grows to meet load an h2 balancer sends, so the
1444
+ // ceiling, not the floor, is the admission to advertise.
1445
+ let topology = registry::Topology {
1446
+ mode: "ractor".to_string(),
1447
+ workers: 2,
1448
+ max_workers: 8,
1449
+ threads: 3,
1450
+ batch: 1,
1451
+ };
1452
+ assert_eq!(stream_capacity(&topology), 24);
1453
+ }
1454
+
1455
+ #[test]
1456
+ fn stream_capacity_of_a_fixed_pool_is_its_slot_count() {
1457
+ let topology = registry::Topology {
1458
+ mode: "threaded".to_string(),
1459
+ workers: 4,
1460
+ max_workers: 4,
1461
+ threads: 3,
1462
+ batch: 1,
1463
+ };
1464
+ assert_eq!(stream_capacity(&topology), 12);
1465
+ }
1466
+
1358
1467
  #[tokio::test]
1359
1468
  async fn streams_beyond_the_advertised_cap_queue_instead_of_failing() {
1360
1469
  use http_body_util::Full;
@@ -1566,4 +1675,110 @@ mod tests {
1566
1675
  assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1567
1676
  assert_eq!(server.lane_depths(), Some(vec![0, 2]));
1568
1677
  }
1678
+
1679
+ #[test]
1680
+ fn dispatch_skips_retired_lanes() {
1681
+ let server = test_server(true, 4);
1682
+ server.register_worker();
1683
+ server.register_worker();
1684
+ server.slots.read()[0].retire();
1685
+
1686
+ // A retiring worker gets nothing new: both land on slot 1.
1687
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1688
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1689
+ assert_eq!(server.lane_depths(), Some(vec![0, 2]));
1690
+ }
1691
+
1692
+ #[test]
1693
+ fn dispatch_retries_rather_than_closing_when_only_retired_lanes_remain() {
1694
+ let server = test_server(true, 4);
1695
+ server.register_worker();
1696
+ server.slots.read()[0].retire();
1697
+
1698
+ // Retired is not draining: the caller keeps retrying until the
1699
+ // queue timeout, so a replacement worker can still pick this up.
1700
+ assert!(matches!(
1701
+ try_dispatch(&server, test_ctx()),
1702
+ Dispatch::Full(_)
1703
+ ));
1704
+ }
1705
+
1706
+ #[test]
1707
+ fn dispatch_skips_a_retired_lane_even_when_it_is_the_only_awake_one() {
1708
+ let server = test_server(true, 4);
1709
+ server.register_worker();
1710
+ server.register_worker();
1711
+ server.slots.read()[0].retire();
1712
+ server.slots.read()[1].parked.store(true, Ordering::Relaxed);
1713
+
1714
+ // The second pass (parked lanes allowed) still skips the retired one.
1715
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1716
+ assert_eq!(server.lane_depths(), Some(vec![0, 1]));
1717
+ }
1718
+
1719
+ #[test]
1720
+ fn dispatch_keeps_retrying_when_the_other_lanes_are_closed() {
1721
+ let server = test_server(true, 4);
1722
+ server.register_worker();
1723
+ server.register_worker();
1724
+ server.slots.read()[0].lane_tx.lock().take(); // a crashed worker's lane
1725
+ server.slots.read()[1].retire();
1726
+
1727
+ assert!(matches!(
1728
+ try_dispatch(&server, test_ctx()),
1729
+ Dispatch::Full(_)
1730
+ ));
1731
+ }
1732
+
1733
+ #[test]
1734
+ fn a_reset_lane_receives_dispatches_again() {
1735
+ let server = test_server(true, 4);
1736
+ server.register_worker();
1737
+ server.register_worker();
1738
+ server.slots.read()[0].retire();
1739
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1740
+ assert_eq!(server.lane_depths(), Some(vec![0, 1]));
1741
+
1742
+ server.slots.read()[0].reset();
1743
+ for _ in 0..2 {
1744
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1745
+ }
1746
+ // Back in the round-robin.
1747
+ assert_eq!(server.lane_depths(), Some(vec![1, 2]));
1748
+ }
1749
+
1750
+ #[test]
1751
+ fn nothing_lands_in_a_lane_after_its_retirement_and_final_drain() {
1752
+ let server = test_server(true, 4);
1753
+ server.register_worker();
1754
+ server.register_worker();
1755
+ let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1756
+ let dispatcher = std::thread::spawn({
1757
+ let server = server.clone();
1758
+ let stop = stop.clone();
1759
+ move || {
1760
+ while !stop.load(Ordering::Relaxed) {
1761
+ let _ = try_dispatch(&server, test_ctx());
1762
+ // Lane 1 keeps draining, so dispatch never stalls on Full.
1763
+ let slots = server.slots.read();
1764
+ if let Some(rx) = slots[1].lane_rx.as_ref() {
1765
+ while rx.try_recv().is_ok() {}
1766
+ }
1767
+ }
1768
+ }
1769
+ });
1770
+ std::thread::sleep(Duration::from_millis(20));
1771
+
1772
+ // The scaler retires slot 0; its worker then drains what it holds.
1773
+ let slot = server.slots.read()[0].clone();
1774
+ slot.retire();
1775
+ if let Some(rx) = slot.lane_rx.as_ref() {
1776
+ while rx.try_recv().is_ok() {}
1777
+ }
1778
+
1779
+ std::thread::sleep(Duration::from_millis(50));
1780
+ stop.store(true, Ordering::Relaxed);
1781
+ dispatcher.join().expect("dispatcher thread");
1782
+ assert_eq!(server.lane_depths(), Some(vec![0, 0]));
1783
+ }
1569
1784
  }
@@ -92,3 +92,47 @@ pub fn close(ruby: &magnus::Ruby, id: u64) -> Result<(), magnus::Error> {
92
92
  chan.tx.lock().take();
93
93
  Ok(())
94
94
  }
95
+
96
+ /// Build the cache-backed slice of a Rack env directly, the way `build_env`
97
+ /// does for a real request: REMOTE_ADDR from `ip`, SERVER_NAME/SERVER_PORT
98
+ /// from `host` (the Host-header path) or, when `authority` is given, from
99
+ /// it (the :authority path, which also sets HTTP_HOST), plus one interned
100
+ /// header value. The rooting stress spec calls this from several ractors
101
+ /// at once with more distinct inputs than the LRU caches hold.
102
+ pub fn env_probe(
103
+ ruby: &magnus::Ruby,
104
+ host: String,
105
+ authority: Option<String>,
106
+ ip: String,
107
+ user_agent: String,
108
+ ) -> Result<magnus::RHash, magnus::Error> {
109
+ use crate::env_strings;
110
+ use crate::request::split_host_port;
111
+
112
+ let ip: std::net::IpAddr = ip.parse().map_err(|e| {
113
+ magnus::Error::new(ruby.exception_arg_error(), format!("bad ip {ip:?}: {e}"))
114
+ })?;
115
+ let env = ruby.hash_new();
116
+ env_strings::set_addr_env(ruby, env, ip)?;
117
+ match &authority {
118
+ Some(authority) => {
119
+ env_strings::set_authority_env(ruby, env, authority, || split_host_port(authority, 80))?
120
+ }
121
+ None => {
122
+ env_strings::set_host_env(ruby, env, host.as_bytes(), || split_host_port(&host, 80))?
123
+ }
124
+ }
125
+ let strings = env_strings::get();
126
+ let key = strings
127
+ .header_names
128
+ .get("user-agent")
129
+ .expect("user-agent is a common header");
130
+ env_strings::set_value_env(
131
+ ruby,
132
+ env,
133
+ ruby.get_inner(*key),
134
+ "user-agent",
135
+ user_agent.as_bytes(),
136
+ )?;
137
+ Ok(env)
138
+ }