Skip to content

Commit c42873f

Browse files
committed
feat(napi): meter page-backed V8 buffers per application
1 parent f472255 commit c42873f

7 files changed

Lines changed: 1373 additions & 3 deletions

File tree

‎src/budget.rs‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ pub enum Pool {
155155
/// slots, finalizer records, deferreds, scope frames, callback
156156
/// registrations), granted in chunks by [`napi_host_bookkeeping_charge`].
157157
HostBookkeeping,
158+
/// Committed pages of buffers V8 reserves through its page allocator
159+
/// rather than the `ArrayBuffer::Allocator`: resizable `ArrayBuffer`s and
160+
/// growable `SharedArrayBuffer`s. Charged softly (see
161+
/// [`ResourceBudget::try_charge_soft`]): a refused commit surfaces in JS as
162+
/// a `RangeError` and does not stop the context.
163+
V8BackingPages,
158164
}
159165

160166
/// Embedder-owned aggregate accounting for byte reservations made by N-API.
@@ -165,6 +171,14 @@ pub trait NapiMemoryAccountant: Send + Sync {
165171
fn memory_limit(&self) -> u64;
166172
fn memory_charged(&self) -> u64;
167173
fn try_charge(&self, bytes: u64) -> bool;
174+
/// Charge bytes whose refusal the provider handles gracefully (V8 turns
175+
/// it into a JS `RangeError`). Must deny without side effects such as
176+
/// stopping the application; it may be called from V8 background
177+
/// threads and must not call back into V8. Embedders whose
178+
/// [`try_charge`](Self::try_charge) is terminal should override it.
179+
fn try_charge_soft(&self, bytes: u64) -> bool {
180+
self.try_charge(bytes)
181+
}
168182
fn uncharge(&self, bytes: u64);
169183
}
170184

