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}