Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?=
Expand Down
255 changes: 220 additions & 35 deletions src/budget.rs

Large diffs are not rendered by default.

109 changes: 101 additions & 8 deletions src/ctx.rs
Original file line number Diff line number Diff line change
@@ -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},
};

Expand All @@ -32,9 +35,10 @@ impl NapiLimits {
}
}

#[derive(Debug, Default)]
#[derive(Default)]
pub struct NapiCtxBuilder {
limits: NapiLimits,
accountant: Option<Arc<dyn NapiMemoryAccountant>>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -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<Mutex<HashSet<usize>>>,
host_stopped: Arc<AtomicBool>,
}

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,
Expand All @@ -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<ResourceBudget>,
envs: Arc<Mutex<HashSet<usize>>>,
host_stopped: Arc<AtomicBool>,
}

struct NapiSessionInner {
Expand Down Expand Up @@ -124,16 +162,27 @@ impl NapiCtxBuilder {
self
}

/// Delegate total-memory admission to the embedder.
pub fn memory_accountant(mut self, accountant: Arc<dyn NapiMemoryAccountant>) -> 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)),
}),
}
}
Expand Down Expand Up @@ -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<NapiSession> {
let previous = self.inner.active_sessions.fetch_add(1, Ordering::AcqRel);
if let Some(max_sessions) = self.inner.limits.max_sessions
Expand Down Expand Up @@ -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);
{
Expand Down Expand Up @@ -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();
Expand Down
74 changes: 67 additions & 7 deletions src/env.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -94,6 +94,8 @@ pub(crate) struct NapiEnv {
pub(crate) budget: Arc<ResourceBudget>,
/// Per-app cap on live V8 isolates (`None` = unlimited).
pub(crate) max_envs: Option<usize>,
env_registry: Arc<std::sync::Mutex<HashSet<usize>>>,
host_stopped: Arc<AtomicBool>,
/// 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<u32, EnvHeapChargeHandle>,
Expand Down Expand Up @@ -163,10 +165,17 @@ pub(crate) struct NapiEnv {
}

impl NapiEnv {
pub(crate) fn new(budget: Arc<ResourceBudget>, max_envs: Option<usize>) -> Self {
pub(crate) fn new(
budget: Arc<ResourceBudget>,
max_envs: Option<usize>,
env_registry: Arc<std::sync::Mutex<HashSet<usize>>>,
host_stopped: Arc<AtomicBool>,
) -> Self {
Self {
budget,
max_envs,
env_registry,
host_stopped,
env_heap_charges: HashMap::new(),
external_declared: 0,
callback_depth: 0,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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());
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/guest/napi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}

Expand Down
Loading
Loading