Skip to main content

reddb_file/
dst.rs

1//! Deterministic simulation context for DST fault decisions.
2//!
3//! `buggify!()` is intentionally debug-only. Release builds compile the macro
4//! to `false`, so production crash hooks keep their legacy env-var behavior.
5//!
6//! # Named fault classes (ADR 0074 §1)
7//!
8//! Beyond the crash/delay boundaries `buggify!` guards, the context owns the
9//! vocabulary for the four modeled storage fault classes — [`FaultClass`]. Each
10//! is individually addressable by name with its own probability (ppm), is **off
11//! by default** (`0` ppm), and is release-inert exactly like `buggify!`: the
12//! [`buggify_fault!`] macro compiles to `None` outside `debug_assertions`.
13//!
14//! A fired knob yields a [`FaultDecision`] whose parameters are drawn from the
15//! same seeded stream as every other simulation decision, so a seed reproduces
16//! the whole fault schedule byte for byte. Every applied injection is appended
17//! to the campaign's **fault log** ([`FaultRecord`]) with class, target file,
18//! offset and length, so an oracle can compute which durable objects a campaign
19//! actually touched.
20
21use crate::clock::{Clock, SimClock};
22use std::cell::RefCell;
23use std::fmt;
24use std::rc::Rc;
25
26const DEFAULT_BUGGIFY_PPM: u64 = 10_000;
27const PPM_DENOMINATOR: u64 = 1_000_000;
28
29/// The simulated sector size a torn write is cut at.
30pub const SECTOR_BYTES: u64 = 512;
31
32/// The `env` label the fault-class knobs record under in the buggify trace.
33pub const FAULT_ENV: &str = "REDDB_DST_FAULT";
34
35thread_local! {
36    static ACTIVE: RefCell<Option<Rc<RefCell<ActiveSimulation>>>> = const { RefCell::new(None) };
37}
38
39/// The four modeled storage fault classes (ADR 0074 §1). Crash-at-any-point is
40/// modeled separately by the existing `buggify!` crash boundaries.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
42pub enum FaultClass {
43    /// A write persists only a prefix of its buffer, cut at a sector boundary.
44    TornWrite,
45    /// A write persists the correct bytes at a wrong offset, reporting success.
46    MisdirectedWrite,
47    /// Stored bytes acquire a flipped bit between the write and a later read.
48    BitRot,
49    /// The write (or its fsync effect) is dropped entirely; success is reported.
50    LostWrite,
51}
52
53impl FaultClass {
54    /// Every class, in a stable order (the order knobs are rolled in).
55    pub const ALL: [Self; 4] = [
56        Self::TornWrite,
57        Self::MisdirectedWrite,
58        Self::BitRot,
59        Self::LostWrite,
60    ];
61
62    /// The stable, machine-readable name a campaign selects the knob by.
63    pub const fn name(self) -> &'static str {
64        match self {
65            Self::TornWrite => "torn_write",
66            Self::MisdirectedWrite => "misdirected_write",
67            Self::BitRot => "bit_rot",
68            Self::LostWrite => "lost_write",
69        }
70    }
71
72    /// Parse a class from its [`name`](Self::name).
73    pub fn from_name(name: &str) -> Option<Self> {
74        Self::ALL.into_iter().find(|class| class.name() == name)
75    }
76
77    const fn index(self) -> usize {
78        match self {
79            Self::TornWrite => 0,
80            Self::MisdirectedWrite => 1,
81            Self::BitRot => 2,
82            Self::LostWrite => 3,
83        }
84    }
85}
86
87impl fmt::Display for FaultClass {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(self.name())
90    }
91}
92
93/// A fired knob's deterministic parameters — what the backend must actually do.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum FaultDecision {
96    /// Persist only `persisted` bytes of the buffer, then report the full length.
97    TornWrite { persisted: u64 },
98    /// Persist the whole buffer at `actual_offset` instead of the requested one.
99    MisdirectedWrite { actual_offset: u64 },
100    /// Flip bit `bit` of the byte at absolute `byte_offset` when it is read back.
101    BitRot { byte_offset: u64, bit: u8 },
102    /// Persist nothing; report success.
103    LostWrite,
104}
105
106impl FaultDecision {
107    /// The class this decision belongs to.
108    pub const fn class(self) -> FaultClass {
109        match self {
110            Self::TornWrite { .. } => FaultClass::TornWrite,
111            Self::MisdirectedWrite { .. } => FaultClass::MisdirectedWrite,
112            Self::BitRot { .. } => FaultClass::BitRot,
113            Self::LostWrite => FaultClass::LostWrite,
114        }
115    }
116}
117
118/// One applied injection, as an oracle consumes it: which class hit which file,
119/// at which offset, over how many bytes, with the class-specific parameters.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct FaultRecord {
122    pub class: FaultClass,
123    pub file: String,
124    pub offset: u64,
125    pub length: u64,
126    pub decision: FaultDecision,
127}
128
129impl FaultRecord {
130    /// Build a record for a decision applied to `file` over `[offset, offset + length)`.
131    pub fn new(file: impl Into<String>, offset: u64, length: u64, decision: FaultDecision) -> Self {
132        Self {
133            class: decision.class(),
134            file: file.into(),
135            offset,
136            length,
137            decision,
138        }
139    }
140}
141
142/// One `key=value` line per injection: the machine-readable fault-log form.
143impl fmt::Display for FaultRecord {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        write!(
146            f,
147            "class={} file={} offset={} length={}",
148            self.class, self.file, self.offset, self.length
149        )?;
150        match self.decision {
151            FaultDecision::TornWrite { persisted } => write!(f, " persisted={persisted}"),
152            FaultDecision::MisdirectedWrite { actual_offset } => {
153                write!(f, " actual_offset={actual_offset}")
154            }
155            FaultDecision::BitRot { byte_offset, bit } => {
156                write!(f, " byte_offset={byte_offset} bit={bit}")
157            }
158            FaultDecision::LostWrite => Ok(()),
159        }
160    }
161}
162
163#[derive(Debug, Clone, Copy)]
164pub struct SimulationContext {
165    seed: u64,
166    clock_start_ms: u64,
167    buggify_ppm: u64,
168    /// Per-class probability in parts-per-million, indexed by [`FaultClass::index`].
169    /// All zero: every named fault class is off unless a campaign asks for it.
170    fault_ppm: [u64; 4],
171}
172
173impl SimulationContext {
174    pub fn new(seed: u64) -> Self {
175        Self {
176            seed,
177            clock_start_ms: seed,
178            buggify_ppm: DEFAULT_BUGGIFY_PPM,
179            fault_ppm: [0; 4],
180        }
181    }
182
183    pub fn with_buggify_ppm(seed: u64, buggify_ppm: u64) -> Self {
184        Self {
185            buggify_ppm: buggify_ppm.min(PPM_DENOMINATOR),
186            ..Self::new(seed)
187        }
188    }
189
190    /// Arm one named fault class at `ppm`. `0` leaves it off (the default).
191    #[must_use]
192    pub fn with_fault_class(mut self, class: FaultClass, ppm: u64) -> Self {
193        self.fault_ppm[class.index()] = ppm.min(PPM_DENOMINATOR);
194        self
195    }
196
197    /// The probability currently armed for `class`.
198    pub fn fault_ppm(&self, class: FaultClass) -> u64 {
199        self.fault_ppm[class.index()]
200    }
201
202    pub fn install(self) -> SimulationGuard {
203        let active = Rc::new(RefCell::new(ActiveSimulation::new(self)));
204        let previous = ACTIVE.with(|slot| slot.replace(Some(Rc::clone(&active))));
205        SimulationGuard { active, previous }
206    }
207}
208
209pub struct SimulationGuard {
210    active: Rc<RefCell<ActiveSimulation>>,
211    previous: Option<Rc<RefCell<ActiveSimulation>>>,
212}
213
214impl SimulationGuard {
215    pub fn trace(&self) -> Vec<u8> {
216        self.active.borrow().trace.clone()
217    }
218
219    /// Every injection applied under this context, in order.
220    pub fn fault_log(&self) -> Vec<FaultRecord> {
221        self.active.borrow().fault_log.clone()
222    }
223
224    /// The fault log as newline-terminated `key=value` lines.
225    pub fn fault_log_lines(&self) -> String {
226        self.fault_log()
227            .iter()
228            .map(|record| format!("{record}\n"))
229            .collect()
230    }
231}
232
233impl Drop for SimulationGuard {
234    fn drop(&mut self) {
235        let previous = self.previous.take();
236        ACTIVE.with(|slot| {
237            slot.replace(previous);
238        });
239    }
240}
241
242struct ActiveSimulation {
243    clock: SimClock,
244    rng: SplitMix64,
245    buggify_ppm: u64,
246    fault_ppm: [u64; 4],
247    tick: u64,
248    trace: Vec<u8>,
249    fault_log: Vec<FaultRecord>,
250}
251
252impl ActiveSimulation {
253    fn new(context: SimulationContext) -> Self {
254        let mut trace = Vec::new();
255        trace.extend_from_slice(format!("seed={}\n", context.seed).as_bytes());
256        Self {
257            clock: SimClock::from_seed(context.clock_start_ms),
258            rng: SplitMix64::new(context.seed ^ 0x4255_4747_4946_595F), // "BUGGIFY_"
259            buggify_ppm: context.buggify_ppm,
260            fault_ppm: context.fault_ppm,
261            tick: 0,
262            trace,
263            fault_log: Vec::new(),
264        }
265    }
266
267    fn boundary(&mut self, env: &str, point: &str, ppm: u64) -> bool {
268        self.tick = self.tick.saturating_add(1);
269        self.clock.advance_ms(1);
270        let roll = self.rng.below(PPM_DENOMINATOR);
271        let threshold = ppm.min(PPM_DENOMINATOR);
272        let fired = roll < threshold;
273        self.trace.extend_from_slice(
274            format!(
275                "tick={} clock_ms={} env={} point={} roll={} ppm={} fired={}\n",
276                self.tick,
277                self.clock.now_unix_millis(),
278                env,
279                point,
280                roll,
281                threshold,
282                fired
283            )
284            .as_bytes(),
285        );
286        fired
287    }
288}
289
290pub fn buggify(env: &str, point: &str) -> bool {
291    ACTIVE.with(|slot| {
292        let Some(active) = slot.borrow().clone() else {
293            return false;
294        };
295        let ppm = { active.borrow().buggify_ppm };
296        let fired = active.borrow_mut().boundary(env, point, ppm);
297        fired
298    })
299}
300
301pub fn buggify_at(env: &str, point: &str, ppm: u64) -> bool {
302    ACTIVE.with(|slot| {
303        let Some(active) = slot.borrow().clone() else {
304            return false;
305        };
306        let fired = active.borrow_mut().boundary(env, point, ppm);
307        fired
308    })
309}
310
311pub fn is_active() -> bool {
312    ACTIVE.with(|slot| slot.borrow().is_some())
313}
314
315/// Roll the seeded coin for one named fault class over `[offset, offset + length)`.
316///
317/// Returns `None` when no simulation is installed, when the class is not armed
318/// (`0` ppm — the default), or when the coin simply did not fire. A fired knob
319/// consumes further draws from the same stream to derive its parameters, so the
320/// whole schedule is a pure function of the seed and the armed probabilities.
321///
322/// This only *decides*; the backend that applies the fault calls
323/// [`record_fault`] with the target file so the log names a durable object.
324pub fn roll_fault(class: FaultClass, offset: u64, length: u64) -> Option<FaultDecision> {
325    ACTIVE.with(|slot| {
326        let active = slot.borrow().clone()?;
327        let mut active = active.borrow_mut();
328        let ppm = active.fault_ppm[class.index()];
329        // An unarmed class draws nothing: "off by default" must not perturb the
330        // stream a campaign without it would have seen.
331        if ppm == 0 || !active.boundary(FAULT_ENV, class.name(), ppm) {
332            return None;
333        }
334        let decision = derive_fault(class, offset, length, &mut || active.rng.next_u64());
335        Some(decision)
336    })
337}
338
339/// Append an applied injection to the active campaign's fault log. A no-op when
340/// no simulation is installed.
341pub fn record_fault(record: FaultRecord) {
342    ACTIVE.with(|slot| {
343        if let Some(active) = slot.borrow().clone() {
344            active.borrow_mut().fault_log.push(record);
345        }
346    });
347}
348
349/// Derive a fired knob's parameters from `next`, the campaign's seeded stream.
350///
351/// Pure and stream-driven so both the simulation context and an out-of-context
352/// backend (a `SimVfs` rolling its own coins) produce the same fault shapes.
353///
354/// A [`FaultClass::TornWrite`] is cut at the first simulated sector boundary the
355/// write crosses, choosing uniformly among them. A write that never crosses one
356/// — the common case for small WAL frames — is cut at a byte prefix instead,
357/// modeling sub-sector tearing; the cut is always a strict prefix, so a fired
358/// knob always loses bytes.
359pub fn derive_fault(
360    class: FaultClass,
361    offset: u64,
362    length: u64,
363    next: &mut dyn FnMut() -> u64,
364) -> FaultDecision {
365    match class {
366        FaultClass::TornWrite => FaultDecision::TornWrite {
367            persisted: torn_prefix(offset, length, next),
368        },
369        FaultClass::MisdirectedWrite => {
370            let delta = SECTOR_BYTES * (1 + next() % 4);
371            let backwards = next() % 2 == 1;
372            let actual_offset = if backwards && offset >= delta {
373                offset - delta
374            } else {
375                offset.saturating_add(delta)
376            };
377            FaultDecision::MisdirectedWrite { actual_offset }
378        }
379        FaultClass::BitRot => {
380            let byte_offset = offset + if length == 0 { 0 } else { next() % length };
381            let bit = u8::try_from(next() % 8).unwrap_or(0);
382            FaultDecision::BitRot { byte_offset, bit }
383        }
384        FaultClass::LostWrite => FaultDecision::LostWrite,
385    }
386}
387
388/// How many bytes of a torn write reach the platter: a sector-aligned cut when
389/// the write crosses a sector boundary, otherwise a strict byte prefix.
390fn torn_prefix(offset: u64, length: u64, next: &mut dyn FnMut() -> u64) -> u64 {
391    if length == 0 {
392        return 0;
393    }
394    let end = offset + length;
395    let first_boundary = (offset / SECTOR_BYTES + 1) * SECTOR_BYTES;
396    if first_boundary >= end {
397        // Entirely inside one sector: tear at a strict byte prefix (0..length-1).
398        return next() % length;
399    }
400    let boundaries = (end - 1 - first_boundary) / SECTOR_BYTES + 1;
401    let chosen = first_boundary + (next() % boundaries) * SECTOR_BYTES;
402    chosen - offset
403}
404
405#[derive(Debug, Clone)]
406struct SplitMix64 {
407    state: u64,
408}
409
410impl SplitMix64 {
411    fn new(seed: u64) -> Self {
412        Self { state: seed }
413    }
414
415    fn next_u64(&mut self) -> u64 {
416        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
417        let mut z = self.state;
418        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
419        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
420        z ^ (z >> 31)
421    }
422
423    fn below(&mut self, bound: u64) -> u64 {
424        if bound == 0 {
425            0
426        } else {
427            self.next_u64() % bound
428        }
429    }
430}
431
432#[macro_export]
433macro_rules! buggify {
434    ($env:expr, $point:expr) => {{
435        #[cfg(debug_assertions)]
436        {
437            $crate::dst::buggify($env, $point)
438        }
439        #[cfg(not(debug_assertions))]
440        {
441            let _ = ($env, $point);
442            false
443        }
444    }};
445}
446
447/// Roll one named fault class, release-inert like [`buggify!`].
448///
449/// Debug builds consult the installed simulation context; release builds
450/// compile to `None`, so no production write path can ever be perturbed.
451#[macro_export]
452macro_rules! buggify_fault {
453    ($class:expr, $offset:expr, $length:expr) => {{
454        #[cfg(debug_assertions)]
455        {
456            $crate::dst::roll_fault($class, $offset, $length)
457        }
458        #[cfg(not(debug_assertions))]
459        {
460            let _ = ($class, $offset, $length);
461            ::core::option::Option::<$crate::dst::FaultDecision>::None
462        }
463    }};
464}
465
466#[cfg(test)]
467mod tests {
468    use crate::dst::{FaultClass, FaultDecision, FaultRecord, SECTOR_BYTES};
469    use crate::SimulationContext;
470
471    const ENV: &str = "REDDB_EMBEDDED_RDB_CRASH_AT";
472
473    fn trace_for(seed: u64) -> Vec<u8> {
474        let context = SimulationContext::with_buggify_ppm(seed, 1_000_000);
475        let guard = context.install();
476        assert!(crate::buggify!(ENV, "wal_after_frame_write"));
477        assert!(crate::buggify!(ENV, "wal_after_frame_sync"));
478        assert!(crate::buggify!(ENV, "wal_after_superblock_write"));
479        guard.trace()
480    }
481
482    #[test]
483    fn same_seed_produces_byte_identical_buggify_trace() {
484        let first = trace_for(0x5EED);
485        let second = trace_for(0x5EED);
486        assert_eq!(first, second);
487    }
488
489    #[test]
490    fn buggify_can_fire_at_named_crash_boundary() {
491        let context = SimulationContext::with_buggify_ppm(123, 1_000_000);
492        let guard = context.install();
493        assert!(crate::buggify!(ENV, "snapshot_after_manifest_write"));
494        let trace = String::from_utf8(guard.trace()).unwrap();
495        assert!(trace.contains("env=REDDB_EMBEDDED_RDB_CRASH_AT"));
496        assert!(trace.contains("point=snapshot_after_manifest_write"));
497        assert!(trace.contains("fired=true"));
498    }
499
500    #[test]
501    fn fault_classes_round_trip_through_their_names() {
502        for class in FaultClass::ALL {
503            assert_eq!(FaultClass::from_name(class.name()), Some(class));
504        }
505        assert_eq!(FaultClass::from_name("not_a_class"), None);
506    }
507
508    #[test]
509    fn every_fault_class_is_off_by_default() {
510        let context = SimulationContext::new(7);
511        for class in FaultClass::ALL {
512            assert_eq!(context.fault_ppm(class), 0, "{class} must default to off");
513        }
514        let guard = context.install();
515        for class in FaultClass::ALL {
516            assert_eq!(crate::buggify_fault!(class, 0, 64), None);
517        }
518        assert!(guard.fault_log().is_empty());
519        // An unarmed class draws nothing, so it cannot perturb the seed stream.
520        assert!(!String::from_utf8(guard.trace())
521            .unwrap()
522            .contains("env=REDDB_DST_FAULT"));
523    }
524
525    #[test]
526    fn each_class_is_individually_triggerable_by_name_and_ppm() {
527        for armed in FaultClass::ALL {
528            let guard = SimulationContext::new(99)
529                .with_fault_class(armed, 1_000_000)
530                .install();
531            for class in FaultClass::ALL {
532                let decision = crate::buggify_fault!(class, SECTOR_BYTES + 8, 64);
533                if class == armed {
534                    let decision = decision.unwrap_or_else(|| panic!("{armed} must fire at 1e6"));
535                    assert_eq!(decision.class(), armed);
536                } else {
537                    assert_eq!(
538                        decision, None,
539                        "{class} must stay off while {armed} is armed"
540                    );
541                }
542            }
543            let trace = String::from_utf8(guard.trace()).unwrap();
544            assert!(trace.contains(&format!("point={armed}")));
545        }
546    }
547
548    #[test]
549    fn a_torn_write_crossing_a_sector_is_cut_at_a_sector_boundary() {
550        let guard = SimulationContext::new(5)
551            .with_fault_class(FaultClass::TornWrite, 1_000_000)
552            .install();
553        // Spans sectors 0..=2, so the cut must land on 512 or 1024.
554        let offset = 8;
555        let Some(FaultDecision::TornWrite { persisted }) =
556            crate::buggify_fault!(FaultClass::TornWrite, offset, 1_200)
557        else {
558            panic!("torn_write must fire at 1e6")
559        };
560        assert!(
561            (offset + persisted) % SECTOR_BYTES == 0,
562            "cut at {persisted}"
563        );
564        assert!(persisted < 1_200, "a torn write always loses bytes");
565        drop(guard);
566    }
567
568    #[test]
569    fn a_torn_write_inside_one_sector_is_cut_at_a_strict_byte_prefix() {
570        let _guard = SimulationContext::new(6)
571            .with_fault_class(FaultClass::TornWrite, 1_000_000)
572            .install();
573        // A 48-byte WAL frame never crosses a sector: sub-sector tearing.
574        let Some(FaultDecision::TornWrite { persisted }) =
575            crate::buggify_fault!(FaultClass::TornWrite, 64, 48)
576        else {
577            panic!("torn_write must fire at 1e6")
578        };
579        assert!(persisted < 48, "a torn write always loses bytes");
580    }
581
582    #[test]
583    fn a_misdirected_write_lands_a_whole_number_of_sectors_away() {
584        let _guard = SimulationContext::new(11)
585            .with_fault_class(FaultClass::MisdirectedWrite, 1_000_000)
586            .install();
587        for offset in [0, 64, 4_096, 10_000] {
588            let Some(FaultDecision::MisdirectedWrite { actual_offset }) =
589                crate::buggify_fault!(FaultClass::MisdirectedWrite, offset, 64)
590            else {
591                panic!("misdirected_write must fire at 1e6")
592            };
593            assert_ne!(actual_offset, offset, "the write must land elsewhere");
594            assert_eq!(actual_offset.abs_diff(offset) % SECTOR_BYTES, 0);
595        }
596    }
597
598    #[test]
599    fn bit_rot_targets_a_byte_inside_the_read_region() {
600        let _guard = SimulationContext::new(13)
601            .with_fault_class(FaultClass::BitRot, 1_000_000)
602            .install();
603        for _ in 0..32 {
604            let Some(FaultDecision::BitRot { byte_offset, bit }) =
605                crate::buggify_fault!(FaultClass::BitRot, 100, 40)
606            else {
607                panic!("bit_rot must fire at 1e6")
608            };
609            assert!((100..140).contains(&byte_offset));
610            assert!(bit < 8);
611        }
612    }
613
614    #[test]
615    fn the_fault_log_records_class_file_offset_and_length() {
616        let guard = SimulationContext::new(21)
617            .with_fault_class(FaultClass::LostWrite, 1_000_000)
618            .install();
619        let decision = crate::buggify_fault!(FaultClass::LostWrite, 4_096, 512)
620            .expect("lost_write must fire at 1e6");
621        crate::dst::record_fault(FaultRecord::new("/db/wal.log", 4_096, 512, decision));
622
623        let log = guard.fault_log();
624        assert_eq!(log.len(), 1);
625        assert_eq!(log[0].class, FaultClass::LostWrite);
626        assert_eq!(log[0].file, "/db/wal.log");
627        assert_eq!(log[0].offset, 4_096);
628        assert_eq!(log[0].length, 512);
629        assert_eq!(
630            guard.fault_log_lines(),
631            "class=lost_write file=/db/wal.log offset=4096 length=512\n"
632        );
633    }
634
635    #[test]
636    fn same_seed_produces_an_identical_fault_schedule() {
637        fn schedule(seed: u64) -> Vec<String> {
638            let mut context = SimulationContext::new(seed);
639            for class in FaultClass::ALL {
640                context = context.with_fault_class(class, 300_000);
641            }
642            let guard = context.install();
643            for step in 0..64u64 {
644                for class in FaultClass::ALL {
645                    let offset = step * 97;
646                    if let Some(decision) = crate::buggify_fault!(class, offset, 64) {
647                        crate::dst::record_fault(FaultRecord::new(
648                            "/db/wal.log",
649                            offset,
650                            64,
651                            decision,
652                        ));
653                    }
654                }
655            }
656            guard.fault_log().iter().map(ToString::to_string).collect()
657        }
658
659        let first = schedule(0xDEAD);
660        assert!(!first.is_empty(), "the sweep must inject something");
661        assert_eq!(first, schedule(0xDEAD), "same seed → same fault schedule");
662        assert_ne!(first, schedule(0xBEEF), "different seeds must diverge");
663    }
664
665    #[test]
666    fn fault_classes_compose_with_the_crash_knob_deterministically() {
667        fn run(seed: u64) -> Vec<u8> {
668            let guard = SimulationContext::with_buggify_ppm(seed, 500_000)
669                .with_fault_class(FaultClass::TornWrite, 400_000)
670                .install();
671            for _ in 0..32 {
672                let _crashed = crate::buggify!(ENV, "wal_after_frame_write");
673                if let Some(decision) = crate::buggify_fault!(FaultClass::TornWrite, 64, 48) {
674                    crate::dst::record_fault(FaultRecord::new("/db/wal.log", 64, 48, decision));
675                }
676            }
677            let mut out = guard.trace();
678            out.extend_from_slice(guard.fault_log_lines().as_bytes());
679            out
680        }
681        assert_eq!(
682            run(1234),
683            run(1234),
684            "crash + torn_write must replay exactly"
685        );
686    }
687
688    #[test]
689    fn no_fault_fires_without_an_installed_context() {
690        for class in FaultClass::ALL {
691            assert_eq!(crate::buggify_fault!(class, 0, 64), None);
692        }
693    }
694
695    /// The `buggify_fault!` posture matches `buggify!`: debug builds consult the
696    /// installed context, release builds compile the call away to `None`, so no
697    /// production write path can be perturbed. Written to hold under both
698    /// profiles, which also type-checks the release arm of the macro.
699    #[test]
700    fn the_fault_macro_is_release_inert() {
701        let _guard = SimulationContext::new(3)
702            .with_fault_class(FaultClass::LostWrite, 1_000_000)
703            .install();
704        let fired = crate::buggify_fault!(FaultClass::LostWrite, 0, 8);
705        if cfg!(debug_assertions) {
706            assert_eq!(fired, Some(FaultDecision::LostWrite));
707        } else {
708            assert_eq!(fired, None, "release builds must never inject a fault");
709        }
710    }
711}