xref: /wasmtime-44.0.1/crates/fuzzing/src/oom.rs (revision fee9be21)
1 //! Utilities for testing and fuzzing out-of-memory handling.
2 //!
3 //! Inspired by SpiderMonkey's `oomTest()` helper:
4 //! https://firefox-source-docs.mozilla.org/js/hacking_tips.html#how-to-debug-oomtest-failures
5 
6 use anyhow::bail;
7 use backtrace::Backtrace;
8 use std::{alloc::GlobalAlloc, cell::Cell, mem, ptr, time};
9 use wasmtime::{Error, Result};
10 
11 /// An allocator for use with `OomTest`.
12 #[non_exhaustive]
13 pub struct OomTestAllocator;
14 
15 impl OomTestAllocator {
16     /// Create a new OOM test allocator.
17     pub const fn new() -> Self {
18         OomTestAllocator
19     }
20 }
21 
22 #[derive(Clone, Debug, Default, PartialEq, Eq)]
23 enum OomState {
24     /// We are in code that is not part of an OOM test.
25     #[default]
26     OutsideOomTest,
27 
28     /// We are inside an OOM test and should inject an OOM when the counter
29     /// reaches zero.
30     OomOnAlloc(u32),
31 
32     /// We are inside an OOM test and we already injected an OOM.
33     DidOom,
34 }
35 
36 thread_local! {
37     static OOM_STATE: Cell<OomState> = const { Cell::new(OomState::OutsideOomTest) };
38 }
39 
40 /// Set the new OOM state, returning the old state.
41 fn set_oom_state(state: OomState) -> OomState {
42     OOM_STATE.with(|s| s.replace(state))
43 }
44 
45 /// RAII helper to set the OOM state within a block of code and reset it upon
46 /// exiting that block (even if exiting via panic unwinding).
47 struct ScopedOomState {
48     prev_state: OomState,
49 }
50 
51 impl ScopedOomState {
52     fn new(state: OomState) -> Self {
53         ScopedOomState {
54             prev_state: set_oom_state(state),
55         }
56     }
57 
58     /// Finish this OOM state scope early, resetting the OOM state to what it
59     /// was before this scope was created, and returning the previous state that
60     /// was just overwritten by the reset.
61     fn finish(&self) -> OomState {
62         set_oom_state(self.prev_state.clone())
63     }
64 }
65 
66 impl Drop for ScopedOomState {
67     fn drop(&mut self) {
68         set_oom_state(mem::take(&mut self.prev_state));
69     }
70 }
71 
72 unsafe impl GlobalAlloc for OomTestAllocator {
73     unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
74         let old_state = set_oom_state(OomState::OutsideOomTest);
75 
76         let new_state;
77         let ptr;
78         {
79             // NB: It's okay to log/backtrace/etc... in this block because the
80             // current state is `OutsideOomTest`, so any re-entrant allocations
81             // will be passed through to the system allocator.
82 
83             match old_state {
84                 OomState::OutsideOomTest => {
85                     new_state = OomState::OutsideOomTest;
86                     ptr = unsafe { std::alloc::System.alloc(layout) };
87                 }
88                 OomState::OomOnAlloc(0) => {
89                     log::trace!(
90                         "injecting OOM for allocation: {layout:?}\nAllocation backtrace:\n{:?}",
91                         Backtrace::new(),
92                     );
93                     new_state = OomState::DidOom;
94                     ptr = ptr::null_mut();
95                 }
96                 OomState::OomOnAlloc(c) => {
97                     new_state = OomState::OomOnAlloc(c - 1);
98                     ptr = unsafe { std::alloc::System.alloc(layout) };
99                 }
100                 OomState::DidOom => {
101                     panic!("OOM test attempted to allocate after OOM: {layout:?}")
102                 }
103             }
104         }
105 
106         set_oom_state(new_state);
107         ptr
108     }
109 
110     unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
111         unsafe {
112             std::alloc::System.dealloc(ptr, layout);
113         }
114     }
115 }
116 
117 /// A test helper that checks that some code handles OOM correctly.
118 ///
119 /// `OomTest` will only work correctly when `OomTestAllocator` is configured as
120 /// the global allocator.
121 ///
122 /// `OomTest` does not support reentrancy, so you cannot run an `OomTest` within
123 /// an `OomTest`.
124 ///
125 /// # Example
126 ///
127 /// ```no_run
128 /// use std::time::Duration;
129 /// use wasmtime::Result;
130 /// use wasmtime_fuzzing::oom::{OomTest, OomTestAllocator};
131 ///
132 /// #[global_allocator]
133 /// static GLOBAL_ALOCATOR: OomTestAllocator = OomTestAllocator::new();
134 ///
135 /// #[test]
136 /// fn my_oom_test() -> Result<()> {
137 ///     OomTest::new()
138 ///         .max_iters(1_000_000)
139 ///         .max_duration(Duration::from_secs(5))
140 ///         .test(|| {
141 ///             todo!("insert code here that should handle OOM here...")
142 ///         })
143 /// }
144 /// ```
145 pub struct OomTest {
146     max_iters: Option<u32>,
147     max_duration: Option<time::Duration>,
148 }
149 
150 impl OomTest {
151     /// Create a new OOM test.
152     ///
153     /// By default there is no iteration or time limit, tests will be executed
154     /// until the pass (or fail).
155     pub fn new() -> Self {
156         let _ = env_logger::try_init();
157         OomTest {
158             max_iters: None,
159             max_duration: None,
160         }
161     }
162 
163     /// Configure the maximum number of times to run an OOM test.
164     pub fn max_iters(&mut self, max_iters: u32) -> &mut Self {
165         self.max_iters = Some(max_iters);
166         self
167     }
168 
169     /// Configure the maximum duration of time to run an OOM text.
170     pub fn max_duration(&mut self, max_duration: time::Duration) -> &mut Self {
171         self.max_duration = Some(max_duration);
172         self
173     }
174 
175     /// Repeatedly run the given test function, injecting OOMs at different
176     /// times and checking that it correctly handles them.
177     ///
178     /// The test function should not use threads, or else allocations may not be
179     /// tracked correctly and OOM injection may be incorrect.
180     ///
181     /// The test function should return an `Err(_)` if and only if it encounters
182     /// an OOM.
183     ///
184     /// Returns early once the test function returns `Ok(())` before an OOM has
185     /// been injected.
186     pub fn test(&self, test_func: impl Fn() -> Result<()>) -> Result<()> {
187         let start = time::Instant::now();
188 
189         for i in 0.. {
190             if self.max_iters.is_some_and(|n| i >= n)
191                 || self.max_duration.is_some_and(|d| start.elapsed() >= d)
192             {
193                 break;
194             }
195 
196             log::trace!("=== Injecting OOM after {i} allocations ===");
197             let (result, old_state) = {
198                 let guard = ScopedOomState::new(OomState::OomOnAlloc(i));
199                 assert_eq!(guard.prev_state, OomState::OutsideOomTest);
200 
201                 let result = test_func();
202 
203                 (result, guard.finish())
204             };
205 
206             match (result, old_state) {
207                 (_, OomState::OutsideOomTest) => unreachable!(),
208 
209                 // The test function completed successfully before we ran out of
210                 // allocation fuel, so we're done.
211                 (Ok(()), OomState::OomOnAlloc(_)) => break,
212 
213                 // We injected an OOM and the test function handled it
214                 // correctly; continue to the next iteration.
215                 (Err(e), OomState::DidOom) if self.is_oom_error(&e) => {}
216 
217                 // Missed OOMs.
218                 (Ok(()), OomState::DidOom) => {
219                     bail!("OOM test function missed an OOM: returned Ok(())");
220                 }
221                 (Err(e), OomState::DidOom) => {
222                     return Err(
223                         e.context("OOM test function missed an OOM: returned non-OOM error")
224                     );
225                 }
226 
227                 // Unexpected error.
228                 (Err(e), OomState::OomOnAlloc(_)) => {
229                     return Err(
230                         e.context("OOM test function returned an error when there was no OOM")
231                     );
232                 }
233             }
234         }
235 
236         Ok(())
237     }
238 
239     fn is_oom_error(&self, _: &Error) -> bool {
240         // TODO: We don't have an OOM error yet. Will likely need to make it so
241         // that `wasmtime::Error != anyhow::Error` as a first step here.
242         false
243     }
244 }
245