Skip to main content

grafeo_core/testing/
crash.rs

1//! Crash injection for testing recovery paths.
2//!
3//! When the `testing-crash-injection` feature is enabled, [`maybe_crash`]
4//! counts down a **thread-local** counter and panics when it reaches zero.
5//! Tests use [`with_crash_at`] to run a closure that crashes at a
6//! deterministic point, then verify that recovery produces a consistent state.
7//!
8//! Thread-local storage ensures that concurrent tests never interfere with
9//! each other; only the thread that calls [`enable_crash_at`] is affected.
10//!
11//! When the feature is **disabled**, all functions compile to no-ops with zero
12//! runtime overhead.
13//!
14//! # Example
15//!
16//! ```ignore
17//! use grafeo_core::testing::crash::{with_crash_at, CrashResult};
18//!
19//! for point in 1..20 {
20//!     let result = with_crash_at(point, || {
21//!         // operations that call maybe_crash() internally
22//!     });
23//!     match result {
24//!         CrashResult::Completed(value) => { /* ran to completion */ }
25//!         CrashResult::Crashed => { /* verify recovery */ }
26//!     }
27//! }
28//! ```
29
30#[cfg(feature = "testing-crash-injection")]
31mod inner {
32    use std::cell::Cell;
33
34    thread_local! {
35        static CRASH_COUNTER: Cell<u64> = const { Cell::new(u64::MAX) };
36        static CRASH_ENABLED: Cell<bool> = const { Cell::new(false) };
37    }
38
39    /// Conditionally panic when the crash counter reaches zero.
40    ///
41    /// Insert this at interesting recovery boundaries (before/after WAL
42    /// writes, flushes, checkpoints). When crash injection is disabled,
43    /// this compiles to nothing.
44    ///
45    /// Uses thread-local state so concurrent tests don't interfere.
46    #[inline]
47    pub fn maybe_crash(point: &'static str) {
48        CRASH_ENABLED.with(|enabled| {
49            if !enabled.get() {
50                return;
51            }
52            CRASH_COUNTER.with(|counter| {
53                let prev = counter.get();
54                counter.set(prev.wrapping_sub(1));
55                assert!(prev != 1, "crash injection at: {point}");
56            });
57        });
58    }
59
60    /// Enable crash injection to fire after `count` calls to [`maybe_crash`].
61    ///
62    /// Only affects the calling thread.
63    pub fn enable_crash_at(count: u64) {
64        CRASH_COUNTER.with(|c| c.set(count));
65        CRASH_ENABLED.with(|e| e.set(true));
66    }
67
68    /// Disable crash injection (reset to no-op behavior).
69    ///
70    /// Only affects the calling thread.
71    pub fn disable_crash() {
72        CRASH_ENABLED.with(|e| e.set(false));
73        CRASH_COUNTER.with(|c| c.set(u64::MAX));
74    }
75}
76
77#[cfg(not(feature = "testing-crash-injection"))]
78mod inner {
79    /// No-op when crash injection is disabled.
80    #[inline(always)]
81    pub fn maybe_crash(_point: &'static str) {}
82
83    /// No-op when crash injection is disabled.
84    pub fn enable_crash_at(_count: u64) {}
85
86    /// No-op when crash injection is disabled.
87    pub fn disable_crash() {}
88}
89
90pub use inner::*;
91
92/// Outcome of a crash-injected run.
93pub enum CrashResult<T> {
94    /// The closure completed without crashing.
95    Completed(T),
96    /// A crash was injected (panic caught).
97    Crashed,
98}
99
100/// Run `f` with crash injection armed to fire after `crash_after` calls to
101/// [`maybe_crash`]. Returns [`CrashResult::Crashed`] if the injected panic
102/// was caught, or [`CrashResult::Completed`] with the return value otherwise.
103///
104/// Crash injection is automatically disabled after the closure returns
105/// (whether normally or via panic).
106pub fn with_crash_at<F, T>(crash_after: u64, f: F) -> CrashResult<T>
107where
108    F: FnOnce() -> T + std::panic::UnwindSafe,
109{
110    enable_crash_at(crash_after);
111    let result = std::panic::catch_unwind(f);
112    disable_crash();
113
114    match result {
115        Ok(value) => CrashResult::Completed(value),
116        Err(_) => CrashResult::Crashed,
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    #[cfg(feature = "testing-crash-injection")]
126    fn crash_at_exact_count() {
127        let result = with_crash_at(3, || {
128            maybe_crash("point_1");
129            maybe_crash("point_2");
130            maybe_crash("point_3"); // should crash here
131            42 // should not reach
132        });
133        assert!(matches!(result, CrashResult::Crashed));
134    }
135
136    #[test]
137    fn completes_when_count_exceeds_calls() {
138        let result = with_crash_at(100, || {
139            maybe_crash("a");
140            maybe_crash("b");
141            42
142        });
143        match result {
144            CrashResult::Completed(v) => assert_eq!(v, 42),
145            CrashResult::Crashed => panic!("should not crash"),
146        }
147    }
148
149    #[test]
150    fn disabled_by_default() {
151        // Without enabling, maybe_crash is a no-op
152        maybe_crash("should_not_crash");
153    }
154
155    #[test]
156    fn disable_resets_state() {
157        enable_crash_at(2);
158        disable_crash();
159        // After disable, crash should not fire
160        maybe_crash("a");
161        maybe_crash("b");
162        maybe_crash("c");
163    }
164}