Skip to main content

dial9_core/
primitives.rs

1//! Cfg-gated concurrency primitives.
2//!
3//! Under normal compilation this re-exports from `std`. With `--cfg shuttle`
4//! it re-exports from `shuttle`, giving the shuttle scheduler control over all
5//! synchronization points so that tests can explore thread interleavings
6//! deterministically.
7
8// ── std path (production) ───────────────────────────────────────────────────
9
10#[cfg(not(shuttle))]
11pub mod sync {
12    pub use std::sync::atomic;
13    pub use std::sync::mpsc;
14    #[allow(unused_imports)]
15    pub use std::sync::{Arc, Barrier, Mutex, Weak};
16}
17
18#[cfg(not(shuttle))]
19pub mod thread {
20    #[allow(unused_imports)]
21    pub use std::thread::{JoinHandle, sleep, spawn};
22
23    /// Spawn a named thread. Uses `std::thread::Builder` in production,
24    /// falls back to plain `spawn` under shuttle (which has no Builder).
25    pub fn spawn_named<F, T>(name: &str, f: F) -> JoinHandle<T>
26    where
27        F: FnOnce() -> T + Send + 'static,
28        T: Send + 'static,
29    {
30        std::thread::Builder::new()
31            .name(name.into())
32            .spawn(f)
33            .expect("failed to spawn thread")
34    }
35}
36
37#[cfg(not(shuttle))]
38#[macro_export]
39macro_rules! define_thread_local {
40    ($($tt:tt)*) => { std::thread_local! { $($tt)* } };
41}
42#[cfg(not(shuttle))]
43pub use crate::define_thread_local as thread_local;
44
45// ── shuttle path (deterministic testing) ────────────────────────────────────
46
47#[cfg(shuttle)]
48pub mod sync {
49    pub use shuttle::sync::atomic;
50    #[allow(unused_imports)]
51    pub use shuttle::sync::{Arc, Barrier, Mutex, Weak};
52
53    /// Shuttle's `recv_timeout` ignores the timeout and blocks forever,
54    /// which would stop the flush loop from ever looping. This wraps it to
55    /// randomly return `Timeout` instead, so shuttle can explore multiple
56    /// flush-loop cycles.
57    pub mod mpsc {
58        pub use shuttle::sync::mpsc::{RecvTimeoutError, SyncSender};
59
60        pub struct Receiver<T> {
61            inner: shuttle::sync::mpsc::Receiver<T>,
62        }
63
64        // SAFETY: shuttle's Receiver<T> is Send when T: Send, so the wrapper can be too.
65        unsafe impl<T: Send> Send for Receiver<T> {}
66
67        impl<T> Receiver<T> {
68            pub fn recv_timeout(
69                &self,
70                _timeout: std::time::Duration,
71            ) -> Result<T, RecvTimeoutError> {
72                // Randomly decide whether to simulate a timeout, giving
73                // the flush loop a chance to execute its body.
74                if shuttle::rand::thread_rng().gen_bool(0.8) {
75                    match self.inner.try_recv() {
76                        Ok(val) => Ok(val),
77                        Err(shuttle::sync::mpsc::TryRecvError::Empty) => {
78                            Err(RecvTimeoutError::Timeout)
79                        }
80                        Err(shuttle::sync::mpsc::TryRecvError::Disconnected) => {
81                            Err(RecvTimeoutError::Disconnected)
82                        }
83                    }
84                } else {
85                    // Delegate to shuttle's blocking recv to explore the
86                    // "flush loop blocks waiting for command" path.
87                    self.inner
88                        .recv()
89                        .map_err(|_| RecvTimeoutError::Disconnected)
90                }
91            }
92
93            pub fn recv(&self) -> Result<T, shuttle::sync::mpsc::RecvError> {
94                self.inner.recv()
95            }
96        }
97
98        use shuttle::rand::Rng;
99
100        /// Wraps shuttle's `sync_channel` to return our `Receiver` wrapper.
101        pub fn sync_channel<T>(bound: usize) -> (SyncSender<T>, Receiver<T>) {
102            let (tx, rx) = shuttle::sync::mpsc::sync_channel(bound);
103            (tx, Receiver { inner: rx })
104        }
105    }
106}
107
108#[cfg(shuttle)]
109pub mod thread {
110    #[allow(unused_imports)]
111    pub use shuttle::thread::{JoinHandle, sleep, spawn};
112
113    pub fn spawn_named<F, T>(_name: &str, f: F) -> JoinHandle<T>
114    where
115        F: FnOnce() -> T + Send + 'static,
116        T: Send + 'static,
117    {
118        spawn(f)
119    }
120}
121
122#[cfg(shuttle)]
123#[macro_export]
124macro_rules! define_thread_local {
125    ($($tt:tt)*) => { shuttle::thread_local! { $($tt)* } };
126}
127#[cfg(shuttle)]
128pub use crate::define_thread_local as thread_local;
129
130/// Pairs a shuttle scenario with `check_pct` and `check_uncontrolled_nondeterminism`
131/// Nests the scenario in its own module so `pct`/`determinism` can be fixed leaf names.
132///
133/// ```ignore
134/// shuttle_test! {
135///     num_iters = 5_000, depth = 3;
136///     fn my_scenario() { /* ... */ }
137/// }
138/// ```
139///
140/// Modifiers, added after `depth = $depth`:
141/// - `should_panic` -- document a known bug instead of asserting
142///   correctness. Add `expect_panic = "..."` to pin the panic message (default: any panic).
143///   Add `replay = "<schedule>"` to also check in a
144///   `replay_known_failure` test pinning one captured failing schedule (from
145///   a `pct` run's "failing schedule" output -- not `determinism`'s "failing
146///   seed", which `shuttle::replay` can't take).
147/// - `should_panic, flaky_sigabrt_determinism_only` -- same, but `#[ignore]`s
148///   `determinism` because it's confirmed to sometimes SIGABRT the process
149///   (see that arm's comment below). Confirm the crash first; don't use
150///   defensively.
151/// - `verify_faults_triggered` -- also asserts
152///   `primitives::fs::take_faults_triggered() > 0`, so fault injection can't
153///   silently stop exercising its error path.
154///
155/// Use `num_iters = $num_iters, determinism_only;` instead of `num_iters =
156/// .., depth = ..` for a scenario with no real concurrency to explore
157/// (`check_pct` panics on those). Still needs shuttle's harness whenever the
158/// scenario touches a shuttle-swapped primitive.
159///
160/// Don't use this macro for a scenario touching real global `static` state --
161/// the generated tests run concurrently and would corrupt shuttle's own
162/// bookkeeping; write those by hand behind a real `std::sync::Mutex`.
163///
164/// `default` in place of `num_iters = .., depth = ..` picks up this
165/// codebase's established budget (5,000/3 plain, 100 `determinism_only`,
166/// 10,000/3 `verify_faults_triggered`). Not offered for `should_panic`: pick
167/// and justify an explicit number, since real scenarios there range
168/// 500-5,000 depending on how narrow the race is.
169#[cfg(shuttle)]
170#[macro_export]
171macro_rules! shuttle_test {
172    // Re-dispatches to the explicit-budget arms below, so budgets can't
173    // drift out of sync.
174    (default; $(#[$attr:meta])* fn $name:ident() $body:block) => {
175        $crate::shuttle_test! {
176            num_iters = 5_000, depth = 3;
177            $(#[$attr])* fn $name() $body
178        }
179    };
180    (default, determinism_only; $(#[$attr:meta])* fn $name:ident() $body:block) => {
181        $crate::shuttle_test! {
182            num_iters = 100, determinism_only;
183            $(#[$attr])* fn $name() $body
184        }
185    };
186    (default, verify_faults_triggered; $(#[$attr:meta])* fn $name:ident() $body:block) => {
187        $crate::shuttle_test! {
188            num_iters = 10_000, depth = 3, verify_faults_triggered;
189            $(#[$attr])* fn $name() $body
190        }
191    };
192    (num_iters = $num_iters:expr, depth = $depth:expr; $(#[$attr:meta])* fn $name:ident() $body:block) => {
193        mod $name {
194            use super::*;
195
196            $(#[$attr])*
197            fn $name() $body
198
199            #[test]
200            fn pct() {
201                shuttle::check_pct($name, $num_iters, $depth);
202            }
203
204            #[test]
205            fn determinism() {
206                shuttle::check_uncontrolled_nondeterminism($name, $num_iters);
207            }
208        }
209    };
210    // No `pct`: it panics on a closure with no real concurrency to schedule
211    // (single-threaded, or a fork immediately joined with no
212    // overlapping-runnable window). Still needs shuttle's harness via
213    // `check_uncontrolled_nondeterminism` for any scenario touching a
214    // shuttle-swapped primitive.
215    (num_iters = $num_iters:expr, determinism_only; $(#[$attr:meta])* fn $name:ident() $body:block) => {
216        mod $name {
217            use super::*;
218
219            $(#[$attr])*
220            fn $name() $body
221
222            #[test]
223            fn determinism() {
224                shuttle::check_uncontrolled_nondeterminism($name, $num_iters);
225            }
226        }
227    };
228    (num_iters = $num_iters:expr, depth = $depth:expr, should_panic $(, expect_panic = $msg:expr)? $(, replay = $schedule:expr)?; $(#[$attr:meta])* fn $name:ident() $body:block) => {
229        mod $name {
230            use super::*;
231
232            $(#[$attr])*
233            fn $name() $body
234
235            #[test]
236            #[should_panic $((expected = $msg))?]
237            fn pct() {
238                shuttle::check_pct($name, $num_iters, $depth);
239            }
240
241            #[test]
242            #[should_panic $((expected = $msg))?]
243            fn determinism() {
244                shuttle::check_uncontrolled_nondeterminism($name, $num_iters);
245            }
246
247            $(
248                /// Replays a captured failing schedule so this exact
249                /// failure reproduces deterministically, without waiting on
250                /// `pct`/`determinism` exploration to find it again. No
251                /// `expect_panic` pin needed -- a fixed schedule can't surface
252                /// an unrelated panic.
253                #[test]
254                #[should_panic]
255                fn replay_known_failure() {
256                    shuttle::replay($name, $schedule);
257                }
258            )?
259        }
260    };
261    // Same as plain `should_panic`, but `#[ignore]`s only `determinism`
262    // (`pct` is unaffected) -- for a scenario confirmed to sometimes SIGABRT
263    // the process under shuttle.
264    //
265    // Root cause: `check_uncontrolled_nondeterminism` runs each schedule
266    // twice (record, then replay) to verify the same tasks stay runnable;
267    // `check_pct` doesn't. If a task still holds a shuttle-backed
268    // `ThreadLocalBuffer` when an uncaught panic unwinds through shuttle's
269    // `Execution::run`, its `Drop` runs after shuttle's `EXECUTION_STATE` is
270    // already torn down and panics again mid-unwind -- SIGABRT instead of a
271    // normal test failure.
272    //
273    // Confirm the crash first (run `determinism` alone, repeatedly) before
274    // using this -- don't use it defensively. Still runnable manually with
275    // `--ignored`.
276    (num_iters = $num_iters:expr, depth = $depth:expr, should_panic, flaky_sigabrt_determinism_only $(, expect_panic = $msg:expr)? $(, replay = $schedule:expr)?; $(#[$attr:meta])* fn $name:ident() $body:block) => {
277        mod $name {
278            use super::*;
279
280            $(#[$attr])*
281            fn $name() $body
282
283            #[test]
284            #[should_panic $((expected = $msg))?]
285            fn pct() {
286                shuttle::check_pct($name, $num_iters, $depth);
287            }
288
289            #[test]
290            #[should_panic $((expected = $msg))?]
291            #[ignore = "can SIGABRT the whole process under shuttle -- see shuttle_test!'s flaky_sigabrt_determinism_only arm; run manually with --ignored"]
292            fn determinism() {
293                shuttle::check_uncontrolled_nondeterminism($name, $num_iters);
294            }
295
296            $(
297                /// Replays a captured failing schedule so this exact
298                /// failure reproduces deterministically, without waiting on
299                /// `pct`/`determinism` exploration to find it again. No
300                /// `expect_panic` pin needed -- a fixed schedule can't surface
301                /// an unrelated panic.
302                #[test]
303                #[should_panic]
304                fn replay_known_failure() {
305                    shuttle::replay($name, $schedule);
306                }
307            )?
308        }
309    };
310    // Same as the plain form, but also asserts
311    // `primitives::fs::take_faults_triggered() > 0` across the whole batch,
312    // so a broken fault-visibility thread-local can't silently stop fault
313    // injection without failing loudly. Checked inside the same
314    // `pct`/`determinism` runs, not separate tests, to avoid exploring twice.
315    (num_iters = $num_iters:expr, depth = $depth:expr, verify_faults_triggered; $(#[$attr:meta])* fn $name:ident() $body:block) => {
316        mod $name {
317            use super::*;
318
319            $(#[$attr])*
320            fn $name() $body
321
322            fn assert_faults_were_triggered() {
323                assert!(
324                    $crate::primitives::fs::take_faults_triggered() > 0,
325                    "no run across {} iterations triggered a single fault; fault injection is \
326                     not reaching the flush thread (e.g. a broken fault-visibility thread-local), \
327                     so this test is not exercising any error path.",
328                    $num_iters,
329                );
330            }
331
332            #[test]
333            fn pct() {
334                $crate::primitives::fs::take_faults_triggered(); // drain any count left over from an earlier test
335                shuttle::check_pct($name, $num_iters, $depth);
336                assert_faults_were_triggered();
337            }
338
339            #[test]
340            fn determinism() {
341                $crate::primitives::fs::take_faults_triggered(); // drain any count left over from an earlier test
342                shuttle::check_uncontrolled_nondeterminism($name, $num_iters);
343                assert_faults_were_triggered();
344            }
345        }
346    };
347}
348
349#[cfg(not(shuttle))]
350pub mod fs {
351    use std::io::{self, Write};
352    use std::path::Path;
353
354    pub fn create_dir_all(path: &Path) -> io::Result<()> {
355        std::fs::create_dir_all(path)
356    }
357    pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
358        std::fs::rename(from, to)
359    }
360    pub fn remove_file(path: &Path) -> io::Result<()> {
361        std::fs::remove_file(path)
362    }
363    pub fn remove_dir(path: &Path) -> io::Result<()> {
364        std::fs::remove_dir(path)
365    }
366    pub fn read_dir(path: &Path) -> io::Result<std::fs::ReadDir> {
367        std::fs::read_dir(path)
368    }
369    pub fn metadata(path: &Path) -> io::Result<std::fs::Metadata> {
370        std::fs::metadata(path)
371    }
372    pub fn read(path: &Path) -> io::Result<Vec<u8>> {
373        std::fs::read(path)
374    }
375
376    /// Active-segment file handle.
377    #[derive(Debug)]
378    pub struct File(std::fs::File);
379
380    impl File {
381        pub fn create(path: &Path) -> io::Result<File> {
382            std::fs::File::create(path).map(File)
383        }
384    }
385
386    impl Write for File {
387        #[inline]
388        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
389            self.0.write(buf)
390        }
391        #[inline]
392        fn flush(&mut self) -> io::Result<()> {
393            self.0.flush()
394        }
395    }
396}
397
398#[cfg(shuttle)]
399pub mod fs {
400    use std::cell::Cell;
401    use std::io::{self, ErrorKind, Write};
402    use std::path::Path;
403
404    use shuttle::rand::Rng;
405
406    /// How to fail filesystem operations during a shuttle run.
407    #[derive(Clone, Copy, Debug)]
408    pub enum FaultPolicy {
409        /// Delegate to real `std::fs`, nothing fails.
410        None,
411        /// Every fallible op returns `PermissionDenied`.
412        FailAll,
413        /// Each op independently fails with this probability, drawn from
414        /// shuttle's RNG so the scheduler explores the fault schedule.
415        FailProb(f64),
416    }
417
418    std::thread_local! {
419        static FAULT: Cell<FaultPolicy> = const { Cell::new(FaultPolicy::None) };
420    }
421
422    /// Arm `policy`: the returned guard restores the previous one on drop so a
423    /// fault can't leak into the next shuttle iteration.
424    #[must_use]
425    pub fn set_fault(policy: FaultPolicy) -> FaultGuard {
426        let prev = FAULT.with(|f| f.replace(policy));
427        FaultGuard { prev }
428    }
429
430    pub struct FaultGuard {
431        prev: FaultPolicy,
432    }
433
434    impl Drop for FaultGuard {
435        fn drop(&mut self) {
436            FAULT.with(|f| f.set(self.prev));
437        }
438    }
439
440    fn check() -> io::Result<()> {
441        let fail = match FAULT.with(|f| f.get()) {
442            FaultPolicy::None => false,
443            FaultPolicy::FailAll => true,
444            FaultPolicy::FailProb(p) => shuttle::rand::thread_rng().gen_bool(p),
445        };
446        if fail {
447            Err(io::Error::from(ErrorKind::PermissionDenied))
448        } else {
449            Ok(())
450        }
451    }
452
453    pub fn create_dir_all(path: &Path) -> io::Result<()> {
454        check()?;
455        std::fs::create_dir_all(path)
456    }
457    pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
458        check()?;
459        std::fs::rename(from, to)
460    }
461    pub fn remove_file(path: &Path) -> io::Result<()> {
462        check()?;
463        std::fs::remove_file(path)
464    }
465    pub fn remove_dir(path: &Path) -> io::Result<()> {
466        check()?;
467        std::fs::remove_dir(path)
468    }
469    pub fn read_dir(path: &Path) -> io::Result<std::fs::ReadDir> {
470        check()?;
471        std::fs::read_dir(path)
472    }
473    pub fn metadata(path: &Path) -> io::Result<std::fs::Metadata> {
474        check()?;
475        std::fs::metadata(path)
476    }
477    pub fn read(path: &Path) -> io::Result<Vec<u8>> {
478        check()?;
479        std::fs::read(path)
480    }
481
482    /// Active-segment file handle whose `write`/`flush` honor the armed fault
483    /// policy. `create` is never faulted, so a writer can always be built.
484    #[derive(Debug)]
485    pub struct File(std::fs::File);
486
487    impl File {
488        pub fn create(path: &Path) -> io::Result<File> {
489            std::fs::File::create(path).map(File)
490        }
491    }
492
493    impl Write for File {
494        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
495            check()?;
496            self.0.write(buf)
497        }
498        fn flush(&mut self) -> io::Result<()> {
499            check()?;
500            self.0.flush()
501        }
502    }
503}
504
505// ── BoundedQueue ────────────────────────────────────────────────────────────
506
507/// A bounded MPMC queue. Production uses `crossbeam_queue::ArrayQueue`;
508/// under shuttle it uses a `Mutex<VecDeque>` so the scheduler can control
509/// access.
510#[cfg(not(shuttle))]
511pub struct BoundedQueue<T> {
512    inner: crossbeam_queue::ArrayQueue<T>,
513}
514
515#[cfg(not(shuttle))]
516impl<T> std::fmt::Debug for BoundedQueue<T> {
517    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518        f.debug_struct("BoundedQueue")
519            .field("len", &self.inner.len())
520            .field("capacity", &self.inner.capacity())
521            .finish()
522    }
523}
524
525#[cfg(not(shuttle))]
526impl<T> BoundedQueue<T> {
527    pub fn new(capacity: usize) -> Self {
528        Self {
529            inner: crossbeam_queue::ArrayQueue::new(capacity),
530        }
531    }
532
533    /// Push a value, evicting the oldest if full. Returns the evicted value.
534    pub fn force_push(&self, value: T) -> Option<T> {
535        self.inner.force_push(value)
536    }
537
538    pub fn pop(&self) -> Option<T> {
539        self.inner.pop()
540    }
541}
542
543#[cfg(shuttle)]
544pub struct BoundedQueue<T> {
545    inner: shuttle::sync::Mutex<std::collections::VecDeque<T>>,
546    capacity: usize,
547}
548
549#[cfg(shuttle)]
550impl<T> std::fmt::Debug for BoundedQueue<T> {
551    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552        f.debug_struct("BoundedQueue")
553            .field("capacity", &self.capacity)
554            .finish_non_exhaustive()
555    }
556}
557
558#[cfg(shuttle)]
559impl<T> BoundedQueue<T> {
560    pub fn new(capacity: usize) -> Self {
561        Self {
562            inner: shuttle::sync::Mutex::new(std::collections::VecDeque::with_capacity(capacity)),
563            capacity,
564        }
565    }
566
567    pub fn force_push(&self, value: T) -> Option<T> {
568        let mut q = self.inner.lock().unwrap();
569        let evicted = if q.len() >= self.capacity {
570            q.pop_front()
571        } else {
572            None
573        };
574        q.push_back(value);
575        evicted
576    }
577
578    pub fn pop(&self) -> Option<T> {
579        self.inner.lock().unwrap().pop_front()
580    }
581}