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