xref: /wasmtime-44.0.1/crates/fuzzing/src/oom.rs (revision bda02c19)
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 backtrace::Backtrace;
7 use std::{alloc::GlobalAlloc, cell::Cell, mem, ptr, time};
8 use wasmtime_core::error::{Error, OutOfMemory, Result, bail};
9 
10 /// An allocator for use with `OomTest`.
11 #[non_exhaustive]
12 pub struct OomTestAllocator;
13 
14 impl OomTestAllocator {
15     /// Create a new OOM test allocator.
16     pub const fn new() -> Self {
17         OomTestAllocator
18     }
19 }
20 
21 #[derive(Clone, Debug, Default, PartialEq, Eq)]
22 enum OomState {
23     /// We are in code that is not part of an OOM test.
24     #[default]
25     OutsideOomTest,
26 
27     /// We are inside an OOM test and should inject an OOM when the counter
28     /// reaches zero.
29     OomOnAlloc {
30         counter: u32,
31         allow_alloc_after: bool,
32     },
33 
34     /// We are inside an OOM test and we already injected an OOM.
35     DidOom { allow_alloc: bool },
36 }
37 
38 thread_local! {
39     static OOM_STATE: Cell<OomState> = const { Cell::new(OomState::OutsideOomTest) };
40 }
41 
42 /// Set the new OOM state, returning the old state.
43 fn set_oom_state(state: OomState) -> OomState {
44     OOM_STATE.with(|s| s.replace(state))
45 }
46 
47 /// RAII helper to set the OOM state within a block of code and reset it upon
48 /// exiting that block (even if exiting via panic unwinding).
49 struct ScopedOomState {
50     prev_state: OomState,
51 }
52 
53 impl ScopedOomState {
54     fn new(state: OomState) -> Self {
55         ScopedOomState {
56             prev_state: set_oom_state(state),
57         }
58     }
59 
60     /// Finish this OOM state scope early, resetting the OOM state to what it
61     /// was before this scope was created, and returning the previous state that
62     /// was just overwritten by the reset.
63     fn finish(&self) -> OomState {
64         set_oom_state(self.prev_state.clone())
65     }
66 }
67 
68 impl Drop for ScopedOomState {
69     fn drop(&mut self) {
70         set_oom_state(mem::take(&mut self.prev_state));
71     }
72 }
73 
74 unsafe impl GlobalAlloc for OomTestAllocator {
75     unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
76         let old_state = set_oom_state(OomState::OutsideOomTest);
77 
78         let new_state;
79         let ptr;
80 
81         'outside_oom_test: {
82             // NB: It's okay to log/backtrace/etc... in this block because the
83             // current state is `OutsideOomTest`, so any re-entrant allocations
84             // will be passed through to the system allocator.
85 
86             // Don't panic on allocation-after-OOM attempts if we
87             // are already in the middle of panicking. That will
88             // cause an abort and we won't get as good of an error
89             // message for the original panic, which is most likely
90             // some kind of test failure.
91             if old_state == OomState::OutsideOomTest || std::thread::panicking() {
92                 new_state = old_state;
93                 ptr = unsafe { std::alloc::System.alloc(layout) };
94                 break 'outside_oom_test;
95             }
96 
97             let bt = Backtrace::new();
98             let bt = format!("{bt:?}");
99 
100             // XXX: `env_logger` internally buffers writes in a `Vec` which
101             // means our OOM tests might sporadically fail when you enable
102             // logging to debug stuff, so simply let the allocation through if
103             // `env_logger` is on the stack.
104             if bt.contains("env_logger") {
105                 new_state = old_state;
106                 ptr = unsafe { std::alloc::System.alloc(layout) };
107                 break 'outside_oom_test;
108             }
109 
110             match old_state {
111                 OomState::OutsideOomTest => unreachable!("handled above"),
112 
113                 OomState::OomOnAlloc {
114                     counter: 0,
115                     allow_alloc_after,
116                 } => {
117                     log::trace!(
118                         "injecting OOM for allocation: {layout:?}\nAllocation backtrace:\n{bt}"
119                     );
120                     new_state = OomState::DidOom {
121                         allow_alloc: allow_alloc_after,
122                     };
123                     ptr = ptr::null_mut();
124                 }
125 
126                 OomState::OomOnAlloc {
127                     counter: c,
128                     allow_alloc_after,
129                 } => {
130                     new_state = OomState::OomOnAlloc {
131                         counter: c - 1,
132                         allow_alloc_after,
133                     };
134                     ptr = unsafe { std::alloc::System.alloc(layout) };
135                 }
136 
137                 OomState::DidOom { allow_alloc } => {
138                     log::trace!("Attempt to allocate {layout:?} after OOM:\n{bt}");
139                     if allow_alloc {
140                         new_state = OomState::DidOom { allow_alloc: true };
141                         ptr = ptr::null_mut();
142                     } else {
143                         panic!(
144                             "OOM test attempted to allocate after OOM: {layout:?}\n\
145                              \n\
146                              Hint: if this is acceptable, configure the OOM test to allow allocation \
147                              after OOM with `OomTest::allow_alloc_after_oom`"
148                         )
149                     }
150                 }
151             }
152         }
153 
154         set_oom_state(new_state);
155         ptr
156     }
157 
158     unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
159         unsafe {
160             std::alloc::System.dealloc(ptr, layout);
161         }
162     }
163 }
164 
165 /// A test helper that checks that some code handles OOM correctly.
166 ///
167 /// `OomTest` will only work correctly when `OomTestAllocator` is configured as
168 /// the global allocator.
169 ///
170 /// `OomTest` does not support reentrancy, so you cannot run an `OomTest` within
171 /// an `OomTest`.
172 ///
173 /// # Example
174 ///
175 /// ```no_run
176 /// use std::time::Duration;
177 /// use wasmtime::Result;
178 /// use wasmtime_fuzzing::oom::{OomTest, OomTestAllocator};
179 ///
180 /// #[global_allocator]
181 /// static GLOBAL_ALOCATOR: OomTestAllocator = OomTestAllocator::new();
182 ///
183 /// #[test]
184 /// fn my_oom_test() -> Result<()> {
185 ///     OomTest::new()
186 ///         .max_iters(1_000_000)
187 ///         .max_duration(Duration::from_secs(5))
188 ///         .allow_alloc_after_oom(true)
189 ///         .test(|| {
190 ///             todo!("insert code here that should handle OOM here...")
191 ///         })
192 /// }
193 /// ```
194 pub struct OomTest {
195     max_iters: Option<u32>,
196     max_duration: Option<time::Duration>,
197     allow_alloc_after_oom: bool,
198 }
199 
200 impl OomTest {
201     /// Create a new OOM test.
202     ///
203     /// By default there is no iteration or time limit, tests will be executed
204     /// until the pass (or fail).
205     pub fn new() -> Self {
206         let _ = env_logger::try_init();
207 
208         // NB: `std::backtrace::Backtrace` doesn't have ways to handle
209         // OOM. Ideally we would just disable the `"backtrace"` cargo feature,
210         // but workspace feature resolution doesn't play nice with that.
211         wasmtime_core::error::disable_backtrace();
212 
213         OomTest {
214             max_iters: None,
215             max_duration: None,
216             allow_alloc_after_oom: false,
217         }
218     }
219 
220     /// Configure the maximum number of times to run an OOM test.
221     pub fn max_iters(&mut self, max_iters: u32) -> &mut Self {
222         self.max_iters = Some(max_iters);
223         self
224     }
225 
226     /// Configure the maximum duration of time to run an OOM text.
227     pub fn max_duration(&mut self, max_duration: time::Duration) -> &mut Self {
228         self.max_duration = Some(max_duration);
229         self
230     }
231 
232     /// Configure whether to allow allocation attempts after an OOM has already
233     /// been injected.
234     ///
235     /// The default is `false`.
236     pub fn allow_alloc_after_oom(&mut self, allow: bool) -> &mut Self {
237         self.allow_alloc_after_oom = allow;
238         self
239     }
240 
241     /// Repeatedly run the given test function, injecting OOMs at different
242     /// times and checking that it correctly handles them.
243     ///
244     /// The test function should not use threads, or else allocations may not be
245     /// tracked correctly and OOM injection may be incorrect.
246     ///
247     /// The test function should return an `Err(_)` if and only if it encounters
248     /// an OOM.
249     ///
250     /// Returns early once the test function returns `Ok(())` before an OOM has
251     /// been injected.
252     pub fn test(&self, test_func: impl Fn() -> Result<()>) -> Result<()> {
253         let start = time::Instant::now();
254 
255         for i in 0.. {
256             if self.max_iters.is_some_and(|n| i >= n)
257                 || self.max_duration.is_some_and(|d| start.elapsed() >= d)
258             {
259                 break;
260             }
261 
262             log::trace!("=== Injecting OOM after {i} allocations ===");
263             let (result, old_state) = {
264                 let guard = ScopedOomState::new(OomState::OomOnAlloc {
265                     counter: i,
266                     allow_alloc_after: self.allow_alloc_after_oom,
267                 });
268                 assert_eq!(guard.prev_state, OomState::OutsideOomTest);
269 
270                 let result = test_func();
271 
272                 (result, guard.finish())
273             };
274 
275             match (result, old_state) {
276                 (_, OomState::OutsideOomTest) => unreachable!(),
277 
278                 // The test function completed successfully before we ran out of
279                 // allocation fuel, so we're done.
280                 (Ok(()), OomState::OomOnAlloc { .. }) => break,
281 
282                 // We injected an OOM and the test function handled it
283                 // correctly; continue to the next iteration.
284                 (Err(e), OomState::DidOom { .. }) if self.is_oom_error(&e) => {}
285 
286                 // Missed OOMs.
287                 (Ok(()), OomState::DidOom { .. }) => {
288                     bail!("OOM test function missed an OOM: returned Ok(())");
289                 }
290                 (Err(e), OomState::DidOom { .. }) => {
291                     return Err(
292                         e.context("OOM test function missed an OOM: returned non-OOM error")
293                     );
294                 }
295 
296                 // Unexpected error.
297                 (Err(e), OomState::OomOnAlloc { .. }) => {
298                     return Err(
299                         e.context("OOM test function returned an error when there was no OOM")
300                     );
301                 }
302             }
303         }
304 
305         Ok(())
306     }
307 
308     fn is_oom_error(&self, e: &Error) -> bool {
309         e.is::<OutOfMemory>()
310     }
311 }
312