diff --git a/Makefile b/Makefile index c2e4d20..d0af1ab 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ NAPI_NATIVE_TEST_OUT_DIR ?= $(abspath $(BUILD_WASIX_NAPI_DIR)/native) NAPI_WASIX_TEST_OUT_DIR ?= $(abspath $(BUILD_WASIX_NAPI_DIR)/wasm32-wasix/release) NAPI_V8_CTEST_ARGS ?= -E 'SandboxGlobalThisAndMarkerAreNotEnumerableForDeepFreeze' NAPI_QUICKJS_CTEST_ARGS ?= -E 'SandboxGlobalThisAndMarkerAreNotEnumerableForDeepFreeze' -NAPI_V8_PREBUILT_VERSION ?= 11.9.2 +NAPI_V8_PREBUILT_VERSION ?= 11.9.7 NAPI_V8_PLATFORM := NAPI_V8_DIST_ROOT ?= NAPI_V8_CARGO_DIST_ROOT ?= diff --git a/src/budget.rs b/src/budget.rs index c0bf0eb..d0a8974 100644 --- a/src/budget.rs +++ b/src/budget.rs @@ -25,7 +25,7 @@ use std::ffi::c_void; use std::sync::{ Arc, - atomic::{AtomicU64, AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }; #[cfg(not(all(target_arch = "wasm32", feature = "js")))] use std::time::Duration; @@ -50,6 +50,9 @@ 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. +pub const DEFAULT_UNWIND_SLACK: u64 = 16 * MIB; /// 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; @@ -81,8 +84,8 @@ pub enum Pool { /// Guest wasm linear memory (wasmer `WasmMmap`). WasmLinear, /// V8 per-isolate heap *ceiling* (old + young + code range + per-isolate - /// overhead), charged by reservation at env creation and raised in - /// grow-steps by the near-heap-limit callback. Charged by ceiling, not + /// 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. V8HeapReserved, /// V8 external memory the guest has explicitly declared via @@ -93,6 +96,17 @@ pub enum Pool { V8External, } +/// Embedder-owned aggregate accounting for byte reservations made by N-API. +/// +/// N-API retains its own per-pool counters; the embedder sees only byte totals, +/// keeping pool policy and future N-API implementation details out of Edge. +pub trait NapiMemoryAccountant: Send + Sync { + fn memory_limit(&self) -> u64; + fn memory_charged(&self) -> u64; + fn try_charge(&self, bytes: u64) -> bool; + fn uncharge(&self, bytes: u64); +} + /// Error returned by [`ResourceBudget::try_charge`] when a charge would push /// total live bytes past the app's memory budget. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -124,7 +138,8 @@ impl std::error::Error for OverBudget {} pub struct ResourceUsage { /// The app's total memory budget (`u64::MAX` if unlimited). pub mem_total: u64, - /// Sum of all currently-charged bytes across every pool. + /// Aggregate bytes currently charged. With an embedder accountant this also + /// includes non-N-API memory sharing the same application budget. pub mem_charged: u64, /// Currently-charged guest wasm linear memory bytes. pub wasm_linear: u64, @@ -174,12 +189,12 @@ pub enum EnvRejected { /// moment they become live, so `actual usage <= mem_charged <= mem_total` /// always holds and enforcement never races a garbage collector. All state is /// atomic, so worker threads (each its own store + isolate) share one budget. -#[derive(Debug)] pub struct ResourceBudget { /// Total byte budget. `UNLIMITED` disables enforcement (tracking only). mem_total: u64, /// Sum of all currently-charged bytes across every pool. mem_charged: AtomicU64, + accountant: Option>, /// Per-pool charge, for observability and reconciliation. wasm_linear: AtomicU64, v8_heap_reserved: AtomicU64, @@ -188,6 +203,22 @@ pub struct ResourceBudget { live_isolates: AtomicUsize, } +impl std::fmt::Debug for ResourceBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceBudget") + .field("mem_total", &self.memory_limit()) + .field("mem_charged", &self.memory_charged()) + .field("wasm_linear", &self.wasm_linear.load(Ordering::Acquire)) + .field( + "v8_heap_reserved", + &self.v8_heap_reserved.load(Ordering::Acquire), + ) + .field("v8_external", &self.v8_external.load(Ordering::Acquire)) + .field("live_isolates", &self.live_isolates.load(Ordering::Acquire)) + .finish() + } +} + impl ResourceBudget { /// A budget that tracks charges but never denies one. pub fn unlimited() -> Arc { @@ -199,10 +230,35 @@ impl ResourceBudget { Arc::new(Self::with_total(bytes)) } + /// Use an embedder-owned total while retaining N-API per-pool policy. + /// Whether an embedder owns wasm linear-memory accounting. + /// + /// The guest heap grows the guest's memory through the store, so an + /// embedder that meters growth through its own tunables has already + /// charged those bytes by the time we see them; charging again would + /// count one allocation twice against the same total. With no accountant + /// nothing else is counting, so the heap charges its own claims. + pub(crate) fn wasm_is_externally_accounted(&self) -> bool { + self.accountant.is_some() + } + + pub fn with_accountant(accountant: Arc) -> Arc { + Arc::new(Self { + mem_total: accountant.memory_limit(), + mem_charged: AtomicU64::new(0), + accountant: Some(accountant), + wasm_linear: AtomicU64::new(0), + v8_heap_reserved: AtomicU64::new(0), + v8_external: AtomicU64::new(0), + live_isolates: AtomicUsize::new(0), + }) + } + fn with_total(mem_total: u64) -> Self { Self { mem_total, mem_charged: AtomicU64::new(0), + accountant: None, wasm_linear: AtomicU64::new(0), v8_heap_reserved: AtomicU64::new(0), v8_external: AtomicU64::new(0), @@ -212,22 +268,27 @@ impl ResourceBudget { /// Whether this budget enforces a limit. pub fn is_unlimited(&self) -> bool { - self.mem_total == UNLIMITED + self.memory_limit() == UNLIMITED } /// The total memory budget (`u64::MAX` if unlimited). pub fn memory_limit(&self) -> u64 { - self.mem_total + self.accountant + .as_ref() + .map_or(self.mem_total, |x| x.memory_limit()) } /// Bytes currently charged across all pools. pub fn memory_charged(&self) -> u64 { - self.mem_charged.load(Ordering::Acquire) + self.accountant.as_ref().map_or_else( + || self.mem_charged.load(Ordering::Acquire), + |x| x.memory_charged(), + ) } /// Bytes still available before the budget is exhausted. pub fn memory_remaining(&self) -> u64 { - self.mem_total.saturating_sub(self.memory_charged()) + self.memory_limit().saturating_sub(self.memory_charged()) } /// Atomically charge `bytes` against `pool`; `Err` if it would exceed the @@ -240,7 +301,17 @@ impl ResourceBudget { return Ok(()); } - if self.mem_total == UNLIMITED { + if let Some(accountant) = &self.accountant { + if !accountant.try_charge(bytes) { + return Err(OverBudget { + pool, + requested: bytes, + charged: accountant.memory_charged(), + total: accountant.memory_limit(), + }); + } + self.mem_charged.fetch_add(bytes, Ordering::AcqRel); + } else if self.mem_total == UNLIMITED { self.mem_charged.fetch_add(bytes, Ordering::AcqRel); } else { // CAS loop so a concurrent charge can never let the sum slip past @@ -281,6 +352,9 @@ impl ResourceBudget { } self.pool_counter(pool).fetch_sub(bytes, Ordering::AcqRel); self.mem_charged.fetch_sub(bytes, Ordering::AcqRel); + if let Some(accountant) = &self.accountant { + accountant.uncharge(bytes); + } } fn pool_counter(&self, pool: Pool) -> &AtomicU64 { @@ -294,8 +368,8 @@ impl ResourceBudget { /// Snapshot the current charges for metrics / billing. pub fn snapshot(&self) -> ResourceUsage { ResourceUsage { - mem_total: self.mem_total, - mem_charged: self.mem_charged.load(Ordering::Acquire), + mem_total: self.memory_limit(), + mem_charged: self.memory_charged(), wasm_linear: self.wasm_linear.load(Ordering::Acquire), v8_heap_reserved: self.v8_heap_reserved.load(Ordering::Acquire), v8_external: self.v8_external.load(Ordering::Acquire), @@ -316,7 +390,7 @@ impl ResourceBudget { if self.is_unlimited() { None } else { - Some((self.mem_total / EST_HOST_BYTES_PER_VALUE).max(1)) + Some((self.memory_limit() / EST_HOST_BYTES_PER_VALUE).max(1)) } } @@ -405,6 +479,7 @@ impl ResourceBudget { let young = u64::from(req.max_young); let code = u64::from(req.code_range); let fixed = DEFAULT_PER_ISOLATE_OVERHEAD + .checked_add(DEFAULT_UNWIND_SLACK)? .checked_add(young)? .checked_add(code)?; @@ -442,6 +517,9 @@ impl ResourceBudget { /// bytes granted beyond the initial ceiling. pub(crate) struct EnvHeapCharge { pub(crate) budget: Arc, + pub(crate) env: usize, + pub(crate) host_stopped: Arc, + pub(crate) unwind_slack_available: AtomicBool, /// Bytes granted beyond the initial ceiling by grow-step grants. pub(crate) granted: AtomicU64, } @@ -450,9 +528,9 @@ pub(crate) struct EnvHeapCharge { /// /// 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 we leave -/// the limit unchanged, at which point V8 takes its own OOM path (graceful, -/// abort-free teardown is a later phase). The budget is atomic, so this is safe +/// 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. /// /// # Safety @@ -477,7 +555,21 @@ pub extern "C" fn napi_host_near_heap_limit_grant( tracker.granted.fetch_add(step, Ordering::AcqRel); current_limit.saturating_add(step as usize) } - Err(_) => current_limit, + Err(_) => { + tracker.host_stopped.store(true, Ordering::Release); + if tracker.env != 0 { + unsafe { + crate::snapi::snapi_bridge_unofficial_terminate_execution( + tracker.env as crate::snapi::SnapiEnv, + ); + } + } + if tracker.unwind_slack_available.swap(false, Ordering::AcqRel) { + current_limit.saturating_add(DEFAULT_UNWIND_SLACK as usize) + } else { + current_limit + } + } } } @@ -942,10 +1034,10 @@ mod tests { .try_reserve_env(RequestedHeap::default(), None) .expect("env fits"); assert!(res.clamped); - // Default old-gen (64 MiB) fits, plus 8 MiB overhead. + // Default old-gen (64 MiB) fits, plus 8 MiB overhead and 16 MiB unwind slack. assert_eq!(u64::from(res.max_old), DEFAULT_INITIAL_ISOLATE_HEAP); - assert_eq!(res.ceiling_bytes, 72 * MIB); - assert_eq!(budget.snapshot().v8_heap_reserved, 72 * MIB); + assert_eq!(res.ceiling_bytes, 88 * MIB); + assert_eq!(budget.snapshot().v8_heap_reserved, 88 * MIB); assert_eq!(budget.live_isolates(), 1); budget.release_env(res.ceiling_bytes); @@ -955,14 +1047,14 @@ mod tests { #[test] fn env_reservation_clamps_old_gen_to_fit() { - // Only 20 MiB: overhead (8) leaves 12 MiB for old-gen, below the 64 MiB - // default, so old-gen is clamped down and the whole budget is charged. - let budget = ResourceBudget::with_memory_limit(20 * MIB); + // Only 40 MiB: overhead (8) and unwind slack (16) leave 16 MiB for + // old-gen, below the 64 MiB default, so it is clamped to fit. + let budget = ResourceBudget::with_memory_limit(40 * MIB); let res = budget .try_reserve_env(RequestedHeap::default(), None) .expect("clamped env fits"); - assert_eq!(u64::from(res.max_old), 12 * MIB); - assert_eq!(res.ceiling_bytes, 20 * MIB); + assert_eq!(u64::from(res.max_old), 16 * MIB); + assert_eq!(res.ceiling_bytes, 40 * MIB); } #[test] @@ -974,16 +1066,16 @@ mod tests { code_range: (2 * MIB) as u32, }; let res = budget.try_reserve_env(req, None).expect("fits"); - // ceiling = overhead(8) + young(4) + code(2) + old(16) = 30 MiB. + // ceiling = overhead(8) + unwind(16) + young(4) + code(2) + old(16). assert_eq!(res.max_young, (4 * MIB) as u32); assert_eq!(res.max_old, (16 * MIB) as u32); assert_eq!(res.code_range, (2 * MIB) as u32); - assert_eq!(res.ceiling_bytes, 30 * MIB); + assert_eq!(res.ceiling_bytes, 46 * MIB); } #[test] fn env_reservation_refused_when_heap_cannot_fit() { - // Below the per-isolate overhead, so no viable heap exists. + // Below overhead plus unwind slack, so no viable heap exists. let budget = ResourceBudget::with_memory_limit(4 * MIB); let err = budget .try_reserve_env(RequestedHeap::default(), None) @@ -1042,10 +1134,19 @@ mod tests { #[test] fn near_heap_limit_callback_grants_until_budget_exhausted() { let step = DEFAULT_HEAP_GROW_STEP as usize; - // Room for exactly two grow-step grants. - let budget = ResourceBudget::with_memory_limit(2 * DEFAULT_HEAP_GROW_STEP); + // The unwind slack is pre-reserved as part of the env ceiling, with room + // for exactly two additional grow-step grants. + let budget = + ResourceBudget::with_memory_limit(DEFAULT_UNWIND_SLACK + 2 * DEFAULT_HEAP_GROW_STEP); + 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 { budget: Arc::clone(&budget), + env: 0, + host_stopped: Arc::clone(&host_stopped), + unwind_slack_available: AtomicBool::new(true), granted: AtomicU64::new(0), })); let data = ptr as *const c_void; @@ -1062,25 +1163,36 @@ mod tests { ); assert_eq!( budget.snapshot().v8_heap_reserved, - 2 * DEFAULT_HEAP_GROW_STEP + DEFAULT_UNWIND_SLACK + 2 * DEFAULT_HEAP_GROW_STEP ); - // Budget exhausted: the limit is left unchanged (V8 then OOMs on its own). + // Budget exhaustion requests termination and exposes the already-reserved + // unwind slack exactly once. assert_eq!( napi_host_near_heap_limit_grant(data, base + 2 * step, base), - base + 2 * step + base + 2 * step + DEFAULT_UNWIND_SLACK as usize + ); + 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 ); assert_eq!( budget.snapshot().v8_heap_reserved, - 2 * DEFAULT_HEAP_GROW_STEP + DEFAULT_UNWIND_SLACK + 2 * DEFAULT_HEAP_GROW_STEP ); + assert!(host_stopped.load(Ordering::Acquire)); + // The tracker recorded exactly what was granted, and releasing it (as // env teardown does) returns the pool to zero. let tracker = unsafe { Box::from_raw(ptr) }; let granted = tracker.granted.load(Ordering::Acquire); assert_eq!(granted, 2 * DEFAULT_HEAP_GROW_STEP); - budget.uncharge(Pool::V8HeapReserved, granted); + budget.uncharge(Pool::V8HeapReserved, granted + DEFAULT_UNWIND_SLACK); assert_eq!(budget.snapshot().v8_heap_reserved, 0); } @@ -1128,3 +1240,76 @@ mod tests { assert_eq!(budget.snapshot().v8_external, 0); } } + +#[cfg(test)] +mod external_accountant_tests { + use super::*; + + struct TestAccountant { + limit: u64, + charged: AtomicU64, + } + + impl TestAccountant { + fn new(limit: u64) -> Arc { + Arc::new(Self { + limit, + charged: AtomicU64::new(0), + }) + } + } + + impl NapiMemoryAccountant for TestAccountant { + fn memory_limit(&self) -> u64 { + self.limit + } + + fn memory_charged(&self) -> u64 { + self.charged.load(Ordering::Acquire) + } + + fn try_charge(&self, bytes: u64) -> bool { + let mut current = self.charged.load(Ordering::Acquire); + loop { + let Some(next) = current + .checked_add(bytes) + .filter(|next| *next <= self.limit) + else { + return false; + }; + match self.charged.compare_exchange_weak( + current, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(observed) => current = observed, + } + } + } + + fn uncharge(&self, bytes: u64) { + self.charged.fetch_sub(bytes, Ordering::AcqRel); + } + } + + #[test] + fn external_accountant_includes_non_napi_charges() { + let accountant = TestAccountant::new(100); + assert!(accountant.try_charge(40)); + + let external: Arc = accountant.clone(); + let budget = ResourceBudget::with_accountant(external); + budget.try_charge(Pool::V8HeapReserved, 60).unwrap(); + + let usage = budget.snapshot(); + assert_eq!(usage.mem_charged, 100); + assert_eq!(usage.v8_heap_reserved, 60); + assert!(budget.try_charge(Pool::V8External, 1).is_err()); + + budget.uncharge(Pool::V8HeapReserved, 60); + assert_eq!(accountant.memory_charged(), 40); + assert_eq!(budget.snapshot().v8_heap_reserved, 0); + } +} diff --git a/src/ctx.rs b/src/ctx.rs index d64c569..8d25023 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,14 +1,17 @@ use anyhow::{Context, Result, bail}; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, +use std::{ + collections::HashSet, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, }; use wasmer::{Extern, ExternType, FunctionEnv, Imports, Instance, Module, StoreMut, Table, Value}; use crate::{ NAPI_EXTENSION_WASMER_MODULE_NAME, NAPI_EXTENSION_WASMER_MODULE_PREFIX, NAPI_MODULE_NAME, NapiEnv, NapiVersion, NapiWasmerExtensionVersion, - budget::ResourceBudget, + budget::{NapiMemoryAccountant, ResourceBudget}, guest::napi::{is_known_napi_import, register_env_imports, register_napi_imports}, }; @@ -32,9 +35,10 @@ impl NapiLimits { } } -#[derive(Debug, Default)] +#[derive(Default)] pub struct NapiCtxBuilder { limits: NapiLimits, + accountant: Option>, } #[derive(Clone, Debug)] @@ -67,6 +71,38 @@ pub struct NapiRuntimeHooks { ctx: NapiCtx, } +/// Opaque control surface for stopping every V8 isolate owned by a context. +#[derive(Clone, Debug)] +pub struct NapiRuntimeControl { + envs: Arc>>, + host_stopped: Arc, +} + +impl NapiRuntimeControl { + /// Permanently stop every currently-live V8 isolate owned by this context. + /// + /// Embedders call this both when the app exceeded its memory budget and + /// when the instance was killed for an unrelated reason; N-API itself + /// draws no distinction, since either way the app's JS must never run + /// again. The stop is sticky: guest code cannot undo it with + /// `napi_cancel_terminate_execution`, and isolates created afterwards + /// stay terminated ([`NapiEnv::commit_isolate`] re-checks the flag under + /// the same registry lock this holds). + /// + /// [`NapiEnv::commit_isolate`]: crate::env::NapiEnv::commit_isolate + pub fn terminate_all(&self) { + self.host_stopped.store(true, Ordering::Release); + let envs = self.envs.lock().expect("poisoned N-API env registry"); + for env in envs.iter().copied() { + unsafe { + crate::snapi::snapi_bridge_unofficial_terminate_execution( + env as crate::snapi::SnapiEnv, + ); + } + } + } +} + #[derive(Debug)] struct NapiCtxInner { limits: NapiLimits, @@ -75,6 +111,8 @@ struct NapiCtxInner { /// tunables (guest wasm linear memory) and, in later phases, the V8 heap, /// external-memory, and CPU paths. budget: Arc, + envs: Arc>>, + host_stopped: Arc, } struct NapiSessionInner { @@ -124,16 +162,27 @@ impl NapiCtxBuilder { self } + /// Delegate total-memory admission to the embedder. + pub fn memory_accountant(mut self, accountant: Arc) -> Self { + self.accountant = Some(accountant); + self + } + pub fn build(self) -> NapiCtx { - let budget = match self.limits.memory_budget_bytes() { - Some(bytes) => ResourceBudget::with_memory_limit(bytes), - None => ResourceBudget::unlimited(), + let budget = match self.accountant { + Some(accountant) => ResourceBudget::with_accountant(accountant), + None => match self.limits.memory_budget_bytes() { + Some(bytes) => ResourceBudget::with_memory_limit(bytes), + None => ResourceBudget::unlimited(), + }, }; NapiCtx { inner: Arc::new(NapiCtxInner { limits: self.limits, active_sessions: AtomicUsize::new(0), budget, + envs: Arc::new(Mutex::new(HashSet::new())), + host_stopped: Arc::new(AtomicBool::new(false)), }), } } @@ -205,6 +254,13 @@ impl NapiCtx { NapiRuntimeHooks { ctx: self.clone() } } + pub fn runtime_control(&self) -> NapiRuntimeControl { + NapiRuntimeControl { + envs: Arc::clone(&self.inner.envs), + host_stopped: Arc::clone(&self.inner.host_stopped), + } + } + pub fn new_session(&self, module: &Module) -> Result { let previous = self.inner.active_sessions.fetch_add(1, Ordering::AcqRel); if let Some(max_sessions) = self.inner.limits.max_sessions @@ -333,6 +389,8 @@ impl NapiSession { let napi_env = NapiEnv::new( Arc::clone(&self.inner.ctx.budget), self.inner.ctx.limits.max_envs, + Arc::clone(&self.inner.ctx.envs), + Arc::clone(&self.inner.ctx.host_stopped), ); let func_env = FunctionEnv::new(store, napi_env); { @@ -470,6 +528,41 @@ mod tests { const EMPTY_WASM_MODULE: &[u8] = b"\0asm\x01\0\0\0"; + #[test] + fn runtime_stop_is_sticky_without_live_envs() { + let ctx = NapiCtx::default(); + ctx.runtime_control().terminate_all(); + assert!( + ctx.inner + .host_stopped + .load(std::sync::atomic::Ordering::Acquire) + ); + } + + #[test] + fn runtime_control_shares_stop_state_with_the_context() { + // Edge holds a control surface for both the memory-budget callback and + // the instance-kill path, so a stop requested through any clone must be + // visible to every env the context later hands out. + let ctx = NapiCtx::default(); + let control = ctx.runtime_control(); + let other_control = control.clone(); + + assert!( + !ctx.inner + .host_stopped + .load(std::sync::atomic::Ordering::Acquire) + ); + + other_control.terminate_all(); + + assert!( + ctx.inner + .host_stopped + .load(std::sync::atomic::Ordering::Acquire) + ); + } + #[test] fn max_sessions_limit_is_enforced() { let store = Store::default(); diff --git a/src/env.rs b/src/env.rs index 7db5df7..f36cbdb 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,7 +1,7 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::c_void; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; #[cfg(all(target_arch = "wasm32", feature = "js"))] use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; @@ -94,6 +94,8 @@ pub(crate) struct NapiEnv { pub(crate) budget: Arc, /// Per-app cap on live V8 isolates (`None` = unlimited). pub(crate) max_envs: Option, + env_registry: Arc>>, + host_stopped: Arc, /// 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, @@ -163,10 +165,17 @@ pub(crate) struct NapiEnv { } impl NapiEnv { - pub(crate) fn new(budget: Arc, max_envs: Option) -> Self { + pub(crate) fn new( + budget: Arc, + max_envs: Option, + env_registry: Arc>>, + host_stopped: Arc, + ) -> Self { Self { budget, max_envs, + env_registry, + host_stopped, env_heap_charges: HashMap::new(), external_declared: 0, callback_depth: 0, @@ -263,10 +272,33 @@ impl NapiEnv { reservation: &HeapReservation, ) -> (u32, u32) { let (env_id, scope_id) = self.register_napi_env(env); + { + let mut registry = self + .env_registry + .lock() + .expect("poisoned N-API env registry"); + registry.insert(env as usize); + + // If the host already stopped this app's JS, an isolate created + // afterwards must not start running either. Checking under the + // registry lock closes the race against a concurrent + // `NapiRuntimeControl::terminate_all`, which sets the flag before + // taking this lock: either it sees this env in the registry, or + // we see its flag here. + if self.host_stopped.load(Ordering::Acquire) { + // SAFETY: `env` is the isolate just created and is still live. + unsafe { + crate::snapi::snapi_bridge_unofficial_terminate_execution(env); + } + } + } 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), })); // SAFETY: `env` is the isolate just created; `boxed` outlives the @@ -329,6 +361,10 @@ impl NapiEnv { self.budget.uncharge(Pool::V8External, release); } + pub(crate) fn host_stopped(&self) -> bool { + self.host_stopped.load(Ordering::Acquire) + } + /// Claim one level of guest↔host callback reentrancy. Returns `false` (the /// callback must be refused) once [`MAX_CALLBACK_DEPTH`] is reached, so a /// runaway recursion across the FFI boundary cannot overflow the host native @@ -417,6 +453,15 @@ impl NapiEnv { if self.default_napi_env_id == Some(env_id) { self.default_napi_env_id = None; } + let env = *self.napi_envs.get(&env_id)?; + // Remove from the shared registry before any env- or isolate-owned + // state is reclaimed. This synchronizes against terminate_all, which + // holds the same mutex while calling into V8. + self.env_registry + .lock() + .expect("poisoned N-API env registry") + .remove(&env); + // Leases are environment-owned resources. Explicit release publishes // writes; environment teardown only discards snapshots and returns any // guest allocations because the JavaScript value is being destroyed. @@ -438,7 +483,7 @@ impl NapiEnv { self.budget.uncharge(Pool::V8HeapReserved, granted); self.budget.release_env(handle.ceiling); } - let env = self.napi_envs.remove(&env_id)?; + self.napi_envs.remove(&env_id); #[cfg(all(target_arch = "wasm32", feature = "js"))] unsafe { snapi_bridge_swap_active_callback_ctx(env as SnapiEnv, std::ptr::null_mut()); @@ -489,7 +534,12 @@ mod tests { #[test] fn declared_external_charges_denies_and_clamps() { let budget = ResourceBudget::with_memory_limit(10 * MIB); - let mut env = NapiEnv::new(Arc::clone(&budget), None); + let mut env = NapiEnv::new( + Arc::clone(&budget), + None, + Arc::new(std::sync::Mutex::new(HashSet::new())), + Arc::new(AtomicBool::new(false)), + ); assert!(env.charge_declared_external(6 * MIB)); assert_eq!(budget.snapshot().v8_external, 6 * MIB); @@ -513,7 +563,12 @@ mod tests { fn declared_external_released_on_drop() { let budget = ResourceBudget::with_memory_limit(10 * MIB); { - let mut env = NapiEnv::new(Arc::clone(&budget), None); + let mut env = NapiEnv::new( + Arc::clone(&budget), + None, + Arc::new(std::sync::Mutex::new(HashSet::new())), + Arc::new(AtomicBool::new(false)), + ); assert!(env.charge_declared_external(4 * MIB)); assert_eq!(budget.snapshot().v8_external, 4 * MIB); } @@ -526,7 +581,12 @@ mod tests { #[test] fn callback_reentrancy_is_bounded() { - let mut env = NapiEnv::new(ResourceBudget::unlimited(), None); + let mut env = NapiEnv::new( + ResourceBudget::unlimited(), + None, + Arc::new(std::sync::Mutex::new(HashSet::new())), + Arc::new(AtomicBool::new(false)), + ); let max = crate::guest::MAX_CALLBACK_DEPTH; // Reentrancy is allowed up to the limit, then refused. diff --git a/src/guest/napi.rs b/src/guest/napi.rs index 94f4418..6d8a26f 100644 --- a/src/guest/napi.rs +++ b/src/guest/napi.rs @@ -730,6 +730,12 @@ fn guest_unofficial_napi_cancel_terminate_execution( napi_env: i32, ) -> i32 { let env_handle = snapi_env(&env, napi_env); + if env.data().host_stopped() { + unsafe { + snapi_bridge_unofficial_terminate_execution(env_handle); + } + return 1; + } unsafe { snapi_bridge_unofficial_cancel_terminate_execution(env_handle) } } diff --git a/src/guest_heap.rs b/src/guest_heap.rs index 59ede75..3b9dbac 100644 --- a/src/guest_heap.rs +++ b/src/guest_heap.rs @@ -445,21 +445,29 @@ impl GuestHeap { let delta_pages = want_bytes.div_ceil(WASM_PAGE); let delta_bytes = u64::from(delta_pages) * u64::from(WASM_PAGE); - // Reserve against the budget first, exactly like a guest-issued grow: - // a denied charge must look like hitting the memory's maximum. - if self - .budget - .try_charge(Pool::WasmLinear, delta_bytes) - .is_err() - { - return None; - } + // Growth goes through the store, so an embedder metering it through + // its own tunables charges these bytes itself; see + // `wasm_is_externally_accounted`. Standing alone, the heap reserves + // first, exactly like a guest-issued grow: a denied charge must look + // like hitting the memory's maximum. + let charge = if self.budget.wasm_is_externally_accounted() { + 0 + } else { + if self + .budget + .try_charge(Pool::WasmLinear, delta_bytes) + .is_err() + { + return None; + } + delta_bytes + }; let Some((prev, base_now)) = grow_lent_memory(&inner.memories, Pages(delta_pages)) else { - self.budget.uncharge(Pool::WasmLinear, delta_bytes); + self.budget.uncharge(Pool::WasmLinear, charge); return None; }; - self.finish_claim(inner, prev, delta_pages, base_now, delta_bytes) + self.finish_claim(inner, prev, delta_pages, base_now, charge) } /// Top the arena up when free space is low or an allocation recently came @@ -493,25 +501,32 @@ impl GuestHeap { }; let want_bytes = chunk_size_for(min_bytes); let delta_pages = want_bytes.div_ceil(WASM_PAGE); + // Same rule as the lent-store lane above: only charge when no embedder + // is already accounting for growth through its tunables. let delta_bytes = u64::from(delta_pages) * u64::from(WASM_PAGE); + let charge = if self.budget.wasm_is_externally_accounted() { + 0 + } else { + if self + .budget + .try_charge(Pool::WasmLinear, delta_bytes) + .is_err() + { + return false; + } + delta_bytes + }; - if self - .budget - .try_charge(Pool::WasmLinear, delta_bytes) - .is_err() - { - return false; - } let prev = match memory.grow(&mut *store, Pages(delta_pages)) { Ok(prev) => prev, Err(_) => { - self.budget.uncharge(Pool::WasmLinear, delta_bytes); + self.budget.uncharge(Pool::WasmLinear, charge); return false; } }; let base_now = memory.view(store).data_ptr(); inner.pending_want = 0; - self.finish_claim(inner, prev, delta_pages, base_now, delta_bytes) + self.finish_claim(inner, prev, delta_pages, base_now, charge) .is_some() } @@ -537,15 +552,15 @@ impl GuestHeap { let start = u64::from(prev.0) * u64::from(WASM_PAGE); let len = u64::from(delta_pages) * u64::from(WASM_PAGE); + // Once the physical grow succeeds its bytes remain resident even if an + // integrity check prevents this allocator from using the new range. + self.charged.fetch_add(charged_bytes, Ordering::AcqRel); if start + len > self.max_bytes { // Cannot happen (grow enforces the maximum), but never hand out // ranges beyond the reservation. integrity_warn("claimed range beyond memory maximum"); - self.budget.uncharge(Pool::WasmLinear, charged_bytes); return None; } - self.charged.fetch_add(charged_bytes, Ordering::AcqRel); - let start = start as u32; let len = len as u32; inner.chunks.push(Chunk { @@ -1008,6 +1023,27 @@ mod tests { ); } + #[test] + fn successful_shared_grow_stays_charged_after_integrity_rejection() { + let mut store = Store::default(); + let budget = ResourceBudget::with_memory_limit(2 * u64::from(WASM_PAGE)); + let (_memory, heap) = shared_heap(&mut store, Arc::clone(&budget)); + let charged = u64::from(WASM_PAGE); + budget.try_charge(Pool::WasmLinear, charged).unwrap(); + + { + let mut inner = heap.inner.lock().unwrap(); + assert!( + heap.finish_claim(&mut inner, Pages(2048), 1, heap.base, charged) + .is_none() + ); + } + assert_eq!(budget.memory_charged(), charged); + + drop(heap); + assert_eq!(budget.memory_charged(), 0); + } + #[test] fn prefunds_and_refills() { let mut store = Store::default(); diff --git a/src/lib.rs b/src/lib.rs index 907ab51..c346589 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,10 +20,12 @@ pub const NAPI_EXTENSION_WASMER_MODULE_NAME: &str = "napi_extension_wasmer_v0"; #[cfg(not(all(target_arch = "wasm32", feature = "js")))] pub use budget::{BudgetedMemory, BudgetedTunables, budgeted_tunables}; pub use budget::{ - EnvRejected, HeapReservation, OverBudget, Pool, RequestedHeap, ResourceBudget, ResourceUsage, + EnvRejected, HeapReservation, NapiMemoryAccountant, OverBudget, Pool, RequestedHeap, + ResourceBudget, ResourceUsage, }; pub use ctx::{ - NapiCtx, NapiCtxBuilder, NapiInstantiationState, NapiLimits, NapiRuntimeHooks, NapiSession, + NapiCtx, NapiCtxBuilder, NapiInstantiationState, NapiLimits, NapiRuntimeControl, + NapiRuntimeHooks, NapiSession, }; use enum_iterator::Sequence; pub(crate) use env::NapiEnv; @@ -54,6 +56,47 @@ pub fn host_js_capabilities() -> Option { } } +/// Sets how many background worker threads V8 gets, process-wide. +/// +/// V8 sizes this pool from the host's processor count, which suits a process +/// running one JS app. A host running many gets a pool whose threads compete +/// with every tenant's foreground JS, and whose work is attributed to no one: +/// GC marking and sweeping, parallel scavenging, and background compilation +/// all land there. +/// +/// Passing `None` keeps V8's default. Lowering it doesn't strand work — V8's +/// parallel jobs are cooperative and the posting thread participates, so the +/// work migrates onto whichever thread asked for it. +/// +/// The pool is built with the process-wide platform, when the first V8 isolate +/// is created, so this must be called before then. Afterwards, asking for the +/// size already in place still succeeds — several components may configure the +/// runtime from one setting — and asking for a different one returns +/// [`WorkerThreadsAlreadyFixed`]. +pub fn set_v8_worker_threads(count: Option) -> Result<(), WorkerThreadsAlreadyFixed> { + // Safety: plain integer in, no pointers; the callee only stores it. + let applied = unsafe { snapi::snapi_bridge_set_v8_worker_thread_count(count.unwrap_or(0)) }; + if applied == 0 { + return Err(WorkerThreadsAlreadyFixed); + } + Ok(()) +} + +/// The V8 platform already exists, so its worker pool can no longer be resized. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkerThreadsAlreadyFixed; + +impl Display for WorkerThreadsAlreadyFixed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "the V8 worker thread count must be set before the first isolate is created, and \ + the V8 platform already exists", + ) + } +} + +impl std::error::Error for WorkerThreadsAlreadyFixed {} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Sequence)] pub enum NapiVersion { V10, diff --git a/src/napi_bridge_init.cc b/src/napi_bridge_init.cc index 12330f8..42a2d57 100644 --- a/src/napi_bridge_init.cc +++ b/src/napi_bridge_init.cc @@ -24,6 +24,7 @@ #include "node_api.h" #include "unofficial_napi.h" +#include "edge_v8_platform.h" #include "internal/napi_v8_env.h" #include "edge_napi_embedder_hooks.h" @@ -454,6 +455,13 @@ napi_status DisposeBridgeStateLocked(SnapiEnvState* state) { // Initialization // ============================================================ +// Host-only: sizes the process-wide V8 background worker pool. Not reachable +// from guest code -- a guest must not get to choose how many threads the host +// runs. Returns 0 once the platform exists, since the pool is fixed then. +extern "C" int snapi_bridge_set_v8_worker_thread_count(uint32_t count) { + return EdgeV8Platform::SetWorkerThreadCount(static_cast(count)) ? 1 : 0; +} + extern "C" int snapi_bridge_init() { std::lock_guard lock(g_mu); // Intentionally do not create a N-API env here. diff --git a/src/snapi.rs b/src/snapi.rs index 5829ebf..7c4827b 100644 --- a/src/snapi.rs +++ b/src/snapi.rs @@ -48,6 +48,7 @@ pub struct SnapiUnofficialHeapCodeStatistics { unsafe extern "C" { pub fn snapi_bridge_init() -> i32; + pub fn snapi_bridge_set_v8_worker_thread_count(count: u32) -> i32; pub fn snapi_bridge_unofficial_set_flags_from_string(flags: *const i8, length: u32) -> i32; pub fn snapi_bridge_unofficial_create_env( module_api_version: i32, diff --git a/tests/build-test-wasix.sh b/tests/build-test-wasix.sh index 50cdda8..a05d35d 100755 --- a/tests/build-test-wasix.sh +++ b/tests/build-test-wasix.sh @@ -60,6 +60,11 @@ fi # Compile to WASIX. Core N-API functions import from "napi" and Wasmer-specific # unofficial APIs import from "napi_extension_wasmer_v0" based on the headers. +# +# `wasixcc` runs `wasm-opt` on the result, passing the wasm features it built +# with. If the installed binaryen predates one of them it fails with e.g. +# "Unknown option '--enable-wide-arithmetic'"; refresh it with +# `wasixccenv download-binaryen`. WASIX_DRIVER="wasixcc" case "$TEST_SRC" in *.cc|*.cpp) diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..2f3ad6c --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,39 @@ +//! Shared plumbing for the integration tests that run a WASIX guest against a +//! real V8 isolate. + +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use wasmer_napi::{NapiCtx, cli::run_wasix_main_capture_stdio_with_ctx}; + +pub fn crate_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +pub fn wasix_test_dir() -> PathBuf { + std::env::var_os("NAPI_WASIX_TEST_OUT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| crate_root().join("target")) + .join("wasm32-wasix/release") + }) +} + +/// Builds `tests/programs/` for WASIX and returns the resulting module. +pub fn build_wasix_test(name: &str) -> PathBuf { + let status = Command::new("./tests/build-test-wasix.sh") + .arg(name) + .current_dir(crate_root()) + .status() + .expect("failed to execute tests/build-test-wasix.sh"); + assert!(status.success(), "failed to build WASIX test: {name}"); + wasix_test_dir().join(format!("{name}.wasm")) +} + +pub fn run_guest(ctx: &NapiCtx, wasm: &Path) -> anyhow::Result<(i32, String, String)> { + run_wasix_main_capture_stdio_with_ctx(ctx, wasm, &[], &[]) +} diff --git a/tests/programs/test_js_infinite_loop.cc b/tests/programs/test_js_infinite_loop.cc new file mode 100644 index 0000000..daaff9f --- /dev/null +++ b/tests/programs/test_js_infinite_loop.cc @@ -0,0 +1,50 @@ +// Runs JavaScript that never returns on its own, so a host can prove it is +// able to stop the isolate. Driven by tests/v8_host_control.rs, and +// deliberately absent from tests/programs/manifest.json: that harness also +// runs each program natively, where nothing would ever stop the loop. +// +// The marker is printed (and flushed) before entering JS so the host can tell +// "the loop was stopped" apart from "the guest never got that far". + +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != nullptr, "napi_wasm_init_env returned NULL"); + + napi_value script; + NAPI_CALL(env, napi_create_string_utf8(env, "while (true) {}", + NAPI_AUTO_LENGTH, &script)); + + printf("JS_LOOP_ENTERED\n"); + fflush(stdout); + + napi_value result; + napi_status status = napi_run_script(env, script, &result); + + // Reaching this at all means the host stopped the loop. A terminated + // isolate reports a pending exception rather than success. + printf("JS_LOOP_LEFT status=%d\n", (int)status); + fflush(stdout); + + // Now behave like a guest that doesn't want to stop: clear the termination + // and try to run more JS. A host-requested stop is sticky, so both of these + // have to keep failing. + printf("CANCEL_STATUS=%d\n", + (int)unofficial_napi_cancel_terminate_execution(env)); + + napi_value resumed_script; + napi_value resumed_result; + napi_status resumed = + napi_create_string_utf8(env, "1 + 1", NAPI_AUTO_LENGTH, &resumed_script); + if (resumed == napi_ok) { + resumed = napi_run_script(env, resumed_script, &resumed_result); + } + printf("RESUME_STATUS=%d\n", (int)resumed); + fflush(stdout); + + return 0; +} diff --git a/tests/v8_host_control.rs b/tests/v8_host_control.rs new file mode 100644 index 0000000..f151544 --- /dev/null +++ b/tests/v8_host_control.rs @@ -0,0 +1,170 @@ +#![cfg(feature = "cli")] + +//! Host-side control over live V8 isolates, exercised against real V8 rather +//! than in isolation: a WASIX guest runs JS through the N-API host imports, +//! and the host stops it or refuses it a budget. +//! +//! These need the guest `.wasm` files, which `tests/build-test-wasix.sh` +//! builds with `wasixcc`. + +use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +use wasmer_napi::NapiCtx; + +mod common; +use common::{build_wasix_test, run_guest}; + +/// Budget that comfortably fits a guest plus a default V8 isolate. +const GENEROUS_BUDGET: u64 = 512 * 1024 * 1024; + +/// `napi_pending_exception`, which is what a terminated isolate reports. +const NAPI_PENDING_EXCEPTION: i32 = 10; + +/// Budget below the fixed per-isolate floor (per-isolate overhead plus unwind +/// slack), so no isolate can be admitted no matter how far its old-space +/// ceiling is clamped. +const BUDGET_BELOW_ISOLATE_FLOOR: u64 = 20 * 1024 * 1024; + +/// The kill path Edge relies on: a JS loop that never returns on its own has +/// to stop when the host says so. Nothing in the guest cooperates here — the +/// isolate is executing JS when the request arrives. +#[test] +fn terminate_all_stops_running_js() { + let wasm = build_wasix_test("test_js_infinite_loop"); + + let ctx = NapiCtx::default(); + let control = ctx.runtime_control(); + + let (finished_tx, finished_rx) = mpsc::channel(); + let guest = thread::spawn(move || { + let result = run_guest(&ctx, &wasm); + // Signal completion separately so a hang fails the test instead of + // blocking it forever on `join`. + let _ = finished_tx.send(()); + result + }); + + // Give the guest time to reach JS. The loop never ends by itself, so being + // late only makes the test slower — it can't make it pass spuriously. + thread::sleep(Duration::from_secs(2)); + + let requested_at = Instant::now(); + control.terminate_all(); + + finished_rx + .recv_timeout(Duration::from_secs(60)) + .expect("terminate_all() did not stop the running JS"); + let elapsed = requested_at.elapsed(); + + let (exit_code, stdout, stderr) = guest + .join() + .expect("guest thread panicked") + .expect("guest run failed"); + + assert!( + stdout.contains("JS_LOOP_ENTERED"), + "the guest never reached JS, so nothing was terminated\ + \n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains(&format!("JS_LOOP_LEFT status={NAPI_PENDING_EXCEPTION}")), + "the JS loop didn't end in a terminated isolate\ + \n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + + // The guest then tries to clear the termination and keep running, the way + // a workload that doesn't want to be killed would. The host's stop is + // sticky, so it must not get back in. + assert!( + !stdout.contains("RESUME_STATUS=0"), + "the guest cancelled a host-requested termination and resumed JS\ + \n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert!( + stdout.contains("RESUME_STATUS="), + "the guest never reported whether it could resume\ + \n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert_eq!(exit_code, 0, "guest exited with {exit_code}\n{stderr}"); + + eprintln!( + "JS loop stopped {}ms after the request", + elapsed.as_millis() + ); +} + +/// Every byte an isolate reserves has to come back when it goes away, +/// otherwise a long-lived app leaks budget until it can't start an isolate at +/// all. Charging is by reservation, so this covers creation and teardown of a +/// real isolate rather than the accountant's own bookkeeping. +#[test] +fn isolate_reservations_are_released_when_the_guest_exits() { + let wasm = build_wasix_test("run_script_test"); + + let ctx = NapiCtx::builder() + .total_memory_bytes(GENEROUS_BUDGET) + .build(); + let (exit_code, stdout, stderr) = run_guest(&ctx, &wasm).expect("guest run failed"); + + assert_eq!(exit_code, 0, "guest exited with {exit_code}\n{stderr}"); + assert!( + stdout.contains("RUN_SCRIPT_TEST_OK=1"), + "guest did not report success\n--- stdout ---\n{stdout}" + ); + + let usage = ctx.budget().snapshot(); + assert_eq!( + usage.live_isolates, 0, + "an isolate outlived the guest: {usage:?}" + ); + assert_eq!( + usage.v8_heap_reserved, 0, + "V8 heap reservations were not released: {usage:?}" + ); + assert_eq!( + usage.v8_external, 0, + "V8 external memory was not released: {usage:?}" + ); +} + +/// The budget has to be able to say no. Below the per-isolate floor there is +/// no ceiling small enough to admit an isolate, so env creation must be +/// refused rather than quietly allocating outside the budget. +#[test] +fn isolate_creation_is_refused_below_the_budget_floor() { + let wasm = build_wasix_test("hello_napi_test"); + + let ctx = NapiCtx::builder() + .total_memory_bytes(BUDGET_BELOW_ISOLATE_FLOOR) + .build(); + let outcome = run_guest(&ctx, &wasm); + + // Either the run fails outright or the guest reports failure; what must + // not happen is a successful run, which would mean the isolate was + // created outside the budget. + if let Ok((exit_code, stdout, stderr)) = outcome { + assert!( + !stdout.contains("HELLO_NAPI_TEST_OK=1"), + "the guest created a V8 isolate that the budget can't cover\ + \n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}" + ); + assert_ne!( + exit_code, 0, + "guest reported success under a budget that cannot hold an isolate\n{stdout}" + ); + } + + let usage = ctx.budget().snapshot(); + assert_eq!( + usage.live_isolates, 0, + "a refused isolate still holds a slot: {usage:?}" + ); + assert_eq!( + usage.v8_heap_reserved, 0, + "a refused isolate still holds a heap reservation: {usage:?}" + ); +} diff --git a/tests/v8_worker_threads.rs b/tests/v8_worker_threads.rs new file mode 100644 index 0000000..061dbad --- /dev/null +++ b/tests/v8_worker_threads.rs @@ -0,0 +1,70 @@ +#![cfg(all(feature = "cli", target_os = "linux"))] + +//! Sizing V8's background worker pool. +//! +//! The pool belongs to the process-wide V8 platform and is built with it, so +//! this test needs a process to itself — which is what a separate integration +//! test binary gives it. Thread names come from `/proc`, hence Linux only. + +use std::fs; + +use wasmer_napi::NapiCtx; + +mod common; +use common::{build_wasix_test, run_guest}; + +/// Linux truncates thread names to 15 characters, so V8's "V8 DefaultWorker" +/// arrives as this. +const V8_WORKER_COMM: &str = "V8 DefaultWorke"; + +const REQUESTED_WORKERS: u32 = 2; + +fn v8_worker_thread_count() -> usize { + let tasks = fs::read_dir("/proc/self/task").expect("failed to read /proc/self/task"); + tasks + .filter_map(Result::ok) + .filter(|task| { + fs::read_to_string(task.path().join("comm")) + .map(|comm| comm.trim() == V8_WORKER_COMM) + .unwrap_or(false) + }) + .count() +} + +#[test] +fn worker_pool_size_is_configurable_before_the_first_isolate() { + assert_eq!( + v8_worker_thread_count(), + 0, + "V8 should not have started any workers before the platform exists" + ); + + wasmer_napi::set_v8_worker_threads(Some(REQUESTED_WORKERS)) + .expect("the pool should be sizeable before any isolate exists"); + + // Running a guest creates the isolate, and with it the platform and its + // worker pool. + let wasm = build_wasix_test("hello_napi_test"); + let ctx = NapiCtx::default(); + let (exit_code, stdout, stderr) = run_guest(&ctx, &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "guest exited with {exit_code}\n{stderr}"); + assert!( + stdout.contains("HELLO_NAPI_TEST_OK=1"), + "guest did not report success\n{stdout}" + ); + + // Without this, V8 sizes the pool from the host's processor count. + assert_eq!( + v8_worker_thread_count(), + REQUESTED_WORKERS as usize, + "V8 did not honour the configured worker count" + ); + + // The pool is fixed once the platform exists, and saying so beats + // pretending a later change took effect. + assert!( + wasmer_napi::set_v8_worker_threads(Some(REQUESTED_WORKERS + 2)).is_err(), + "resizing the pool after the platform was built should be refused" + ); + assert_eq!(v8_worker_thread_count(), REQUESTED_WORKERS as usize); +} diff --git a/v8/src/edge_v8_platform.cc b/v8/src/edge_v8_platform.cc index 9ac25b5..5aedc53 100644 --- a/v8/src/edge_v8_platform.cc +++ b/v8/src/edge_v8_platform.cc @@ -296,8 +296,29 @@ class EdgeV8Platform::ForegroundTaskRunner final : public v8::TaskRunner { } }; +namespace { +// Worker threads for the shared platform: 0 means "let V8 decide". Read once, +// when the platform is built. +std::atomic g_worker_thread_count{0}; +std::atomic g_platform_created{false}; +} // namespace + +bool EdgeV8Platform::SetWorkerThreadCount(int count) { + const int requested = count < 0 ? 0 : count; + if (g_platform_created.load(std::memory_order_acquire)) { + // The pool is already built. Asking for what is already in place is not an + // error -- several embedder components may configure the runtime from the + // same setting -- but asking for anything else cannot be honoured. + return g_worker_thread_count.load(std::memory_order_acquire) == requested; + } + g_worker_thread_count.store(requested, std::memory_order_release); + return true; +} + std::unique_ptr EdgeV8Platform::Create() { - std::unique_ptr fallback = v8::platform::NewDefaultPlatform(); + g_platform_created.store(true, std::memory_order_release); + std::unique_ptr fallback = v8::platform::NewDefaultPlatform( + g_worker_thread_count.load(std::memory_order_acquire)); if (!fallback) return nullptr; WarmUpFallbackWorkerThreads(fallback.get()); return std::unique_ptr(new EdgeV8Platform(std::move(fallback))); diff --git a/v8/src/edge_v8_platform.h b/v8/src/edge_v8_platform.h index 707fa32..8544948 100644 --- a/v8/src/edge_v8_platform.h +++ b/v8/src/edge_v8_platform.h @@ -17,6 +17,16 @@ class EdgeV8Platform final : public v8::Platform { static std::unique_ptr Create(); + // Sets how many background worker threads the platform gets. Zero restores + // V8's own default, which sizes the pool from the host's processor count -- + // reasonable for a process running one JS app, less so for a host running + // many, where those threads compete with every tenant's foreground JS and + // their work is not attributed to anyone. + // + // The pool is process-wide and built when the first isolate is created, so + // this has no effect afterwards; it returns false in that case. + static bool SetWorkerThreadCount(int count); + ~EdgeV8Platform() override; bool RegisterIsolate(v8::Isolate* isolate);