@@ -219,6 +233,8 @@ pub struct ResourceUsage {
219233
/// [`DEFAULT_HEAP_EMERGENCY_HEADROOM`]). Non-zero means at least one
220234
/// isolate of this budget is stopping after exhausting its heap.
221235
pub v8_heap_emergency: u64,
236+
/// Committed bytes of page-allocated V8 buffers (resizable buffers).
237+
pub v8_backing_pages: u64,
222238
/// Number of live V8 isolates (envs) counted against `max_envs`.
223239
pub live_isolates: usize,
224240
}
@@ -281,6 +297,7 @@ pub struct ResourceBudget {
281297
/// Emergency headroom currently exposed, summed over stopping isolates.
282298
/// Not part of `mem_charged`: it is by definition over the budget.
283299
v8_heap_emergency: AtomicU64,
300+
v8_backing_pages: AtomicU64,
284301
/// Live V8 isolates (envs), counted against `max_envs`.
285302
live_isolates: AtomicUsize,
286303
}
@@ -316,6 +333,10 @@ impl std::fmt::Debug for ResourceBudget {
316333
"v8_heap_emergency",
317334
&self.v8_heap_emergency.load(Ordering::Acquire),
318335
)
336+
.field(
337+
"v8_backing_pages",
338+
&self.v8_backing_pages.load(Ordering::Acquire),
339+
)
319340
.field("live_isolates", &self.live_isolates.load(Ordering::Acquire))
320341
.finish()
321342
}
@@ -358,6 +379,7 @@ impl ResourceBudget {
358379
host_bookkeeping: AtomicU64::new(0),
359380
heap_emergency_headroom: AtomicU64::new(DEFAULT_HEAP_EMERGENCY_HEADROOM),
360381
v8_heap_emergency: AtomicU64::new(0),
382+
v8_backing_pages: AtomicU64::new(0),
361383
live_isolates: AtomicUsize::new(0),
362384
})
363385
}
@@ -376,6 +398,7 @@ impl ResourceBudget {
376398
host_bookkeeping: AtomicU64::new(0),
377399
heap_emergency_headroom: AtomicU64::new(DEFAULT_HEAP_EMERGENCY_HEADROOM),
378400
v8_heap_emergency: AtomicU64::new(0),
401+
v8_backing_pages: AtomicU64::new(0),
379402
live_isolates: AtomicUsize::new(0),
380403
}
381404
}
@@ -411,12 +434,29 @@ impl ResourceBudget {
411434
///
412435
/// [`uncharge`]: ResourceBudget::uncharge
413436
pub fn try_charge(&self, pool: Pool, bytes: u64) -> Result<(), OverBudget> {
437+
self.charge(pool, bytes, false)
438+
}
439+
440+
/// Like [`try_charge`](Self::try_charge), but a refusal is reported only
441+
/// to the caller: an embedder accountant is asked through
442+
/// [`NapiMemoryAccountant::try_charge_soft`], so the application is not
443+
/// stopped. For allocations whose failure V8 handles gracefully.
444+
pub fn try_charge_soft(&self, pool: Pool, bytes: u64) -> Result<(), OverBudget> {
445+
self.charge(pool, bytes, true)
446+
}
447+
448+
fn charge(&self, pool: Pool, bytes: u64, soft: bool) -> Result<(), OverBudget> {
414449
if bytes == 0 {
415450
return Ok(());
416451
}
417452

418453
if let Some(accountant) = &self.accountant {
419-
if !accountant.try_charge(bytes) {
454+
let granted = if soft {
455+
accountant.try_charge_soft(bytes)
456+
} else {
457+
accountant.try_charge(bytes)
458+
};
459+
if !granted {
420460
return Err(OverBudget {
421461
pool,
422462
requested: bytes,
@@ -480,6 +520,7 @@ impl ResourceBudget {
480520
Pool::HostTransient => &self.host_transient,
481521
Pool::SerializedMessage => &self.serialized_message,
482522
Pool::HostBookkeeping => &self.host_bookkeeping,
523+
Pool::V8BackingPages => &self.v8_backing_pages,
483524
}
484525
}
485526

@@ -496,6 +537,7 @@ impl ResourceBudget {
496537
serialized_message: self.serialized_message.load(Ordering::Acquire),
497538
host_bookkeeping: self.host_bookkeeping.load(Ordering::Acquire),
498539
v8_heap_emergency: self.v8_heap_emergency.load(Ordering::Acquire),
540+
v8_backing_pages: self.v8_backing_pages.load(Ordering::Acquire),
499541
live_isolates: self.live_isolates.load(Ordering::Acquire),
500542
}
501543
}
@@ -1968,6 +2010,54 @@ mod external_accountant_tests {
19682010
assert_eq!(budget.snapshot().v8_heap_reserved, 0);
19692011
}
19702012

2013+
/// Counts terminal charges; soft charges must never take that path.
2014+
struct SoftAccountant {
2015+
inner: Arc<TestAccountant>,
2016+
terminal_calls: AtomicU64,
2017+
}
2018+
2019+
impl NapiMemoryAccountant for SoftAccountant {
2020+
fn memory_limit(&self) -> u64 {
2021+
self.inner.memory_limit()
2022+
}
2023+
2024+
fn memory_charged(&self) -> u64 {
2025+
self.inner.memory_charged()
2026+
}
2027+
2028+
fn try_charge(&self, bytes: u64) -> bool {
2029+
self.terminal_calls.fetch_add(1, Ordering::AcqRel);
2030+
self.inner.try_charge(bytes)
2031+
}
2032+
2033+
fn try_charge_soft(&self, bytes: u64) -> bool {
2034+
self.inner.try_charge(bytes)
2035+
}
2036+
2037+
fn uncharge(&self, bytes: u64) {
2038+
self.inner.uncharge(bytes);
2039+
}
2040+
}
2041+
2042+
#[test]
2043+
fn soft_charges_use_the_accountants_soft_path() {
2044+
let accountant = Arc::new(SoftAccountant {
2045+
inner: TestAccountant::new(100),
2046+
terminal_calls: AtomicU64::new(0),
2047+
});
2048+
let budget = ResourceBudget::with_accountant(accountant.clone());
2049+
budget.try_charge_soft(Pool::V8BackingPages, 80).unwrap();
2050+
let refused = budget
2051+
.try_charge_soft(Pool::V8BackingPages, 40)
2052+
.unwrap_err();
2053+
assert_eq!(refused.pool, Pool::V8BackingPages);
2054+
assert_eq!(accountant.terminal_calls.load(Ordering::Acquire), 0);
2055+
assert_eq!(budget.snapshot().v8_backing_pages, 80);
2056+
budget.uncharge(Pool::V8BackingPages, 80);
2057+
assert_eq!(budget.snapshot().v8_backing_pages, 0);
2058+
assert_eq!(budget.memory_charged(), 0);
2059+
}
2060+
19712061
#[cfg(all(napi_standalone_legacy_wait, not(target_arch = "wasm32")))]
19722062
#[test]
19732063
fn external_accountant_cannot_use_legacy_raw_detachment() {

‎src/env.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ impl NapiEnv {
272272
if self.host_stopped.load(Ordering::Acquire) {
273273
anyhow::bail!("N-API instance stopped during activation");
274274
}
275+
lane.attach_page_accountant(&self.budget);
275276
self.managed_lane = Some(lane);
276277
}
277278
Ok(self.managed_lane.as_ref().map(ManagedV8Lane::enter))

‎src/lane.rs‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use std::{cell::RefCell, ffi::c_void, ptr::NonNull, sync::Arc};
44

55
use anyhow::{Result, bail};
66

7+
use crate::budget::{Pool, ResourceBudget};
8+
79
/// Called for each actual V8 background task. Dropping the returned guard
810
/// ends that task's metered active interval.
911
pub type BackgroundTaskScope = Arc<dyn Fn() -> Box<dyn Send> + Send + Sync>;
@@ -24,6 +26,13 @@ unsafe extern "C" {
2426
fn snapi_v8_lane_stop(handle: *mut c_void);
2527
fn snapi_v8_lane_delete(handle: *mut c_void);
2628
fn snapi_v8_lane_swap_current(handle: *mut c_void) -> *mut c_void;
29+
fn snapi_v8_lane_set_page_accountant(
30+
handle: *mut c_void,
31+
context: *mut c_void,
32+
charge: unsafe extern "C" fn(*mut c_void, u64) -> bool,
33+
uncharge: unsafe extern "C" fn(*mut c_void, u64),
34+
release: unsafe extern "C" fn(*mut c_void),
35+
) -> bool;
2736
}
2837

2938
pub struct ManagedV8Lane {
@@ -88,6 +97,29 @@ impl ManagedV8Lane {
8897
unsafe { snapi_v8_lane_run(self.handle.as_ptr()) }
8998
}
9099

100+
/// Charge commits of page-backed V8 buffers (resizable `ArrayBuffer`s,
101+
/// growable `SharedArrayBuffer`s) reserved while this lane is bound to
102+
/// `budget`. The first budget attached to a lane is kept; every context
103+
/// sharing a lane shares its application's budget.
104+
pub(crate) fn attach_page_accountant(&self, budget: &Arc<ResourceBudget>) {
105+
let context = Arc::into_raw(Arc::clone(budget))
106+
.cast_mut()
107+
.cast::<c_void>();
108+
let attached = unsafe {
109+
snapi_v8_lane_set_page_accountant(
110+
self.handle.as_ptr(),
111+
context,
112+
charge_backing_pages,
113+
uncharge_backing_pages,
114+
release_page_accountant,
115+
)
116+
};
117+
if !attached {
118+
// SAFETY: the lane did not take ownership of the reference.
119+
drop(unsafe { Arc::from_raw(context.cast::<ResourceBudget>()) });
120+
}
121+
}
122+
91123
/// Bind this queue to the calling thread while V8 creates or enters an
92124
/// isolate. The guard restores the previous binding on drop.
93125
pub(crate) fn enter(self: &Arc<Self>) -> ManagedV8LaneScope {
@@ -122,6 +154,29 @@ impl Drop for ManagedV8Lane {
122154
}
123155
}
124156

157+
// The page-accountant context is an `Arc<ResourceBudget>` reference owned by
158+
// the native lane and its tracked regions; see `attach_page_accountant`.
159+
unsafe extern "C" fn charge_backing_pages(context: *mut c_void, bytes: u64) -> bool {
160+
let budget = unsafe { &*context.cast::<ResourceBudget>() };
161+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
162+
budget.try_charge_soft(Pool::V8BackingPages, bytes).is_ok()
163+
}))
164+
.unwrap_or(false)
165+
}
166+
167+
unsafe extern "C" fn uncharge_backing_pages(context: *mut c_void, bytes: u64) {
168+
let budget = unsafe { &*context.cast::<ResourceBudget>() };
169+
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
170+
budget.uncharge(Pool::V8BackingPages, bytes);
171+
}));
172+
}
173+
174+
unsafe extern "C" fn release_page_accountant(context: *mut c_void) {
175+
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
176+
drop(unsafe { Arc::from_raw(context.cast::<ResourceBudget>()) });
177+
}));
178+
}
179+
125180
unsafe extern "C" fn enter_task_scope(context: *mut c_void) -> *mut c_void {
126181
if context.is_null() {
127182
return std::ptr::null_mut();
@@ -237,6 +292,24 @@ mod tests {
237292

238293
unsafe extern "C" fn run_noop_task(_data: *mut c_void) {}
239294

295+
#[test]
296+
fn page_accountant_charges_softly_and_releases_its_budget_reference() {
297+
let budget = ResourceBudget::with_memory_limit(8 * 4096);
298+
let lane = ManagedV8Lane::new(1, Arc::new(|| Box::new(())), Arc::new(|| {})).unwrap();
299+
lane.attach_page_accountant(&budget);
300+
// A second attach is refused and must not leak its reference.
301+
lane.attach_page_accountant(&budget);
302+
assert_eq!(Arc::strong_count(&budget), 2);
303+
let context = Arc::as_ptr(&budget).cast_mut().cast();
304+
assert!(unsafe { charge_backing_pages(context, 6 * 4096) });
305+
assert!(!unsafe { charge_backing_pages(context, 4 * 4096) });
306+
assert_eq!(budget.snapshot().v8_backing_pages, 6 * 4096);
307+
unsafe { uncharge_backing_pages(context, 6 * 4096) };
308+
assert_eq!(budget.snapshot().v8_backing_pages, 0);
309+
drop(lane);
310+
assert_eq!(Arc::strong_count(&budget), 1);
311+
}
312+
240313
#[test]
241314
fn zero_queue_capacity_is_rejected() {
242315
assert!(ManagedV8Lane::new(0, Arc::new(|| Box::new(())), Arc::new(|| {})).is_err());

0 commit comments

Comments
 (0)