kino 0.5.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.
data/ext/kino/src/pin.rs CHANGED
@@ -22,6 +22,10 @@
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.
25
29
 
26
30
  use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27
31
  use std::sync::Arc;
@@ -50,8 +54,9 @@ extern "C" {
50
54
  fn rb_str_tmp_frozen_acquire(orig: rb_sys::VALUE) -> rb_sys::VALUE;
51
55
  }
52
56
 
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).
57
+ /// A fixed slab of atomic VALUE slots, each a pinning GC root while it
58
+ /// is non-zero. Slot value 0 = empty (a VALUE of 0 is Qfalse, which can
59
+ /// never be a rooted string).
55
60
  pub struct PinSlab {
56
61
  slots: Box<[AtomicU64]>,
57
62
  /// Rotating claim cursor: keeps the free-slot scan O(1) amortized.
@@ -59,15 +64,21 @@ pub struct PinSlab {
59
64
  }
60
65
 
61
66
  impl PinSlab {
67
+ /// A slab for one server's in-flight response buffers.
62
68
  pub fn new() -> Self {
69
+ Self::with_capacity(SLAB_CAPACITY)
70
+ }
71
+
72
+ pub fn with_capacity(capacity: usize) -> Self {
63
73
  PinSlab {
64
- slots: (0..SLAB_CAPACITY).map(|_| AtomicU64::new(0)).collect(),
74
+ slots: (0..capacity).map(|_| AtomicU64::new(0)).collect(),
65
75
  cursor: AtomicUsize::new(0),
66
76
  }
67
77
  }
68
78
 
69
- /// Root `value`; None when the slab is full (caller copies instead).
70
- fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
79
+ /// Root `value`; None when the slab is full (the caller falls back to
80
+ /// a copy, or to an uncached string).
81
+ pub(crate) fn insert(&self, value: rb_sys::VALUE) -> Option<usize> {
71
82
  let start = self.cursor.fetch_add(1, Ordering::Relaxed);
72
83
  for offset in 0..self.slots.len() {
73
84
  let index = (start + offset) % self.slots.len();
@@ -83,7 +94,7 @@ impl PinSlab {
83
94
 
84
95
  /// Drop the root. Called from tokio threads: a plain atomic store,
85
96
  /// no Ruby API. The string stays alive until the next GC sweep.
86
- fn release(&self, index: usize) {
97
+ pub(crate) fn release(&self, index: usize) {
87
98
  self.slots[index].store(0, Ordering::SeqCst);
88
99
  }
89
100
 
@@ -113,9 +124,10 @@ impl PinSlab {
113
124
  }
114
125
  }
115
126
 
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.
127
+ /// The GC-visible face of a PinSlab: Ruby's GC marks every rooted string
128
+ /// through it. `Kino::Server` holds one for its response-buffer slab for
129
+ /// the server's lifetime (surviving worker-ractor crashes); env_strings
130
+ /// registers one for its cache slab as an immortal root at init.
119
131
  #[derive(magnus::TypedData)]
120
132
  #[magnus(class = "Kino::Native::PinKeeper", free_immediately, mark)]
121
133
  pub struct PinKeeper(pub Arc<PinSlab>);
@@ -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
  });
@@ -138,54 +155,73 @@ fn admit(
138
155
  Ok(env)
139
156
  }
140
157
 
141
- type Checkout = (Arc<ServerInner>, Arc<WorkerSlot>, BoxedCtx);
158
+ /// Per-worker native handle: the registry lookup and slot resolution are
159
+ /// paid once at worker boot instead of on every take. Created inside the
160
+ /// worker thread/ractor that uses it (like Request handles), so ownership
161
+ /// is correct by construction. Holds the server weakly: after teardown the
162
+ /// upgrade fails, which is the same clean shutdown signal the per-take
163
+ /// registry lookup used to give.
164
+ #[magnus::wrap(class = "Kino::Native::Worker", free_immediately)]
165
+ pub struct Worker {
166
+ server: std::sync::Weak<ServerInner>,
167
+ slot: Arc<WorkerSlot>,
168
+ }
142
169
 
143
- fn checkout(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Checkout>, Error> {
170
+ /// Resolve a (server, worker) pair into a Worker handle; nil when the
171
+ /// server is already gone (the caller treats that as shutdown).
172
+ pub fn worker(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<Worker>, Error> {
144
173
  let Some(server) = registry::try_get(server_id) else {
145
- return Ok(None); // server torn down → clean shutdown signal
174
+ return Ok(None);
146
175
  };
147
176
  let slot = server.slot(ruby, worker_id)?;
177
+ Ok(Some(Worker {
178
+ server: Arc::downgrade(&server),
179
+ slot,
180
+ }))
181
+ }
148
182
 
149
- // The previous batch is fully answered once the worker comes back.
150
- slot.current.lock().clear();
151
- slot.in_flight.store(0, Ordering::Relaxed);
152
- slot.interrupted.store(false, Ordering::SeqCst);
183
+ impl Worker {
184
+ fn checkout(&self) -> Result<Option<(Arc<ServerInner>, BoxedCtx)>, Error> {
185
+ let Some(server) = self.server.upgrade() else {
186
+ return Ok(None); // server torn down → clean shutdown signal
187
+ };
153
188
 
154
- Ok(block_take(&server, &slot)?.map(|ctx| (server, slot, ctx)))
155
- }
189
+ // The previous batch is fully answered once the worker comes back.
190
+ self.slot.current.lock().clear();
191
+ self.slot.in_flight.store(0, Ordering::Relaxed);
192
+ self.slot.interrupted.store(false, Ordering::SeqCst);
156
193
 
157
- /// Take one request; returns its env Hash (request handle inside under
158
- /// "kino.request") or nil on shutdown. The batch-of-one hot path: no
159
- /// arrays allocated at all.
160
- pub fn take_one(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<Option<RHash>, Error> {
161
- match checkout(ruby, server_id, worker_id)? {
162
- Some((server, slot, ctx)) => Ok(Some(admit(ruby, &server, &slot, ctx)?)),
163
- None => Ok(None),
194
+ Ok(block_take(&server, &self.slot)?.map(|ctx| (server, ctx)))
164
195
  }
165
- }
166
196
 
167
- /// Take up to `max` requests: block for the first, drain the rest
168
- /// non-blocking (they only batch when the queue is already deep).
169
- /// Returns nil on shutdown; otherwise an Array of env Hashes.
170
- pub fn take_batch(
171
- ruby: &Ruby,
172
- server_id: u64,
173
- worker_id: usize,
174
- max: usize,
175
- ) -> Result<Option<RArray>, Error> {
176
- let Some((server, slot, first)) = checkout(ruby, server_id, worker_id)? else {
177
- return Ok(None);
178
- };
197
+ /// Take one request; returns its env Hash (request handle inside under
198
+ /// "kino.request") or nil on shutdown. The batch-of-one hot path: no
199
+ /// arrays allocated at all.
200
+ pub fn take_one(ruby: &Ruby, rb_self: &Worker) -> Result<Option<RHash>, Error> {
201
+ match rb_self.checkout()? {
202
+ Some((server, ctx)) => Ok(Some(admit(ruby, &server, &rb_self.slot, ctx)?)),
203
+ None => Ok(None),
204
+ }
205
+ }
206
+
207
+ /// Take up to `max` requests: block for the first, drain the rest
208
+ /// non-blocking (they only batch when the queue is already deep).
209
+ /// Returns nil on shutdown; otherwise an Array of env Hashes.
210
+ pub fn take_batch(ruby: &Ruby, rb_self: &Worker, max: usize) -> Result<Option<RArray>, Error> {
211
+ let Some((server, first)) = rb_self.checkout()? else {
212
+ return Ok(None);
213
+ };
179
214
 
180
- let batch = ruby.ary_new_capa(max.max(1));
181
- batch.push(admit(ruby, &server, &slot, first)?)?;
182
- for _ in 1..max {
183
- match server.req_rx.try_recv() {
184
- Ok(ctx) => batch.push(admit(ruby, &server, &slot, ctx)?)?,
185
- Err(_) => break,
215
+ let batch = ruby.ary_new_capa(max.max(1));
216
+ batch.push(admit(ruby, &server, &rb_self.slot, first)?)?;
217
+ for _ in 1..max {
218
+ match server.req_rx.try_recv() {
219
+ Ok(ctx) => batch.push(admit(ruby, &server, &rb_self.slot, ctx)?)?,
220
+ Err(_) => break,
221
+ }
186
222
  }
223
+ Ok(Some(batch))
187
224
  }
188
- Ok(Some(batch))
189
225
  }
190
226
 
191
227
  /// The fused hot path: answer `request` (complete response in one shot)
@@ -194,28 +230,104 @@ pub fn take_batch(
194
230
  pub fn respond_and_take_one(
195
231
  ruby: &Ruby,
196
232
  request: &Request,
197
- server_id: u64,
198
- worker_id: usize,
233
+ worker: magnus::typed_data::Obj<Worker>,
199
234
  status: u16,
200
235
  headers: RHash,
201
236
  body: RString,
202
237
  ) -> Result<Option<RHash>, Error> {
203
238
  crate::request::respond_simple(ruby, request, status, headers, body)?;
204
- take_one(ruby, server_id, worker_id)
239
+ Worker::take_one(ruby, &worker)
205
240
  }
206
241
 
207
242
  /// Batch variant of the fused call.
208
- #[allow(clippy::too_many_arguments)]
209
243
  pub fn respond_and_take(
210
244
  ruby: &Ruby,
211
245
  request: &Request,
212
- server_id: u64,
213
- worker_id: usize,
246
+ worker: magnus::typed_data::Obj<Worker>,
214
247
  max: usize,
215
248
  status: u16,
216
249
  headers: RHash,
217
250
  body: RString,
218
251
  ) -> Result<Option<RArray>, Error> {
219
252
  crate::request::respond_simple(ruby, request, status, headers, body)?;
220
- take_batch(ruby, server_id, worker_id, max)
253
+ Worker::take_batch(ruby, &worker, max)
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
+ }
221
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,8 +117,18 @@ 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,
129
+ /// HTTP/2 serving (ALPN over TLS, prior-knowledge h2c on plaintext);
130
+ /// false pins every connection to HTTP/1.
131
+ pub http2: bool,
118
132
  /// The socket file of a `unix://` bind, removed at shutdown.
119
133
  pub unix_path: Option<std::path::PathBuf>,
120
134
  /// Native access log sink (None unless log_requests is on).
@@ -155,6 +169,11 @@ pub struct WorkerSlot {
155
169
  /// Set by the quarantine monitor when this slot is abandoned as wedged:
156
170
  /// excluded from wedge detection, and its busy_ms is reported as 0.
157
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,
158
177
  }
159
178
 
160
179
  /// Per-lane depth cap: small, so a slow handler can only ever delay this
@@ -248,8 +267,35 @@ impl WorkerSlot {
248
267
  in_flight: AtomicUsize::new(0),
249
268
  last_started_ms: AtomicU64::new(0),
250
269
  quarantined: std::sync::atomic::AtomicBool::new(false),
270
+ retired: std::sync::atomic::AtomicBool::new(false),
251
271
  }
252
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
+ }
253
299
  }
254
300
 
255
301
  static REGISTRY: OnceLock<RwLock<HashMap<u64, Arc<ServerInner>>>> = OnceLock::new();
@@ -347,13 +393,18 @@ pub fn test_server(lanes: bool, queue_depth: usize) -> Arc<ServerInner> {
347
393
  state: std::sync::atomic::AtomicU8::new(STATE_BOOTING),
348
394
  respawns: AtomicU64::new(0),
349
395
  quarantine_replacements: AtomicU64::new(0),
396
+ active_workers: AtomicUsize::new(0),
397
+ scale_ups: AtomicU64::new(0),
398
+ scale_downs: AtomicU64::new(0),
350
399
  topology: Topology {
351
400
  mode: "threaded".to_string(),
352
401
  workers: 0,
402
+ max_workers: 0,
353
403
  threads: 0,
354
404
  batch: 1,
355
405
  },
356
406
  https: false,
407
+ http2: true,
357
408
  unix_path: None,
358
409
  access_log: None,
359
410
  lanes,
@@ -487,6 +538,18 @@ mod tests {
487
538
  assert_eq!(server.topology.batch, 1);
488
539
  }
489
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
+
490
553
  #[test]
491
554
  fn fresh_slot_has_zeroed_per_worker_sensors() {
492
555
  let server = test_server(false, 4);
@@ -506,6 +569,88 @@ mod tests {
506
569
  assert_eq!(server.quarantine_replacements.load(Ordering::Relaxed), 0);
507
570
  }
508
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
+
509
654
  #[test]
510
655
  fn queue_histogram_buckets_by_wait() {
511
656
  let h = QueueHistogram::new();