Skip to content

Commit cf48211

Browse files
authored
Add initial OOM test infrastructure (#12070)
* Add initial OOM test infrastructure Baby steps towards #12069 * Add a `ScopedOomState` RAII helper to be robust to panics in OOM tests
1 parent 19d9a4a commit cf48211

File tree

6 files changed

+275
-0
lines changed

6 files changed

+275
-0
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ json-from-wast = "0.241.0"
353353
# Non-Bytecode Alliance maintained dependencies:
354354
# --------------------------
355355
arbitrary = "1.4.2"
356+
backtrace = "0.3.75"
356357
mutatis = "0.3.2"
357358
cc = "1.2.41"
358359
object = { version = "0.37.3", default-features = false, features = ['read_core', 'elf'] }

crates/fuzzing/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ wasmtime-test-util = { workspace = true, features = ['wast'] }
1616

1717
[dependencies]
1818
anyhow = { workspace = true }
19+
backtrace = { workspace = true }
1920
arbitrary = { workspace = true, features = ["derive"] }
2021
env_logger = { workspace = true }
2122
log = { workspace = true }

crates/fuzzing/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub use wasm_mutate;
88
pub use wasm_smith;
99
pub mod generators;
1010
pub mod mutators;
11+
pub mod oom;
1112
pub mod oracles;
1213
pub mod single_module_fuzzer;
1314

crates/fuzzing/src/oom.rs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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+
}

crates/fuzzing/tests/oom.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use std::alloc::{Layout, alloc};
2+
use wasmtime::Result;
3+
use wasmtime_fuzzing::oom::{OomTest, OomTestAllocator};
4+
5+
#[global_allocator]
6+
static GLOBAL_ALOCATOR: OomTestAllocator = OomTestAllocator::new();
7+
8+
#[test]
9+
fn smoke_test_ok() -> Result<()> {
10+
OomTest::new().test(|| Ok(()))
11+
}
12+
13+
#[test]
14+
fn smoke_test_missed_oom() -> Result<()> {
15+
let err = OomTest::new()
16+
.test(|| {
17+
let _ = unsafe { alloc(Layout::new::<u64>()) };
18+
Ok(())
19+
})
20+
.unwrap_err();
21+
let err = format!("{err:?}");
22+
assert!(
23+
err.contains("OOM test function missed an OOM"),
24+
"should have missed an OOM, got: {err}"
25+
);
26+
Ok(())
27+
}

0 commit comments

Comments
 (0)