Skip to main content

alloc_chaos/
lib.rs

1#![deny(unsafe_op_in_unsafe_fn)]
2#![warn(missing_docs, rust_2018_idioms, unreachable_pub)]
3
4//! Deterministic allocation-failure testing for Rust.
5//!
6//! `alloc-chaos` wraps the process global allocator and can run a test closure
7//! repeatedly while failing allocation attempt `0`, then `1`, then `2`, and so
8//! on. This is useful for libraries that intentionally use fallible allocation
9//! APIs and want tests for their OOM paths.
10//!
11//! # Example
12//!
13//! ```no_run
14//! #[global_allocator]
15//! static GLOBAL: alloc_chaos::ChaosAllocator = alloc_chaos::ChaosAllocator::system();
16//!
17//! #[derive(Debug, Clone, Copy, PartialEq, Eq)]
18//! struct OutOfMemory;
19//!
20//! fn build_buffer(size: usize) -> Result<Vec<u8>, OutOfMemory> {
21//!     let mut bytes = Vec::new();
22//!     bytes.try_reserve_exact(size).map_err(|_| OutOfMemory)?;
23//!     bytes.resize(size, 0);
24//!     Ok(bytes)
25//! }
26//!
27//! #[test]
28//! fn buffer_builder_handles_oom() {
29//!     alloc_chaos::check(|| {
30//!         match build_buffer(1024) {
31//!             Ok(bytes) => assert_eq!(bytes.len(), 1024),
32//!             Err(OutOfMemory) => {}
33//!         }
34//!     })
35//!     .assert_success();
36//! }
37//! ```
38//!
39//! # Limits
40//!
41//! This crate verifies a concrete execution path. It does not prove that all
42//! possible inputs or all possible control-flow branches are OOM-safe.
43//!
44//! Only one check can be active in a process, but allocation counting and
45//! failure injection are limited to the thread executing the checked closure.
46//! Allocations performed by other threads are ignored.
47//!
48//! The closure is executed many times. Keep all state needed by the code under
49//! test inside the closure, or otherwise make the closure deterministic across
50//! repeated runs.
51//!
52//! The in-process checker cannot contain process aborts. Code that uses
53//! infallible allocation APIs may abort when an allocation fails instead of
54//! returning a recoverable error.
55
56use std::alloc::{GlobalAlloc, Layout, System};
57use std::any::Any;
58use std::cell::Cell;
59use std::error::Error;
60use std::fmt;
61use std::ops::Range;
62use std::panic::{self, AssertUnwindSafe};
63use std::ptr;
64use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
65
66const MODE_DISABLED: u8 = 0;
67const MODE_COUNTING: u8 = 1;
68const MODE_FAILING: u8 = 2;
69
70const NO_TARGET: usize = usize::MAX;
71const NO_INJECTED_INDEX: usize = usize::MAX;
72const NO_REALLOC_NEW_SIZE: usize = usize::MAX;
73
74const ALLOC_OP_NONE: u8 = 0;
75const ALLOC_OP_ALLOC: u8 = 1;
76const ALLOC_OP_ALLOC_ZEROED: u8 = 2;
77const ALLOC_OP_REALLOC: u8 = 3;
78
79static MODE: AtomicU8 = AtomicU8::new(MODE_DISABLED);
80static TARGET: AtomicUsize = AtomicUsize::new(NO_TARGET);
81static SEEN: AtomicUsize = AtomicUsize::new(0);
82static INJECTED: AtomicBool = AtomicBool::new(false);
83static INJECTED_INDEX: AtomicUsize = AtomicUsize::new(NO_INJECTED_INDEX);
84static INJECTED_OP: AtomicU8 = AtomicU8::new(ALLOC_OP_NONE);
85static INJECTED_SIZE: AtomicUsize = AtomicUsize::new(0);
86static INJECTED_ALIGN: AtomicUsize = AtomicUsize::new(0);
87static INJECTED_NEW_SIZE: AtomicUsize = AtomicUsize::new(NO_REALLOC_NEW_SIZE);
88static CHECK_ACTIVE: AtomicBool = AtomicBool::new(false);
89static ALLOCATOR_WAS_USED: AtomicBool = AtomicBool::new(false);
90
91std::thread_local! {
92    static TRACK_ALLOCATIONS_ON_THREAD: Cell<bool> = const { Cell::new(false) };
93}
94
95/// A global allocator wrapper that can deterministically fail selected
96/// allocation attempts during an [`alloc_chaos`](crate) check.
97///
98/// Install it once in a test binary:
99///
100/// ```no_run
101/// #[global_allocator]
102/// static GLOBAL: alloc_chaos::ChaosAllocator = alloc_chaos::ChaosAllocator::system();
103/// ```
104///
105/// The wrapper delegates to its inner allocator unless a check is active and
106/// the current allocation attempt is the selected failure target.
107pub struct ChaosAllocator<A = System> {
108    inner: A,
109}
110
111impl ChaosAllocator<System> {
112    /// Creates a wrapper around [`std::alloc::System`].
113    #[must_use]
114    pub const fn system() -> Self {
115        Self { inner: System }
116    }
117}
118
119impl<A> ChaosAllocator<A> {
120    /// Creates a wrapper around a custom global allocator.
121    #[must_use]
122    pub const fn new(inner: A) -> Self {
123        Self { inner }
124    }
125}
126
127impl<A> fmt::Debug for ChaosAllocator<A> {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.debug_struct("ChaosAllocator").finish_non_exhaustive()
130    }
131}
132
133// SAFETY: The wrapper preserves the `GlobalAlloc` contract by delegating all
134// successful operations to the inner allocator. For injected failures it returns
135// null from `alloc`, `alloc_zeroed`, or `realloc`. Returning null is the
136// allocator-level failure signal. For `realloc`, returning null leaves the
137// original allocation untouched, which is the required behavior.
138unsafe impl<A> GlobalAlloc for ChaosAllocator<A>
139where
140    A: GlobalAlloc,
141{
142    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
143        if should_inject_failure(AllocOp::Alloc, layout, None) {
144            ptr::null_mut()
145        } else {
146            // SAFETY: Delegates the exact layout received from the caller to the
147            // wrapped allocator.
148            unsafe { self.inner.alloc(layout) }
149        }
150    }
151
152    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
153        if should_inject_failure(AllocOp::AllocZeroed, layout, None) {
154            ptr::null_mut()
155        } else {
156            // SAFETY: Delegates the exact layout received from the caller to the
157            // wrapped allocator.
158            unsafe { self.inner.alloc_zeroed(layout) }
159        }
160    }
161
162    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
163        // SAFETY: Delegates the pointer and layout received from the caller to
164        // the wrapped allocator. Deallocation is never failed or counted.
165        unsafe { self.inner.dealloc(ptr, layout) }
166    }
167
168    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
169        if should_inject_failure(AllocOp::Realloc, layout, Some(new_size)) {
170            ptr::null_mut()
171        } else {
172            // SAFETY: Delegates the exact pointer, old layout, and requested new
173            // size received from the caller to the wrapped allocator.
174            unsafe { self.inner.realloc(ptr, layout, new_size) }
175        }
176    }
177}
178
179/// Runs `f` once to count allocations and then once per observed allocation,
180/// failing one allocation attempt per run.
181///
182/// This is equivalent to `Check::new().run(f)`.
183///
184/// # Panics
185///
186/// Panics if another check is already active in the process. Use
187/// [`try_check`] to handle that case explicitly.
188pub fn check<F>(f: F) -> Report
189where
190    F: Fn(),
191{
192    Check::new().run(f)
193}
194
195/// Fallible variant of [`check`].
196pub fn try_check<F>(f: F) -> Result<Report, AlreadyRunning>
197where
198    F: Fn(),
199{
200    Check::new().try_run(f)
201}
202
203/// Configuration for an allocation-failure check.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205#[must_use]
206pub struct Check {
207    max_failures: Option<usize>,
208    stop_on_failure: bool,
209    target_start: usize,
210    target_end: Option<usize>,
211    stability_runs: usize,
212}
213
214impl Check {
215    /// Creates a check with no failure limit, no early stop, and one baseline
216    /// run.
217    pub const fn new() -> Self {
218        Self {
219            max_failures: None,
220            stop_on_failure: false,
221            target_start: 0,
222            target_end: None,
223            stability_runs: 1,
224        }
225    }
226
227    /// Limits the number of allocation attempts that will be failed.
228    ///
229    /// This is useful when a baseline run performs many allocations and a full
230    /// exhaustive check would be too expensive. A limited report is considered
231    /// truncated, so [`Report::assert_success`] will fail unless the limit still
232    /// covers every observed baseline allocation.
233    pub const fn max_failures(mut self, max_failures: usize) -> Self {
234        self.max_failures = Some(max_failures);
235        self
236    }
237
238    /// Removes a previously configured failure limit.
239    pub const fn unlimited_failures(mut self) -> Self {
240        self.max_failures = None;
241        self
242    }
243
244    /// Tests exactly one zero-based allocation attempt.
245    ///
246    /// This is primarily a reproduction and debugging aid after a full check
247    /// has identified an interesting allocation number. A single-target report
248    /// is considered truncated when the baseline contains any other allocation
249    /// attempts, so [`Report::assert_success`] remains exhaustive by default.
250    ///
251    /// # Panics
252    ///
253    /// Panics if `target` is [`usize::MAX`].
254    pub fn only_failure(mut self, target: usize) -> Self {
255        self.target_start = target;
256        self.target_end = Some(
257            target
258                .checked_add(1)
259                .expect("alloc-chaos failure target must be less than usize::MAX"),
260        );
261        self
262    }
263
264    /// Tests a zero-based half-open range of allocation attempts.
265    ///
266    /// For example, `failure_range(30..40)` tests allocation attempts `30`
267    /// through `39`. Ranges that do not cover every observed baseline
268    /// allocation produce truncated reports.
269    ///
270    /// # Panics
271    ///
272    /// Panics if `range.start > range.end`.
273    pub fn failure_range(mut self, range: Range<usize>) -> Self {
274        assert!(
275            range.start <= range.end,
276            "alloc-chaos failure range start must be less than or equal to range end"
277        );
278
279        self.target_start = range.start;
280        self.target_end = Some(range.end);
281        self
282    }
283
284    /// Restores the default target selection, which tests every observed
285    /// baseline allocation attempt.
286    pub const fn all_failures(mut self) -> Self {
287        self.target_start = 0;
288        self.target_end = None;
289        self
290    }
291
292    /// Configures how many baseline counting runs are used to check allocation
293    /// sequence stability before injection begins.
294    ///
295    /// The default is `1`, which performs no extra stability comparison. Values
296    /// greater than `1` rerun the closure in counting mode and require every
297    /// baseline run to complete with the same allocation count. Passing `0` is
298    /// treated as `1`.
299    pub const fn stability_runs(mut self, runs: usize) -> Self {
300        self.stability_runs = if runs == 0 { 1 } else { runs };
301        self
302    }
303
304    /// Stops after the first failed iteration.
305    ///
306    /// A failed iteration is one that panics or does not reach the selected
307    /// allocation attempt. Early-stop reports are considered truncated unless
308    /// the stopped attempt was the final observed allocation.
309    pub const fn stop_on_failure(mut self, enabled: bool) -> Self {
310        self.stop_on_failure = enabled;
311        self
312    }
313
314    /// Runs this check.
315    ///
316    /// # Panics
317    ///
318    /// Panics if another check is already active in the process. Use
319    /// [`Check::try_run`] to handle that case explicitly.
320    pub fn run<F>(self, f: F) -> Report
321    where
322        F: Fn(),
323    {
324        self.try_run(f)
325            .expect("alloc-chaos check is already active in this process")
326    }
327
328    /// Fallible variant of [`Check::run`].
329    pub fn try_run<F>(self, f: F) -> Result<Report, AlreadyRunning>
330    where
331        F: Fn(),
332    {
333        let _active = ActiveCheck::enter()?;
334
335        let allocator_installed = probe_allocator_installed();
336        let baseline = run_counting(&f);
337        let baseline_allocations = baseline.observed_allocations;
338
339        let mut stability_baselines = Vec::new();
340        let mut baseline_stable = true;
341
342        if baseline.outcome.is_completed() {
343            stability_baselines.reserve_exact(self.stability_runs.saturating_sub(1));
344
345            for _ in 1..self.stability_runs {
346                let candidate = run_counting(&f);
347                if !baseline_matches(&baseline, &candidate) {
348                    baseline_stable = false;
349                }
350                stability_baselines.push(candidate);
351            }
352        }
353
354        let mut attempts = Vec::new();
355        let mut truncated = baseline_allocations > 0;
356
357        if baseline.outcome.is_completed() && baseline_stable {
358            let plan = FailurePlan::for_check(self, baseline_allocations);
359            truncated = !plan.is_exhaustive_for(baseline_allocations);
360            attempts.reserve_exact(plan.len());
361
362            for target in plan.targets() {
363                let attempt = run_failing(target, &f);
364                let stop = self.stop_on_failure && !attempt.is_success();
365                attempts.push(attempt);
366
367                if stop {
368                    truncated = !plan.is_exhaustive_after_stop(target, baseline_allocations);
369                    break;
370                }
371            }
372        }
373
374        Ok(Report {
375            baseline,
376            stability_baselines,
377            baseline_stable,
378            attempts,
379            truncated,
380            allocator_installed,
381        })
382    }
383}
384
385impl Default for Check {
386    fn default() -> Self {
387        Self::new()
388    }
389}
390
391/// Error returned when a check is requested while another check is already
392/// active in this process.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub struct AlreadyRunning;
395
396impl fmt::Display for AlreadyRunning {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        f.write_str("an alloc-chaos check is already active in this process")
399    }
400}
401
402impl Error for AlreadyRunning {}
403
404/// Allocator operation that was selected for injected failure.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406#[must_use]
407pub enum AllocOp {
408    /// A call to [`GlobalAlloc::alloc`].
409    Alloc,
410
411    /// A call to [`GlobalAlloc::alloc_zeroed`].
412    AllocZeroed,
413
414    /// A call to [`GlobalAlloc::realloc`].
415    Realloc,
416}
417
418impl AllocOp {
419    const fn as_u8(self) -> u8 {
420        match self {
421            Self::Alloc => ALLOC_OP_ALLOC,
422            Self::AllocZeroed => ALLOC_OP_ALLOC_ZEROED,
423            Self::Realloc => ALLOC_OP_REALLOC,
424        }
425    }
426
427    const fn from_u8(value: u8) -> Option<Self> {
428        match value {
429            ALLOC_OP_ALLOC => Some(Self::Alloc),
430            ALLOC_OP_ALLOC_ZEROED => Some(Self::AllocZeroed),
431            ALLOC_OP_REALLOC => Some(Self::Realloc),
432            _ => None,
433        }
434    }
435}
436
437impl fmt::Display for AllocOp {
438    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439        match self {
440            Self::Alloc => f.write_str("alloc"),
441            Self::AllocZeroed => f.write_str("alloc_zeroed"),
442            Self::Realloc => f.write_str("realloc"),
443        }
444    }
445}
446
447/// Metadata for the allocation attempt that was selected for injected failure.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449#[must_use]
450pub struct Allocation {
451    index: usize,
452    operation: AllocOp,
453    size: usize,
454    align: usize,
455    new_size: Option<usize>,
456}
457
458impl Allocation {
459    /// Zero-based allocation attempt index.
460    #[must_use]
461    pub fn index(&self) -> usize {
462        self.index
463    }
464
465    /// Allocator operation used by this attempt.
466    pub fn operation(&self) -> AllocOp {
467        self.operation
468    }
469
470    /// Requested layout size in bytes.
471    #[must_use]
472    pub fn size(&self) -> usize {
473        self.size
474    }
475
476    /// Requested layout alignment in bytes.
477    #[must_use]
478    pub fn align(&self) -> usize {
479        self.align
480    }
481
482    /// Requested new size for [`AllocOp::Realloc`] attempts.
483    #[must_use]
484    pub fn new_size(&self) -> Option<usize> {
485        self.new_size
486    }
487}
488
489impl fmt::Display for Allocation {
490    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
491        write!(f, "{} size={} align={}", self.operation, self.size, self.align)?;
492
493        if let Some(new_size) = self.new_size {
494            write!(f, " new_size={new_size}")?;
495        }
496
497        Ok(())
498    }
499}
500
501/// Result of a full allocation-failure check.
502#[derive(Debug, Clone, PartialEq, Eq)]
503#[must_use]
504pub struct Report {
505    baseline: Baseline,
506    stability_baselines: Vec<Baseline>,
507    baseline_stable: bool,
508    attempts: Vec<Attempt>,
509    truncated: bool,
510    allocator_installed: bool,
511}
512
513impl Report {
514    /// Returns the primary baseline run.
515    pub fn baseline(&self) -> &Baseline {
516        &self.baseline
517    }
518
519    /// Returns extra baseline runs used to validate allocation-count stability.
520    pub fn stability_baselines(&self) -> &[Baseline] {
521        &self.stability_baselines
522    }
523
524    /// Returns whether every baseline run completed with the same allocation
525    /// count as the primary baseline run.
526    #[must_use]
527    pub fn baseline_is_stable(&self) -> bool {
528        self.baseline_stable
529    }
530
531    /// Returns the number of allocation attempts observed during the primary
532    /// baseline run.
533    #[must_use]
534    pub fn baseline_allocations(&self) -> usize {
535        self.baseline.observed_allocations
536    }
537
538    /// Returns all injected-failure runs.
539    pub fn attempts(&self) -> &[Attempt] {
540        &self.attempts
541    }
542
543    /// Returns all injected-failure runs that did not complete successfully.
544    pub fn failed_attempts(&self) -> impl Iterator<Item = &Attempt> {
545        self.attempts.iter().filter(|attempt| !attempt.is_success())
546    }
547
548    /// Returns the first injected-failure run that did not complete
549    /// successfully.
550    #[must_use]
551    pub fn first_failure(&self) -> Option<&Attempt> {
552        self.attempts.iter().find(|attempt| !attempt.is_success())
553    }
554
555    /// Returns the number of selected failure targets that were executed.
556    #[must_use]
557    pub fn tested_failures(&self) -> usize {
558        self.attempts.len()
559    }
560
561    /// Returns the number of executed runs that actually reached and failed the
562    /// selected allocation attempt.
563    #[must_use]
564    pub fn injected_failures(&self) -> usize {
565        self.attempts
566            .iter()
567            .filter(|attempt| attempt.injected())
568            .count()
569    }
570
571    /// Returns the number of observed baseline allocation attempts that did not
572    /// have a corresponding injected failure.
573    #[must_use]
574    pub fn untested_failures(&self) -> usize {
575        self.baseline_allocations()
576            .saturating_sub(self.injected_failures())
577    }
578
579    /// Returns `true` if not every observed baseline allocation was tested.
580    ///
581    /// This can happen when the baseline run panics, when the baseline is
582    /// unstable, when [`Check::max_failures`] limits the run count, when
583    /// [`Check::only_failure`] or [`Check::failure_range`] selects a subset, or
584    /// when [`Check::stop_on_failure`] stops the check early.
585    #[must_use]
586    pub fn is_truncated(&self) -> bool {
587        self.truncated
588    }
589
590    /// Returns whether the [`ChaosAllocator`] was observed during an explicit
591    /// allocator-installation probe at the start of the check.
592    ///
593    /// `false` usually means the global allocator wrapper was not installed.
594    #[must_use]
595    pub fn allocator_installed(&self) -> bool {
596        self.allocator_installed
597    }
598
599    /// Returns `true` only for an exhaustive, valid, fully successful check.
600    ///
601    /// Success requires all of the following:
602    ///
603    /// - the global allocator wrapper was installed;
604    /// - the primary baseline completed;
605    /// - all configured stability baseline runs matched the primary baseline;
606    /// - the report is not truncated;
607    /// - every observed baseline allocation was tested; and
608    /// - every injected run reached its target allocation and completed without
609    ///   panic.
610    #[must_use]
611    pub fn is_success(&self) -> bool {
612        self.allocator_installed
613            && self.baseline.outcome.is_completed()
614            && self.baseline_stable
615            && !self.truncated
616            && self.tested_failures() == self.baseline_allocations()
617            && self.attempts.iter().all(Attempt::is_success)
618    }
619
620    /// Panics with a human-readable report if [`Report::is_success`] is `false`.
621    pub fn assert_success(&self) {
622        assert!(self.is_success(), "{self}");
623    }
624}
625
626impl fmt::Display for Report {
627    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
628        writeln!(f, "alloc-chaos report")?;
629        writeln!(
630            f,
631            "  baseline: {}, {} allocation attempt(s)",
632            self.baseline.outcome, self.baseline.observed_allocations
633        )?;
634
635        if !self.stability_baselines.is_empty() {
636            writeln!(
637                f,
638                "  stability: {} across {} baseline run(s)",
639                if self.baseline_stable {
640                    "stable"
641                } else {
642                    "unstable"
643                },
644                self.stability_baselines.len() + 1
645            )?;
646
647            if !self.baseline_stable {
648                for (index, baseline) in self.stability_baselines.iter().enumerate() {
649                    writeln!(
650                        f,
651                        "    baseline #{}: {}, {} allocation attempt(s)",
652                        index + 2,
653                        baseline.outcome,
654                        baseline.observed_allocations
655                    )?;
656                }
657            }
658        }
659
660        writeln!(
661            f,
662            "  tested: {}/{} allocation failure target(s){}",
663            self.tested_failures(),
664            self.baseline.observed_allocations,
665            if self.truncated { " (truncated)" } else { "" }
666        )?;
667
668        let injected_failures = self.injected_failures();
669        if injected_failures != self.tested_failures() {
670            writeln!(
671                f,
672                "  injected: {injected_failures}/{} selected target(s)",
673                self.tested_failures()
674            )?;
675        }
676
677        if self.is_truncated() || injected_failures != self.baseline_allocations() {
678            writeln!(f, "  untested: {} allocation failure(s)", self.untested_failures())?;
679        }
680        writeln!(f, "  allocator installed: {}", self.allocator_installed)?;
681
682        if self.is_success() {
683            writeln!(f, "  status: exhaustive success")?;
684        } else if !self.allocator_installed {
685            writeln!(f, "  status: invalid check: allocator wrapper not observed")?;
686        } else if !self.baseline.outcome.is_completed() {
687            writeln!(f, "  status: failure")?;
688            writeln!(f, "  baseline failed: {}", self.baseline.outcome)?;
689        } else if !self.baseline_stable {
690            writeln!(f, "  status: invalid check: unstable baseline")?;
691        } else if self.first_failure().is_some() {
692            writeln!(f, "  status: failure")?;
693
694            for attempt in self.failed_attempts() {
695                write!(
696                    f,
697                    "  allocation #{}: {}, injected={}, observed={} allocation attempt(s)",
698                    attempt.target_allocation,
699                    attempt.outcome,
700                    attempt.injected,
701                    attempt.observed_allocations
702                )?;
703
704                if let Some(allocation) = attempt.injected_allocation {
705                    write!(f, ", {allocation}")?;
706                }
707
708                writeln!(f)?;
709            }
710        } else if self.truncated {
711            writeln!(f, "  status: partial success")?;
712        } else {
713            writeln!(f, "  status: failure")?;
714        }
715
716        Ok(())
717    }
718}
719
720/// Baseline run used to count allocation attempts before injecting failures.
721#[derive(Debug, Clone, PartialEq, Eq)]
722#[must_use]
723pub struct Baseline {
724    observed_allocations: usize,
725    outcome: Outcome,
726}
727
728impl Baseline {
729    /// Number of allocation attempts observed in the baseline run.
730    #[must_use]
731    pub fn observed_allocations(&self) -> usize {
732        self.observed_allocations
733    }
734
735    /// Outcome of the baseline run.
736    pub fn outcome(&self) -> &Outcome {
737        &self.outcome
738    }
739}
740
741/// One run with one selected allocation attempt failed.
742#[derive(Debug, Clone, PartialEq, Eq)]
743#[must_use]
744pub struct Attempt {
745    target_allocation: usize,
746    observed_allocations: usize,
747    injected: bool,
748    injected_allocation: Option<Allocation>,
749    outcome: Outcome,
750}
751
752impl Attempt {
753    /// Zero-based allocation attempt selected for failure.
754    #[must_use]
755    pub fn target_allocation(&self) -> usize {
756        self.target_allocation
757    }
758
759    /// Number of allocation attempts observed during this run.
760    #[must_use]
761    pub fn observed_allocations(&self) -> usize {
762        self.observed_allocations
763    }
764
765    /// Returns whether the selected allocation attempt was actually reached and
766    /// failed.
767    #[must_use]
768    pub fn injected(&self) -> bool {
769        self.injected
770    }
771
772    /// Returns metadata for the allocation attempt that was failed, if the
773    /// target allocation was reached.
774    #[must_use]
775    pub fn injected_allocation(&self) -> Option<Allocation> {
776        self.injected_allocation
777    }
778
779    /// Outcome of this run.
780    pub fn outcome(&self) -> &Outcome {
781        &self.outcome
782    }
783
784    /// Returns `true` if the selected allocation was injected and the closure
785    /// completed without panic.
786    #[must_use]
787    pub fn is_success(&self) -> bool {
788        self.injected && self.outcome.is_completed()
789    }
790}
791
792/// Outcome of one checked run.
793#[derive(Debug, Clone, PartialEq, Eq)]
794#[must_use]
795pub enum Outcome {
796    /// The closure returned normally.
797    Completed,
798
799    /// The closure panicked.
800    Panicked(Panic),
801}
802
803impl Outcome {
804    /// Returns `true` if the run returned normally.
805    #[must_use]
806    pub fn is_completed(&self) -> bool {
807        matches!(self, Self::Completed)
808    }
809
810    /// Returns `true` if the run panicked.
811    #[must_use]
812    pub fn is_panicked(&self) -> bool {
813        matches!(self, Self::Panicked(_))
814    }
815}
816
817impl fmt::Display for Outcome {
818    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
819        match self {
820            Self::Completed => f.write_str("completed"),
821            Self::Panicked(panic) => write!(f, "panicked: {panic}"),
822        }
823    }
824}
825
826/// Captured panic payload summary.
827#[derive(Debug, Clone, PartialEq, Eq)]
828#[must_use]
829pub struct Panic {
830    message: Option<String>,
831}
832
833impl Panic {
834    /// Returns the panic message when the payload was a `String` or
835    /// `&'static str`.
836    #[must_use]
837    pub fn message(&self) -> Option<&str> {
838        self.message.as_deref()
839    }
840}
841
842impl fmt::Display for Panic {
843    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
844        match self.message() {
845            Some(message) => f.write_str(message),
846            None => f.write_str("non-string panic payload"),
847        }
848    }
849}
850
851#[derive(Debug, Clone, Copy, PartialEq, Eq)]
852struct FailurePlan {
853    start: usize,
854    end: usize,
855}
856
857impl FailurePlan {
858    fn for_check(check: Check, baseline_allocations: usize) -> Self {
859        let selection_start = check.target_start.min(baseline_allocations);
860        let selection_end = check
861            .target_end
862            .unwrap_or(baseline_allocations)
863            .min(baseline_allocations);
864        let selected_failures = selection_end.saturating_sub(selection_start);
865        let failure_limit = check
866            .max_failures
867            .map_or(selected_failures, |max| max.min(selected_failures));
868
869        Self {
870            start: selection_start,
871            end: selection_start + failure_limit,
872        }
873    }
874
875    fn len(self) -> usize {
876        self.end - self.start
877    }
878
879    fn targets(self) -> Range<usize> {
880        self.start..self.end
881    }
882
883    fn is_exhaustive_for(self, baseline_allocations: usize) -> bool {
884        self.start == 0 && self.end == baseline_allocations
885    }
886
887    fn is_exhaustive_after_stop(self, stopped_target: usize, baseline_allocations: usize) -> bool {
888        self.start == 0 && stopped_target.saturating_add(1) >= baseline_allocations
889    }
890}
891
892#[derive(Debug, Clone, Copy)]
893enum RunMode {
894    Count,
895    Fail { target: usize },
896}
897
898#[derive(Debug, Clone, Copy)]
899struct RunStats {
900    observed_allocations: usize,
901    injected: bool,
902    injected_allocation: Option<Allocation>,
903}
904
905#[derive(Debug)]
906struct ActiveCheck;
907
908impl ActiveCheck {
909    fn enter() -> Result<Self, AlreadyRunning> {
910        CHECK_ACTIVE
911            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
912            .map(|_| Self)
913            .map_err(|_| AlreadyRunning)
914    }
915}
916
917impl Drop for ActiveCheck {
918    fn drop(&mut self) {
919        MODE.store(MODE_DISABLED, Ordering::SeqCst);
920        CHECK_ACTIVE.store(false, Ordering::SeqCst);
921    }
922}
923
924#[derive(Debug)]
925struct ModeGuard {
926    finished: bool,
927    previous_thread_tracking: bool,
928}
929
930impl ModeGuard {
931    fn enter(mode: RunMode) -> Self {
932        let previous_thread_tracking = current_thread_tracking_enabled();
933        set_current_thread_tracking(true);
934
935        SEEN.store(0, Ordering::SeqCst);
936        INJECTED.store(false, Ordering::SeqCst);
937        INJECTED_INDEX.store(NO_INJECTED_INDEX, Ordering::SeqCst);
938        INJECTED_OP.store(ALLOC_OP_NONE, Ordering::SeqCst);
939        INJECTED_SIZE.store(0, Ordering::SeqCst);
940        INJECTED_ALIGN.store(0, Ordering::SeqCst);
941        INJECTED_NEW_SIZE.store(NO_REALLOC_NEW_SIZE, Ordering::SeqCst);
942
943        match mode {
944            RunMode::Count => {
945                TARGET.store(NO_TARGET, Ordering::SeqCst);
946                MODE.store(MODE_COUNTING, Ordering::SeqCst);
947            }
948            RunMode::Fail { target } => {
949                TARGET.store(target, Ordering::SeqCst);
950                MODE.store(MODE_FAILING, Ordering::SeqCst);
951            }
952        }
953
954        Self {
955            finished: false,
956            previous_thread_tracking,
957        }
958    }
959
960    fn finish(mut self) -> RunStats {
961        MODE.store(MODE_DISABLED, Ordering::SeqCst);
962
963        let injected = INJECTED.load(Ordering::SeqCst);
964        let injected_allocation = if injected {
965            AllocOp::from_u8(INJECTED_OP.load(Ordering::SeqCst)).map(|operation| Allocation {
966                index: INJECTED_INDEX.load(Ordering::SeqCst),
967                operation,
968                size: INJECTED_SIZE.load(Ordering::SeqCst),
969                align: INJECTED_ALIGN.load(Ordering::SeqCst),
970                new_size: non_sentinel_usize(INJECTED_NEW_SIZE.load(Ordering::SeqCst)),
971            })
972        } else {
973            None
974        };
975
976        let stats = RunStats {
977            observed_allocations: SEEN.load(Ordering::SeqCst),
978            injected,
979            injected_allocation,
980        };
981
982        set_current_thread_tracking(self.previous_thread_tracking);
983        self.finished = true;
984        stats
985    }
986}
987
988impl Drop for ModeGuard {
989    fn drop(&mut self) {
990        if !self.finished {
991            MODE.store(MODE_DISABLED, Ordering::SeqCst);
992            set_current_thread_tracking(self.previous_thread_tracking);
993        }
994    }
995}
996
997fn run_counting<F>(f: &F) -> Baseline
998where
999    F: Fn(),
1000{
1001    let (stats, outcome) = run_in_mode(RunMode::Count, f);
1002    Baseline {
1003        observed_allocations: stats.observed_allocations,
1004        outcome,
1005    }
1006}
1007
1008fn run_failing<F>(target_allocation: usize, f: &F) -> Attempt
1009where
1010    F: Fn(),
1011{
1012    let (stats, outcome) = run_in_mode(
1013        RunMode::Fail {
1014            target: target_allocation,
1015        },
1016        f,
1017    );
1018
1019    Attempt {
1020        target_allocation,
1021        observed_allocations: stats.observed_allocations,
1022        injected: stats.injected,
1023        injected_allocation: stats.injected_allocation,
1024        outcome,
1025    }
1026}
1027
1028fn run_in_mode<F>(mode: RunMode, f: &F) -> (RunStats, Outcome)
1029where
1030    F: Fn(),
1031{
1032    let guard = ModeGuard::enter(mode);
1033    let result = panic::catch_unwind(AssertUnwindSafe(f));
1034    let stats = guard.finish();
1035
1036    let outcome = match result {
1037        Ok(()) => Outcome::Completed,
1038        Err(payload) => Outcome::Panicked(panic_from_payload(payload)),
1039    };
1040
1041    (stats, outcome)
1042}
1043
1044fn panic_from_payload(payload: Box<dyn Any + Send>) -> Panic {
1045    let message = if let Some(message) = payload.downcast_ref::<&'static str>() {
1046        Some((*message).to_owned())
1047    } else {
1048        payload.downcast_ref::<String>().cloned()
1049    };
1050
1051    Panic { message }
1052}
1053
1054fn baseline_matches(reference: &Baseline, candidate: &Baseline) -> bool {
1055    reference.outcome.is_completed()
1056        && candidate.outcome.is_completed()
1057        && reference.observed_allocations == candidate.observed_allocations
1058}
1059
1060fn set_current_thread_tracking(enabled: bool) {
1061    TRACK_ALLOCATIONS_ON_THREAD.with(|tracking| tracking.set(enabled));
1062}
1063
1064fn current_thread_tracking_enabled() -> bool {
1065    TRACK_ALLOCATIONS_ON_THREAD
1066        .try_with(Cell::get)
1067        .unwrap_or(false)
1068}
1069
1070fn should_inject_failure(operation: AllocOp, layout: Layout, new_size: Option<usize>) -> bool {
1071    ALLOCATOR_WAS_USED.store(true, Ordering::SeqCst);
1072
1073    // Do not inject into panic machinery. If the code under test panics, the
1074    // checker should report that panic rather than potentially failing an
1075    // allocation while the panic runtime is building or formatting payloads.
1076    if std::thread::panicking() {
1077        return false;
1078    }
1079
1080    let mode = MODE.load(Ordering::SeqCst);
1081
1082    if mode == MODE_DISABLED || !current_thread_tracking_enabled() {
1083        return false;
1084    }
1085
1086    let index = SEEN.fetch_add(1, Ordering::SeqCst);
1087
1088    if mode == MODE_FAILING && index == TARGET.load(Ordering::SeqCst) {
1089        INJECTED_INDEX.store(index, Ordering::SeqCst);
1090        INJECTED_OP.store(operation.as_u8(), Ordering::SeqCst);
1091        INJECTED_SIZE.store(layout.size(), Ordering::SeqCst);
1092        INJECTED_ALIGN.store(layout.align(), Ordering::SeqCst);
1093        INJECTED_NEW_SIZE.store(new_size.unwrap_or(NO_REALLOC_NEW_SIZE), Ordering::SeqCst);
1094        INJECTED.store(true, Ordering::SeqCst);
1095        true
1096    } else {
1097        false
1098    }
1099}
1100
1101fn probe_allocator_installed() -> bool {
1102    ALLOCATOR_WAS_USED.store(false, Ordering::SeqCst);
1103
1104    let layout = Layout::from_size_align(1, 1).expect("valid allocator probe layout");
1105
1106    // SAFETY: The layout is non-zero-sized and has a valid power-of-two
1107    // alignment. The returned pointer is deallocated with the same layout if
1108    // allocation succeeds.
1109    let ptr = unsafe { std::alloc::alloc(layout) };
1110
1111    if !ptr.is_null() {
1112        // SAFETY: `ptr` was allocated by `std::alloc::alloc` with `layout`
1113        // above and has not been deallocated yet.
1114        unsafe { std::alloc::dealloc(ptr, layout) };
1115    }
1116
1117    ALLOCATOR_WAS_USED.load(Ordering::SeqCst)
1118}
1119
1120fn non_sentinel_usize(value: usize) -> Option<usize> {
1121    if value == NO_REALLOC_NEW_SIZE {
1122        None
1123    } else {
1124        Some(value)
1125    }
1126}
1127
1128#[cfg(test)]
1129#[global_allocator]
1130static TEST_ALLOCATOR: ChaosAllocator = ChaosAllocator::system();
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use std::sync::atomic::{AtomicUsize, Ordering};
1136    use std::sync::{Mutex, MutexGuard};
1137
1138    static TEST_LOCK: Mutex<()> = Mutex::new(());
1139
1140    fn test_lock() -> MutexGuard<'static, ()> {
1141        TEST_LOCK
1142            .lock()
1143            .unwrap_or_else(|poisoned| poisoned.into_inner())
1144    }
1145
1146    fn two_fallible_allocations() {
1147        let mut first = Vec::<u8>::new();
1148        let mut second = Vec::<u8>::new();
1149
1150        let _ = first.try_reserve_exact(64);
1151        let _ = second.try_reserve_exact(64);
1152    }
1153
1154    fn with_quiet_expected_panics<R>(f: impl FnOnce() -> R) -> R {
1155        let previous_hook = std::panic::take_hook();
1156        std::panic::set_hook(Box::new(|_| {}));
1157
1158        let result = std::panic::catch_unwind(AssertUnwindSafe(f));
1159
1160        std::panic::set_hook(previous_hook);
1161
1162        match result {
1163            Ok(value) => value,
1164            Err(payload) => std::panic::resume_unwind(payload),
1165        }
1166    }
1167
1168    #[test]
1169    fn reports_zero_allocations_for_empty_closure() {
1170        let _lock = test_lock();
1171
1172        let report = check(|| {});
1173
1174        assert_eq!(report.baseline_allocations(), 0);
1175        assert!(report.attempts().is_empty());
1176        assert!(report.allocator_installed());
1177        assert!(report.is_success(), "{report}");
1178    }
1179
1180    #[test]
1181    fn injects_try_reserve_failure() {
1182        let _lock = test_lock();
1183
1184        let report = check(|| {
1185            let mut values = Vec::<u8>::new();
1186            let _ = values.try_reserve_exact(1024);
1187        });
1188
1189        assert_eq!(report.baseline_allocations(), 1, "{report}");
1190        assert_eq!(report.attempts().len(), 1);
1191        assert!(report.attempts()[0].injected(), "{report}");
1192        assert!(report.attempts()[0].injected_allocation().is_some());
1193        assert!(report.is_success(), "{report}");
1194    }
1195
1196    #[test]
1197    fn records_injected_allocation_metadata() {
1198        let _lock = test_lock();
1199
1200        let report = check(|| {
1201            let mut values = Vec::<u8>::new();
1202            let _ = values.try_reserve_exact(1024);
1203        });
1204
1205        let allocation = report.attempts()[0]
1206            .injected_allocation()
1207            .expect("allocation metadata should be recorded");
1208
1209        assert_eq!(allocation.index(), 0);
1210        assert_eq!(allocation.operation(), AllocOp::Alloc);
1211        assert_eq!(allocation.size(), 1024);
1212        assert_eq!(allocation.align(), 1);
1213        assert_eq!(allocation.new_size(), None);
1214    }
1215
1216    #[test]
1217    fn reports_baseline_panic() {
1218        let _lock = test_lock();
1219
1220        let report = with_quiet_expected_panics(|| check(|| panic!("baseline failed")));
1221
1222        assert!(!report.is_success());
1223        assert!(report.attempts().is_empty());
1224        assert!(matches!(report.baseline().outcome(), Outcome::Panicked(_)));
1225    }
1226
1227    #[test]
1228    fn assert_success_panics_on_failure_report() {
1229        let _lock = test_lock();
1230
1231        let report = with_quiet_expected_panics(|| check(|| panic!("expected test panic")));
1232        let panic =
1233            with_quiet_expected_panics(|| std::panic::catch_unwind(|| report.assert_success()));
1234
1235        assert!(panic.is_err());
1236    }
1237
1238    #[test]
1239    fn rejects_nested_checks() {
1240        let _lock = test_lock();
1241
1242        let report = check(|| {
1243            assert_eq!(try_check(|| {}).unwrap_err(), AlreadyRunning);
1244        });
1245
1246        assert!(report.is_success(), "{report}");
1247    }
1248
1249    #[test]
1250    fn max_failures_limits_attempts_and_marks_report_truncated() {
1251        let _lock = test_lock();
1252
1253        let report = Check::new().max_failures(1).run(two_fallible_allocations);
1254
1255        assert!(report.baseline_allocations() >= 2, "{report}");
1256        assert_eq!(report.tested_failures(), 1);
1257        assert_eq!(report.attempts().len(), 1);
1258        assert!(report.is_truncated());
1259        assert_eq!(
1260            report.untested_failures(),
1261            report.baseline_allocations() - report.tested_failures()
1262        );
1263        assert!(!report.is_success(), "{report}");
1264    }
1265
1266    #[test]
1267    fn unlimited_failures_removes_limit() {
1268        let _lock = test_lock();
1269
1270        let report = Check::new()
1271            .max_failures(1)
1272            .unlimited_failures()
1273            .run(two_fallible_allocations);
1274
1275        assert!(report.baseline_allocations() >= 2, "{report}");
1276        assert_eq!(report.tested_failures(), report.baseline_allocations());
1277        assert!(!report.is_truncated(), "{report}");
1278        assert!(report.is_success(), "{report}");
1279    }
1280
1281    #[test]
1282    fn only_failure_reproduces_one_target_and_marks_report_truncated() {
1283        let _lock = test_lock();
1284
1285        let report = Check::new().only_failure(1).run(two_fallible_allocations);
1286
1287        assert!(report.baseline_allocations() >= 2, "{report}");
1288        assert_eq!(report.tested_failures(), 1);
1289        assert_eq!(report.attempts()[0].target_allocation(), 1);
1290        assert!(report.attempts()[0].is_success(), "{report}");
1291        assert!(report.is_truncated(), "{report}");
1292        assert!(!report.is_success(), "{report}");
1293    }
1294
1295    #[test]
1296    fn failure_range_selects_requested_targets() {
1297        let _lock = test_lock();
1298
1299        let report = Check::new()
1300            .failure_range(1..2)
1301            .run(two_fallible_allocations);
1302
1303        assert!(report.baseline_allocations() >= 2, "{report}");
1304        assert_eq!(report.tested_failures(), 1);
1305        assert_eq!(report.attempts()[0].target_allocation(), 1);
1306        assert!(report.is_truncated(), "{report}");
1307    }
1308
1309    #[test]
1310    fn failure_range_rejects_reversed_ranges() {
1311        let _lock = test_lock();
1312
1313        let panic = std::panic::catch_unwind(|| {
1314            #[allow(clippy::reversed_empty_ranges)]
1315            let _ = Check::new().failure_range(2..1);
1316        });
1317
1318        assert!(panic.is_err());
1319    }
1320
1321    #[test]
1322    fn stop_on_failure_preserves_range_truncation() {
1323        let _lock = test_lock();
1324
1325        let report = Check::new()
1326            .failure_range(1..2)
1327            .stop_on_failure(true)
1328            .run(|| {
1329                let mut first = Vec::<u8>::new();
1330                let _ = first.try_reserve_exact(64);
1331
1332                let mut second = Vec::<u8>::new();
1333                if second.try_reserve_exact(64).is_err() {
1334                    panic!("second allocation failed");
1335                }
1336            });
1337
1338        assert!(report.baseline_allocations() >= 2, "{report}");
1339        assert_eq!(report.tested_failures(), 1, "{report}");
1340        assert_eq!(report.attempts()[0].target_allocation(), 1);
1341        assert!(report.is_truncated(), "{report}");
1342        assert!(!report.is_success(), "{report}");
1343    }
1344
1345    #[test]
1346    fn reports_unstable_baseline() {
1347        let _lock = test_lock();
1348        let runs = AtomicUsize::new(0);
1349
1350        let report = Check::new().stability_runs(2).run(|| {
1351            if runs.fetch_add(1, Ordering::SeqCst) == 0 {
1352                let mut values = Vec::<u8>::new();
1353                let _ = values.try_reserve_exact(64);
1354            }
1355        });
1356
1357        assert!(!report.baseline_is_stable(), "{report}");
1358        assert_eq!(report.stability_baselines().len(), 1);
1359        assert!(report.attempts().is_empty(), "{report}");
1360        assert!(report.is_truncated(), "{report}");
1361        assert!(!report.is_success(), "{report}");
1362    }
1363
1364    #[test]
1365    fn reports_panic_in_injected_failure_run() {
1366        let _lock = test_lock();
1367
1368        let report = with_quiet_expected_panics(|| {
1369            Check::new().max_failures(1).run(|| {
1370                let mut values = Vec::<u8>::new();
1371                if values.try_reserve_exact(1024).is_err() {
1372                    panic!("allocation failure was not handled");
1373                }
1374            })
1375        });
1376
1377        assert!(report.baseline().outcome().is_completed(), "{report}");
1378        assert_eq!(report.attempts().len(), 1);
1379        assert!(report.attempts()[0].injected(), "{report}");
1380        assert!(matches!(
1381            report.attempts()[0].outcome(),
1382            Outcome::Panicked(panic)
1383                if panic.message() == Some("allocation failure was not handled")
1384        ));
1385        assert!(!report.is_success());
1386    }
1387
1388    #[test]
1389    fn stop_on_failure_stops_after_first_failed_attempt() {
1390        let _lock = test_lock();
1391
1392        let report = with_quiet_expected_panics(|| {
1393            Check::new().stop_on_failure(true).run(|| {
1394                let mut first = Vec::<u8>::new();
1395                if first.try_reserve_exact(64).is_err() {
1396                    panic!("first allocation failed");
1397                }
1398
1399                let mut second = Vec::<u8>::new();
1400                let _ = second.try_reserve_exact(64);
1401            })
1402        });
1403
1404        assert!(report.baseline_allocations() >= 2, "{report}");
1405        assert_eq!(report.tested_failures(), 1, "{report}");
1406        assert!(report.is_truncated(), "{report}");
1407        assert!(!report.is_success());
1408    }
1409
1410    #[test]
1411    fn exposes_failed_attempts() {
1412        let _lock = test_lock();
1413
1414        let report = with_quiet_expected_panics(|| {
1415            Check::new().max_failures(1).run(|| {
1416                let mut values = Vec::<u8>::new();
1417                if values.try_reserve_exact(1024).is_err() {
1418                    panic!("not handled");
1419                }
1420            })
1421        });
1422
1423        assert!(report.first_failure().is_some());
1424        assert_eq!(report.failed_attempts().count(), 1);
1425    }
1426
1427    #[test]
1428    fn reports_targets_that_are_not_reached_as_untested() {
1429        let _lock = test_lock();
1430        let run = AtomicUsize::new(0);
1431
1432        let report = Check::new().run(|| {
1433            let current_run = run.fetch_add(1, Ordering::SeqCst);
1434
1435            let mut first = Vec::<u8>::new();
1436            let _ = first.try_reserve_exact(64);
1437
1438            if current_run != 2 {
1439                let mut second = Vec::<u8>::new();
1440                let _ = second.try_reserve_exact(64);
1441            }
1442        });
1443
1444        assert_eq!(report.baseline_allocations(), 2, "{report}");
1445        assert_eq!(report.tested_failures(), 2, "{report}");
1446        assert_eq!(report.injected_failures(), 1, "{report}");
1447        assert_eq!(report.untested_failures(), 1, "{report}");
1448        assert!(!report.is_success(), "{report}");
1449    }
1450
1451    #[test]
1452    fn display_mentions_untested_failures_when_truncated() {
1453        let _lock = test_lock();
1454
1455        let report = Check::new().max_failures(1).run(two_fallible_allocations);
1456        let rendered = report.to_string();
1457
1458        assert!(rendered.contains("truncated"), "{rendered}");
1459        assert!(rendered.contains("untested:"), "{rendered}");
1460        assert!(rendered.contains("partial success"), "{rendered}");
1461    }
1462
1463    #[test]
1464    fn display_mentions_unstable_baseline() {
1465        let _lock = test_lock();
1466        let runs = AtomicUsize::new(0);
1467
1468        let report = Check::new().stability_runs(2).run(|| {
1469            if runs.fetch_add(1, Ordering::SeqCst) == 0 {
1470                let mut values = Vec::<u8>::new();
1471                let _ = values.try_reserve_exact(64);
1472            }
1473        });
1474        let rendered = report.to_string();
1475
1476        assert!(rendered.contains("unstable baseline"), "{rendered}");
1477    }
1478}