kino 0.6.0 → 0.7.1

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/pin.rs CHANGED
@@ -22,6 +22,22 @@
22
22
  //! GVL held), release is a single atomic store from whatever tokio
23
23
  //! thread drops the buffer, and the mark hook only loads atomics, so
24
24
  //! no path takes a lock. A full slab degrades to the copy path.
25
+ //!
26
+ //! The slab is also how env_strings.rs roots its cached env strings (a
27
+ //! second slab, marked through its own keeper), for the same reason: no
28
+ //! GC registration API may be called from parallel ractors.
29
+ //!
30
+ //! Per-ractor GC (Ruby 4.1): each ractor collects its own heap, and a
31
+ //! local collection marks only that ractor's roots. A keeper on the main
32
+ //! ractor is invisible to a worker ractor's local GC, so a string the
33
+ //! worker allocated and rooted only here would be swept while hyper
34
+ //! still reads its bytes. Shareable objects are exempt: only a global
35
+ //! GC, which marks every root including the keeper, may free them. A
36
+ //! slab serving worker ractors on such a Ruby therefore pins only
37
+ //! strings already flagged shareable (typically an app's frozen
38
+ //! constants) and copies the rest; making each per-request body
39
+ //! shareable instead would push it into the population only a
40
+ //! stop-the-world collection reclaims.
25
41
 
26
42
  use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27
43
  use std::sync::Arc;
@@ -43,6 +59,10 @@ pub const ZERO_COPY_MIN: usize = 4096;
43
59
  /// throughput heuristic, not a limit on concurrency.
44
60
  const SLAB_CAPACITY: usize = 4096;
45
61
 
62
+ /// Whether this Ruby collects each ractor's heap separately (see the
63
+ /// module docs). Ruby 4.0 has one heap for every ractor.
64
+ const PER_RACTOR_GC: bool = cfg!(ruby_gte_4_1);
65
+
46
66
  extern "C" {
47
67
  // Exported by libruby but absent from the public headers: io.c's
48
68
  // buffer-stabilizing primitive (see module docs). Signature per
@@ -50,24 +70,41 @@ extern "C" {
50
70
  fn rb_str_tmp_frozen_acquire(orig: rb_sys::VALUE) -> rb_sys::VALUE;
51
71
  }
52
72
 
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).
73
+ /// A fixed slab of atomic VALUE slots, each a pinning GC root while it
74
+ /// is non-zero. Slot value 0 = empty (a VALUE of 0 is Qfalse, which can
75
+ /// never be a rooted string).
55
76
  pub struct PinSlab {
56
77
  slots: Box<[AtomicU64]>,
57
78
  /// Rotating claim cursor: keeps the free-slot scan O(1) amortized.
58
79
  cursor: AtomicUsize,
80
+ /// Pin only strings flagged shareable (pinned_bytes): set for a
81
+ /// slab that worker ractors fill on a per-ractor-GC Ruby.
82
+ shareable_only: bool,
59
83
  }
60
84
 
