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 } 245