Skip to main content

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}