diff --git a/lib/src/napi_heap_limit.h b/lib/src/napi_heap_limit.h new file mode 100644 index 00000000..7cbd1e30 --- /dev/null +++ b/lib/src/napi_heap_limit.h @@ -0,0 +1,43 @@ +#ifndef NAPI_PRIVATE_HEAP_LIMIT_H_ +#define NAPI_PRIVATE_HEAP_LIMIT_H_ + +#include "js_native_api.h" + +// Provider-private heap accounting hooks used by the WASIX host bridge. +// These are not part of the native module API or the guest import surface. +#ifdef __cplusplus +extern "C" { +#endif + +// Committed bytes of the environment's old generation: every heap space the +// engine's hard heap limit governs (everything but the young generation and +// read-only space). Intended for near-heap-limit callbacks, which must return +// a limit that covers this figure; the engine admits the first large young +// object without consulting the limit and aborts the process after the next +// collection if the limit still does not cover the promoted object. Engines +// that do not expose the figure report zero. +napi_status NAPI_CDECL snapi_private_get_heap_committed_old_generation( + napi_env env, + size_t* result); + +// Runs a full collection if the environment's heap holds committed memory +// beyond its hard limit that no collection has examined yet. The engine admits +// the first large young object without consulting the heap limit, so after a +// script returns the heap may exceed the limit with no near-heap-limit +// callback having run; an idle environment would keep that memory +// unaccounted for indefinitely. Call from the environment's thread after +// running JavaScript. `old_generation_limit` is the engine's current hard +// old-generation limit as the embedder last set or answered it (the figure the +// engine's own post-collection check uses); zero means unknown, in which case +// the engine's reported total heap limit is used instead, which under-triggers +// by the young-generation allowance. `*collected` reports whether a collection +// ran. Engines without the behaviour report `false`. +napi_status NAPI_CDECL snapi_private_collect_garbage_if_over_heap_limit( + napi_env env, + size_t old_generation_limit, + bool* collected); +#ifdef __cplusplus +} +#endif + +#endif // NAPI_PRIVATE_HEAP_LIMIT_H_ diff --git a/quickjs/src/unofficial_napi.cc b/quickjs/src/unofficial_napi.cc index ba1ab2a1..d6bd006d 100644 --- a/quickjs/src/unofficial_napi.cc +++ b/quickjs/src/unofficial_napi.cc @@ -1,4 +1,5 @@ #include "unofficial_napi.h" +#include "napi_heap_limit.h" #include "internal/napi_callsite.h" #include "internal/napi_serdes.h" @@ -474,6 +475,31 @@ extern "C" return napi_ok; } + napi_status NAPI_CDECL snapi_private_get_heap_committed_old_generation( + napi_env env, + size_t *result) + { + if (!napi_util__::check_env(env) || result == nullptr) + return napi_invalid_arg; + // QuickJS has no generational heap; the figure is only consumed by + // near-heap-limit callbacks, which this engine never invokes. + *result = 0; + return napi_ok; + } + + napi_status NAPI_CDECL snapi_private_collect_garbage_if_over_heap_limit( + napi_env env, + size_t old_generation_limit, + bool *collected) + { + if (!napi_util__::check_env(env) || collected == nullptr) + return napi_invalid_arg; + (void)old_generation_limit; + // QuickJS enforces its memory limit at every allocation. + *collected = false; + return napi_ok; + } + napi_status NAPI_CDECL unofficial_napi_get_promise_details(napi_env env, napi_value promise, int32_t *state_out, diff --git a/src/budget.rs b/src/budget.rs index 87fd1953..7d750366 100644 --- a/src/budget.rs +++ b/src/budget.rs @@ -32,7 +32,7 @@ use parking_lot::Mutex; use std::ffi::c_void; use std::sync::{ Arc, - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}, }; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; @@ -59,9 +59,56 @@ const MIB: u64 = 1024 * 1024; pub const DEFAULT_INITIAL_ISOLATE_HEAP: u64 = 64 * MIB; /// Increment the near-heap-limit callback reserves per grow grant. pub const DEFAULT_HEAP_GROW_STEP: u64 = 32 * MIB; -/// One-time V8 heap slack reserved up front so a denied growth callback can -/// terminate and unwind without entering V8s fatal out-of-memory path. +/// One-time V8 heap slack reserved up front (and charged to the budget) so a +/// denied growth callback can terminate and unwind without entering V8's fatal +/// out-of-memory path. pub const DEFAULT_UNWIND_SLACK: u64 = 16 * MIB; +/// Heap headroom, beyond the budget, that a denied growth callback exposes to +/// V8 once per isolate together with [`DEFAULT_UNWIND_SLACK`]. +/// +/// V8 consults the near-heap-limit callback exactly once per last-resort +/// collection and then retries the failed allocation against the hard limit; +/// a retry that still does not fit is `FatalProcessOutOfMemory`, which aborts +/// the whole process after the embedder's OOM callback. The headroom must +/// therefore cover the largest single allocation JavaScript can request: V8 +/// caps every heap object at 1 GiB (`FixedArray`/`FixedDoubleArray` at 128 Mi +/// entries, strings at `String::kMaxLength` two-byte characters), so this +/// holds one such object plus large-object page headers and the small +/// allocations a non-interruptible builtin makes before the termination +/// request is observed. Raising the limit commits no memory by itself; the +/// isolate is already being terminated, so the bytes a context actually takes +/// from this headroom are bounded by what it allocates before termination +/// lands and are released with the isolate. +pub const DEFAULT_HEAP_EMERGENCY_HEADROOM: u64 = 1088 * MIB; + +/// Process-wide count of isolates that received emergency heap headroom. +static HEAP_EMERGENCY_GRANTS: AtomicU64 = AtomicU64::new(0); +/// Process-wide count of near-heap-limit callbacks that found the emergency +/// headroom already spent. Each one means the stopping isolate asked for more +/// heap than the headroom before termination landed; V8 aborts the process on +/// the next failed retry, so a non-zero value is an incident, not a metric. +static HEAP_EMERGENCY_EXHAUSTED: AtomicU64 = AtomicU64::new(0); + +/// Process-wide counters for the emergency heap headroom path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HeapEmergencyStats { + /// Isolates that were stopped and granted emergency headroom. + pub grants: u64, + /// Callbacks that could not raise the limit further (see + /// [`HEAP_EMERGENCY_EXHAUSTED`]); V8 may have aborted the process after + /// any of them, so observing this counter non-zero in a live process is + /// luck. + pub exhausted: u64, +} + +/// Process-wide counters for the emergency heap headroom path, for the +/// embedder's metrics and alerts. +pub fn heap_emergency_stats() -> HeapEmergencyStats { + HeapEmergencyStats { + grants: HEAP_EMERGENCY_GRANTS.load(Ordering::Acquire), + exhausted: HEAP_EMERGENCY_EXHAUSTED.load(Ordering::Acquire), + } +} /// Fixed per-isolate overhead charged to cover young generation, code range, /// and V8's own malloc'd metadata without sampling. pub const DEFAULT_PER_ISOLATE_OVERHEAD: u64 = 8 * MIB; @@ -86,7 +133,9 @@ pub enum Pool { /// V8 per-isolate heap *ceiling* (old + young + code range + per-isolate /// overhead + pre-reserved unwind slack), charged by reservation at env /// creation and raised in grow-steps by the near-heap-limit callback. Charged by ceiling, not - /// live usage, so the guarantee never races V8's GC. + /// live usage, so the guarantee never races V8's GC. A refused grow step + /// stops the isolate and exposes [`DEFAULT_HEAP_EMERGENCY_HEADROOM`] + /// outside the budget (see [`ResourceUsage::v8_heap_emergency`]). V8HeapReserved, /// V8 external memory the guest has explicitly declared via /// `napi_adjust_external_memory` (`NapiEnv::charge_declared_external`). @@ -165,6 +214,11 @@ pub struct ResourceUsage { pub serialized_message: u64, /// Bytes granted to the bridge for per-handle host bookkeeping. pub host_bookkeeping: u64, + /// V8 heap headroom currently exposed *outside* the budget to isolates + /// whose growth was refused and that are being terminated (see + /// [`DEFAULT_HEAP_EMERGENCY_HEADROOM`]). Non-zero means at least one + /// isolate of this budget is stopping after exhausting its heap. + pub v8_heap_emergency: u64, /// Number of live V8 isolates (envs) counted against `max_envs`. pub live_isolates: usize, } @@ -221,6 +275,12 @@ pub struct ResourceBudget { host_transient: AtomicU64, serialized_message: AtomicU64, host_bookkeeping: AtomicU64, + /// Heap headroom exposed outside the budget per stopping isolate + /// ([`DEFAULT_HEAP_EMERGENCY_HEADROOM`] unless the embedder overrides it). + heap_emergency_headroom: AtomicU64, + /// Emergency headroom currently exposed, summed over stopping isolates. + /// Not part of `mem_charged`: it is by definition over the budget. + v8_heap_emergency: AtomicU64, /// Live V8 isolates (envs), counted against `max_envs`. live_isolates: AtomicUsize, } @@ -252,6 +312,10 @@ impl std::fmt::Debug for ResourceBudget { "host_bookkeeping", &self.host_bookkeeping.load(Ordering::Acquire), ) + .field( + "v8_heap_emergency", + &self.v8_heap_emergency.load(Ordering::Acquire), + ) .field("live_isolates", &self.live_isolates.load(Ordering::Acquire)) .finish() } @@ -292,6 +356,8 @@ impl ResourceBudget { host_transient: AtomicU64::new(0), serialized_message: AtomicU64::new(0), host_bookkeeping: AtomicU64::new(0), + heap_emergency_headroom: AtomicU64::new(DEFAULT_HEAP_EMERGENCY_HEADROOM), + v8_heap_emergency: AtomicU64::new(0), live_isolates: AtomicUsize::new(0), }) } @@ -308,6 +374,8 @@ impl ResourceBudget { host_transient: AtomicU64::new(0), serialized_message: AtomicU64::new(0), host_bookkeeping: AtomicU64::new(0), + heap_emergency_headroom: AtomicU64::new(DEFAULT_HEAP_EMERGENCY_HEADROOM), + v8_heap_emergency: AtomicU64::new(0), live_isolates: AtomicUsize::new(0), } } @@ -427,6 +495,7 @@ impl ResourceBudget { host_transient: self.host_transient.load(Ordering::Acquire), serialized_message: self.serialized_message.load(Ordering::Acquire), host_bookkeeping: self.host_bookkeeping.load(Ordering::Acquire), + v8_heap_emergency: self.v8_heap_emergency.load(Ordering::Acquire), live_isolates: self.live_isolates.load(Ordering::Acquire), } } @@ -436,6 +505,36 @@ impl ResourceBudget { self.live_isolates.load(Ordering::Acquire) } + /// Heap headroom exposed to an isolate whose growth this budget refused + /// (see [`DEFAULT_HEAP_EMERGENCY_HEADROOM`]). + pub fn heap_emergency_headroom(&self) -> u64 { + self.heap_emergency_headroom.load(Ordering::Acquire) + } + + /// Override the emergency headroom for isolates created after this call. + /// + /// Anything below [`DEFAULT_HEAP_EMERGENCY_HEADROOM`] reintroduces process + /// aborts for single allocations larger than the configured value; the + /// knob exists so a host that runs few, large isolates can trade that risk + /// against a smaller transient overshoot. + pub fn set_heap_emergency_headroom(&self, bytes: u64) { + self.heap_emergency_headroom.store(bytes, Ordering::Release); + } + + /// Record emergency headroom exposed to a stopping isolate. It is not a + /// charge: the budget is exhausted when this happens. + fn expose_heap_emergency(&self, bytes: u64) { + self.v8_heap_emergency.fetch_add(bytes, Ordering::AcqRel); + HEAP_EMERGENCY_GRANTS.fetch_add(1, Ordering::AcqRel); + } + + /// The isolate that used emergency headroom is gone. + pub(crate) fn release_heap_emergency(&self, bytes: u64) { + if bytes != 0 { + self.v8_heap_emergency.fetch_sub(bytes, Ordering::AcqRel); + } + } + /// Reserve budget for a new V8 env: acquire an isolate slot against /// `max_envs` and charge the (clamped) heap ceiling. On success the caller /// owns both and must release them with [`release_env`] exactly once — on @@ -561,11 +660,71 @@ pub(crate) struct EnvHeapCharge { pub(crate) budget: Arc, pub(crate) env: usize, pub(crate) host_stopped: Arc, - pub(crate) unwind_slack_available: AtomicBool, + /// Heap bytes exposed outside the budget by the first refused grow step + /// (the pre-charged unwind slack plus the emergency headroom); `0` until + /// then. Set once, so a stopping isolate cannot expand its limit + /// repeatedly. + pub(crate) emergency_exposed: AtomicU64, /// Bytes granted beyond the initial ceiling by grow-step grants. pub(crate) granted: AtomicU64, /// Bytes granted to [`Pool::HostBookkeeping`] and not yet returned. pub(crate) bookkeeping_granted: AtomicU64, + /// The isolate's old-generation limit as last answered to V8 + /// (`max_old_generation_size`): the ceiling forwarded at creation, then + /// every value the near-heap-limit callback returned. This is the exact + /// figure V8's post-collection check compares the committed old generation + /// against, so the settle hook needs no V8 query to know the limit. + pub(crate) old_limit: AtomicU64, + /// Set by the near-heap-limit callback, cleared by the settle hook: V8 was + /// near the limit since the last settle, so an off-limit retry allocation + /// may have happened and the next JS return must examine the heap. + pub(crate) settle_dirty: AtomicBool, + /// JS entries since the last settle sweep (see + /// [`EnvHeapCharge::take_settle_turn`]). + pub(crate) settle_entries: AtomicU32, +} + +/// Without a near-limit signal the settle hook polls: a large young object can +/// land above the limit with no callback at all, so every this-many JS entries +/// the heap is examined regardless. The sweep closes V8's linear allocation +/// areas and walks the spaces, so it must not run on every entry. +pub(crate) const HEAP_SETTLE_EVERY: u32 = 16; + +impl EnvHeapCharge { + pub(crate) fn new( + budget: Arc, + env: usize, + host_stopped: Arc, + old_limit: u64, + ) -> Self { + Self { + budget, + env, + host_stopped, + emergency_exposed: AtomicU64::new(0), + granted: AtomicU64::new(0), + bookkeeping_granted: AtomicU64::new(0), + old_limit: AtomicU64::new(old_limit), + settle_dirty: AtomicBool::new(true), + settle_entries: AtomicU32::new(0), + } + } + + /// Whether this JS return should examine the heap for an off-limit + /// overshoot, and the old-generation limit to compare against. Lock-free + /// and V8-free: true on the first entry, whenever the near-heap-limit + /// callback fired since the last sweep, and every + /// [`HEAP_SETTLE_EVERY`] entries otherwise. + pub(crate) fn take_settle_turn(&self) -> Option { + let entries = self.settle_entries.fetch_add(1, Ordering::AcqRel); + let due = self.settle_dirty.swap(false, Ordering::AcqRel) + || entries.is_multiple_of(HEAP_SETTLE_EVERY); + if !due { + return None; + } + self.settle_entries.store(1, Ordering::Release); + Some(self.old_limit.load(Ordering::Acquire)) + } } impl EnvHeapCharge { @@ -635,11 +794,35 @@ pub extern "C" fn napi_host_bookkeeping_uncharge(data: *const c_void, bytes: u64 /// Host-owned near-heap-limit callback for a budgeted V8 isolate. /// /// When V8 approaches a heap ceiling it invokes this on the isolate's JS -/// thread. We charge one [`DEFAULT_HEAP_GROW_STEP`] against the budget and, if -/// granted, raise the limit by that step. If the budget is exhausted, request -/// isolate termination and expose the pre-reserved unwind slack once. A second -/// denial leaves the limit unchanged, so the quota cannot be expanded repeatedly. The budget is atomic, so this is safe -/// to call concurrently with charges on other threads. +/// thread. We charge the bytes the committed old generation already exceeds +/// the limit by (`committed_old_generation - current_limit`, normally zero) +/// plus one [`DEFAULT_HEAP_GROW_STEP`] against the budget and, if granted, +/// raise the limit by that amount. If the budget refuses, request isolate +/// termination and expose, once, the pre-reserved unwind slack plus the +/// budget's emergency headroom ([`DEFAULT_HEAP_EMERGENCY_HEADROOM`], or the +/// overshoot if that is larger). +/// +/// Both rules exist because V8 gives this callback exactly one answer per +/// occasion and aborts the process if the answer is too small: +/// +/// * After every collection, `Heap::CollectGarbage` checks that the committed +/// old generation fits the limit, invokes the callback once if it does not, +/// and calls `FatalProcessOutOfMemory("Reached heap limit")` if it still +/// does not. The old generation can exceed the limit without any prior +/// callback because `NewLargeObjectSpace::AllocateRaw` admits the first +/// large young object regardless of the limit (a `new Array(3e7)` is one +/// 120 MB object) and the next full collection promotes it. Hence the +/// overshoot term. +/// * A failed allocation ends in `AllocateRawWithRetryOrFailSlowPath`, which +/// invokes the callback once before the last-resort collection and aborts +/// with `CALL_AND_RETRY_LAST` if the retry still does not fit. A single +/// allocation can be 1 GiB, so a grow step or the slack alone is not enough +/// for a refusal. Hence the emergency headroom. +/// +/// Termination lands at the next interrupt check; allocations until then come +/// out of the exposed headroom. A second refusal leaves the limit unchanged, +/// so a stopping isolate cannot expand its heap repeatedly. The budget is +/// atomic, so this is safe to call concurrently with charges on other threads. /// /// # Safety /// `data` must be null or a pointer to an [`EnvHeapCharge`] that outlives the @@ -651,27 +834,50 @@ pub extern "C" fn napi_host_near_heap_limit_grant( data: *const c_void, current_limit: usize, _initial_limit: usize, + committed_old_generation: usize, ) -> usize { if data.is_null() { return current_limit; } // SAFETY: see the function's safety contract. let tracker = unsafe { &*(data as *const EnvHeapCharge) }; - let step = DEFAULT_HEAP_GROW_STEP; - match tracker.budget.try_charge(Pool::V8HeapReserved, step) { + // Bytes by which the old generation already exceeds the limit. V8 admits + // the first large object of the young generation without consulting the + // limit and promotes it on the next full collection, so this can be as + // large as one heap object (1 GiB). Right after this callback V8 compares + // the committed old generation against the returned limit and aborts the + // process if it is still exceeded, so every answer below covers it. + let overshoot = committed_old_generation.saturating_sub(current_limit) as u64; + let grant = overshoot.saturating_add(DEFAULT_HEAP_GROW_STEP); + // V8 may now allocate off-limit on its retry; make the next JS return look. + tracker.settle_dirty.store(true, Ordering::Release); + let answer = match tracker.budget.try_charge(Pool::V8HeapReserved, grant) { Ok(()) => { - tracker.granted.fetch_add(step, Ordering::AcqRel); - current_limit.saturating_add(step as usize) + tracker.granted.fetch_add(grant, Ordering::AcqRel); + current_limit.saturating_add(usize::try_from(grant).unwrap_or(usize::MAX)) } Err(_) => { tracker.deny(); - if tracker.unwind_slack_available.swap(false, Ordering::AcqRel) { - current_limit.saturating_add(DEFAULT_UNWIND_SLACK as usize) + let exposed = DEFAULT_UNWIND_SLACK + .saturating_add(tracker.budget.heap_emergency_headroom().max(overshoot)); + if tracker + .emergency_exposed + .compare_exchange(0, exposed, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + tracker.budget.expose_heap_emergency(exposed); + current_limit.saturating_add(usize::try_from(exposed).unwrap_or(usize::MAX)) } else { + // The stopping isolate kept allocating past the headroom + // without reaching an interrupt check. Nothing more can be + // granted safely; V8 aborts the process if its retry fails. + HEAP_EMERGENCY_EXHAUSTED.fetch_add(1, Ordering::AcqRel); current_limit } } - } + }; + tracker.old_limit.store(answer as u64, Ordering::Release); + answer } /// The live charge for one physical wasm-memory allocation. @@ -1509,24 +1715,23 @@ mod tests { .try_charge(Pool::V8HeapReserved, DEFAULT_UNWIND_SLACK) .unwrap(); let host_stopped = Arc::new(AtomicBool::new(false)); - let ptr = Box::into_raw(Box::new(EnvHeapCharge { - budget: Arc::clone(&budget), - env: 0, - host_stopped: Arc::clone(&host_stopped), - unwind_slack_available: AtomicBool::new(true), - granted: AtomicU64::new(0), - bookkeeping_granted: AtomicU64::new(0), - })); + let ptr = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&budget), + 0, + Arc::clone(&host_stopped), + 0, + ))); let data = ptr as *const c_void; let base = 100 * 1024 * 1024usize; + let emergency = (DEFAULT_UNWIND_SLACK + DEFAULT_HEAP_EMERGENCY_HEADROOM) as usize; // Each of the first two grants raises the limit by a step and charges it. assert_eq!( - napi_host_near_heap_limit_grant(data, base, base), + napi_host_near_heap_limit_grant(data, base, base, 0), base + step ); assert_eq!( - napi_host_near_heap_limit_grant(data, base + step, base), + napi_host_near_heap_limit_grant(data, base + step, base, 0), base + 2 * step ); assert_eq!( @@ -1535,23 +1740,26 @@ mod tests { ); // Budget exhaustion requests termination and exposes the already-reserved - // unwind slack exactly once. + // unwind slack plus the emergency headroom exactly once; the headroom + // is recorded outside the budget, not charged to it. assert_eq!( - napi_host_near_heap_limit_grant(data, base + 2 * step, base), - base + 2 * step + DEFAULT_UNWIND_SLACK as usize + napi_host_near_heap_limit_grant(data, base + 2 * step, base, 0), + base + 2 * step + emergency ); assert_eq!( - napi_host_near_heap_limit_grant( - data, - base + 2 * step + DEFAULT_UNWIND_SLACK as usize, - base, - ), - base + 2 * step + DEFAULT_UNWIND_SLACK as usize + napi_host_near_heap_limit_grant(data, base + 2 * step + emergency, base, 0), + base + 2 * step + emergency, + "a second refusal must not expand the limit again" ); + let usage = budget.snapshot(); assert_eq!( - budget.snapshot().v8_heap_reserved, + usage.v8_heap_reserved, DEFAULT_UNWIND_SLACK + 2 * DEFAULT_HEAP_GROW_STEP ); + assert_eq!(usage.v8_heap_emergency, emergency as u64); + assert_eq!(usage.mem_charged, usage.v8_heap_reserved); + assert!(heap_emergency_stats().grants >= 1); + assert!(heap_emergency_stats().exhausted >= 1); assert!(host_stopped.load(Ordering::Acquire)); @@ -1561,13 +1769,67 @@ mod tests { let granted = tracker.granted.load(Ordering::Acquire); assert_eq!(granted, 2 * DEFAULT_HEAP_GROW_STEP); budget.uncharge(Pool::V8HeapReserved, granted + DEFAULT_UNWIND_SLACK); - assert_eq!(budget.snapshot().v8_heap_reserved, 0); + budget.release_heap_emergency(tracker.emergency_exposed.load(Ordering::Acquire)); + let usage = budget.snapshot(); + assert_eq!(usage.v8_heap_reserved, 0); + assert_eq!(usage.v8_heap_emergency, 0); + } + + #[test] + fn emergency_headroom_covers_the_largest_v8_heap_object() { + // V8 caps FixedArray/FixedDoubleArray at 128 Mi entries and strings at + // String::kMaxLength two-byte characters: both are 1 GiB objects. A + // single refused allocation of that size has to fit into what one + // callback exposes, or V8 aborts the process on its retry. + let largest_object = 1024 * MIB; + assert!(DEFAULT_HEAP_EMERGENCY_HEADROOM > largest_object); + let budget = ResourceBudget::with_memory_limit(DEFAULT_UNWIND_SLACK); + budget + .try_charge(Pool::V8HeapReserved, DEFAULT_UNWIND_SLACK) + .unwrap(); + let ptr = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&budget), + 0, + Arc::new(AtomicBool::new(false)), + 0, + ))); + let base = 64 * MIB as usize; + let raised = napi_host_near_heap_limit_grant(ptr as *const c_void, base, base, 0); + assert!(raised >= base + largest_object as usize); + let tracker = unsafe { Box::from_raw(ptr) }; + budget.release_heap_emergency(tracker.emergency_exposed.load(Ordering::Acquire)); + assert_eq!(budget.snapshot().v8_heap_emergency, 0); + } + + #[test] + fn emergency_headroom_is_configurable_per_budget() { + let budget = ResourceBudget::with_memory_limit(DEFAULT_UNWIND_SLACK); + budget.set_heap_emergency_headroom(3 * MIB); + budget + .try_charge(Pool::V8HeapReserved, DEFAULT_UNWIND_SLACK) + .unwrap(); + let ptr = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&budget), + 0, + Arc::new(AtomicBool::new(false)), + 0, + ))); + let base = 64 * MIB as usize; + assert_eq!( + napi_host_near_heap_limit_grant(ptr as *const c_void, base, base, 0), + base + (DEFAULT_UNWIND_SLACK + 3 * MIB) as usize + ); + assert_eq!( + budget.snapshot().v8_heap_emergency, + DEFAULT_UNWIND_SLACK + 3 * MIB + ); + drop(unsafe { Box::from_raw(ptr) }); } #[test] fn near_heap_limit_callback_ignores_null_data() { assert_eq!( - napi_host_near_heap_limit_grant(std::ptr::null(), 42, 7), + napi_host_near_heap_limit_grant(std::ptr::null(), 42, 7, 0), 42, "a null tracker leaves the limit unchanged" ); @@ -1577,14 +1839,12 @@ mod tests { fn bookkeeping_grants_until_budget_exhausted_then_stops_the_env() { let budget = ResourceBudget::with_memory_limit(3 * MIB); let host_stopped = Arc::new(AtomicBool::new(false)); - let ptr = Box::into_raw(Box::new(EnvHeapCharge { - budget: Arc::clone(&budget), - env: 0, - host_stopped: Arc::clone(&host_stopped), - unwind_slack_available: AtomicBool::new(true), - granted: AtomicU64::new(0), - bookkeeping_granted: AtomicU64::new(0), - })); + let ptr = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&budget), + 0, + Arc::clone(&host_stopped), + 0, + ))); let data = ptr as *const c_void; assert_eq!(napi_host_bookkeeping_charge(data, 2 * MIB), 1); @@ -1725,4 +1985,60 @@ mod external_accountant_tests { drop(wrapped); assert_eq!(accountant.memory_charged(), 0); } + + #[test] + fn grant_covers_an_old_generation_already_over_the_limit() { + // V8 admits the first large young object regardless of the limit and + // promotes it on the next full collection; the callback then has one + // answer to cover the committed old generation or the process aborts. + let budget = ResourceBudget::with_memory_limit(DEFAULT_UNWIND_SLACK + 512 * MIB); + budget + .try_charge(Pool::V8HeapReserved, DEFAULT_UNWIND_SLACK) + .unwrap(); + let ptr = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&budget), + 0, + Arc::new(AtomicBool::new(false)), + 0, + ))); + let data = ptr as *const c_void; + let limit = 64 * MIB as usize; + let committed = limit + 120 * MIB as usize; + let raised = napi_host_near_heap_limit_grant(data, limit, limit, committed); + assert!(raised >= committed, "{raised} does not cover {committed}"); + assert_eq!(raised, committed + DEFAULT_HEAP_GROW_STEP as usize); + assert_eq!( + budget.snapshot().v8_heap_reserved, + DEFAULT_UNWIND_SLACK + 120 * MIB + DEFAULT_HEAP_GROW_STEP + ); + assert_eq!(budget.snapshot().v8_heap_emergency, 0); + drop(unsafe { Box::from_raw(ptr) }); + } + + #[test] + fn refusal_covers_an_overshoot_larger_than_the_headroom() { + let budget = ResourceBudget::with_memory_limit(DEFAULT_UNWIND_SLACK); + budget.set_heap_emergency_headroom(MIB); + budget + .try_charge(Pool::V8HeapReserved, DEFAULT_UNWIND_SLACK) + .unwrap(); + let host_stopped = Arc::new(AtomicBool::new(false)); + let ptr = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&budget), + 0, + Arc::clone(&host_stopped), + 0, + ))); + let data = ptr as *const c_void; + let limit = 64 * MIB as usize; + let committed = limit + 300 * MIB as usize; + let raised = napi_host_near_heap_limit_grant(data, limit, limit, committed); + assert!(host_stopped.load(Ordering::Acquire)); + assert_eq!(raised, committed + DEFAULT_UNWIND_SLACK as usize); + assert_eq!( + budget.snapshot().v8_heap_emergency, + 300 * MIB + DEFAULT_UNWIND_SLACK + ); + drop(unsafe { Box::from_raw(ptr) }); + } } diff --git a/src/ctx.rs b/src/ctx.rs index 69e5139b..32dec6b3 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -30,6 +30,11 @@ pub struct NapiLimits { pub total_memory_bytes: Option, pub max_total_external_memory: Option, pub max_total_heap_bytes: Option, + /// V8 heap headroom exposed outside the budget to an isolate whose growth + /// was refused, so that V8 can complete the failed allocation while the + /// isolate is terminated instead of aborting the process. `None` uses + /// [`crate::budget::DEFAULT_HEAP_EMERGENCY_HEADROOM`]. + pub heap_emergency_headroom_bytes: Option, } impl NapiLimits { @@ -190,6 +195,15 @@ impl NapiCtxBuilder { self } + /// Override the V8 heap headroom exposed to an isolate whose growth was + /// refused (see [`NapiLimits::heap_emergency_headroom_bytes`]). Values + /// below [`crate::budget::DEFAULT_HEAP_EMERGENCY_HEADROOM`] reintroduce + /// process aborts for single allocations larger than the override. + pub fn heap_emergency_headroom(mut self, bytes: u64) -> Self { + self.limits.heap_emergency_headroom_bytes = Some(bytes); + self + } + /// Delegate total-memory admission to the embedder. pub fn memory_accountant(mut self, accountant: Arc) -> Self { self.accountant = Some(accountant); @@ -227,6 +241,9 @@ impl NapiCtxBuilder { None => ResourceBudget::unlimited(), }, }; + if let Some(bytes) = self.limits.heap_emergency_headroom_bytes { + budget.set_heap_emergency_headroom(bytes); + } let envs = Arc::new(Mutex::new(HashSet::new())); let host_stopped = Arc::new(AtomicBool::new(false)); Arc::new(NapiProviderBindings { diff --git a/src/env.rs b/src/env.rs index e7dc0db2..15402586 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::ffi::c_void; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; #[cfg(all(target_arch = "wasm32", feature = "js"))] use wasmer::TypedFunction; @@ -107,6 +107,12 @@ pub(crate) struct NapiEnv { /// Heap charge per live V8 env, keyed by guest env id, so teardown releases /// exactly what creation charged plus what the callback later granted. env_heap_charges: HashMap, + /// Single-entry cache for [`NapiEnv::heap_tracker_for`]: the last native + /// env asked about and its tracker pointer (`0` for an unlimited budget). + /// JS entries overwhelmingly come from one env in a row, and the hook runs + /// on every entry, so two hash lookups per entry are avoided. Invalidated + /// when an env is unregistered. + heap_settle_cache: (usize, usize), /// External memory the guest has declared via `napi_adjust_external_memory`, /// charged to [`Pool::V8External`]. Tracked so a negative adjustment can /// only release what this env actually declared, never more, and so @@ -198,6 +204,7 @@ impl NapiEnv { #[cfg(not(all(target_arch = "wasm32", feature = "js")))] managed_lane: None, env_heap_charges: HashMap::new(), + heap_settle_cache: (0, 0), external_declared: 0, callback_depth: 0, memory: None, @@ -339,14 +346,12 @@ impl NapiEnv { } let tracker = if reservation.clamped { - let boxed = Box::into_raw(Box::new(EnvHeapCharge { - budget: Arc::clone(&self.budget), - env: env as usize, - host_stopped: Arc::clone(&self.host_stopped), - unwind_slack_available: std::sync::atomic::AtomicBool::new(true), - granted: AtomicU64::new(0), - bookkeeping_granted: AtomicU64::new(0), - })); + let boxed = Box::into_raw(Box::new(EnvHeapCharge::new( + Arc::clone(&self.budget), + env as usize, + Arc::clone(&self.host_stopped), + u64::from(reservation.max_old), + ))); // SAFETY: `env` is the isolate just created; `boxed` outlives the // bridge's hooks (freed only at this env's teardown, below). unsafe { @@ -367,6 +372,30 @@ impl NapiEnv { Some((env_id, scope_id)) } + /// The heap budget tracker of a live budgeted env, or `None` for an env + /// under an unlimited budget (which has no heap limit to settle). + pub(crate) fn heap_tracker_for(&mut self, env: SnapiEnv) -> Option<&EnvHeapCharge> { + let key = env as usize; + let tracker = if self.heap_settle_cache.0 == key && key != 0 { + self.heap_settle_cache.1 + } else { + let tracker = self + .napi_state_to_guest_env + .get(&key) + .and_then(|env_id| self.env_heap_charges.get(env_id)) + .map_or(0, |handle| handle.tracker); + self.heap_settle_cache = (key, tracker); + tracker + }; + if tracker == 0 { + return None; + } + // SAFETY: the box lives until `finish_unregister_napi_env` removes the + // handle (and clears this cache), which only happens while no JS runs + // on this env. + Some(unsafe { &*(tracker as *const EnvHeapCharge) }) + } + /// Release a reservation whose env creation failed after [`reserve_isolate`]. /// /// [`reserve_isolate`]: NapiEnv::reserve_isolate @@ -551,6 +580,8 @@ impl NapiEnv { Pool::HostBookkeeping, boxed.bookkeeping_granted.load(Ordering::Acquire), ); + self.budget + .release_heap_emergency(boxed.emergency_exposed.load(Ordering::Acquire)); } self.budget.release_env(handle.ceiling); } @@ -561,6 +592,9 @@ impl NapiEnv { self.persistent_callback_contexts.remove(&env_id); } self.napi_state_to_guest_env.remove(&(env as usize)); + if self.heap_settle_cache.0 == env as usize { + self.heap_settle_cache = (0, 0); + } } #[cfg(all(target_arch = "wasm32", feature = "js"))] diff --git a/src/guest/napi.rs b/src/guest/napi.rs index 48edd406..23867f1d 100644 --- a/src/guest/napi.rs +++ b/src/guest/napi.rs @@ -221,6 +221,21 @@ fn with_cb_context( ) -> Result { let snapi_env = env.data().resolve_napi_env(guest_env); let r = with_callback_state(env, snapi_env, f); + if !snapi_env.is_null() { + // JavaScript may have left a large object above the heap limit that no + // collection has examined; settle it so the heap budget sees it before + // the guest continues (see `budget.rs`). The sweep is not free, so the + // tracker decides lock-free whether this entry is due. + let due = env + .data_mut() + .heap_tracker_for(snapi_env) + .and_then(|tracker| tracker.take_settle_turn()); + if let Some(old_limit) = due { + unsafe { + snapi_bridge_unofficial_settle_heap_overshoot(snapi_env, old_limit); + } + } + } if let Some(code) = take_pending_guest_exit() { return Err(WasiError::Exit(code)); } diff --git a/src/napi_bridge_init.cc b/src/napi_bridge_init.cc index 02ab7325..4cf5eb90 100644 --- a/src/napi_bridge_init.cc +++ b/src/napi_bridge_init.cc @@ -27,6 +27,7 @@ #include "edge_v8_platform.h" #include "internal/napi_external_transfer_observer.h" #include "internal/napi_v8_env.h" +#include "napi_heap_limit.h" #include "node_api.h" #include "unofficial_napi.h" @@ -3961,18 +3962,27 @@ extern "C" int snapi_bridge_unofficial_create_env_with_options( return napi_ok; } -// Rust-exported grant callback (see budget.rs). Charges a grow-step against -// the resource budget and returns the raised (or unchanged) heap limit. +// Rust-exported grant callback (see budget.rs). Charges the heap growth +// against the resource budget and returns the raised (or unchanged) heap +// limit. `committed_old_generation` is what V8 will compare against the +// returned limit right after the callback (zero if unknown). extern "C" size_t napi_host_near_heap_limit_grant(const void *data, size_t current_limit, - size_t initial_limit); + size_t initial_limit, + size_t committed_old_generation); namespace { // V8-shaped trampoline forwarding to the Rust grant callback. The budget // tracker rides in `data`. -size_t HostNearHeapLimitTrampoline(napi_env /*env*/, void *data, +size_t HostNearHeapLimitTrampoline(napi_env env, void *data, size_t current_limit, size_t initial_limit) { - return napi_host_near_heap_limit_grant(data, current_limit, initial_limit); + size_t committed = 0; + if (snapi_private_get_heap_committed_old_generation(env, &committed) != + napi_ok) { + committed = 0; + } + return napi_host_near_heap_limit_grant(data, current_limit, initial_limit, + committed); } } // namespace @@ -4273,6 +4283,37 @@ extern "C" int snapi_bridge_unofficial_attach_env( return unofficial_napi_attach_env(env, &hooks, accepted_hooks_out); } +// After JavaScript ran on this thread: collect if the heap holds memory +// beyond its limit that no collection has examined (the engine admits the +// first large young object without a limit check). The collection routes the +// figure through the near-heap-limit callback, which charges or stops the +// env. Returns nonzero when a collection ran. +extern "C" int snapi_bridge_unofficial_settle_heap_overshoot( + SnapiEnvState *env_state, uint64_t old_generation_limit) { + auto state = LookupEnvState(env_state); + if (state == nullptr) + return 0; + napi_env env; + { + std::lock_guard lock(state->control_mutex); + if (state->disposing || state->env == nullptr) + return 0; + ++state->active_control_calls; + env = state->env; + } + bool collected = false; + const napi_status status = + snapi_private_collect_garbage_if_over_heap_limit( + env, static_cast(old_generation_limit), &collected); + { + std::lock_guard lock(state->control_mutex); + --state->active_control_calls; + if (state->active_control_calls == 0) + state->control_cv.notify_all(); + } + return status == napi_ok && collected ? 1 : 0; +} + extern "C" int snapi_bridge_unofficial_terminate_execution(SnapiEnvState *env_state) { // Termination must be able to run while the guest holds the environment diff --git a/src/snapi.rs b/src/snapi.rs index 05665efb..b9f84849 100644 --- a/src/snapi.rs +++ b/src/snapi.rs @@ -178,6 +178,13 @@ unsafe extern "C" { oom_callback_id: u32, accepted_hooks_out: *mut u64, ) -> i32; + /// After JavaScript ran on this thread: run a collection if the heap holds + /// memory beyond its limit that no collection has examined yet, so the + /// near-heap-limit callback sees it. Nonzero when a collection ran. + pub fn snapi_bridge_unofficial_settle_heap_overshoot( + env: SnapiEnv, + old_generation_limit: u64, + ) -> i32; pub fn snapi_bridge_unofficial_terminate_execution(env: SnapiEnv) -> i32; pub fn snapi_bridge_unofficial_enqueue_microtask(env: SnapiEnv, callback_id: u32) -> i32; pub fn snapi_bridge_unofficial_set_promise_reject_callback( diff --git a/tests/v8_heap_limit.rs b/tests/v8_heap_limit.rs new file mode 100644 index 00000000..bed5defd --- /dev/null +++ b/tests/v8_heap_limit.rs @@ -0,0 +1,441 @@ +//! V8 heap exhaustion must stop the offending context, never the process. +//! +//! V8 gives the near-heap-limit callback one answer per occasion and aborts +//! the process (`FatalProcessOutOfMemory`) if that answer leaves the committed +//! old generation or the retried allocation over the hard limit. Two V8 +//! behaviours make a naive grow-step policy fatal in a multi-tenant host: the +//! first large young object is admitted without consulting the limit and +//! promoted by the next full collection, and a single allocation can be a +//! 1 GiB object. These tests drive such allocations through the guest imports +//! and assert that the context ends terminated while the process survives. + +use std::{ + sync::{Arc, Barrier}, + thread, +}; + +use wasmer::{AsStoreMut, Instance, Module, Store}; +use wasmer_napi::NapiCtx; +use wasmer_napi::budget::{DEFAULT_HEAP_EMERGENCY_HEADROOM, ResourceUsage, heap_emergency_stats}; + +const MIB: u64 = 1024 * 1024; + +/// `napi_pending_exception`: what a terminated isolate reports from +/// `napi_run_script`. +const NAPI_PENDING_EXCEPTION: i32 = 10; + +/// Leaves a default isolate about one grow step before the budget refuses. +const BUDGET: u64 = 160 * MIB; + +/// Escapes `js` for a WAT string literal. +fn wat_string(js: &str) -> String { + let mut out = String::with_capacity(js.len()); + for ch in js.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + c if c.is_ascii_graphic() || c == ' ' => out.push(c), + c => { + let mut buf = [0u8; 4]; + for byte in c.encode_utf8(&mut buf).bytes() { + out.push_str(&format!("\\{byte:02x}")); + } + } + } + } + out +} + +/// Statuses of the two `napi_run_script` calls one guest makes in one env. +#[derive(Debug, PartialEq, Eq)] +struct Statuses { + /// The script under test. + script: i32, + /// A trivial script run right after it in the same env. A termination + /// requested while the first script sat in a native builtin is only + /// observed at the next JavaScript entry, so this is where a stopped + /// context shows. + after: i32, + /// The budget while the env was still alive. + in_flight: ResourceUsage, +} + +impl Statuses { + fn completed(&self) -> bool { + self.script == 0 && self.after == 0 + } + + /// The context was terminated: either the script under test unwound on the + /// near-heap-limit refusal (`script` reports the pending exception), or the + /// refusal landed on the post-script settle collection and the probe script + /// sees it (`after`). + fn terminated(&self) -> bool { + self.script == NAPI_PENDING_EXCEPTION || self.after == NAPI_PENDING_EXCEPTION + } +} + +/// Creates an env in `ctx`, runs `js` through `napi_run_script`, then runs +/// `1+1` in the same env. +fn run_script(ctx: &NapiCtx, js: &str) -> Statuses { + assert!(!js.contains('\0')); + let wat = format!( + r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 256) "1+1\00") + (data (i32.const 512) "{}\00") + (func $run_at (param $source i32) (result i32) + (if (call $string (i32.load (i32.const 4)) (local.get $source) (i32.const -1) (i32.const 20)) + (then (return (i32.const -2)))) + (call $script (i32.load (i32.const 4)) (i32.load (i32.const 20)) (i32.const 24))) + (func (export "run") (result i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const -1)))) + (call $run_at (i32.const 512))) + (func (export "after") (result i32) + (call $run_at (i32.const 256))))"#, + wat_string(js) + ); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let call = |name: &str, store: &mut Store| { + instance + .exports + .get_typed_function::<(), i32>(store, name) + .unwrap() + .call(store) + .unwrap() + }; + let script = call("run", &mut store); + let after = call("after", &mut store); + // Before the store (and with it the env) is dropped. + let in_flight = ctx.budget().snapshot(); + Statuses { + script, + after, + in_flight, + } +} + +/// Scripts that retain one or more heap allocations far larger than the +/// budget. Each used to abort the process; each must now terminate its own +/// context instead, having used (and released) emergency headroom. Every one +/// builds objects that stay reachable through the env's global so a collection +/// cannot reclaim them, which is what forces the near-heap-limit refusal. +const RETAINING_BOMBS: &[(&str, &str)] = &[ + ("array_fill", "var a=new Array(30000000).fill(1.5);"), + ( + "array_from_length", + "var a=Array.from({length:40000000},function(_,i){return i;});", + ), + ( + "json_parse", + "var a=JSON.parse('['+'1,'.repeat(30000000)+'1]');", + ), + ( + "map_growth", + "var m=globalThis.m=new Map();for(var i=0;i<100000000;i++){m.set(i,i);}", + ), + ( + "object_graph", + "var r=globalThis.r=[];for(var i=0;i<100000000;i++){r.push({a:i,b:[i]});}", + ), + ( + "nested_arrays", + "function f(n){var o=[];for(var i=0;i<96;i++){o.push(n>0?f(n-1):i);}return o;}globalThis.t=f(5);", + ), +]; + +/// Scripts that request an enormous allocation but do not necessarily retain a +/// large object (V8 may throw, reclaim, or never materialize it). The only +/// guarantee for these is the one that matters: the process stays alive and +/// the result is deterministic (completed or a terminated context), never an +/// abort. +const TRANSIENT_BOMBS: &[(&str, &str)] = &[ + ("string_repeat", "var s=globalThis.s='x'.repeat(500000000);"), + ( + "two_byte_string_repeat", + "var s=globalThis.s='\\u0100'.repeat(400000000);", + ), + ( + "array_fill_transient", + "for(var i=0;i<8;i++){new Array(30000000).fill(1.5);}", + ), +]; + +/// Off-heap backing stores are refused softly by the allocator today; they +/// must stay that way (a `RangeError`, no termination, no abort). +const BACKING_STORE_BOMBS: &[(&str, &str)] = &[ + ( + "typed_array", + "try{new Float64Array(400000000);}catch(e){if(!(e instanceof RangeError))throw e;}", + ), + ( + "array_buffer_2gib", + "try{new ArrayBuffer(2147483648);}catch(e){if(!(e instanceof RangeError))throw e;}", + ), +]; + +#[test] +fn retained_heap_bombs_terminate_only_their_context() { + for (name, js) in RETAINING_BOMBS { + let grants_before = heap_emergency_stats().grants; + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + let statuses = run_script(&ctx, js); + // The retained allocation cannot fit the budget, so the near-heap-limit + // refusal terminated the context, either while the script ran or on the + // settle collection right after it. + assert!( + statuses.terminated(), + "{name}: expected the context to be terminated, got {statuses:?}" + ); + let usage = statuses.in_flight; + assert!( + usage.v8_heap_emergency > 0, + "{name}: the emergency headroom was not used: {usage:?}" + ); + assert!( + usage.v8_heap_emergency <= DEFAULT_HEAP_EMERGENCY_HEADROOM + 2048 * MIB, + "{name}: implausible emergency exposure: {usage:?}" + ); + assert!(heap_emergency_stats().grants > grants_before, "{name}"); + // Env teardown returned the exposure together with the ceiling. + let released = ctx.budget().snapshot(); + assert_eq!(released.v8_heap_emergency, 0, "{name}: {released:?}"); + assert_eq!(released.v8_heap_reserved, 0, "{name}: {released:?}"); + assert_eq!(released.live_isolates, 0, "{name}: {released:?}"); + } +} + +/// The weaker but universal guarantee for allocations that may or may not +/// retain a large object: the process survives and the outcome is one of the +/// two safe ones, never an abort. Any emergency headroom used is bounded and +/// released, and any context that used it is terminated. +#[test] +fn transient_heap_bombs_never_abort_the_process() { + for (name, js) in TRANSIENT_BOMBS { + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + let statuses = run_script(&ctx, js); + assert!( + statuses.completed() || statuses.terminated(), + "{name}: unexpected status {statuses:?}" + ); + if statuses.in_flight.v8_heap_emergency > 0 { + assert!( + statuses.terminated(), + "{name}: used emergency headroom but was not terminated: {statuses:?}" + ); + } + assert!( + statuses.in_flight.v8_heap_emergency <= DEFAULT_HEAP_EMERGENCY_HEADROOM + 2048 * MIB, + "{name}: implausible emergency exposure: {statuses:?}" + ); + let released = ctx.budget().snapshot(); + assert_eq!(released.v8_heap_emergency, 0, "{name}: {released:?}"); + assert_eq!(released.live_isolates, 0, "{name}: {released:?}"); + } +} + +#[test] +fn backing_store_bombs_are_refused_softly() { + for (name, js) in BACKING_STORE_BOMBS { + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + let statuses = run_script(&ctx, js); + assert!( + statuses.completed(), + "{name}: expected a soft RangeError, got {statuses:?}" + ); + assert_eq!(statuses.in_flight.v8_heap_emergency, 0, "{name}"); + } +} + +/// A benign tenant keeps running while another tenant's context is stopped +/// for blowing its heap. +#[test] +fn a_heap_bomb_does_not_disturb_a_benign_context() { + let start = Arc::new(Barrier::new(2)); + let bomb = { + let start = Arc::clone(&start); + thread::spawn(move || { + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + start.wait(); + run_script(&ctx, "var a=new Array(30000000).fill(1.5);") + }) + }; + let benign = { + let start = Arc::clone(&start); + thread::spawn(move || { + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + start.wait(); + let mut statuses = Vec::new(); + for _ in 0..20 { + statuses.push(run_script( + &ctx, + "var a=[];for(var i=0;i<1000;i++){a.push({i:i});}if(a.length!==1000)throw new Error('bad');", + )); + } + statuses + }) + }; + let bomb = bomb.join().unwrap(); + assert_eq!(bomb.after, NAPI_PENDING_EXCEPTION, "{bomb:?}"); + assert!(bomb.in_flight.v8_heap_emergency > 0, "{bomb:?}"); + let benign = benign.join().unwrap(); + assert!( + benign.iter().all(Statuses::completed), + "the benign context was disturbed: {benign:?}" + ); + assert!( + benign + .iter() + .all(|statuses| statuses.in_flight.v8_heap_emergency == 0), + "the benign context's budget saw the other tenant's emergency: {benign:?}" + ); +} + +/// Runs `js` in a *second* env of `ctx` (a worker isolate: own heap limit and +/// own emergency headroom, shared budget) while a first env stays idle, then +/// probes both envs with `1+1`. Returns the worker's statuses and the main +/// env's probe status. +fn run_script_in_worker(ctx: &NapiCtx, js: &str) -> (Statuses, i32) { + assert!(!js.contains('\0')); + let wat = format!( + r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 256) "1+1\00") + (data (i32.const 512) "{}\00") + (func $run_at (param $env i32) (param $source i32) (result i32) + (if (call $string (local.get $env) (local.get $source) (i32.const -1) (i32.const 20)) + (then (return (i32.const -2)))) + (call $script (local.get $env) (i32.load (i32.const 20)) (i32.const 24))) + (func (export "run") (result i32) + ;; main env -> mem[4]; worker env -> mem[40] + (if (call $create (i32.const 0) (i32.const 0) (i32.const 4) (i32.const 12)) + (then (return (i32.const -1)))) + (if (call $create (i32.const 0) (i32.const 0) (i32.const 40) (i32.const 44)) + (then (return (i32.const -3)))) + (call $run_at (i32.load (i32.const 40)) (i32.const 512))) + (func (export "after") (result i32) + (call $run_at (i32.load (i32.const 40)) (i32.const 256))) + (func (export "after_main") (result i32) + (call $run_at (i32.load (i32.const 4)) (i32.const 256))))"#, + wat_string(js) + ); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let call = |name: &str, store: &mut Store| { + instance + .exports + .get_typed_function::<(), i32>(store, name) + .unwrap() + .call(store) + .unwrap() + }; + let script = call("run", &mut store); + let after = call("after", &mut store); + let after_main = call("after_main", &mut store); + let in_flight = ctx.budget().snapshot(); + ( + Statuses { + script, + after, + in_flight, + }, + after_main, + ) +} + +/// Two isolates share one budget (a main env and a worker); the bomb runs in +/// the worker. The worker's own near-heap-limit callback refuses, terminates +/// the worker and exposes headroom; the process survives; the shared budget +/// is released with the envs. +#[test] +fn a_heap_bomb_in_a_worker_isolate_terminates_only_that_tenant() { + let grants_before = heap_emergency_stats().grants; + // Room for two default isolates plus the guest heap. + let ctx = NapiCtx::builder().total_memory_bytes(256 * MIB).build(); + let (worker, after_main) = run_script_in_worker(&ctx, "var a=new Array(30000000).fill(1.5);"); + assert!( + worker.terminated(), + "the worker isolate should be terminated, got {worker:?}" + ); + assert_eq!(worker.in_flight.live_isolates, 2, "{worker:?}"); + assert!(worker.in_flight.v8_heap_emergency > 0, "{worker:?}"); + assert!(heap_emergency_stats().grants > grants_before); + // The refusal is sticky for the whole context (the embedder kills the + // instance), so the main env either still answers or reports the stop; + // what it must not do is take the process with it. + assert!( + after_main == 0 || after_main == NAPI_PENDING_EXCEPTION, + "unexpected main env status {after_main}" + ); + let released = ctx.budget().snapshot(); + assert_eq!(released.v8_heap_emergency, 0, "{released:?}"); + assert_eq!(released.live_isolates, 0, "{released:?}"); +} + +/// Node-wide worst case: several tenants hit emergency at the same time. Each +/// is terminated with its own headroom; the process survives; a benign third +/// tenant is undisturbed; every exposure is released. +#[test] +fn simultaneous_heap_bombs_each_terminate_and_a_benign_tenant_is_unaffected() { + let grants_before = heap_emergency_stats().grants; + let start = Arc::new(Barrier::new(3)); + let bombs: Vec<_> = (0..2) + .map(|_| { + let start = Arc::clone(&start); + thread::spawn(move || { + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + start.wait(); + let statuses = run_script(&ctx, "var a=new Array(30000000).fill(1.5);"); + (statuses, ctx.budget().snapshot()) + }) + }) + .collect(); + let benign = { + let start = Arc::clone(&start); + thread::spawn(move || { + let ctx = NapiCtx::builder().total_memory_bytes(BUDGET).build(); + start.wait(); + (0..20) + .map(|_| { + run_script( + &ctx, + "var a=[];for(var i=0;i<1000;i++){a.push({i:i});}if(a.length!==1000)throw new Error('bad');", + ) + }) + .collect::>() + }) + }; + for bomb in bombs { + let (statuses, released) = bomb.join().unwrap(); + assert!(statuses.terminated(), "{statuses:?}"); + assert!(statuses.in_flight.v8_heap_emergency > 0, "{statuses:?}"); + assert_eq!(released.v8_heap_emergency, 0, "{released:?}"); + assert_eq!(released.live_isolates, 0, "{released:?}"); + } + assert!(heap_emergency_stats().grants >= grants_before + 2); + let benign = benign.join().unwrap(); + assert!( + benign.iter().all(Statuses::completed), + "the benign tenant was disturbed: {benign:?}" + ); +} diff --git a/v8/src/unofficial_napi.cc b/v8/src/unofficial_napi.cc index 5bd653a4..7f21e4f1 100644 --- a/v8/src/unofficial_napi.cc +++ b/v8/src/unofficial_napi.cc @@ -1,4 +1,5 @@ #include "unofficial_napi.h" +#include "napi_heap_limit.h" #include #include @@ -1944,6 +1945,80 @@ napi_status NAPI_CDECL unofficial_napi_configure_near_heap_limit_callback( return napi_ok; } +napi_status NAPI_CDECL snapi_private_get_heap_committed_old_generation( + napi_env env, size_t* result) { + if (env == nullptr || env->isolate == nullptr || result == nullptr) { + return napi_invalid_arg; + } + // Heap::OldGenerationCapacity() is what V8 compares against the hard limit + // (Heap::CanExpandOldGeneration): the paged old-generation spaces plus the + // old large-object spaces. It is not exported, so sum the committed memory + // of every space except the young generation and the read-only space; the + // committed figure is an upper bound of the capacity, so a limit raised to + // cover it always satisfies V8. Reading space statistics neither allocates + // nor collects, so this is safe from inside a near-heap-limit callback. + v8::Isolate* isolate = env->isolate; + size_t total = 0; + const size_t spaces = isolate->NumberOfHeapSpaces(); + for (size_t index = 0; index < spaces; ++index) { + v8::HeapSpaceStatistics stats; + if (!isolate->GetHeapSpaceStatistics(&stats, index)) continue; + const char* name = stats.space_name(); + if (name == nullptr || std::strcmp(name, "read_only_space") == 0 || + std::strcmp(name, "new_space") == 0 || + std::strcmp(name, "new_large_object_space") == 0) { + continue; + } + total += stats.space_size(); + } + *result = total; + return napi_ok; +} + +napi_status NAPI_CDECL snapi_private_collect_garbage_if_over_heap_limit( + napi_env env, size_t old_generation_limit, bool* collected) { + if (env == nullptr || env->isolate == nullptr || collected == nullptr) { + return napi_invalid_arg; + } + *collected = false; + v8::Isolate* isolate = env->isolate; + // The old generation can hold committed memory beyond the hard limit that + // no limit check has examined: NewLargeObjectSpace::AllocateRaw admits its + // first large object unconditionally, and a large object allocated on the + // retry-of-a-failed-allocation path takes a page from the OS directly + // (OldLargeObjectSpace::AllocateRaw via ShouldExpandOldGenerationOnSlowAllocation), + // in both cases without consulting the limit. An idle environment would + // keep that memory unaccounted for. If the old generation already exceeds + // the limit, run a collection: Heap::CollectGarbage promotes and frees, + // then compares the (post-GC, live) old generation against the limit and + // invokes the near-heap-limit callback if it is still exceeded, which + // charges the growth or stops the environment. So this terminates only an + // environment that *retains* memory beyond its budget, never one whose + // overshoot was transient garbage. + size_t committed_old = 0; + if (snapi_private_get_heap_committed_old_generation(env, &committed_old) != + napi_ok) { + return napi_ok; + } + size_t limit = old_generation_limit; + if (limit == 0) { + // Unknown: fall back to the reported total limit (MaxReserved = old + // generation + young generation), which is looser than V8's own check by + // the young-generation allowance, so this under-triggers slightly. + v8::HeapStatistics heap; + isolate->GetHeapStatistics(&heap); + limit = heap.heap_size_limit(); + } + if (committed_old <= limit) { + return napi_ok; + } + // The callback invoked during the collection may create handles. + v8::HandleScope handle_scope(isolate); + isolate->LowMemoryNotification(); + *collected = true; + return napi_ok; +} + napi_status NAPI_CDECL unofficial_napi_wrap_existing_value(napi_env env, v8::Local value, napi_value* result) {