subetha_cxc/shared_deque.rs
1//! `SharedDeque<T>` - cross-thread / cross-process Chase-Lev work-
2//! stealing deque backed by a memory-mapped file.
3//!
4//! Chase-Lev's signature asymmetry is what makes this primitive
5//! interesting: the *owner* of the deque pushes and pops the bottom
6//! end with no atomic CAS on the fast path (a Relaxed store on the
7//! `bottom` index), while any number of *thieves* steal from the top
8//! end with one CAS each. There is no MPMC ring contention; the
9//! local-pop fast path costs roughly one cache-line write.
10//!
11//! Lifting this protocol into a memory-mapped file lets the *same*
12//! deque serve in-process worker-thread stealing AND cross-process
13//! work distribution. A second process opens the same file via
14//! [`SharedDeque::open_as_thief`] and steals from a remote owner with
15//! the identical CAS protocol, because the atomics touch physical
16//! pages whose coherence is identical to the cross-thread case
17//! (kernel uninvolved on the steal hot path).
18//!
19//! The trade is a discriminant on the stored type: values stored in
20//! the deque must implement [`Marshal`], the type-system contract
21//! that the value's bytes mean the same thing in every address
22//! space. Closures with environment-capturing pointers cannot be
23//! stored directly; they must travel through
24//! [`pass_registry`](crate::pass_registry) as `(closure_id, args)`
25//! pairs where `args: T: Marshal`.
26//!
27//! # Source
28//!
29//! - David Chase and Yossi Lev, *Dynamic Circular Work-Stealing
30//! Deque*, SPAA 2005.
31//! - The capacity is fixed at create time so the slot layout matches
32//! the MMF's fixed file size; the paper's resizing variant is a
33//! different primitive shape with a different contract and is not
34//! what this file implements.
35//!
36//! # Layout
37//!
38//! ```text
39//! +-----------------------------+
40//! | DequeHeader (64B aligned) |
41//! | magic, capacity, slot_bytes
42//! | owner_pid (informational) |
43//! | top: AtomicI64 |
44//! | bottom: AtomicI64 |
45//! +-----------------------------+
46//! | Slot[0] (slot_bytes) | marshalled T payload
47//! | Slot[1] |
48//! | ... |
49//! | Slot[capacity - 1] |
50//! +-----------------------------+
51//! ```
52//!
53//! `capacity` is required to be a power of two so the slot-index
54//! computation is `b & (capacity - 1)`. Each slot stores exactly
55//! `T::PAYLOAD_BYTES` rounded up to 8-byte alignment.
56
57use std::fs::{File, OpenOptions};
58use std::marker::PhantomData;
59use std::path::Path;
60use std::sync::atomic::{fence, AtomicI64, AtomicU64, Ordering};
61
62use memmap2::{MmapMut, MmapOptions};
63use subetha_core::Marshal;
64
65/// Prefetch the cache line at `addr` with write-intent (M-state
66/// hint). Emits `PREFETCHW` directly via inline asm on x86_64
67/// because Rust's stable `_mm_prefetch` only exposes the
68/// T0/T1/T2/NTA hints, which force a publisher write to pay an RFO
69/// coherence upgrade. `PREFETCHW` brings the line to M-state
70/// directly so the subsequent slot write costs one cycle instead of
71/// a cross-core RFO. `PREFETCHW` is a NOP on x86_64 CPUs without
72/// the PRFCHW feature flag (3DNow-era AMD has it natively; Intel
73/// since Broadwell), so it is safe to unconditionally emit.
74#[inline(always)]
75fn prefetchw_line(addr: *const u8) {
76 #[cfg(target_arch = "x86_64")]
77 {
78 // SAFETY: `prefetchw` is a hardware hint and never faults on
79 // unmapped memory; the CPU ignores invalid addresses.
80 unsafe {
81 core::arch::asm!(
82 "prefetchw [{ptr}]",
83 ptr = in(reg) addr,
84 options(nostack, preserves_flags),
85 );
86 }
87 }
88 #[cfg(not(target_arch = "x86_64"))]
89 {
90 // Mark `addr` as used on non-x86_64 targets where the
91 // PREFETCHW path is cfg'd out. `_ = addr` is a statement
92 // assignment that satisfies the unused-variable lint
93 // without inserting a real drop (the value is `*const u8`,
94 // which is `Copy` and has no drop glue anyway).
95 _ = addr;
96 }
97}
98
99/// ASCII 'WDEQ' + version 1.
100pub const DEQUE_MAGIC: u64 = 0x5744_4551_0000_0001;
101
102/// Errors returned by `SharedDeque` operations.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum DequeError {
105 Io(String),
106 InvalidCapacity,
107 InvalidMagic,
108 CapacityMismatch { file_capacity: u64, requested: u64 },
109 SlotBytesMismatch { file_slot_bytes: u32, type_slot_bytes: u32 },
110 Full,
111 Marshal(subetha_core::MarshalError),
112}
113
114impl From<std::io::Error> for DequeError {
115 fn from(e: std::io::Error) -> Self { Self::Io(e.to_string()) }
116}
117
118impl From<subetha_core::MarshalError> for DequeError {
119 fn from(e: subetha_core::MarshalError) -> Self { Self::Marshal(e) }
120}
121
122/// File header. 64-byte aligned, fits in one cache line so the
123/// owner's `bottom` updates and a thief's `top` CAS land on the
124/// same cache-line coherence path.
125#[repr(C, align(64))]
126pub struct DequeHeader {
127 pub magic: u64,
128 pub capacity: u64,
129 pub slot_bytes: u32,
130 pub _reserved_a: u32,
131 pub owner_pid: u64,
132 pub top: AtomicI64,
133 pub bottom: AtomicI64,
134 pub epoch: AtomicU64,
135 pub _reserved_b: [u8; 8],
136}
137
138const _: () = assert!(std::mem::size_of::<DequeHeader>() == 64);
139
140/// Per-T slot byte width, rounded up to 8-byte alignment for atomic-
141/// friendly storage.
142pub const fn slot_bytes_for<T: Marshal>() -> u32 {
143 let raw = T::PAYLOAD_BYTES;
144 let rounded = (raw + 7) & !7;
145 let with_min = if rounded < 8 { 8 } else { rounded };
146 with_min as u32
147}
148
149/// Compute the total MMF byte size for a deque of `capacity` slots
150/// holding `T` values.
151pub const fn deque_file_size<T: Marshal>(capacity: usize) -> usize {
152 std::mem::size_of::<DequeHeader>() + capacity * slot_bytes_for::<T>() as usize
153}
154
155/// Chase-Lev work-stealing deque backed by a memory-mapped file.
156///
157/// See the [module docs](self) for the protocol description and
158/// citation. Drop semantics: dropping the handle unmaps the file but
159/// does NOT delete it (in keeping with the rest of `subetha-cxc`'s
160/// MMF-backed primitives).
161pub struct SharedDeque<T: Marshal> {
162 mmap: MmapMut,
163 capacity: usize,
164 slot_bytes: usize,
165 _file: File,
166 _phantom: PhantomData<T>,
167}
168
169// SAFETY: the underlying mmap is `Send` and `Sync` (memmap2 guarantees
170// this for MmapMut), and the Chase-Lev protocol is the synchronisation.
171// PhantomData<T> carries no runtime data.
172unsafe impl<T: Marshal + Send> Send for SharedDeque<T> {}
173unsafe impl<T: Marshal + Send> Sync for SharedDeque<T> {}
174
175impl<T: Marshal> SharedDeque<T> {
176 /// Create a new MMF-backed deque at `path` with the given
177 /// capacity. `capacity` must be a non-zero power of two.
178 ///
179 /// The calling process is recorded as the "owner" in the header
180 /// for informational purposes; the protocol does not enforce
181 /// single-owner discipline at runtime - that is a contract the
182 /// caller's scheduler is responsible for.
183 pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, DequeError> {
184 if capacity == 0 || !capacity.is_power_of_two() {
185 return Err(DequeError::InvalidCapacity);
186 }
187 let slot_bytes = slot_bytes_for::<T>() as usize;
188 let total = std::mem::size_of::<DequeHeader>() + capacity * slot_bytes;
189 let file = OpenOptions::new()
190 .read(true).write(true).create(true).truncate(true)
191 .open(path.as_ref())?;
192 file.set_len(total as u64)?;
193 // SAFETY: a fresh file of the right size is exclusive to this
194 // process while we initialise the header.
195 let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
196 // Initialise header in place.
197 // SAFETY: the mapped region is exactly `total` bytes; the
198 // first sizeof(DequeHeader) bytes are aligned because mmap
199 // returns page-aligned memory.
200 let header_ptr = mmap.as_mut_ptr() as *mut DequeHeader;
201 unsafe {
202 (*header_ptr).magic = DEQUE_MAGIC;
203 (*header_ptr).capacity = capacity as u64;
204 (*header_ptr).slot_bytes = slot_bytes as u32;
205 (*header_ptr)._reserved_a = 0;
206 (*header_ptr).owner_pid = std::process::id() as u64;
207 (*header_ptr).top.store(0, Ordering::Relaxed);
208 (*header_ptr).bottom.store(0, Ordering::Relaxed);
209 (*header_ptr).epoch.store(0, Ordering::Relaxed);
210 (*header_ptr)._reserved_b = [0; 8];
211 }
212 mmap.flush()?;
213 Ok(Self { mmap, capacity, slot_bytes, _file: file, _phantom: PhantomData })
214 }
215
216 /// Open an existing MMF-backed deque created by another handle.
217 ///
218 /// The caller asserts the role of "thief" - the same protocol
219 /// works for any number of thief handles open at once, in any
220 /// number of processes. The header's `slot_bytes` field is
221 /// verified against `T::PAYLOAD_BYTES`; opening a deque whose
222 /// slot width does not match `T` returns
223 /// [`DequeError::SlotBytesMismatch`].
224 pub fn open_as_thief(path: impl AsRef<Path>) -> Result<Self, DequeError> {
225 let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
226 let len = file.metadata()?.len() as usize;
227 // SAFETY: the file's bytes back this process's view; the
228 // owner process is the only writer of slot payloads, and we
229 // are about to validate the header.
230 let mmap = unsafe { MmapOptions::new().len(len).map_mut(&file)? };
231 let header_ptr = mmap.as_ptr() as *const DequeHeader;
232 let (magic, capacity, slot_bytes) = unsafe {
233 ((*header_ptr).magic, (*header_ptr).capacity, (*header_ptr).slot_bytes)
234 };
235 if magic != DEQUE_MAGIC { return Err(DequeError::InvalidMagic); }
236 let expected_slot_bytes = slot_bytes_for::<T>();
237 if slot_bytes != expected_slot_bytes {
238 return Err(DequeError::SlotBytesMismatch {
239 file_slot_bytes: slot_bytes,
240 type_slot_bytes: expected_slot_bytes,
241 });
242 }
243 Ok(Self {
244 mmap,
245 capacity: capacity as usize,
246 slot_bytes: slot_bytes as usize,
247 _file: file,
248 _phantom: PhantomData,
249 })
250 }
251
252 fn header(&self) -> &DequeHeader {
253 // SAFETY: header was initialised at create time; layout is
254 // stable across the lifetime of the mmap.
255 unsafe { &*(self.mmap.as_ptr() as *const DequeHeader) }
256 }
257
258 fn slot_ptr(&self, idx: usize) -> *mut u8 {
259 let base = self.mmap.as_ptr() as usize + std::mem::size_of::<DequeHeader>();
260 (base + idx * self.slot_bytes) as *mut u8
261 }
262
263 /// Capacity (power of two).
264 pub fn capacity(&self) -> usize { self.capacity }
265
266 /// Approximate current length. Not authoritative under
267 /// concurrent steal / push; useful for heuristics and observers.
268 pub fn approx_len(&self) -> usize {
269 let h = self.header();
270 let b = h.bottom.load(Ordering::Relaxed);
271 let t = h.top.load(Ordering::Relaxed);
272 (b - t).max(0) as usize
273 }
274
275 /// Owner side: push a value onto the bottom of the deque.
276 ///
277 /// This is the only operation safe to call from the owner thread
278 /// alone; calling it concurrently from multiple threads breaks
279 /// the Chase-Lev protocol. The fast path is one Relaxed load on
280 /// `bottom`, one Acquire load on `top`, the marshal, a Release
281 /// fence, and one Relaxed store on `bottom`. No CAS, no mutex.
282 pub fn push(&self, value: &T) -> Result<(), DequeError> {
283 let h = self.header();
284 let b = h.bottom.load(Ordering::Relaxed);
285 // Issue `PREFETCHW` on the slot the marshal is about to write
286 // BEFORE the `top.load(Acquire)`. The Acquire-load hides the
287 // prefetch's latency: by the time we drop into `value.marshal`
288 // the slot's cache line is already arriving in M-state, so the
289 // write does not pay a cross-core RFO upgrade.
290 let idx = (b as usize) & (self.capacity - 1);
291 prefetchw_line(self.slot_ptr(idx));
292 let t = h.top.load(Ordering::Acquire);
293 if b - t >= self.capacity as i64 {
294 return Err(DequeError::Full);
295 }
296 // SAFETY: idx is in [0, capacity); each slot is slot_bytes
297 // long; the slice covers a valid mapped region.
298 let slot = unsafe { std::slice::from_raw_parts_mut(self.slot_ptr(idx), self.slot_bytes) };
299 value.marshal(slot);
300 fence(Ordering::Release);
301 h.bottom.store(b + 1, Ordering::Relaxed);
302 Ok(())
303 }
304
305 /// Owner-side batched push via a per-slot fill closure.
306 /// Reserves `n` contiguous slots under ONE `top.load(Acquire)`,
307 /// then calls `fill(i, slot_bytes)` for each slot, then ONE
308 /// Release fence and ONE `bottom.store(Relaxed)` publishes all
309 /// `n` slots atomically from the thieves' perspective.
310 ///
311 /// The closure writes directly into the slot's raw bytes,
312 /// avoiding any intermediate `T` buffer. This is the path
313 /// caller-defined fat-slot types take when they want to bypass
314 /// the [`Marshal`] indirection on the hot path. Returns
315 /// `Err(Full)` if the batch would overflow capacity at the
316 /// current `top` snapshot.
317 pub fn push_batch_with<F>(&self, n: usize, mut fill: F) -> Result<(), DequeError>
318 where
319 F: FnMut(usize, &mut [u8]),
320 {
321 if n == 0 {
322 return Ok(());
323 }
324 let h = self.header();
325 let b = h.bottom.load(Ordering::Relaxed);
326 let t = h.top.load(Ordering::Acquire);
327 if (b - t) + n as i64 > self.capacity as i64 {
328 return Err(DequeError::Full);
329 }
330 let mask = self.capacity - 1;
331 prefetchw_line(self.slot_ptr((b as usize) & mask));
332 for i in 0..n {
333 let idx = ((b + i as i64) as usize) & mask;
334 if i + 1 < n {
335 prefetchw_line(self.slot_ptr(((b + (i + 1) as i64) as usize) & mask));
336 }
337 // SAFETY: idx is in [0, capacity); each slot is
338 // slot_bytes long; the slice covers a valid mapped
339 // region; the capacity check above guarantees the
340 // producer-side reservation is free of consumer claims.
341 let slot = unsafe {
342 std::slice::from_raw_parts_mut(self.slot_ptr(idx), self.slot_bytes)
343 };
344 fill(i, slot);
345 }
346 fence(Ordering::Release);
347 h.bottom.store(b + n as i64, Ordering::Relaxed);
348 Ok(())
349 }
350
351 /// Owner-side batched push. Amortizes ONE `top.load(Acquire)`,
352 /// ONE Release fence, and ONE `bottom.store(Relaxed)` across the
353 /// whole batch instead of paying them per item. Critical for
354 /// producer-fast workloads where the per-item `top` load goes
355 /// cross-core to the thief and dominates per-push cost.
356 ///
357 /// Returns `Err(Full)` (and writes no slots) if the batch would
358 /// overflow capacity at the current `top` snapshot.
359 pub fn push_batch(&self, values: &[T]) -> Result<(), DequeError> {
360 if values.is_empty() {
361 return Ok(());
362 }
363 let h = self.header();
364 let b = h.bottom.load(Ordering::Relaxed);
365 // Single Acquire-load on top covers the whole batch.
366 let t = h.top.load(Ordering::Acquire);
367 if (b - t) + values.len() as i64 > self.capacity as i64 {
368 return Err(DequeError::Full);
369 }
370 // Prefetch the first slot before the marshal loop.
371 let mask = self.capacity - 1;
372 prefetchw_line(self.slot_ptr((b as usize) & mask));
373 for (i, v) in values.iter().enumerate() {
374 let idx = ((b + i as i64) as usize) & mask;
375 // Warm the next slot while we write this one.
376 if i + 1 < values.len() {
377 prefetchw_line(self.slot_ptr(((b + (i + 1) as i64) as usize) & mask));
378 }
379 // SAFETY: idx is in [0, capacity); each slot is
380 // slot_bytes long; the slice covers a valid mapped
381 // region; the capacity check above guarantees the
382 // producer-side reservation is free of consumer claims.
383 let slot = unsafe {
384 std::slice::from_raw_parts_mut(self.slot_ptr(idx), self.slot_bytes)
385 };
386 v.marshal(slot);
387 }
388 // ONE Release fence + ONE bottom store publishes all N slots
389 // atomically from the thief's perspective: after the store,
390 // bottom advanced by N and every slot in [b, b+N) carries
391 // the marshalled bytes (Release-fence ordered them all
392 // before this store).
393 fence(Ordering::Release);
394 h.bottom.store(b + values.len() as i64, Ordering::Relaxed);
395 Ok(())
396 }
397
398 /// Owner side: pop a value off the bottom of the deque.
399 ///
400 /// Fast path (no contention with thieves) is a single Relaxed
401 /// load + Relaxed store on `bottom`, a SeqCst fence, and a
402 /// Relaxed load on `top`. Only the contended case - one item
403 /// left and a thief is trying to take it - falls back to a CAS
404 /// on `top` to disambiguate.
405 pub fn pop(&self) -> Option<T> {
406 let h = self.header();
407 let b = h.bottom.load(Ordering::Relaxed) - 1;
408 h.bottom.store(b, Ordering::Relaxed);
409 fence(Ordering::SeqCst);
410 let t = h.top.load(Ordering::Relaxed);
411 if b < t {
412 // Deque was empty; restore bottom and return.
413 h.bottom.store(b + 1, Ordering::Relaxed);
414 return None;
415 }
416 let idx = (b as usize) & (self.capacity - 1);
417 // SAFETY: idx is in [0, capacity); slot is fully marshalled.
418 let slot = unsafe { std::slice::from_raw_parts(self.slot_ptr(idx) as *const u8, self.slot_bytes) };
419 let v = T::unmarshal(slot).ok()?;
420 if b > t {
421 // No race; the popped slot was strictly above any thief's
422 // reach.
423 return Some(v);
424 }
425 // b == t: one element left; race a thief.
426 let race_result = h.top.compare_exchange(t, t + 1, Ordering::SeqCst, Ordering::Relaxed);
427 h.bottom.store(b + 1, Ordering::Relaxed);
428 if race_result.is_ok() {
429 Some(v)
430 } else {
431 // Thief won; we lose the value we read.
432 None
433 }
434 }
435
436 /// Thief side: steal a value off the top of the deque.
437 ///
438 /// Any number of threads or processes can call this concurrently
439 /// with each other and with the owner's `pop`. Each call costs
440 /// one Acquire load on `top`, a SeqCst fence, one Acquire load
441 /// on `bottom`, a slot read, and one CAS on `top`. The slot read
442 /// happens before the CAS so a CAS-loss discards a possibly-stale
443 /// value safely.
444 pub fn steal(&self) -> Option<T> {
445 let h = self.header();
446 let t = h.top.load(Ordering::Acquire);
447 fence(Ordering::SeqCst);
448 let b = h.bottom.load(Ordering::Acquire);
449 if t >= b { return None; }
450 let idx = (t as usize) & (self.capacity - 1);
451 // SAFETY: idx is in [0, capacity); slot bytes are stable
452 // until the owner's push wraps around `capacity` operations
453 // later, which cannot happen before this CAS resolves
454 // because `t < b` here and the owner is bounded by capacity.
455 let slot = unsafe { std::slice::from_raw_parts(self.slot_ptr(idx) as *const u8, self.slot_bytes) };
456 let v = T::unmarshal(slot).ok()?;
457 match h.top.compare_exchange(t, t + 1, Ordering::SeqCst, Ordering::Relaxed) {
458 Ok(_) => Some(v),
459 Err(_) => None,
460 }
461 }
462
463 /// Force the mapped region to be written back to disk. Useful
464 /// for the disk-persistent deployment mode.
465 pub fn flush(&self) -> std::io::Result<()> { self.mmap.flush() }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use std::sync::Arc;
472 use std::thread;
473
474 fn tmp(name: &str) -> std::path::PathBuf {
475 let mut p = std::env::temp_dir();
476 let pid = std::process::id();
477 p.push(format!("subetha-deque-{name}-{pid}.bin"));
478 p
479 }
480
481 #[test]
482 fn single_thread_push_pop_lifo() {
483 let path = tmp("st-lifo");
484 let dq = SharedDeque::<u64>::create(&path, 64).unwrap();
485 for i in 0..10u64 { dq.push(&i).unwrap(); }
486 let mut popped = Vec::new();
487 while let Some(v) = dq.pop() { popped.push(v); }
488 assert_eq!(popped, (0..10).rev().collect::<Vec<_>>(),
489 "Chase-Lev owner pop is LIFO");
490 drop(dq);
491 std::fs::remove_file(&path).ok();
492 }
493
494 #[test]
495 fn empty_pop_returns_none() {
496 let path = tmp("empty-pop");
497 let dq = SharedDeque::<u64>::create(&path, 8).unwrap();
498 assert_eq!(dq.pop(), None);
499 assert_eq!(dq.approx_len(), 0);
500 drop(dq);
501 std::fs::remove_file(&path).ok();
502 }
503
504 #[test]
505 fn full_push_returns_err() {
506 let path = tmp("full-push");
507 let dq = SharedDeque::<u64>::create(&path, 4).unwrap();
508 for i in 0..4u64 { dq.push(&i).unwrap(); }
509 assert!(matches!(dq.push(&999), Err(DequeError::Full)));
510 drop(dq);
511 std::fs::remove_file(&path).ok();
512 }
513
514 #[test]
515 fn capacity_must_be_power_of_two() {
516 let path = tmp("badcap");
517 assert!(matches!(
518 SharedDeque::<u64>::create(&path, 7),
519 Err(DequeError::InvalidCapacity)
520 ));
521 }
522
523 #[test]
524 fn second_handle_steals_fifo() {
525 // Steals take from the TOP (oldest first), so a sequence of
526 // pushes then steals reads FIFO order.
527 let path = tmp("steal-fifo");
528 let owner = SharedDeque::<u64>::create(&path, 16).unwrap();
529 for i in 0..5u64 { owner.push(&i).unwrap(); }
530 let thief = SharedDeque::<u64>::open_as_thief(&path).unwrap();
531 let mut stolen = Vec::new();
532 while let Some(v) = thief.steal() { stolen.push(v); }
533 assert_eq!(stolen, (0..5).collect::<Vec<_>>());
534 drop(thief); drop(owner);
535 std::fs::remove_file(&path).ok();
536 }
537
538 #[test]
539 fn one_owner_one_thief_concurrent() {
540 let path = tmp("1o1t");
541 let owner = Arc::new(SharedDeque::<u64>::create(&path, 1024).unwrap());
542 let thief = Arc::new(SharedDeque::<u64>::open_as_thief(&path).unwrap());
543 let n = 10_000u64;
544 let owner_h = owner.clone();
545 let producer = thread::spawn(move || {
546 for i in 0..n {
547 while owner_h.push(&i).is_err() { std::hint::spin_loop(); }
548 }
549 });
550 let consumer = thread::spawn(move || {
551 let mut taken = 0u64;
552 let mut sum = 0u64;
553 while taken < n {
554 if let Some(v) = thief.steal() { sum += v; taken += 1; }
555 else { std::hint::spin_loop(); }
556 }
557 sum
558 });
559 producer.join().unwrap();
560 let sum = consumer.join().unwrap();
561 assert_eq!(sum, (0..n).sum::<u64>());
562 drop(owner);
563 std::fs::remove_file(&path).ok();
564 }
565
566 #[test]
567 fn one_owner_four_thieves_concurrent() {
568 let path = tmp("1o4t");
569 let owner = Arc::new(SharedDeque::<u64>::create(&path, 4096).unwrap());
570 let n = 8_000u64;
571 let total = Arc::new(std::sync::atomic::AtomicU64::new(0));
572
573 let owner_h = owner.clone();
574 let producer = thread::spawn(move || {
575 for i in 1..=n {
576 while owner_h.push(&i).is_err() { std::hint::spin_loop(); }
577 }
578 });
579 let mut thieves = Vec::new();
580 for _ in 0..4 {
581 let path_t = path.clone();
582 let total_t = total.clone();
583 thieves.push(thread::spawn(move || {
584 let h = SharedDeque::<u64>::open_as_thief(&path_t).unwrap();
585 let stop = std::time::Instant::now() + std::time::Duration::from_secs(5);
586 loop {
587 if let Some(v) = h.steal() {
588 total_t.fetch_add(v, std::sync::atomic::Ordering::Relaxed);
589 } else if std::time::Instant::now() > stop {
590 break;
591 } else {
592 std::hint::spin_loop();
593 }
594 }
595 }));
596 }
597 producer.join().unwrap();
598 // Drain remaining from owner side.
599 while let Some(v) = owner.pop() {
600 total.fetch_add(v, std::sync::atomic::Ordering::Relaxed);
601 }
602 std::thread::sleep(std::time::Duration::from_millis(100));
603 for t in thieves { t.join().unwrap(); }
604 // Drain any final stragglers via a fresh thief handle.
605 let drain = SharedDeque::<u64>::open_as_thief(&path).unwrap();
606 while let Some(v) = drain.steal() {
607 total.fetch_add(v, std::sync::atomic::Ordering::Relaxed);
608 }
609 let expected = (1..=n).sum::<u64>();
610 let actual = total.load(std::sync::atomic::Ordering::Relaxed);
611 assert_eq!(actual, expected, "all pushed values must be accounted for");
612 drop(drain); drop(owner);
613 std::fs::remove_file(&path).ok();
614 }
615
616 #[test]
617 fn slot_bytes_mismatch_rejected_on_open() {
618 let path = tmp("mismatch");
619 let _o = SharedDeque::<u64>::create(&path, 16).unwrap();
620 let result = SharedDeque::<u128>::open_as_thief(&path);
621 assert!(matches!(result, Err(DequeError::SlotBytesMismatch { .. })));
622 drop(_o);
623 std::fs::remove_file(&path).ok();
624 }
625}