diff --git a/majit/majit-gc/src/collector.rs b/majit/majit-gc/src/collector.rs index 77918b677fa..e0dded937aa 100644 --- a/majit/majit-gc/src/collector.rs +++ b/majit/majit-gc/src/collector.rs @@ -1035,6 +1035,12 @@ pub struct MiniMarkGC { /// (`out_of_memory`, minimarkpage.py -> fatalerror) instead of raising /// another MemoryError. Only reachable when `max_heap_size > 0` /// (`PYPY_GC_MAX` set), so it stays false in the default unbounded config. + /// + /// Upstream sets this immediately before the raise, so it reads as "the + /// program has already been given a MemoryError". Pyre's channels deliver + /// later, so the flag alone would read as "one has been armed"; the fatal + /// rung restores the original meaning by also requiring that neither + /// channel is still holding an undelivered one. max_heap_size_already_raised: bool, /// Set by `finish_incremental_cycle` when a bounded major collection leaves /// the heap over `max_heap_size`. The allocation that triggered the @@ -1043,6 +1049,17 @@ pub struct MiniMarkGC { /// raises `MemoryError` (incminimark.py:2603-2615 `raise MemoryError`, /// lowered to the NULL-return the backend already understands). oom_pending: bool, + /// Whether the step now running signalled a max-heap `MemoryError`, on + /// either channel. + /// + /// Upstream raises where the two channels are armed, so its + /// `major_collection_step` never reaches the collect-step hook on a breach. + /// Suppressing that hook therefore needs a record of the arming rather than + /// a reading of a channel: `oom_pending` alone answers for one channel + /// only, and the eval-breaker bit is process-global and may be armed by a + /// breach this step had nothing to do with. Cleared at the top of every + /// `major_collection_step`. + oom_signalled_this_step: bool, /// Byte size of the allocation that triggered the current nursery-full /// collection, carried across `do_collect_nursery` so /// `finish_incremental_cycle` can pass it to `threshold_reached` @@ -1230,6 +1247,7 @@ impl MiniMarkGC { max_delta, max_heap_size_already_raised: false, oom_pending: false, + oom_signalled_this_step: false, pending_reserving_size: 0, // incminimark.py:568-569 — both initialized to min_heap_size, // then refined by set_major_threshold_from(0.0) below. @@ -1532,9 +1550,8 @@ impl MiniMarkGC { // incminimark.py:2603-2615 — a bounded major collection that leaves // the heap over `max_heap_size` asks this allocation to fail so the // caller raises MemoryError: NULL propagates to the compiled-code - // `CHECK_MEMORY_ERROR` path and to the interpreter allocation - // chokepoint. Never taken in the unbounded default (`PYPY_GC_MAX` - // unset), so the fallback below is unchanged there. + // `CHECK_MEMORY_ERROR` path. Never taken in the unbounded default + // (`PYPY_GC_MAX` unset), so the fallback below is unchanged there. if std::mem::take(&mut self.oom_pending) { return GcRef(0); } @@ -5744,7 +5761,7 @@ impl MiniMarkGC { fn major_collection_step(&mut self) { let start = GcClock::start(); let old_state = self.gc_state.encoded(); - let oom_was_pending = self.oom_pending; + self.oom_signalled_this_step = false; self.debug_check_consistency(); // incminimark.py:2406-2436: each state-machine step grants half a @@ -5775,9 +5792,10 @@ impl MiniMarkGC { } // incminimark.py:2634-2644. A max-heap MemoryError exits upstream - // before this site; pyre communicates it through `oom_pending`, so - // suppress the transition event when this step newly raised it. - if self.oom_pending == oom_was_pending { + // before this site; pyre defers it instead, so suppress the transition + // event when this step armed one — on either channel, since both stand + // for the same raise. + if !self.oom_signalled_this_step { let duration = start.elapsed_secs(); self.total_gc_time += duration; self.hooks @@ -6576,24 +6594,81 @@ impl MiniMarkGC { // incminimark.py:2601-2615 — max heap size (PYPY_GC_MAX). If the capped // threshold was bounded by `max_heap_size` and the heap has already - // reached it, signal out-of-memory. The first time, ask the triggering - // allocation to return NULL so `CHECK_MEMORY_ERROR` (compiled code) or - // the interpreter allocation chokepoint raises `MemoryError`, giving the - // program a chance to quit cleanly; a second occurrence aborts the + // reached it, signal out-of-memory: the first time so the program gets + // a chance to quit cleanly, and on a second occurrence by aborting the // process (`out_of_memory` -> fatalerror). `max_heap_size == 0` - // (unbounded default) never sets `bounded`, so this is inert unless - // `PYPY_GC_MAX` is set. + // (unbounded default) never sets `bounded`, so this is inert unless a + // limit was set. + // + // Upstream signals it by raising, which unwinds through whichever of + // the many drivers of a collection is on the stack. Pyre has to pick a + // channel, because only one of those drivers can carry an exception, + // and `reserving_size` already says which case this is: it is the size + // of the allocation waiting on this collection, and it is nonzero for + // exactly the two collecting nursery allocators. + // + // * With an allocation waiting, fail it. `oom_pending` is read by + // that allocator the moment the collection returns, and its NULL + // becomes a `MemoryError` at the compiled `CHECK_MEMORY_ERROR`. + // * With none waiting — an explicit `gc.collect()`, a finalizer run, + // and above all the dispatch-loop safepoint, which is where the + // interpreter path's collections happen — there is nothing to fail + // and no frame that can raise, so the eval-breaker bit defers the + // exception to the next bytecode dispatch, exactly as `set_gc` + // defers the collection itself. + // + // The two are mutually exclusive on purpose. Arming both would leave + // whichever channel went undelivered latched onto the *next* breach: + // an `oom_pending` no allocation was waiting for is taken by the next + // unrelated one, failing an allocation that breached nothing. + // + // The fatal rung asks whether the program has already had its + // `MemoryError`. Upstream raises here, so setting the flag and handing + // the program its exception are one event and the question answers + // itself. Deferring delivery pulls them apart, and the two halves of + // that are asked separately: + // + // * *This* breach repeats one this thread has not been given yet — + // `do_collect_full` completes two cycles per call with no dispatch + // between them, so an explicit `gc.collect()` on a heap at its + // limit reaches exactly that. Nothing new has happened, so return. + // The question is per-thread: an exception owed to another thread + // says nothing about this breach, and treating it as this one's + // would let this thread allocate past the limit unremarked. + // * The *fatal* rung needs the exception to have landed, not merely + // to have been armed, or it ends the process before the program + // sees what the rung above promised it. So a breach while any + // thread is still owed one falls through and arms this thread too, + // which is what upstream's raise does for whichever thread ran the + // collection. + // + // Both channels clear as they deliver, so an armed one is the whole + // test in either case. if bounded && self.threshold_reached(reserving_size) { - if self.max_heap_size_already_raised { + if self.oom_pending || majit_ir::eval_breaker_word::memory_error_owed_here() { + // Upstream raises again here, and never reaches the collect-step + // hook; this return stands for that raise, so suppress it too. + self.oom_signalled_this_step = true; + self.gc_state = GcState::Scanning; + return; + } + if self.max_heap_size_already_raised + && !majit_ir::eval_breaker_word::memory_error_armed() + { panic!("using too much memory, aborting"); } self.max_heap_size_already_raised = true; - self.oom_pending = true; + self.oom_signalled_this_step = true; + if reserving_size > 0 { + self.oom_pending = true; + } else { + majit_ir::eval_breaker_word::set_memory_error(); + } // incminimark.py: STATE_SCANNING then an // immediate `raise MemoryError` exits `major_collection_step` // before the finalizing phase. Return before the queue-notification - // triggers so none fire ahead of the `MemoryError` the pending NULL - // will raise. + // triggers so none fire ahead of the `MemoryError` the two channels + // above will raise. self.gc_state = GcState::Scanning; return; } @@ -9718,6 +9793,13 @@ mod tests { gc.roots.clear(); } + /// The max-heap tests read and write the process-global eval-breaker bit, + /// and each one starts by normalising it. Ownership of an armed + /// `MemoryError` is per-thread now, so a normalising call no longer clears + /// one a concurrently running test armed — serialise them instead, the way + /// the destructor and shadow-stack tests serialise their own shared state. + static MAX_HEAP_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// incminimark.py:2601-2615 `PYPY_GC_MAX` out-of-memory policy: a bounded /// major collection over `max_heap_size` signals OOM the first time /// (`oom_pending` so the triggering allocation returns NULL) and aborts on @@ -9725,6 +9807,7 @@ mod tests { /// threshold bounded, so the decision fires on an otherwise empty heap. #[test] fn bounded_max_heap_size_signals_oom_then_aborts() { + let _guard = MAX_HEAP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let mut gc = test_gc(4096); // PYPY_GC_MAX = 1 byte (below min_heap_size), so set_major_threshold_from // caps at 1 and reports `bounded`. @@ -9732,6 +9815,9 @@ mod tests { // The allocation that triggered the collection is larger than the // remaining headroom (1 - total_memory_used), so threshold_reached holds. gc.pending_reserving_size = 4096; + // The dispatch-loop channel is process-global; start from a known state + // so the assertion below reads this breach and not an earlier one. + majit_ir::eval_breaker_word::take_memory_error(); // First bounded breach: flag + signal, no abort. gc.gc_state = GcState::Sweeping; @@ -9741,11 +9827,20 @@ mod tests { "first bounded breach records max_heap_size_already_raised" ); assert!( - gc.oom_pending, + std::mem::take(&mut gc.oom_pending), "first bounded breach asks the allocation to fail (NULL)" ); + // The allocation is waiting and will take `oom_pending` the moment this + // collection returns, so the deferred channel must stay clear. Arming + // both would leave one latched onto an allocation that breached nothing. + assert!( + !majit_ir::eval_breaker_word::take_memory_error(), + "an allocation-driven breach owes the dispatch loop nothing" + ); // Second bounded breach aborts (out_of_memory -> fatalerror == panic). + // The take above is what the waiting allocation does the moment the + // collection returns, and it is what makes this a second arrival. gc.pending_reserving_size = 4096; gc.gc_state = GcState::Sweeping; let second = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -9754,10 +9849,166 @@ mod tests { assert!(second.is_err(), "second bounded breach aborts the process"); } + /// A breach that lands while the previous `MemoryError` is still owed is + /// the same arrival, not a second one. + /// + /// Upstream cannot reach this state: it raises where it sets the flag, so + /// the program holds the exception before any further collection can run. + /// Pyre's channels deliver later, and a driver that completes two cycles + /// without returning to a dispatch — `do_collect_full`, i.e. an explicit + /// `gc.collect()` — puts a whole second cycle inside that window. Aborting + /// there would end the process on a heap limit the program was never told + /// about. + #[test] + fn a_breach_while_the_memory_error_is_still_owed_is_not_a_second_arrival() { + let _guard = MAX_HEAP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut gc = test_gc(4096); + let tid = gc.register_type(TypeInfo::simple(4096)); + let obj = gc.alloc_with_type(tid, 4096); + assert!(!obj.is_null()); + gc.max_heap_size = 1.0; + gc.pending_reserving_size = 0; + majit_ir::eval_breaker_word::take_memory_error(); + + gc.gc_state = GcState::Sweeping; + gc.finish_incremental_cycle(); + assert!(gc.max_heap_size_already_raised); + + // Same breach again with the bit still armed: no abort, and the bit is + // left alone for the dispatch loop that has yet to run. + gc.gc_state = GcState::Sweeping; + gc.finish_incremental_cycle(); + assert_eq!(gc.gc_state, GcState::Scanning); + assert!( + majit_ir::eval_breaker_word::take_memory_error(), + "the undelivered MemoryError is still owed after the second breach" + ); + + // Delivered now, so the next breach is a genuine second arrival. + gc.gc_state = GcState::Sweeping; + let third = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + gc.finish_incremental_cycle(); + })); + assert!( + third.is_err(), + "a breach after delivery takes the fatal rung" + ); + // Whatever that breach armed on its way out belongs to no one now. + majit_ir::eval_breaker_word::take_memory_error(); + } + + /// A breach on a thread that is owed nothing is new, however loudly another + /// thread's exception is still pending. + /// + /// The two questions the ladder asks are deliberately different widths. "Is + /// this the same arrival?" is about the breaching thread, because an + /// exception owed to some other thread says nothing about this collection — + /// reading the process-wide summary there would let this thread allocate on + /// past the limit with nothing owed to it. "May the process die now?" is + /// process-wide, because the fatal rung must not fire while any thread is + /// still to be told. + #[test] + fn a_breach_owed_to_another_thread_arms_this_one_rather_than_silencing_it() { + let _guard = MAX_HEAP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut gc = test_gc(4096); + let tid = gc.register_type(TypeInfo::simple(4096)); + let obj = gc.alloc_with_type(tid, 4096); + assert!(!obj.is_null()); + majit_ir::eval_breaker_word::take_memory_error(); + + // Another thread breaches first and has not reached its own dispatch + // loop yet — the window a stop-the-world collection opens, since its + // guard resumes the other mutators before the collecting one returns. + // It stays parked until this thread has breached, and then delivers, + // so nothing is left owed to a thread that no longer exists. + let (armed_tx, armed_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let other = std::thread::spawn(move || { + majit_ir::eval_breaker_word::set_memory_error(); + armed_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + assert!( + majit_ir::eval_breaker_word::take_memory_error(), + "the other thread's own exception is still owed to it" + ); + }); + armed_rx.recv().unwrap(); + // Burn the one grace event on that thread's behalf, so this breach can + // only reach the fatal rung or the arming below. + gc.max_heap_size_already_raised = true; + + gc.max_heap_size = 1.0; + gc.pending_reserving_size = 0; + gc.gc_state = GcState::Sweeping; + gc.finish_incremental_cycle(); + + assert_eq!( + gc.gc_state, + GcState::Scanning, + "the fatal rung must not fire while a thread is still owed one" + ); + assert!( + majit_ir::eval_breaker_word::take_memory_error(), + "this thread breached and had none owed to it, so it is owed one now" + ); + assert!( + majit_ir::eval_breaker_word::memory_error_armed(), + "delivering this thread's does not clear the one the other is owed" + ); + + release_tx.send(()).unwrap(); + other.join().unwrap(); + assert!( + !majit_ir::eval_breaker_word::memory_error_armed(), + "the last owner to deliver clears the summary" + ); + } + + /// The other half of the same policy: a collection with no allocation + /// waiting on it. + /// + /// This is the dispatch-loop safepoint's collection — where the interpreter + /// path's majors actually happen — and also `gc.collect()`'s. Upstream + /// raises out of all of them alike; pyre has nothing to fail here, so the + /// breach has to reach the eval-breaker instead or it is silent, and the + /// program's only sign of a full heap becomes the abort on the next one. + #[test] + fn a_breach_with_no_allocation_waiting_defers_to_the_dispatch_loop() { + let _guard = MAX_HEAP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut gc = test_gc(4096); + // What separates this from the test above: no allocation is waiting, so + // `threshold_reached(0)` decides on the heap as it stands, and the heap + // has to actually hold something for it to decide yes. One object past + // `large_object_threshold` is born in the old generation, which is what + // `get_total_memory_used` counts. + let tid = gc.register_type(TypeInfo::simple(4096)); + let obj = gc.alloc_with_type(tid, 4096); + assert!(!obj.is_null()); + gc.max_heap_size = 1.0; + gc.pending_reserving_size = 0; + majit_ir::eval_breaker_word::take_memory_error(); + + gc.gc_state = GcState::Sweeping; + gc.finish_incremental_cycle(); + assert!( + gc.max_heap_size_already_raised, + "a driverless breach still burns the one grace event" + ); + assert!( + !gc.oom_pending, + "there is no allocation to fail, so nothing may be latched for the next one" + ); + assert!( + majit_ir::eval_breaker_word::take_memory_error(), + "a driverless breach owes the dispatch loop a MemoryError" + ); + } + /// The default (unbounded, `max_heap_size == 0`) config never sets `bounded`, /// so a completed major collection leaves the OOM signals untouched. #[test] fn unbounded_heap_never_signals_oom() { + let _guard = MAX_HEAP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let mut gc = test_gc(4096); assert_eq!(gc.max_heap_size, 0.0); gc.pending_reserving_size = 4096; diff --git a/majit/majit-gc/src/gc_sync.rs b/majit/majit-gc/src/gc_sync.rs index 118503a97f8..dda8d6c8893 100644 --- a/majit/majit-gc/src/gc_sync.rs +++ b/majit/majit-gc/src/gc_sync.rs @@ -528,6 +528,10 @@ pub fn after_fork_child() { STW_DEPTH.store(0, Ordering::SeqCst); GC_SYNC.stw_requested.store(false, Ordering::Release); majit_ir::eval_breaker_word::clear_stw(); + // Same reason, for the other deferred bit: a `MemoryError` owed to a thread + // that did not survive the fork has no dispatch loop left to raise it in + // the child. + majit_ir::eval_breaker_word::memory_error_after_fork_child(); let mut state = GC_SYNC.quiesce.lock().unwrap(); state.running = usize::from(registered && running); GC_SYNC.stw_generation.fetch_add(1, Ordering::SeqCst); diff --git a/majit/majit-gc/src/oldgen.rs b/majit/majit-gc/src/oldgen.rs index 56f6a5561db..24fad5658a5 100644 --- a/majit/majit-gc/src/oldgen.rs +++ b/majit/majit-gc/src/oldgen.rs @@ -475,11 +475,19 @@ impl Default for OldGen { impl Drop for OldGen { fn drop(&mut self) { - for object in self.old_rawmalloced_objects.drain(..) { - unsafe { alloc::dealloc(object.alloc_start as *mut u8, object.layout) }; - } - for object in self.raw_malloc_might_sweep.drain(..) { - unsafe { alloc::dealloc(object.alloc_start as *mut u8, object.layout) }; + // All three raw-malloc lists, because a block is on exactly one of + // them and teardown can arrive at any point in a cycle: between a + // young rawmalloc birth and the minor that would sweep it, or mid + // sweep with `raw_malloc_might_sweep` still holding the remainder. + let lists = [ + &mut self.old_rawmalloced_objects, + &mut self.raw_malloc_might_sweep, + &mut self.young_rawmalloced_objects, + ]; + for list in lists { + for object in list.drain(..) { + unsafe { alloc::dealloc(object.alloc_start as *mut u8, object.layout) }; + } } } } diff --git a/majit/majit-ir/src/eval_breaker_word.rs b/majit/majit-ir/src/eval_breaker_word.rs index 2d07b16ac4a..7b5d18ad31f 100644 --- a/majit/majit-ir/src/eval_breaker_word.rs +++ b/majit/majit-ir/src/eval_breaker_word.rs @@ -15,6 +15,12 @@ //! bit4 EB_GC — the old-gen allocator reached the next-major threshold; //! OR'd in by the collector, consumed by the interpreter //! dispatch-loop GC safepoint. +//! bit5 EB_MEMORY_ERROR — a bounded major collection exhausted the heap; +//! OR'd in by the collector where upstream raises, consumed +//! by the dispatch loop, which raises `MemoryError` there. +//! Published for every thread because it is a deopt trigger, +//! but delivered only to the thread whose collection +//! exhausted the heap; see `set_memory_error`. //! A compiled loop loads the whole word at the back-edge and deopts to the //! interpreter when it is non-zero. The interpreter/warm-up loop and the STW //! park gate remain authoritative; this word is only the JIT's deopt trigger. @@ -28,6 +34,7 @@ //! can briefly deopt before the request is visible, then resume and re-deopt //! until coherence propagates it; this is a bounded park-latency window. +use std::cell::Cell; use std::sync::atomic::{AtomicUsize, Ordering}; /// bit0 — async action / signal pending (mirrors a negative ticker). @@ -41,12 +48,19 @@ pub const EB_GC_INTERP: usize = 8; /// bit4 — the old-gen allocator crossed the next-major threshold and a /// collection is owed at the next root-complete point. pub const EB_GC: usize = 16; +/// bit5 — a bounded major collection reached `max_heap_size` and a +/// `MemoryError` is owed at the next root-complete point. +pub const EB_MEMORY_ERROR: usize = 32; /// Bits that require a compiled loop to deopt to the interpreter. /// /// `EB_GC` belongs here: the allocator only arms the request, and the /// collection itself runs at the dispatch-loop safepoint, so a compiled loop /// has to leave machine code for the request to be serviced at all. -pub const JIT_BREAKER_MASK: usize = EB_ASYNC | EB_STW | EB_FINALIZING | EB_GC; +/// +/// `EB_MEMORY_ERROR` belongs here for the same reason: the exception is raised +/// by the dispatch loop, so a compiled loop that never returns to it would run +/// on past a heap the collector has already declared exhausted. +pub const JIT_BREAKER_MASK: usize = EB_ASYNC | EB_STW | EB_FINALIZING | EB_GC | EB_MEMORY_ERROR; /// The shared eval-breaker word (see module docs). static EVAL_BREAKER_WORD: AtomicUsize = AtomicUsize::new(0); @@ -137,6 +151,154 @@ pub fn take_gc() -> bool { EVAL_BREAKER_WORD.fetch_and(!EB_GC, Ordering::Relaxed) & EB_GC != 0 } +// --- memory error (bit5): armed by the collector, raised by the dispatch loop --- + +thread_local! { + /// Whether *this* thread armed a `MemoryError` its own dispatch loop has + /// not raised yet. + /// + /// The exception belongs to the thread whose collection exhausted the + /// heap, the way upstream's `raise MemoryError` unwinds through whichever + /// driver was on the stack. The bit alone cannot say that: it is process + /// global, and a stop-the-world collection resumes the other mutators + /// before the collecting one returns to its dispatch loop, so without this + /// an unrelated thread could reach a back edge first and take an exception + /// nothing it did earned — leaving the thread that did exhaust the heap + /// running on toward the fatal rung. + static MEMORY_ERROR_OWED: OwedMemoryError = const { OwedMemoryError(Cell::new(false)) }; +} + +/// This thread's half of the debt [`MEMORY_ERROR_OWERS`] counts. +/// +/// A type with a destructor rather than a bare `Cell`, because a thread can +/// exit still owing one: the exception is raised by a dispatch loop, and a +/// thread whose loop has already returned never reaches another. Left on the +/// census that debt is unpayable — the count never falls to zero, so no later +/// owner clears the bit, and `EB_MEMORY_ERROR` is in `JIT_BREAKER_MASK`, so +/// every back edge in the process fails from then on. It also freezes the +/// collector's ladder, which reads [`memory_error_armed`] to tell a breach +/// that repeats an exception the program already has from one that is new. +struct OwedMemoryError(Cell); + +impl Drop for OwedMemoryError { + fn drop(&mut self) { + if self.0.replace(false) { + release_one_owed(); + } + } +} + +/// Take one undelivered exception off the census, clearing the bit if it was +/// the last. +fn release_one_owed() { + if MEMORY_ERROR_OWERS.fetch_sub(1, Ordering::AcqRel) != 1 { + return; + } + EVAL_BREAKER_WORD.fetch_and(!EB_MEMORY_ERROR, Ordering::Relaxed); + // Another thread may have armed one between the decrement and the clear, + // and set a bit this then removed. Re-publish the summary it is owed; + // setting an already-set bit is what the racing arm did anyway, so this is + // idempotent rather than a second signal. + if MEMORY_ERROR_OWERS.load(Ordering::Acquire) != 0 { + EVAL_BREAKER_WORD.fetch_or(EB_MEMORY_ERROR, Ordering::Relaxed); + } +} + +/// Rebuild the census around the one thread `fork()` leaves running. +/// +/// `rthread.thread_after_fork()` parity: the child has only the thread that +/// called `fork()`, and the others' debts have no dispatch loop left to pay +/// them there. A vanished thread runs no destructor, so [`OwedMemoryError`] +/// cannot clear them either, and keeping the count would arm the bit in the +/// child for good. +pub fn memory_error_after_fork_child() { + let owed_here = MEMORY_ERROR_OWED.with(|owed| owed.0.get()); + MEMORY_ERROR_OWERS.store(usize::from(owed_here), Ordering::SeqCst); + if owed_here { + EVAL_BREAKER_WORD.fetch_or(EB_MEMORY_ERROR, Ordering::Relaxed); + } else { + EVAL_BREAKER_WORD.fetch_and(!EB_MEMORY_ERROR, Ordering::Relaxed); + } +} + +/// How many threads owe an undelivered `MemoryError`. +/// +/// The bit is the summary a back edge polls, and only the last owner to +/// deliver may clear it. Counting is what tells that owner apart from the +/// others. +static MEMORY_ERROR_OWERS: AtomicUsize = AtomicUsize::new(0); + +/// Record that a bounded major collection reached `max_heap_size`. +/// +/// incminimark.py `major_collection_step` reacts to that with a plain `raise +/// MemoryError`, so upstream's exception surfaces wherever the collection was +/// driven from. Most of pyre's collections are driven from the dispatch-loop +/// safepoint, which returns `()` and cannot raise, so the collector arms this +/// bit and the loop raises on the next dispatch instead. The bit rather than a +/// return value, because the safepoint is one of several drivers — an +/// allocation, a finalizer run and an explicit collection request reach the +/// same collection — and only the dispatch loop can turn any of them into an +/// exception. +/// +/// Call this on the thread that drove the collection: the exception is owed to +/// that thread, and the bit only publishes that one is owed to someone. +pub fn set_memory_error() { + MEMORY_ERROR_OWED.with(|owed| { + if owed.0.replace(true) { + // Already owed here and not yet delivered, so the count and the + // bit already stand for this thread. + return; + } + MEMORY_ERROR_OWERS.fetch_add(1, Ordering::AcqRel); + EVAL_BREAKER_WORD.fetch_or(EB_MEMORY_ERROR, Ordering::Relaxed); + }); +} + +/// Whether a `MemoryError` armed by [`set_memory_error`] is still owed, to any +/// thread. +/// +/// [`take_memory_error`] clears the bit as the last owner delivers, so this +/// reads true for exactly the window between arming an exception and every +/// thread that was owed one raising it. The collector reads it to tell a breach +/// that arrives inside that window from one that arrives after the program has +/// had its exception — a question about the program, not about a thread, which +/// is why this reads the process-wide summary. +pub fn memory_error_armed() -> bool { + EVAL_BREAKER_WORD.load(Ordering::Relaxed) & EB_MEMORY_ERROR != 0 +} + +/// Whether *this* thread is the one still owed a `MemoryError`. +/// +/// The collector's max-heap ladder asks it to tell a breach that repeats an +/// exception this thread has already been given from one that is new to it. +/// [`memory_error_armed`] cannot answer that: it reports the process-wide +/// summary, so an exception owed to another thread would read as this thread's +/// own and silence a breach that thread never caused. +pub fn memory_error_owed_here() -> bool { + MEMORY_ERROR_OWED.with(|owed| owed.0.get()) +} + +/// Consume the `MemoryError` owed to *this* thread, reporting whether one was. +/// +/// A thread that is not owed one leaves the bit alone and deopts again at its +/// next back edge, until the owner delivers. That window is bounded the way the +/// STW one is: arming happens inside the owner's own safepoint, so its very +/// next dispatch takes it. +pub fn take_memory_error() -> bool { + // Same shape as `take_gc`: the taker runs per dispatch and the armer only + // when a bounded heap is exhausted, so keep the common case a plain load. + if EVAL_BREAKER_WORD.load(Ordering::Relaxed) & EB_MEMORY_ERROR == 0 { + return false; + } + MEMORY_ERROR_OWED.with(|owed| { + if !owed.0.replace(false) { + return false; + } + release_one_owed(); + true + }) +} + /// Depth of the operation chain between the poll's load and its guard. /// /// The recorder emits `RawLoadI -> IntAnd -> IntIsTrue -> GuardFalse`, so two @@ -206,6 +368,164 @@ mod tests { crate::operand::Operand::from_bound_op(&Rc::new(op)) } + /// Both memory-error tests read the process-global bit and assert on its + /// resting state, so they cannot run at the same time as each other. + static MEMORY_ERROR_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// The owed `MemoryError` is one-shot: armed once by the collector, raised + /// once by the dispatch loop of the thread it is owed to. A second take + /// must see nothing, or one breach would raise two `MemoryError`s. + /// + /// It must also be a bit the back-edge poll tests, since the raise happens + /// in the dispatch loop and a compiled loop has to leave machine code to + /// get there. + #[test] + fn the_owed_memory_error_is_taken_exactly_once() { + let _guard = MEMORY_ERROR_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + assert!( + !take_memory_error(), + "nothing has armed the bit, so it must not report one pending" + ); + set_memory_error(); + assert_ne!( + load() & JIT_BREAKER_MASK, + 0, + "an armed MemoryError must fail the back-edge poll" + ); + assert!(take_memory_error(), "the armed bit is reported once"); + assert!(!take_memory_error(), "and only once"); + assert_eq!( + load() & EB_MEMORY_ERROR, + 0, + "taking it clears it, so later back edges are not deopted" + ); + } + + /// The exception belongs to the thread whose collection exhausted the + /// heap, not to whichever dispatch loop polls first. + /// + /// Upstream raises inside the collection, so the driver on that thread's + /// stack is the one that receives it. Pyre defers through a process-global + /// word, and a stop-the-world collection resumes the other mutators before + /// the collecting one returns to its own dispatch loop — so without + /// ownership the first unrelated thread to reach a back edge would take an + /// exception nothing it did earned, and the thread that did exhaust the + /// heap would run on toward the fatal rung with nothing owed to it. + #[test] + fn the_owed_memory_error_goes_to_the_thread_that_armed_it() { + let _guard = MEMORY_ERROR_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + // The owner stays alive until it has delivered. A thread that exits + // still owing hands the debt back — see the test below — so joining it + // here would clear the very bit this test is watching. + let (armed_tx, armed_rx) = std::sync::mpsc::channel(); + let (deliver_tx, deliver_rx) = std::sync::mpsc::channel(); + let owner = std::thread::spawn(move || { + set_memory_error(); + // The summary is published for everyone, so a compiled loop on any + // thread leaves machine code — the bit is the deopt trigger, not + // the delivery. + assert_ne!(load() & EB_MEMORY_ERROR, 0); + assert!(memory_error_armed()); + armed_tx.send(()).unwrap(); + deliver_rx.recv().unwrap(); + assert!(take_memory_error(), "the owner is the thread that raises"); + }); + armed_rx.recv().unwrap(); + + assert!( + memory_error_armed(), + "the exception is still owed, so the summary stays published" + ); + assert!( + !take_memory_error(), + "this thread armed nothing, so it is owed nothing" + ); + assert_ne!( + load() & EB_MEMORY_ERROR, + 0, + "and a thread that is owed nothing must not clear the owner's bit" + ); + + deliver_tx.send(()).unwrap(); + owner.join().unwrap(); + assert_eq!( + load() & EB_MEMORY_ERROR, + 0, + "the last owner to deliver clears the summary" + ); + } + + /// A thread can exit while still owed one, and the debt has to leave the + /// census with it. The exception is raised by a dispatch loop, so a thread + /// whose loop has already returned never reaches another; left counted, + /// nothing can ever clear the bit again. + #[test] + fn a_thread_that_exits_still_owing_hands_the_debt_back() { + let _guard = MEMORY_ERROR_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + std::thread::spawn(|| { + set_memory_error(); + assert!( + memory_error_armed(), + "the summary is published while it lives" + ); + }) + .join() + .unwrap(); + + assert_eq!( + load() & EB_MEMORY_ERROR, + 0, + "the owner exited without delivering, so nothing is owed any more" + ); + assert!( + !take_memory_error(), + "and no other thread inherits an exception it never earned" + ); + } + + /// `fork()` leaves one thread running, so in the child every other thread's + /// debt is unpayable. The census has to be rebuilt around the survivor. + #[test] + fn the_fork_child_keeps_only_the_surviving_threads_debt() { + let _guard = MEMORY_ERROR_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + // A sibling's debt, modelled by hand: a thread that vanishes at + // `fork()` runs no destructor, so an owner spawned here — which does — + // cannot stand in for one. + MEMORY_ERROR_OWERS.fetch_add(1, Ordering::AcqRel); + EVAL_BREAKER_WORD.fetch_or(EB_MEMORY_ERROR, Ordering::Relaxed); + + memory_error_after_fork_child(); + assert_eq!( + load() & EB_MEMORY_ERROR, + 0, + "the thread that called fork owes nothing, so the child owes nothing" + ); + assert_eq!(MEMORY_ERROR_OWERS.load(Ordering::Acquire), 0); + + // The survivor's own debt is the half that is kept. + set_memory_error(); + MEMORY_ERROR_OWERS.fetch_add(1, Ordering::AcqRel); + memory_error_after_fork_child(); + assert_ne!( + load() & EB_MEMORY_ERROR, + 0, + "an exception owed to the surviving thread is still owed after the fork" + ); + assert!( + take_memory_error(), + "and it is still that thread's to raise" + ); + assert_eq!(load() & EB_MEMORY_ERROR, 0); + } + fn int(value: i64) -> crate::operand::Operand { crate::operand::Operand::const_(Const::Int(value)) } diff --git a/pyre/pyre-interpreter/src/eval.rs b/pyre/pyre-interpreter/src/eval.rs index 2e561472c37..29add666ff5 100644 --- a/pyre/pyre-interpreter/src/eval.rs +++ b/pyre/pyre-interpreter/src/eval.rs @@ -2271,6 +2271,38 @@ fn eval_loop(frame: &mut PyFrame) -> PyResult { if dispatch_breaker & majit_ir::eval_breaker_word::EB_STW != 0 { majit_gc::gc_sync::safepoint_poll(); } + // A bounded major collection that reached `max_heap_size` owes a + // `MemoryError` — incminimark.py `major_collection_step` raises one + // there, and the safepoint above is where the interpreter path's + // collections happen but it returns `()` and cannot raise. Deliver it + // here, at the same seam an asynchronously delivered signal uses below: + // the block search runs at `last_instr`, so a `try` around the region + // that was running catches it rather than the frame unwinding. + // + // Reads the word rather than `dispatch_breaker`: the safepoint above is + // the usual armer and it runs after that load, so testing the loaded + // copy would defer delivery by a dispatch this loop is not guaranteed + // to reach. `take_memory_error` opens with a relaxed load and returns + // on it, so the ordinary dispatch pays that load and a branch against a + // word it just touched. + // + // After the park above, not before it: `handle_exception` runs Python + // and allocates, and both bits can be armed at once — this thread's + // own breach and another thread's STW request. Delivering first would + // run a handler through a world the collector has asked to stop. + if majit_ir::eval_breaker_word::take_memory_error() { + // Park again, on a fresh read: the test above ran against the + // dispatch-time copy of the word, and the collection between them + // is a whole major cycle, so a request that arrived during it is + // not in that copy. Delivery runs a Python handler, which must not + // run through a world the collector has asked to stop. + majit_gc::gc_sync::safepoint_poll(); + let mut err = crate::PyError::memory_error(""); + if handle_exception(frame, &mut err, &mut next_instr) { + continue; + } + return Err(err); + } if next_instr >= code.instructions.len() { return Ok(w_none()); diff --git a/pyre/pyre-jit/src/eval.rs b/pyre/pyre-jit/src/eval.rs index 59da526afee..a9dd20688d0 100644 --- a/pyre/pyre-jit/src/eval.rs +++ b/pyre/pyre-jit/src/eval.rs @@ -9064,6 +9064,43 @@ fn eval_loop_jit(frame: &mut PyFrame) -> LoopResult { // maybe_compile_and_run, jit_merge_point). let f: *mut PyFrame = frame_root.frame() as *mut PyFrame; + // The plain evaluator's twin: a bounded major collection that reached + // `max_heap_size` owes a `MemoryError`, and the safepoint above — which + // is where the interpreter path collects — cannot raise one. + // `EB_MEMORY_ERROR` is in `JIT_BREAKER_MASK`, so a compiled loop leaves + // machine code for this seam instead of running on past an exhausted + // heap, and delivering before `jit_merge_point` below keeps a hot loop + // from re-entering compiled code with the exception still owed. + // + // Reads the word rather than `dispatch_breaker`, and the difference is + // not academic: the safepoint above is the usual armer and it runs + // after that load, so `dispatch_breaker` is one dispatch stale. This + // loop is not guaranteed a next dispatch — measured, a run under + // `--heapsize` reaches this seam with the bit set exactly once — so a + // stale test does not delay the exception, it drops it, and the + // program's next sign of a full heap is the abort on the second breach. + if majit_ir::eval_breaker_word::take_memory_error() { + // Park again, on a fresh read: the test above ran against the + // dispatch-time copy of the word, and the collection between them + // is a whole major cycle, so a request that arrived during it is + // not in that copy. Delivery runs a Python handler, which must not + // run through a world the collector has asked to stop. + majit_gc::gc_sync::safepoint_poll(); + let mut err = pyre_interpreter::PyError::memory_error(""); + let mut next_instr = unsafe { &*f }.next_instr(); + if pyre_interpreter::eval::handle_exception( + unsafe { &mut *f }, + &mut err, + &mut next_instr, + ) { + // handle_exception allocates → re-seed before the write. + let f: *mut PyFrame = frame_root.frame() as *mut PyFrame; + unsafe { &mut *f }.set_last_instr_from_next_instr(next_instr); + continue; + } + return LoopResult::Done(Err(err)); + } + if unsafe { &*f }.next_instr() >= code.instructions.len() { return LoopResult::Done(Ok(w_none())); } diff --git a/pyre/pyrex/tests/heap_limit_memory_error.rs b/pyre/pyrex/tests/heap_limit_memory_error.rs new file mode 100644 index 00000000000..7f26014729d --- /dev/null +++ b/pyre/pyrex/tests/heap_limit_memory_error.rs @@ -0,0 +1,259 @@ +//! A bounded heap surfaces as a `MemoryError` before it aborts. +//! +//! incminimark.py's module docstring states the `PYPY_GC_MAX` policy as a +//! ladder: the GC *"will first collect more often, then raise an RPython +//! MemoryError, and if that is not enough, crash the program with a fatal +//! error"*. The middle rung is the only one a program can act on, and +//! `major_collection_step` is where upstream raises it. +//! +//! Pyre reaches that rung differently, and this test exists because the +//! difference is invisible to any unit test. Upstream's `raise MemoryError` +//! unwinds out of whichever driver ran the collection; pyre's collections are +//! mostly driven from the dispatch-loop safepoint, which returns `()` and +//! cannot raise, so the collector defers the exception through the eval-breaker +//! word and the dispatch loop raises it. Only an end-to-end run exercises that +//! hand-off — a collector unit test can assert the flag and a dispatch-loop +//! unit test can assert the raise, and both can pass while nothing joins them. +//! +//! What is asserted, and what is deliberately not. The middle rung's promise is +//! that the limit *becomes a Python exception*; it is not a promise that the +//! program survives one. After the first breach the live set is by definition +//! at the limit, so anything the program does next can reach the rung below — +//! upstream says as much where it raises, and aborts on the second arrival for +//! exactly that reason. So the exception itself is the observable, and nothing +//! downstream of it is: no `except` clause, no marker the program prints, no +//! particular exit status. +//! +//! That rules out running the program as `-c`, and the reason is worth +//! recording because two CI rounds were spent on it. The exception reaches the +//! interpreter's own traceback printer, which is a handler like any other: +//! `eprint_exception` renders the whole report into a buffer and writes nothing +//! until it is complete. For a `-c` program the filename is ``, so +//! `frame_source_line` falls past its filesystem branch into +//! `read_registered_source_line`, which calls app-level `linecache` — a nested +//! dispatch loop whose first safepoint drives a whole major cycle over a heap +//! the breach left at its limit. The abort unwinds out of the half-built buffer +//! and stderr receives nothing at all. Read from a real file the same program +//! prints its report, because `read_source_line` answers from the filesystem +//! and no Python runs between the raise and the write. Measured on both, while +//! both raise, deliver, and reach the printer identically. +//! +//! An `except MemoryError` in the program is no better, and that is what the +//! second round established: `os._exit` is about as small as a handler gets, +//! and it still lost the race on two of three hosts, because the exception +//! object's own allocation re-arms the collection request and the next +//! safepoint breaches again before the handler's first call returns. +//! +//! `--heapsize` rather than `PYPY_GC_MAX` because it also covers the launcher +//! plumbing (`take_heapsize_option` -> `gc_set_max_heap_size`), which the env +//! var bypasses. +//! +//! Do not invoke Cargo from this test: the parent `cargo test` holds the target +//! directory lock. `CARGO_BIN_EXE_pyre-dynasm` supplies the binary Cargo already +//! built for this integration test. + +// `pyre-dynasm` is `required-features = ["dynasm"]`, so under +// `--no-default-features` the binary does not exist and `CARGO_BIN_EXE_…` is +// unset — which would be a compile error rather than a skip. Compile this file +// to nothing in that configuration instead. +#![cfg(feature = "dynasm")] + +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// The dynasm-backend `pyre` binary cargo built for this test. +const PYRE: &str = env!("CARGO_BIN_EXE_pyre-dynasm"); + +/// 96 MiB — comfortably above what interpreter startup needs, so a breach is +/// the program's doing, and far below what `ROUNDS` objects need. +const HEAP_LIMIT: usize = 96 * 1024 * 1024; + +/// 8 KiB of items per object. +const WORDS: usize = 1024; + +/// Allocations attempted: `ROUNDS * WORDS * 8` = 200 MiB, twice the limit, so +/// the limited arms breach well before the end and the unbounded control still +/// finishes in under a second. Bounded rather than `while True` so an arm that +/// never raises terminates with a diagnosis instead of hanging. +const ROUNDS: usize = 25_000; + +/// 160 KiB of items per object, which puts each one over the collector's +/// `large_object_threshold` of `(16384 + 512) * 8` bytes and so onto a +/// different allocation path from `WORDS` — see the large-object test. +const WORDS_LARGE: usize = 20_000; + +/// `ROUNDS_LARGE * WORDS_LARGE * 8` = 320 MiB, again well past the limit. +const ROUNDS_LARGE: usize = 2_000; + +/// Keeps every object reachable, so the live set really does grow — a dead one +/// would be reclaimed and the limit never reached. +/// +/// Nothing catches the `MemoryError`: the traceback the runtime prints for it +/// is the report, and it is written by the runtime before any further Python +/// runs. See the module docs for why a handler cannot be relied on here. +const GROW: &str = "\ +data = [] +for _ in range(ROUNDS): + data.append([0] * WORDS) +print('completed') +"; + +/// [`run_shape`] at the small shape, which is what every arm but the +/// large-object one wants. +fn run(env: &[(&str, &str)], args: &[&str]) -> Output { + run_shape(env, args, ROUNDS, WORDS) +} + +/// Run [`GROW`] at the given shape and collect the child's output. `env` and +/// `args` select the dispatch loop and the heap limit. +/// +/// The program is written to a file and named by path rather than passed with +/// `-c`, because a `` filename sends the traceback printer through +/// app-level `linecache` — see the module docs. `CARGO_TARGET_TMPDIR` is +/// Cargo's own scratch directory for integration tests, and each call takes a +/// fresh name: the arms run concurrently and several share a shape, so a +/// shared name would let one arm read a file another is still writing. +fn run_shape(env: &[(&str, &str)], args: &[&str], rounds: usize, words: usize) -> Output { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + + let src = GROW + .replace("ROUNDS", &rounds.to_string()) + .replace("WORDS", &words.to_string()); + let serial = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); + path.push(format!("heap_limit_{rounds}_{words}_{serial}.py")); + std::fs::write(&path, &src).expect("write the program under test"); + + let mut cmd = Command::new(PYRE); + cmd.args(args); + cmd.arg(&path); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("spawn pyre-dynasm") +} + +/// A failure message carrying the child's status, its stdout, and the tail of +/// its stderr — an assertion here fails in a subprocess, so the parent has to +/// quote it or the reason is lost. +fn report(what: &str, out: &Output) -> String { + format!( + "{what} exited {:?}\n--- stdout ---\n{}\n--- stderr tail ---\n{}", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + .lines() + .rev() + .take(20) + .collect::>() + .join("\n"), + ) +} + +/// Assert a `--heapsize` arm reached the middle rung: the loop did not finish, +/// and the limit was reported as a Python exception rather than only as the +/// fatal rung below it. +fn assert_caught(what: &str, out: &Output) { + assert!( + !String::from_utf8_lossy(&out.stdout).contains("completed"), + "{}", + report( + &format!("{what}: the limited run was not bounded at all"), + out + ) + ); + // `MemoryError` and not merely `Traceback`: the fatal rung below prints + // `using too much memory, aborting`, and a run that reaches only that one + // has skipped the rung this test is about. The two can both appear — the + // report is written first, and whatever the process does afterwards on a + // heap still at its limit is not this test's business. + assert!( + String::from_utf8_lossy(&out.stderr).contains("MemoryError"), + "{}", + report( + &format!("{what}: the limit never became a Python exception"), + out + ) + ); +} + +/// The control. Without a limit the same program runs to completion, so a +/// `MemoryError` in the limited arms is attributable to the limit and not to +/// the allocation volume, the host's memory, or the script. +#[test] +fn the_same_program_completes_when_the_heap_is_unbounded() { + let out = run(&[], &[]); + assert!(out.status.success(), "{}", report("control", &out)); + assert!( + String::from_utf8_lossy(&out.stdout).contains("completed"), + "{}", + report("control did not finish", &out) + ); +} + +/// The middle rung, on the JIT dispatch loop (the default). +/// +/// Reaching it is the whole point: before the breach was routed to a dispatch +/// loop, this run's only sign of the limit was +/// `out_of_memory("using too much memory, aborting")` on the *second* breach, +/// with the rung that names a Python exception skipped entirely. +#[test] +fn a_bounded_heap_reports_a_memory_error_rather_than_only_aborting() { + let limit = HEAP_LIMIT.to_string(); + let out = run(&[], &["--heapsize", &limit]); + assert_caught("jit loop", &out); +} + +/// Objects past `large_object_threshold` are not exempt from the limit. +/// +/// They take a different allocation path — `alloc_with_type_slow` hands them +/// to `alloc_in_oldgen_clear` and returns, ahead of the point where a nursery +/// allocation would consult `oom_pending` — so "the policy is enforced" does +/// not follow from the two tests above, which only ever allocate under the +/// threshold. It holds for a different reason, and that reason is worth +/// pinning: the breach is detected in the collector, by the safepoint-driven +/// collection that observes the old generation growing, rather than in an +/// allocator's return path. Routing the exception through the eval-breaker +/// word is what makes that detection point able to report at all, so this +/// shape would regress with the small one if the channel were removed — +/// but it would also regress alone if delivery were moved back into the +/// nursery allocator, which is the case the tests above cannot see. +/// +/// Both dispatch loops in one test: the seam they share is already covered +/// per-loop above, and what is new here is only the allocation path. +#[test] +fn objects_over_the_large_object_threshold_are_bounded_too() { + // Its own control, for the reason the small shape has one, plus one it + // does not: this shape asks the host for 320 MiB, and a host that cannot + // supply that would fail the assertions below for a reason that has + // nothing to do with `--heapsize`. + let control = run_shape(&[], &[], ROUNDS_LARGE, WORDS_LARGE); + assert!( + control.status.success() && String::from_utf8_lossy(&control.stdout).contains("completed"), + "{}", + report("large objects, unbounded control did not finish", &control) + ); + + let limit = HEAP_LIMIT.to_string(); + for env in [&[][..], &[("PYRE_JIT", "0")][..]] { + let out = run_shape(env, &["--heapsize", &limit], ROUNDS_LARGE, WORDS_LARGE); + let what = if env.is_empty() { + "large objects, jit loop" + } else { + "large objects, plain loop" + }; + assert_caught(what, &out); + } +} + +/// The same, on the plain evaluator. +/// +/// The two dispatch loops carry the delivery separately, so a fix landing in +/// one and not the other reads as green here unless both are run. `PYRE_JIT=0` +/// selects the plain one. +#[test] +fn the_plain_evaluator_reports_it_too() { + let limit = HEAP_LIMIT.to_string(); + let out = run(&[("PYRE_JIT", "0")], &["--heapsize", &limit]); + assert_caught("plain loop", &out); +}