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
3 changes: 3 additions & 0 deletions crates/bridge/src/queue_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ pub enum TrackedLimit {
VmRecursiveFsEntries,
V8HeapBytes,
V8ArrayBufferBytes,
V8ExternalMemoryBytes,
V8CpuTimeMs,
V8WallClockMs,
WasmFuelMs,
Expand Down Expand Up @@ -134,6 +135,7 @@ impl TrackedLimit {
TrackedLimit::VmRecursiveFsEntries => "vm_recursive_fs_entries",
TrackedLimit::V8HeapBytes => "v8_heap_bytes",
TrackedLimit::V8ArrayBufferBytes => "v8_array_buffer_bytes",
TrackedLimit::V8ExternalMemoryBytes => "v8_external_memory_bytes",
TrackedLimit::V8CpuTimeMs => "v8_cpu_time_ms",
TrackedLimit::V8WallClockMs => "v8_wall_clock_ms",
TrackedLimit::WasmFuelMs => "wasm_fuel_ms",
Expand Down Expand Up @@ -170,6 +172,7 @@ impl TrackedLimit {
| TrackedLimit::VmRecursiveFsEntries => LimitCategory::Resource,
TrackedLimit::V8HeapBytes
| TrackedLimit::V8ArrayBufferBytes
| TrackedLimit::V8ExternalMemoryBytes
| TrackedLimit::WasmMemoryBytes => LimitCategory::Memory,
TrackedLimit::V8CpuTimeMs | TrackedLimit::V8WallClockMs | TrackedLimit::WasmFuelMs => {
LimitCategory::Cpu
Expand Down
10 changes: 10 additions & 0 deletions crates/execution/src/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,11 @@ pub struct JavascriptExecutionLimits {
/// Per-call host bridge deadline. Sidecar VMs supply
/// `limits.reactor.operationDeadlineMs`; zero is invalid.
pub bridge_call_timeout_ms: Option<u64>,
/// Stop the script when WebAssembly memory and host-provided buffers pass
/// the heap limit. The sidecar sets this for guest JavaScript only; Python
/// and the WASM runner keep their program memory in WebAssembly under their
/// own limits.
pub limit_external_memory: bool,
}

/// Per-execution guest-runtime config carried as typed fields rather than
Expand Down Expand Up @@ -2417,6 +2422,7 @@ fn register_v8_session<'a, F>(
heap_limit_mb: u32,
cpu_time_limit_ms: u32,
wall_clock_limit_ms: u32,
limit_external_memory: bool,
warm_hint: Option<WarmSessionHint>,
create_session: F,
) -> Result<PendingV8SessionRegistration<'a>, JavascriptExecutionError>
Expand All @@ -2433,6 +2439,7 @@ where
heap_limit_mb: (heap_limit_mb > 0).then_some(heap_limit_mb),
cpu_time_limit_ms: (cpu_time_limit_ms > 0).then_some(cpu_time_limit_ms),
wall_clock_limit_ms: (wall_clock_limit_ms > 0).then_some(wall_clock_limit_ms),
limit_external_memory,
warm_hint,
})
.map_err(JavascriptExecutionError::Spawn)?;
Expand Down Expand Up @@ -2782,6 +2789,7 @@ impl JavascriptExecutionEngine {
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
request.limits.limit_external_memory,
warm_hint,
|command| {
v8_host.create_session_from_command_with_runtime(
Expand Down Expand Up @@ -7584,6 +7592,7 @@ mod tests {
max_timers: Some(321),
reactor_work_quantum: Some(64),
bridge_call_timeout_ms: Some(15_000),
limit_external_memory: false,
},
wasm_module_bytes: None,
inline_code: None,
Expand Down Expand Up @@ -8000,6 +8009,7 @@ mod tests {
0,
0,
0,
false,
None,
|_command| {
Err(std::io::Error::new(
Expand Down
1 change: 1 addition & 0 deletions crates/execution/src/v8_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ impl V8RuntimeHost {
heap_limit_mb: non_zero_option(heap_limit_mb),
cpu_time_limit_ms: non_zero_option(cpu_time_limit_ms),
wall_clock_limit_ms: non_zero_option(wall_clock_limit_ms),
limit_external_memory: false,
warm_hint,
})
}
Expand Down
1 change: 1 addition & 0 deletions crates/native-sidecar/src/execution/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3073,6 +3073,7 @@ pub(super) fn javascript_execution_limits(vm: &VmState) -> JavascriptExecutionLi
max_timers: Some(vm.limits.js_runtime.max_timers),
reactor_work_quantum: vm_reactor_work_quantum(&vm.limits),
bridge_call_timeout_ms: Some(bridge_call_timeout_ms(&vm.limits)),
limit_external_memory: true,
}
}

Expand Down
13 changes: 12 additions & 1 deletion crates/v8-runtime/src/embedded_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ impl EmbeddedV8Runtime {
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
limit_external_memory,
warm_hint,
} => {
let output = self
Expand All @@ -260,6 +261,7 @@ impl EmbeddedV8Runtime {
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
limit_external_memory,
output_generation,
warm_hint,
event_tx,
Expand Down Expand Up @@ -310,6 +312,7 @@ impl EmbeddedV8Runtime {
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
limit_external_memory,
warm_hint,
} = command
else {
Expand Down Expand Up @@ -337,6 +340,7 @@ impl EmbeddedV8Runtime {
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
limit_external_memory,
output_generation,
warm_hint,
event_tx,
Expand Down Expand Up @@ -842,16 +846,19 @@ fn dispatch_runtime_command(
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
limit_external_memory,
warm_hint,
} => {
let mut mgr = session_mgr.lock().expect("session manager lock poisoned");
mgr.create_session_with_output_generation(
mgr.create_session_with_output_generation_and_sender(
session_id,
heap_limit_mb,
cpu_time_limit_ms,
wall_clock_limit_ms,
limit_external_memory,
None,
warm_hint,
None,
)
.map_err(other_io_error)
}
Expand Down Expand Up @@ -1286,6 +1293,7 @@ mod tests {
heap_limit_mb: None,
cpu_time_limit_ms: None,
wall_clock_limit_ms: None,
limit_external_memory: false,
warm_hint: None,
},
vm_runtime,
Expand Down Expand Up @@ -1329,6 +1337,7 @@ mod tests {
heap_limit_mb: None,
cpu_time_limit_ms: None,
wall_clock_limit_ms: None,
limit_external_memory: false,
warm_hint: None,
})
.expect("create session");
Expand Down Expand Up @@ -1708,6 +1717,7 @@ mod tests {
heap_limit_mb: None,
cpu_time_limit_ms: None,
wall_clock_limit_ms: None,
limit_external_memory: false,
warm_hint: None,
})
.expect("create first session");
Expand Down Expand Up @@ -1744,6 +1754,7 @@ mod tests {
heap_limit_mb: None,
cpu_time_limit_ms: None,
wall_clock_limit_ms: None,
limit_external_memory: false,
warm_hint: None,
})
.expect("create reused session");
Expand Down
119 changes: 105 additions & 14 deletions crates/v8-runtime/src/isolate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,14 @@ static ARRAY_BUFFER_BUDGET_VTABLE: v8::RustAllocatorVtable<ArrayBufferBudget> =
drop: budget_drop,
};

/// Isolate parameters shared by fresh and snapshot-restored guest isolates. The
/// heap limit in MB (`None` applies `DEFAULT_HEAP_LIMIT_MB`) caps the V8 heap and,
/// separately, the isolate's ArrayBuffer memory.
pub(crate) fn guest_isolate_params(heap_limit_mb: Option<u32>) -> v8::CreateParams {
/// Create a guest isolate, fresh or snapshot-restored (`customize` adds the
/// snapshot parameters). The heap limit in MB (`None` applies
/// `DEFAULT_HEAP_LIMIT_MB`) caps the V8 heap and, separately, the isolate's
/// ArrayBuffer memory.
pub(crate) fn create_guest_isolate(
heap_limit_mb: Option<u32>,
customize: impl FnOnce(v8::CreateParams) -> v8::CreateParams,
) -> v8::OwnedIsolate {
let limit = heap_limit_mb.unwrap_or(DEFAULT_HEAP_LIMIT_MB);
let limit_bytes = (limit as usize) * 1024 * 1024;
let budget = Arc::new(ArrayBufferBudget {
Expand All @@ -388,11 +392,22 @@ pub(crate) fn guest_isolate_params(heap_limit_mb: Option<u32>) -> v8::CreatePara
});
// Safety: the allocator owns this Arc reference and releases it through
// `budget_drop`; the vtable expects exactly an `ArrayBufferBudget`.
let allocator =
unsafe { v8::new_rust_allocator(Arc::into_raw(budget), &ARRAY_BUFFER_BUDGET_VTABLE) };
v8::CreateParams::default()
.heap_limits(0, limit_bytes)
.array_buffer_allocator(allocator.make_shared())
let allocator = unsafe {
v8::new_rust_allocator(
Arc::into_raw(Arc::clone(&budget)),
&ARRAY_BUFFER_BUDGET_VTABLE,
)
};
let params = customize(
v8::CreateParams::default()
.heap_limits(0, limit_bytes)
.array_buffer_allocator(allocator.make_shared()),
);
let mut isolate = with_isolate_lifecycle_lock(|| v8::Isolate::new(params));
configure_isolate(&mut isolate);
install_heap_limit_guard(&mut isolate);
isolate.set_slot(budget);
isolate
}

/// Create a new V8 isolate with an optional heap limit in MB. `None` applies the
Expand All @@ -401,11 +416,87 @@ pub(crate) fn guest_isolate_params(heap_limit_mb: Option<u32>) -> v8::CreatePara
/// than fatal-aborting the shared process.
pub fn create_isolate(heap_limit_mb: Option<u32>) -> v8::OwnedIsolate {
prepare_current_thread();
let params = guest_isolate_params(heap_limit_mb);
let mut isolate = with_isolate_lifecycle_lock(|| v8::Isolate::new(params));
configure_isolate(&mut isolate);
install_heap_limit_guard(&mut isolate);
isolate
create_guest_isolate(heap_limit_mb, |params| params)
}

/// State the external-memory GC check needs for one isolate.
struct ExternalMemoryGuard {
budget: Arc<ArrayBufferBudget>,
exceeded: Arc<AtomicBool>,
warned: AtomicBool,
}

/// Isolate slot that marks the external-memory guard as installed.
struct ExternalMemoryGuardInstalled;

/// Stop guest JavaScript whose memory outside the ArrayBuffer budget passes the
/// heap limit.
///
/// V8 allocates WebAssembly memory through its page allocator, so the
/// ArrayBuffer budget never sees it, and host-provided buffers are created
/// outside that budget on purpose. V8 counts both as external memory and
/// collects garbage as it grows, so a GC prologue check bounds them. Past the
/// limit the script is terminated and reported as a heap-limit failure.
///
/// Install this only for guest JavaScript sessions. Python (Pyodide) and the
/// WASM runner keep their own program memory in WebAssembly and bound it with
/// their own limits. Calling it again on the same isolate does nothing.
pub fn install_external_memory_guard(isolate: &mut v8::OwnedIsolate) {
if isolate.get_slot::<ExternalMemoryGuardInstalled>().is_some() {
return;
}
let budget = Arc::clone(
isolate
.get_slot::<Arc<ArrayBufferBudget>>()
.expect("guest isolates carry an ArrayBuffer budget"),
);
let exceeded = Arc::clone(
&isolate
.get_slot::<HeapLimitExceeded>()
.expect("guest isolates carry a heap-limit guard")
.0,
);
// Leaked like `HeapLimitGuard`: V8 can call the callback until the isolate
// is disposed, after its slots are gone.
let guard = Box::into_raw(Box::new(ExternalMemoryGuard {
budget,
exceeded,
warned: AtomicBool::new(false),
}));
isolate.add_gc_prologue_callback(
external_memory_gc_callback,
guard as *mut c_void,
v8::GCType::ALL,
);
isolate.set_slot(ExternalMemoryGuardInstalled);
}

extern "C" fn external_memory_gc_callback(
isolate: *mut v8::Isolate,
_gc_type: v8::GCType,
_flags: v8::GCCallbackFlags,
data: *mut c_void,
) {
// Safety: `data` is the guard leaked by `install_external_memory_guard`, and
// V8 runs GC prologue callbacks on the isolate's own thread.
let guard = unsafe { &*(data as *const ExternalMemoryGuard) };
let isolate = unsafe { &mut *isolate };
let mut statistics = v8::HeapStatistics::default();
isolate.get_heap_statistics(&mut statistics);
// External memory also holds the non-shared ArrayBuffers the budget already
// counts, so only the remainder is checked here.
let outside_budget = statistics
.external_memory()
.saturating_sub(guard.budget.used.load(Ordering::Acquire));
let limit = guard.budget.limit;
if outside_budget <= limit {
return;
}
isolate.terminate_execution();
guard.exceeded.store(true, Ordering::Release);
if !guard.warned.swap(true, Ordering::Relaxed) {
warn_limit_exhausted(TrackedLimit::V8ExternalMemoryBytes, outside_budget, limit);
}
}

/// Run V8 isolate create/drop work under a process-wide lifecycle lock.
Expand Down
5 changes: 5 additions & 0 deletions crates/v8-runtime/src/runtime_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ pub enum RuntimeCommand {
heap_limit_mb: Option<u32>,
cpu_time_limit_ms: Option<u32>,
wall_clock_limit_ms: Option<u32>,
/// Stop the script when WebAssembly memory and host-provided buffers pass
/// the heap limit. Set for guest JavaScript only; Python and the WASM
/// runner keep their program memory in WebAssembly under their own limits.
limit_external_memory: bool,
warm_hint: Option<WarmSessionHint>,
},
DestroySession {
Expand Down Expand Up @@ -187,6 +191,7 @@ impl TryFrom<BinaryFrame> for RuntimeCommand {
heap_limit_mb: non_zero_option(heap_limit_mb),
cpu_time_limit_ms: non_zero_option(cpu_time_limit_ms),
wall_clock_limit_ms: non_zero_option(wall_clock_limit_ms),
limit_external_memory: false,
warm_hint: None,
}),
BinaryFrame::DestroySession { session_id } => {
Expand Down
Loading
Loading