//! Utilities for testing and fuzzing out-of-memory handling.1//!2//! Inspired by SpiderMonkey's `oomTest()` helper:3//! https://firefox-source-docs.mozilla.org/js/hacking_tips.html#how-to-debug-oomtest-failures45use backtrace::Backtrace;6use std::{alloc::GlobalAlloc, cell::Cell, mem, ptr, time};7use wasmtime_core::error::{Error, OutOfMemory, Result, bail};89/// An allocator for use with `OomTest`.10#[non_exhaustive]11pub struct OomTestAllocator;1213impl OomTestAllocator {14/// Create a new OOM test allocator.15pub const fn new() -> Self {16OomTestAllocator17}18}1920#[derive(Clone, Debug, Default, PartialEq, Eq)]21enum OomState {22/// We are in code that is not part of an OOM test.23#[default]24OutsideOomTest,2526/// We are inside an OOM test and should inject an OOM when the counter27/// reaches zero.28OomOnAlloc(u32),2930/// We are inside an OOM test and we already injected an OOM.31DidOom,32}3334thread_local! {35static OOM_STATE: Cell<OomState> = const { Cell::new(OomState::OutsideOomTest) };36}3738/// Set the new OOM state, returning the old state.39fn set_oom_state(state: OomState) -> OomState {40OOM_STATE.with(|s| s.replace(state))41}4243/// RAII helper to set the OOM state within a block of code and reset it upon44/// exiting that block (even if exiting via panic unwinding).45struct ScopedOomState {46prev_state: OomState,47}4849impl ScopedOomState {50fn new(state: OomState) -> Self {51ScopedOomState {52prev_state: set_oom_state(state),53}54}5556/// Finish this OOM state scope early, resetting the OOM state to what it57/// was before this scope was created, and returning the previous state that58/// was just overwritten by the reset.59fn finish(&self) -> OomState {60set_oom_state(self.prev_state.clone())61}62}6364impl Drop for ScopedOomState {65fn drop(&mut self) {66set_oom_state(mem::take(&mut self.prev_state));67}68}6970unsafe impl GlobalAlloc for OomTestAllocator {71unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {72let old_state = set_oom_state(OomState::OutsideOomTest);7374let new_state;75let ptr;7677'outside_oom_test: {78// NB: It's okay to log/backtrace/etc... in this block because the79// current state is `OutsideOomTest`, so any re-entrant allocations80// will be passed through to the system allocator.8182// Don't panic on allocation-after-OOM attempts if we83// are already in the middle of panicking. That will84// cause an abort and we won't get as good of an error85// message for the original panic, which is most likely86// some kind of test failure.87if old_state == OomState::OutsideOomTest || std::thread::panicking() {88new_state = old_state;89ptr = unsafe { std::alloc::System.alloc(layout) };90break 'outside_oom_test;91}9293let bt = Backtrace::new();94let bt = format!("{bt:?}");9596// XXX: `env_logger` internally buffers writes in a `Vec` which97// means our OOM tests might sporadically fail when you enable98// logging to debug stuff, so simply let the allocation through if99// `env_logger` is on the stack.100if bt.contains("env_logger") {101new_state = old_state;102ptr = unsafe { std::alloc::System.alloc(layout) };103break 'outside_oom_test;104}105106match old_state {107OomState::OutsideOomTest => unreachable!("handled above"),108109OomState::OomOnAlloc(0) => {110log::trace!(111"injecting OOM for allocation: {layout:?}\nAllocation backtrace:\n{bt}"112);113new_state = OomState::DidOom;114ptr = ptr::null_mut();115}116117OomState::OomOnAlloc(c) => {118new_state = OomState::OomOnAlloc(c - 1);119ptr = unsafe { std::alloc::System.alloc(layout) };120}121122OomState::DidOom => {123log::trace!("Attempt to allocate {layout:?} after OOM:\n{bt}");124panic!("OOM test attempted to allocate after OOM: {layout:?}")125}126}127}128129set_oom_state(new_state);130ptr131}132133unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {134unsafe {135std::alloc::System.dealloc(ptr, layout);136}137}138}139140/// A test helper that checks that some code handles OOM correctly.141///142/// `OomTest` will only work correctly when `OomTestAllocator` is configured as143/// the global allocator.144///145/// `OomTest` does not support reentrancy, so you cannot run an `OomTest` within146/// an `OomTest`.147///148/// # Example149///150/// ```no_run151/// use std::time::Duration;152/// use wasmtime::Result;153/// use wasmtime_fuzzing::oom::{OomTest, OomTestAllocator};154///155/// #[global_allocator]156/// static GLOBAL_ALOCATOR: OomTestAllocator = OomTestAllocator::new();157///158/// #[test]159/// fn my_oom_test() -> Result<()> {160/// OomTest::new()161/// .max_iters(1_000_000)162/// .max_duration(Duration::from_secs(5))163/// .test(|| {164/// todo!("insert code here that should handle OOM here...")165/// })166/// }167/// ```168pub struct OomTest {169max_iters: Option<u32>,170max_duration: Option<time::Duration>,171}172173impl OomTest {174/// Create a new OOM test.175///176/// By default there is no iteration or time limit, tests will be executed177/// until the pass (or fail).178pub fn new() -> Self {179let _ = env_logger::try_init();180181// NB: `std::backtrace::Backtrace` doesn't have ways to handle182// OOM. Ideally we would just disable the `"backtrace"` cargo feature,183// but workspace feature resolution doesn't play nice with that.184wasmtime_core::error::disable_backtrace();185186OomTest {187max_iters: None,188max_duration: None,189}190}191192/// Configure the maximum number of times to run an OOM test.193pub fn max_iters(&mut self, max_iters: u32) -> &mut Self {194self.max_iters = Some(max_iters);195self196}197198/// Configure the maximum duration of time to run an OOM text.199pub fn max_duration(&mut self, max_duration: time::Duration) -> &mut Self {200self.max_duration = Some(max_duration);201self202}203204/// Repeatedly run the given test function, injecting OOMs at different205/// times and checking that it correctly handles them.206///207/// The test function should not use threads, or else allocations may not be208/// tracked correctly and OOM injection may be incorrect.209///210/// The test function should return an `Err(_)` if and only if it encounters211/// an OOM.212///213/// Returns early once the test function returns `Ok(())` before an OOM has214/// been injected.215pub fn test(&self, test_func: impl Fn() -> Result<()>) -> Result<()> {216let start = time::Instant::now();217218for i in 0.. {219if self.max_iters.is_some_and(|n| i >= n)220|| self.max_duration.is_some_and(|d| start.elapsed() >= d)221{222break;223}224225log::trace!("=== Injecting OOM after {i} allocations ===");226let (result, old_state) = {227let guard = ScopedOomState::new(OomState::OomOnAlloc(i));228assert_eq!(guard.prev_state, OomState::OutsideOomTest);229230let result = test_func();231232(result, guard.finish())233};234235match (result, old_state) {236(_, OomState::OutsideOomTest) => unreachable!(),237238// The test function completed successfully before we ran out of239// allocation fuel, so we're done.240(Ok(()), OomState::OomOnAlloc(_)) => break,241242// We injected an OOM and the test function handled it243// correctly; continue to the next iteration.244(Err(e), OomState::DidOom) if self.is_oom_error(&e) => {}245246// Missed OOMs.247(Ok(()), OomState::DidOom) => {248bail!("OOM test function missed an OOM: returned Ok(())");249}250(Err(e), OomState::DidOom) => {251return Err(252e.context("OOM test function missed an OOM: returned non-OOM error")253);254}255256// Unexpected error.257(Err(e), OomState::OomOnAlloc(_)) => {258return Err(259e.context("OOM test function returned an error when there was no OOM")260);261}262}263}264265Ok(())266}267268fn is_oom_error(&self, e: &Error) -> bool {269e.is::<OutOfMemory>()270}271}272273274