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