Skip to main content

aligned_vmem/
fault_injection.rs

1//! Real-path commit fault injection (feature `fault-injection`).
2//!
3//! Distinct from `crate::mock` (cfg `aligned_vmem_mock`, not necessarily set
4//! alongside `fault-injection`): `mock` replaces the *entire* backend for
5//! commit/decommit/recommit (and short-circuits reservations only for the
6//! scripted-failure case) with a thread-local recording stub — a consumer
7//! that needs the REAL OS backend under test (real segment reservations, real
8//! commit accounting, real page-fault behaviour) cannot use it. This module
9//! changes nothing about which backend runs: [`crate::try_commit_range`]
10//! calls the real per-OS `commit_range_impl` — **but only when the
11//! `aligned_vmem_mock` cfg is NOT set** (task #1106/L1). Under
12//! `aligned_vmem_mock`, `try_commit_range`'s real-path branch — the single
13//! call site of `should_fail_commit` — is compiled out and replaced by the
14//! mock backend, so this module's hooks are never consulted: the mock's own
15//! fault script (`crate::mock::fail_next_commit` etc. — a separate mechanism,
16//! compiled only under the mock cfg) takes precedence, and arming THESE hooks
17//! is silently inert. CI deliberately builds exactly that combination
18//! (`.github/workflows/ci.yml` sets the mock cfg alongside the full feature
19//! set including `fault-injection`), so the combination is real, not
20//! theoretical; the hooks are `allow(dead_code)` in
21//! it rather than rejected, because the mock rows exercise the same feature
22//! set and a `compile_error!` on the combination would break them. Outside
23//! the mock cfg, this module splices two armed checks in front of the real
24//! backend call so a test can deterministically force a
25//! specific call to report `VmemError::os_refusal_unknown_code()` (task #713:
26//! not `last_os_error()` — no real syscall runs for a simulated fault, so
27//! there is no real OS code to report) instead of touching the OS —
28//! simulating commit-charge exhaustion at an exact point in a real allocation
29//! sequence.
30//!
31//! task #1219 adds a decommit-side sibling hook with exactly ONE call site of
32//! its own: [`arm_fail_next_decommit`], consulted from
33//! `dispatch_try_decommit` (`api/decommit.rs`) — the single private dispatch
34//! point both fallible decommit entry points (the free `try_decommit` and
35//! `Reservation::try_decommit`) funnel through — in front of the real
36//! `decommit_pages_impl` call. It exists for the same reason and carries the
37//! same mock-caveat as the commit-side hooks (inert under
38//! `aligned_vmem_mock`, where the call site is compiled out). Only the
39//! fail-next tier exists on the decommit side; no `arm_fail_at`-style k-th
40//! hook, because no test has needed one — add it by mirroring
41//! [`arm_fail_at`]/`FAULT_STATE` if one ever does.
42//!
43//! Two independent, additive hooks (mirrors the two-tier hook that
44//! `sefer-alloc` carried before this crate absorbed it):
45//! - [`arm_fail_next`]: the next `n` real commit calls fail.
46//! - [`arm_fail_at`]: the k-th real commit call from now (1-based) fails;
47//!   one-shot, disarms itself after firing.
48//!
49//! `arm_fail_next`'s "fail next N" is checked first and has priority; when it
50//! is disarmed (0), `arm_fail_at`'s "fail the k-th" is checked. Both may be
51//! armed simultaneously.
52//!
53//! Process-wide atomics (not thread-local): a test typically arms a fault
54//! from one thread and triggers the committing call from another (e.g. an
55//! `alloc-xthread` reclaim test spawning worker threads while the main test
56//! thread stays armed), so this module does NOT assume the arming and
57//! committing thread are the same (task #718 -- an earlier revision of this
58//! doc claimed exactly that "owner-only discipline" assumption and used
59//! `Relaxed` throughout on that basis; the assumption does not hold for
60//! multi-threaded consumers, so it is not a safe basis for the ordering
61//! choice). Concretely: [`arm_fail_at`] now uses a `Mutex<FaultState>` to
62//! serialize arming and disarming, closing the concurrent re-arm race
63//! (task #1021/R4-8). `FAIL_NEXT`'s decrement uses [`AtomicU32::fetch_update`
64//! (a genuine atomic read-modify-write) instead of a separate load then store,
65//! which would otherwise race under concurrent callers and lose or duplicate a
66//! decrement.
67//!
68//! Zero cost when the feature is off: this entire module is compiled out
69//! (`#[cfg(feature = "fault-injection")]` on the `mod` declaration in
70//! `lib.rs`), and the call sites that consult it are themselves
71//! `#[cfg(feature = "fault-injection")]`-gated, so the production path is
72//! byte-identical with the feature disabled.
73
74use core::sync::atomic::{AtomicU32, Ordering};
75use std::sync::Mutex;
76
77/// Fault state protected by a mutex to serialize arm/fire operations and
78/// prevent concurrent re-arm races (task #1021/R4-8).
79struct FaultState {
80    /// Target call number for one-shot failure (0 = disarmed).
81    target: u32,
82    /// Running count of commit calls since last arming.
83    counter: u32,
84}
85
86static FAULT_STATE: Mutex<FaultState> = Mutex::new(FaultState {
87    target: 0,
88    counter: 0,
89});
90
91/// When `> 0`, the next real commit call fails without touching the OS and
92/// decrements this counter. `0` disarms. See [`arm_fail_next`].
93static FAIL_NEXT: AtomicU32 = AtomicU32::new(0);
94
95/// Arm the "fail the next N real commits" hook. The next `n` calls to the
96/// real commit path ([`crate::try_commit_range`] / [`crate::commit_range`])
97/// return `Err`/`false` without touching the OS. `n == 0` disarms.
98/// Inert under the `aligned_vmem_mock` cfg — see the module doc.
99///
100/// Uses `Relaxed`, not `Release` like `arm_fail_at`: `FAIL_NEXT` carries no
101/// payload to publish across threads, so there is nothing a stronger ordering
102/// would protect.
103///
104/// Checked BEFORE [`arm_fail_at`]'s hook (this hook has priority).
105#[cfg_attr(docsrs, doc(cfg(feature = "fault-injection")))]
106pub fn arm_fail_next(n: u32) {
107    FAIL_NEXT.store(n, Ordering::Relaxed);
108}
109
110/// Arm the "fail the k-th real commit from now" hook (1-based, one-shot).
111/// The k-th call to the real commit path from now fails; calls already
112/// consumed by [`arm_fail_next`] are not counted. All other calls
113/// (before and after) succeed normally. After firing, the hook disarms
114/// itself. `k == 0` disarms without ever firing.
115///
116/// Resets the internal call counter, so arming always counts from zero.
117/// Checked AFTER [`arm_fail_next`]'s hook.
118/// Inert under the `aligned_vmem_mock` cfg — see the module doc.
119///
120/// task #1021/R4-8: This function now uses a Mutex to serialize arming with
121/// the self-disarm in `should_fail_commit`, preventing the concurrent re-arm
122/// race where a re-arm between the target reset and counter reset would be
123/// lost. The earlier two-atomic approach (`FAIL_AT_COUNTER = 0` then
124/// `FAIL_AT_TARGET = k`) had a race window between those stores.
125#[cfg_attr(docsrs, doc(cfg(feature = "fault-injection")))]
126pub fn arm_fail_at(k: u32) {
127    let mut state = FAULT_STATE.lock().unwrap_or_else(|e| e.into_inner());
128    state.counter = 0;
129    state.target = k;
130}
131
132/// Internal: consult both hooks for the current real commit call. Returns
133/// `true` if this call should be forced to fail. Called once per real commit
134/// attempt, immediately before the OS syscall.
135// mock (task #646/F8): `try_commit_range`'s `#[cfg(not(aligned_vmem_mock))]`
136// branch — the only call site — is compiled out under `aligned_vmem_mock`, so this goes
137// unused whenever the `aligned_vmem_mock` cfg is set alongside `fault-injection`.
138// fault-injection (task #925/V-21): `try_commit_range` itself is gated on
139// `lazy-commit`, so this is unused when `fault-injection` is enabled without
140// `lazy-commit`. Suppressed in both specific combinations.
141#[cfg_attr(
142    any(
143        aligned_vmem_mock,
144        all(feature = "fault-injection", not(feature = "lazy-commit"))
145    ),
146    allow(dead_code)
147)]
148pub(crate) fn should_fail_commit() -> bool {
149    // task #718: `fetch_update` performs the load-check-decrement as one
150    // atomic read-modify-write, closing the race a separate `load` then
151    // `store` had under concurrent callers (two threads could both observe
152    // the same pre-decrement value and either both fire when only one
153    // failure was armed, or both write back the same decremented value and
154    // silently lose a decrement).
155    let fired = FAIL_NEXT
156        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| {
157            // `then_some` evaluates its argument EAGERLY (before the call),
158            // so `next - 1` would underflow-panic when `next == 0` even
159            // though the resulting `Option` would be `None`; `then` with a
160            // closure evaluates lazily, only when `next > 0`.
161            (next > 0).then(|| next - 1)
162        })
163        .is_ok();
164    if fired {
165        return true;
166    }
167
168    // task #1021/R4-8: Use Mutex to prevent concurrent re-arm race.
169    // The earlier two-atomic approach had a window between resetting
170    // FAIL_AT_TARGET and FAIL_AT_COUNTER where a concurrent arm_fail_at
171    // could be lost. Holding the mutex across both stores guarantees
172    // atomicity.
173    let mut state = FAULT_STATE.lock().unwrap_or_else(|e| e.into_inner());
174    if state.target > 0 {
175        state.counter += 1;
176        let call_number = state.counter; // 1-based
177        if call_number == state.target {
178            // One-shot: disarm after firing. Both stores happen under the
179            // mutex, so no concurrent arm can interleave and be lost.
180            state.target = 0;
181            state.counter = 0;
182            return true;
183        }
184    }
185    false
186}
187
188/// When `> 0`, the next real decommit call fails without touching the OS and
189/// decrements this counter. `0` disarms. See [`arm_fail_next_decommit`].
190///
191/// Separate from [`FAIL_NEXT`]: arming the COMMIT hook must not also fire
192/// decommits (and vice versa), so the two tiers keep independent state.
193static FAIL_NEXT_DECOMMIT: AtomicU32 = AtomicU32::new(0);
194
195/// Arm the "fail the next N real decommits" hook (task #1219). The next `n`
196/// calls through the real decommit dispatch point — `dispatch_try_decommit`
197/// (`api/decommit.rs`), reached by BOTH fallible decommit entry points, the
198/// free [`crate::try_decommit`] and [`crate::Reservation::try_decommit`] —
199/// return `Ok(DecommitOutcome::Refused(VmemError::os_refusal_unknown_code()))`
200/// without touching the OS. `n == 0` disarms.
201///
202/// What an armed hook PROVES when it fires, stated precisely because the
203/// decommit-side history is full of overclaims: the `Err(e) =>
204/// DecommitOutcome::Refused(e)` mapping arm is reachable from both fallible
205/// entry points and constructs the outcome carrying exactly the error the
206/// backend layer produced. It does NOT prove an OS refusal — no syscall ran;
207/// the no-code sentinel is used for the same task-#713 reason as the
208/// commit-side hook. What a caller can NOT learn from this hook: whether any
209/// real kernel would refuse any real range — that remains untestable
210/// deterministically from `tests/` alone (see
211/// `decommit_outcome.rs`'s module doc for the avenues rejected).
212///
213/// Inert under the `aligned_vmem_mock` cfg — see the module doc. Deliberately
214/// NOT consulted by the infallible `decommit`/`decommit_lazy`: both discard
215/// the backend outcome by signature, so an injected fault there would have
216/// nothing observable to affect.
217///
218/// Uses `Relaxed` for the same reason as [`arm_fail_next`]: the counter
219/// carries no payload to publish across threads.
220#[cfg_attr(docsrs, doc(cfg(feature = "fault-injection")))]
221pub fn arm_fail_next_decommit(n: u32) {
222    FAIL_NEXT_DECOMMIT.store(n, Ordering::Relaxed);
223}
224
225/// Internal: consult the decommit-side hook for the current real decommit
226/// call. Returns `true` if this call should be forced to fail. Called once
227/// per real decommit attempt, immediately before the OS syscall — the single
228/// call site is `dispatch_try_decommit`'s `#[cfg(not(aligned_vmem_mock))]`
229/// branch (`api/decommit.rs`).
230// mock (task #646/F8 shape): under `aligned_vmem_mock` that call site is
231// compiled out, so this goes unused whenever the cfg is set alongside
232// `fault-injection`. Unlike `should_fail_commit` there is NO
233// `fault-injection`-without-`lazy-commit` dead combination to suppress:
234// `dispatch_try_decommit` is not feature-gated (decommit is core API), so the
235// mock cfg is the only combination that orphans this function.
236#[cfg_attr(aligned_vmem_mock, allow(dead_code))]
237pub(crate) fn should_fail_decommit() -> bool {
238    // Same `fetch_update` shape and task #718 rationale as `should_fail_commit`:
239    // one atomic read-modify-write, so concurrent callers can neither both
240    // fire on one armed failure nor silently lose a decrement.
241    FAIL_NEXT_DECOMMIT
242        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| {
243            // `then` (lazy), not `then_some` (eager) — see the matching
244            // comment in `should_fail_commit` for the underflow trap.
245            (next > 0).then(|| next - 1)
246        })
247        .is_ok()
248}