61
85
  impl PinSlab {
62
- pub fn new() -> Self {
86
+ /// A slab for one server's in-flight response buffers, filled by
87
+ /// worker ractors when `ractor_mode` (else by main-ractor threads).
88
+ pub fn for_responses(ractor_mode: bool) -> Self {
63
89
  PinSlab {
64
- slots: (0..SLAB_CAPACITY).map(|_| AtomicU64::new(0)).collect(),
90
+ shareable_only: ractor_mode && PER_RACTOR_GC,
91
+ ..Self::with_capacity(SLAB_CAPACITY)
92
+ }
93
+ }
94
+
95
+ /// A slab for callers that root only shareable values themselves
96
+ /// (env_strings), or for tests.
97
+ pub fn with_capacity(capacity: usize) -> Self {
98
+ PinSlab {
99
+ slots: (0..capacity).map(|_| AtomicU64::new(0)).collect(),
65
100
  cursor: AtomicUsize::new(0),
101
+ shareable_only: false,
66
102
  }
67
103
  }
68
104
 
69
- /// Root `value`; None when the slab is full (caller copies instead).
70
- fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
105
+ /// Root `value`; None when the slab is full (the caller falls back to
106
+ /// a copy, or to an uncached string).
107
+ pub(crate) fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
71
108
  let start = self.cursor.fetch_add(1, Ordering::Relaxed);
72
109
  for offset in 0..self.slots.len() {
73
110
  let index = (start + offset) % self.slots.len();
@@ -83,7 +120,7 @@ impl PinSlab {
83
120
 
84
121
  /// Drop the root. Called from tokio threads: a plain atomic store,
85
122
  /// no Ruby API. The string stays alive until the next GC sweep.
86
- fn release(&self, index: usize) {
123
+ pub(crate) fn release(&self, index: usize) {
87
124
  self.slots[index].store(0, Ordering::SeqCst);
88
125
  }
89
126
 
@@ -113,9 +150,10 @@ impl PinSlab {
113
150
  }
114
151
  }
115
152
 
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.
153
+ /// The GC-visible face of a PinSlab: Ruby's GC marks every rooted string
154
+ /// through it. `Kino::Server` holds one for its response-buffer slab for
155
+ /// the server's lifetime (surviving worker-ractor crashes); env_strings
156
+ /// registers one for its cache slab as an immortal root at init.
119
157
  #[derive(magnus::TypedData)]
120
158
  #[magnus(class = "Kino::Native::PinKeeper", free_immediately, mark)]
121
159
  pub struct PinKeeper(pub Arc<PinSlab>);
@@ -157,9 +195,10 @@ impl Drop for PinnedBuf {
157
195
  }
158
196
 
159
197
  /// 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.
198
+ /// drops it; None when the body is below ZERO_COPY_MIN, the slab is
199
+ /// full, or the slab takes only shareable strings and this one is not
200
+ /// (caller copies). Requires the calling worker's GVL; safe from any
201
+ /// ractor.
163
202
  pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
164
203
  if body.len() < ZERO_COPY_MIN {
165
204
  return None;
@@ -168,6 +207,9 @@ pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
168
207
  // by the caller. The acquired tmp is rooted by this thread's machine
169
208
  // stack (conservatively scanned) until the slab insert publishes it.
170
209
  let tmp = unsafe { rb_str_tmp_frozen_acquire(body.as_raw()) };
210
+ if slab.shareable_only && !flagged_shareable(tmp) {
211
+ return None;
212
+ }
171
213
  let index = slab.insert(tmp)?;
172
214
  // SAFETY: tmp is frozen, alive, and slab-rooted; len >= ZERO_COPY_MIN
173
215
  // rules out an embedded buffer, so ptr is a stable heap allocation.
@@ -185,13 +227,22 @@ pub fn pinned_bytes(slab: &Arc<PinSlab>, body: RString) -> Option<Bytes> {
185
227
  }))
186
228
  }
187
229
 
230
+ /// Whether `value` (a heap object) carries the shareable flag. A plain
231
+ /// flag read: a frozen string that no one has checked yet may be
232
+ /// shareable in principle but unflagged, and the GC goes by the flag.
233
+ fn flagged_shareable(value: rb_sys::VALUE) -> bool {
234
+ let shareable = rb_sys::ruby_fl_type::RUBY_FL_SHAREABLE as rb_sys::VALUE;
235
+ // SAFETY: value is a live heap object (an RString) under the GVL.
236
+ unsafe { (*(value as *const rb_sys::RBasic)).flags & shareable != 0 }
237
+ }
238
+
188
239
  #[cfg(test)]
189
240
  mod tests {
190
241
  use super::*;
191
242
 
192
243
  #[test]
193
244
  fn insert_release_reuse_cycle() {
194
- let slab = PinSlab::new();
245
+ let slab = PinSlab::with_capacity(SLAB_CAPACITY);
195
246
  let a = slab.insert(0x1000).expect("slot free");
196
247
  let b = slab.insert(0x2000).expect("slot free");
197
248
  assert_ne!(a, b);
@@ -210,7 +261,7 @@ mod tests {
210
261
 
211
262
  #[test]
212
263
  fn full_slab_refuses_instead_of_evicting() {
213
- let slab = PinSlab::new();
264
+ let slab = PinSlab::with_capacity(SLAB_CAPACITY);
214
265
  let indexes: Vec<usize> = (0..SLAB_CAPACITY)
215
266
  .map(|i| slab.insert(0x1000 + i as rb_sys::VALUE).expect("capacity"))
216
267
  .collect();
@@ -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
  },
@@ -362,7 +409,7 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
362
409
  access_log: None,
363
410
  lanes,
364
411
  lane_cursor: AtomicUsize::new(0),
365
- pin_slab: Arc::new(crate::pin::PinSlab::new()),
412
+ pin_slab: Arc::new(crate::pin::PinSlab::for_responses(false)),
366
413
  queue_histogram: QueueHistogram::new(),
367
414
  })
368
415
  }
@@ -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),
@@ -518,7 +518,7 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
518
518
  body_rx: None,
519
519
  leftover: None,
520
520
  slot: None,
521
- pin_slab: Arc::new(crate::pin::PinSlab::new()),
521
+ pin_slab: Arc::new(crate::pin::PinSlab::for_responses(false)),
522
522
  responder: Arc::new(Responder::new(head_tx)),
523
523
  enqueued_at: std::time::Instant::now(),
524
524
  timed: false,