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