Skip to content
Closed
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
43 changes: 43 additions & 0 deletions lib/src/napi_heap_limit.h
Original file line number Diff line number Diff line change
@@ -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_
26 changes: 26 additions & 0 deletions quickjs/src/unofficial_napi.cc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "unofficial_napi.h"
#include "napi_heap_limit.h"

#include "internal/napi_callsite.h"
#include "internal/napi_serdes.h"
Expand Down Expand Up @@ -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,
Expand Down
410 changes: 363 additions & 47 deletions src/budget.rs

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions src/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ pub struct NapiLimits {
pub total_memory_bytes: Option<u64>,
pub max_total_external_memory: Option<u64>,
pub max_total_heap_bytes: Option<u64>,
/// 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<u64>,
}

impl NapiLimits {
Expand Down Expand Up @@ -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<dyn NapiMemoryAccountant>) -> Self {
self.accountant = Some(accountant);
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 43 additions & 9 deletions src/env.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<u32, EnvHeapChargeHandle>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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"))]
Expand Down
15 changes: 15 additions & 0 deletions src/guest/napi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,21 @@ fn with_cb_context<R>(
) -> Result<R, WasiError> {
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));
}
Expand Down
51 changes: 46 additions & 5 deletions src/napi_bridge_init.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<std::mutex> 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<size_t>(old_generation_limit), &collected);
{
std::lock_guard<std::mutex> 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
Expand Down
7 changes: 7 additions & 0 deletions src/snapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading