objects/fault_inject.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Deterministic fault-injection points for crash-recovery tests.
3//!
4//! W2b shipped the rollback machinery — atomic mapping persistence,
5//! mirror Drop guard, HEAD/index restore on failure — but until we
6//! actually crash the process between the load-bearing writes, the
7//! rollback paths only have unit-test coverage of the *helpers*, not
8//! of the *recovery contract itself*. The integration story
9//! ("crashing here doesn't corrupt the Git Projection Mapping") was unverified.
10//!
11//! This module exposes a single `maybe_panic_at(name)` checkpoint
12//! that production code threads at the points where a crash would
13//! exercise a recovery path. Tests opt in by setting the
14//! `HEDDLE_FAULT_INJECT` environment variable to a comma-separated
15//! list of checkpoint names — e.g.
16//! `HEDDLE_FAULT_INJECT=mapping_after_tmp_before_commit` — and the
17//! next process to hit that checkpoint panics with a stable message.
18//!
19//! The next CLI invocation (a separate process, no inherited env)
20//! must recover cleanly. That's the contract under test.
21//!
22//! ## Why an env var instead of a build-time `#[cfg(test)]` gate
23//!
24//! The crash points sit in `objects` and `cli` paths that get spawned
25//! as separate child processes during integration tests. A child
26//! process can't see the parent test's `cfg(test)` flag, but it does
27//! inherit env vars by default. An env var lets the parent test set
28//! the crash point, spawn the child, observe the child crash, then
29//! spawn a fresh child (without the env var) and verify recovery.
30//!
31//! ## Performance
32//!
33//! `maybe_panic_at` is a single env lookup + string split + linear
34//! search. The env var is read once on first call and cached. With no
35//! `HEDDLE_FAULT_INJECT` set (the production default), the cached
36//! `None` short-circuits in well under a microsecond.
37
38use std::sync::OnceLock;
39
40/// Cached parse of the `HEDDLE_FAULT_INJECT` env var. `None` means
41/// the env var was not set; an empty `Vec` means it was set to an
42/// empty string (treated as no checkpoints active).
43static FAULT_POINTS: OnceLock<Option<Vec<String>>> = OnceLock::new();
44
45// Test-only in-process override (avoids `OnceLock` + env pollution).
46#[cfg(test)]
47thread_local! {
48 static TEST_FAULT_POINTS: std::cell::RefCell<Option<Vec<String>>> =
49 const { std::cell::RefCell::new(None) };
50}
51
52fn env_active_points() -> &'static Option<Vec<String>> {
53 FAULT_POINTS.get_or_init(|| {
54 std::env::var("HEDDLE_FAULT_INJECT").ok().map(|raw| {
55 raw.split(',')
56 .map(str::trim)
57 .filter(|s| !s.is_empty())
58 .map(str::to_owned)
59 .collect()
60 })
61 })
62}
63
64fn point_is_active(name: &str) -> bool {
65 #[cfg(test)]
66 {
67 let override_hit = TEST_FAULT_POINTS.with(|cell| {
68 cell.borrow()
69 .as_ref()
70 .map(|points| points.iter().any(|active| active == name))
71 });
72 // Some(true/false) = override active; None = fall through to env cache.
73 if let Some(active) = override_hit {
74 return active;
75 }
76 }
77 env_active_points()
78 .as_ref()
79 .is_some_and(|points| points.iter().any(|active| active == name))
80}
81
82/// Crash the current process if `name` is listed in `HEDDLE_FAULT_INJECT`.
83///
84/// Production callers thread this at points where a crash would
85/// exercise a recovery path. Tests set the env var on a child
86/// process to deterministically trigger the crash, then verify the
87/// next clean process recovers.
88///
89/// The panic message includes the checkpoint name so test logs can
90/// distinguish an intentional fault from a real bug.
91pub fn maybe_panic_at(name: &str) {
92 if point_is_active(name) {
93 panic!("HEDDLE_FAULT_INJECT: crashing at checkpoint `{name}` (intentional)");
94 }
95}
96
97/// Like [`maybe_panic_at`], but returns an `io::Error` instead of
98/// panicking — for exercising *in-process* error-recovery paths (a
99/// graceful failure that drives a rollback) rather than crash recovery.
100///
101/// Production callers thread this where a returned error must unwind a
102/// partially-applied operation; tests opt in by listing the checkpoint
103/// name in `HEDDLE_FAULT_INJECT` and assert the rollback left no
104/// partial state. With the env var unset the cached `None`
105/// short-circuits, exactly like [`maybe_panic_at`].
106pub fn maybe_fail_at(name: &str) -> std::io::Result<()> {
107 if point_is_active(name) {
108 return Err(std::io::Error::other(format!(
109 "HEDDLE_FAULT_INJECT: failing at checkpoint `{name}` (intentional)"
110 )));
111 }
112 Ok(())
113}
114
115/// Run `f` with in-process fault checkpoints active (test only).
116///
117/// Prefer this over mutating `HEDDLE_FAULT_INJECT` in unit tests: the env
118/// parse is memoised in a process-global `OnceLock` and cannot be safely
119/// flipped under parallel `cargo test`.
120#[cfg(test)]
121pub fn with_fault_points<R>(points: &[&str], f: impl FnOnce() -> R) -> R {
122 TEST_FAULT_POINTS.with(|cell| {
123 *cell.borrow_mut() = Some(points.iter().map(|s| (*s).to_owned()).collect());
124 });
125 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
126 TEST_FAULT_POINTS.with(|cell| {
127 *cell.borrow_mut() = None;
128 });
129 match result {
130 Ok(v) => v,
131 Err(payload) => std::panic::resume_unwind(payload),
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn no_env_var_is_a_silent_noop() {
141 // Safety: this test only runs when HEDDLE_FAULT_INJECT is
142 // unset, which is the production default. A test runner
143 // that exports the var globally would change behaviour, but
144 // that would be the runner's problem to surface explicitly.
145 if std::env::var("HEDDLE_FAULT_INJECT").is_ok() {
146 return;
147 }
148 // Should not panic — env var unset, all checkpoints inactive.
149 maybe_panic_at("anything");
150 }
151
152 // NOTE: the original sibling test
153 // `env_var_with_matching_name_panics` lived here and was flaky in
154 // parallel runs. The flake was structural: `FAULT_POINTS` is a
155 // `OnceLock`, so whichever test calls `active_points()` first wins.
156 // If `no_env_var_is_a_silent_noop` ran first it cached `None`, and
157 // the panic test could never re-arm the checkpoint.
158 //
159 // The fix was to move the panic test to its own integration-test
160 // binary (`tests/fault_inject_panic.rs`); each integration test
161 // file gets its own process and its own OnceLock state, so the
162 // test always observes a fresh cache.
163}