diff --git a/crates/bridge/src/queue_tracker.rs b/crates/bridge/src/queue_tracker.rs index a2a9fe8732..f8abb3f327 100644 --- a/crates/bridge/src/queue_tracker.rs +++ b/crates/bridge/src/queue_tracker.rs @@ -96,6 +96,7 @@ pub enum TrackedLimit { VmRecursiveFsEntries, V8HeapBytes, V8ArrayBufferBytes, + V8ExternalMemoryBytes, V8CpuTimeMs, V8WallClockMs, WasmFuelMs, @@ -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", @@ -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 diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index 4faaee424c..a824823983 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -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, + /// 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 @@ -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, create_session: F, ) -> Result, JavascriptExecutionError> @@ -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)?; @@ -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( @@ -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, @@ -8000,6 +8009,7 @@ mod tests { 0, 0, 0, + false, None, |_command| { Err(std::io::Error::new( diff --git a/crates/execution/src/v8_host.rs b/crates/execution/src/v8_host.rs index 6c0f87dfee..6bf3e4ab8b 100644 --- a/crates/execution/src/v8_host.rs +++ b/crates/execution/src/v8_host.rs @@ -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, }) } diff --git a/crates/native-sidecar/src/execution/launch.rs b/crates/native-sidecar/src/execution/launch.rs index 4ad277feda..33560c9f53 100644 --- a/crates/native-sidecar/src/execution/launch.rs +++ b/crates/native-sidecar/src/execution/launch.rs @@ -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, } } diff --git a/crates/v8-runtime/src/embedded_runtime.rs b/crates/v8-runtime/src/embedded_runtime.rs index 2d3a02a08e..283e8d0da9 100644 --- a/crates/v8-runtime/src/embedded_runtime.rs +++ b/crates/v8-runtime/src/embedded_runtime.rs @@ -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 @@ -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, @@ -310,6 +312,7 @@ impl EmbeddedV8Runtime { heap_limit_mb, cpu_time_limit_ms, wall_clock_limit_ms, + limit_external_memory, warm_hint, } = command else { @@ -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, @@ -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) } @@ -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, @@ -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"); @@ -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"); @@ -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"); diff --git a/crates/v8-runtime/src/isolate.rs b/crates/v8-runtime/src/isolate.rs index 4b3074c74c..1acbcc4612 100644 --- a/crates/v8-runtime/src/isolate.rs +++ b/crates/v8-runtime/src/isolate.rs @@ -375,10 +375,14 @@ static ARRAY_BUFFER_BUDGET_VTABLE: v8::RustAllocatorVtable = 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) -> 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, + 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 { @@ -388,11 +392,22 @@ pub(crate) fn guest_isolate_params(heap_limit_mb: Option) -> 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 @@ -401,11 +416,87 @@ pub(crate) fn guest_isolate_params(heap_limit_mb: Option) -> v8::CreatePara /// than fatal-aborting the shared process. pub fn create_isolate(heap_limit_mb: Option) -> 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, + exceeded: Arc, + 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::().is_some() { + return; + } + let budget = Arc::clone( + isolate + .get_slot::>() + .expect("guest isolates carry an ArrayBuffer budget"), + ); + let exceeded = Arc::clone( + &isolate + .get_slot::() + .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. diff --git a/crates/v8-runtime/src/runtime_protocol.rs b/crates/v8-runtime/src/runtime_protocol.rs index 17d4f47e31..f937002bea 100644 --- a/crates/v8-runtime/src/runtime_protocol.rs +++ b/crates/v8-runtime/src/runtime_protocol.rs @@ -17,6 +17,10 @@ pub enum RuntimeCommand { heap_limit_mb: Option, cpu_time_limit_ms: Option, wall_clock_limit_ms: Option, + /// 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, }, DestroySession { @@ -187,6 +191,7 @@ impl TryFrom 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 } => { diff --git a/crates/v8-runtime/src/session.rs b/crates/v8-runtime/src/session.rs index db1f858e26..13fa95b152 100644 --- a/crates/v8-runtime/src/session.rs +++ b/crates/v8-runtime/src/session.rs @@ -506,6 +506,7 @@ struct SessionAssignment { heap_limit_mb: Option, cpu_time_limit_ms: Option, wall_clock_limit_ms: Option, + limit_external_memory: bool, rx: Receiver, shutdown_rx: Receiver<()>, ready_rx: Receiver, @@ -1340,6 +1341,7 @@ impl SessionManager { heap_limit_mb, cpu_time_limit_ms, wall_clock_limit_ms, + false, output_generation, warm_hint, None, @@ -1353,6 +1355,7 @@ impl SessionManager { heap_limit_mb: Option, cpu_time_limit_ms: Option, wall_clock_limit_ms: Option, + limit_external_memory: bool, output_generation: Option, warm_hint: Option, event_tx: Option, @@ -1371,6 +1374,7 @@ impl SessionManager { heap_limit_mb, cpu_time_limit_ms, wall_clock_limit_ms, + limit_external_memory, output_generation, warm_hint, event_tx, @@ -1390,6 +1394,7 @@ impl SessionManager { heap_limit_mb: Option, cpu_time_limit_ms: Option, wall_clock_limit_ms: Option, + limit_external_memory: bool, output_generation: Option, warm_hint: Option, event_tx: Option, @@ -1431,6 +1436,7 @@ impl SessionManager { heap_limit_mb, cpu_time_limit_ms, wall_clock_limit_ms, + limit_external_memory, rx, shutdown_rx, ready_rx, @@ -2237,6 +2243,7 @@ fn session_thread( heap_limit_mb, cpu_time_limit_ms, wall_clock_limit_ms, + limit_external_memory, rx, shutdown_rx, ready_rx, @@ -2263,6 +2270,7 @@ fn session_thread( heap_limit_mb, cpu_time_limit_ms, wall_clock_limit_ms, + limit_external_memory, call_id_router, shared_call_id, snapshot_cache, @@ -2533,6 +2541,9 @@ fn session_thread( let iso = v8_isolate.as_mut().unwrap(); iso.cancel_terminate_execution(); + if limit_external_memory { + isolate::install_external_memory_guard(iso); + } // Language executions set bit 1 to reuse the session's // process-lifetime context. Normal process executions keep diff --git a/crates/v8-runtime/src/snapshot.rs b/crates/v8-runtime/src/snapshot.rs index 974f96e656..813ab3a7a8 100644 --- a/crates/v8-runtime/src/snapshot.rs +++ b/crates/v8-runtime/src/snapshot.rs @@ -580,17 +580,13 @@ where init_v8_platform(); crate::isolate::prepare_current_thread(); - // Same heap and ArrayBuffer caps as the fresh-isolate path — a - // snapshot-restored isolate is never unbounded. - let params = crate::isolate::guest_isolate_params(heap_limit_mb) - .snapshot_blob(blob) - .external_references(&**external_refs()); - let mut isolate = crate::isolate::with_isolate_lifecycle_lock(|| v8::Isolate::new(params)); - crate::isolate::configure_isolate(&mut isolate); - // Same OOM guard as the fresh-isolate path: terminate this isolate on heap - // exhaustion instead of fatal-aborting the shared process (F-003). - crate::isolate::install_heap_limit_guard(&mut isolate); - isolate + // Same heap and ArrayBuffer caps and OOM guard as the fresh-isolate path, so + // a snapshot-restored isolate is never unbounded (F-003). + crate::isolate::create_guest_isolate(heap_limit_mb, |params| { + params + .snapshot_blob(blob) + .external_references(&**external_refs()) + }) } pub type SnapshotCacheKey = [u8; 32]; diff --git a/crates/v8-runtime/tests/embedded_runtime_session.rs b/crates/v8-runtime/tests/embedded_runtime_session.rs index cb3d32a728..05196f930d 100644 --- a/crates/v8-runtime/tests/embedded_runtime_session.rs +++ b/crates/v8-runtime/tests/embedded_runtime_session.rs @@ -93,6 +93,7 @@ fn register_and_create_session( heap_limit_mb: None, cpu_time_limit_ms: None, wall_clock_limit_ms: None, + limit_external_memory: false, warm_hint: None, }, session_runtime, @@ -116,6 +117,7 @@ fn register_and_create_session_with_cpu_time_limit( heap_limit_mb: None, cpu_time_limit_ms, wall_clock_limit_ms: None, + limit_external_memory: false, warm_hint: None, }, session_runtime, @@ -265,6 +267,7 @@ fn assert_create_destroy_reuses_session_ids() -> io::Result<()> { heap_limit_mb: None, cpu_time_limit_ms: None, wall_clock_limit_ms: None, + limit_external_memory: false, warm_hint: None, }) .expect_err("duplicate sessions should be rejected"); @@ -487,6 +490,7 @@ fn assert_overload_rejects_before_thread_and_recovers_after_release() -> io::Res heap_limit_mb: None, cpu_time_limit_ms: None, wall_clock_limit_ms: None, + limit_external_memory: false, warm_hint: None, }) .expect_err("executor saturation must reject before spawning another VM thread"); @@ -607,6 +611,7 @@ fn assert_shared_runtime_handles_share_concurrency_quota() -> io::Result<()> { heap_limit_mb: None, cpu_time_limit_ms: None, wall_clock_limit_ms: None, + limit_external_memory: false, warm_hint: None, }) .expect_err("the shared executor limit must reject a fourth VM before thread creation"); diff --git a/docs/content/docs/resource-limits.mdx b/docs/content/docs/resource-limits.mdx index e10ee0eb8d..81fb82ae42 100644 --- a/docs/content/docs/resource-limits.mdx +++ b/docs/content/docs/resource-limits.mdx @@ -89,8 +89,16 @@ backs typed arrays, `Buffer`, and `SharedArrayBuffer`. The size is fails with `execution_heap_limit_exceeded`. An allocation that goes past the `ArrayBuffer` cap throws `RangeError: Array buffer allocation failed`, and the sidecar reports the `v8_array_buffer_bytes` limit warning. Other VMs on the -sidecar keep running in both cases. WebAssembly memory and resizable -`ArrayBuffer`s are not counted toward either cap. +sidecar keep running in both cases. + +In JavaScript, WebAssembly memory and buffers that hold data from the host, +such as file contents, have a third cap of the same size. V8 checks it during +garbage collection. A script that goes past it fails with +`execution_heap_limit_exceeded`, and the sidecar reports the +`v8_external_memory_bytes` limit warning. Python keeps its interpreter memory +in WebAssembly, so this cap does not apply to Python. Shared WebAssembly +memory, resizable `ArrayBuffer`s, and growable `SharedArrayBuffer`s are not +counted toward any cap. ### Sidecar memory diff --git a/packages/core/tests/code-mode-execution.test.ts b/packages/core/tests/code-mode-execution.test.ts index 83b5325f60..ed86e737de 100644 --- a/packages/core/tests/code-mode-execution.test.ts +++ b/packages/core/tests/code-mode-execution.test.ts @@ -184,4 +184,36 @@ describe("code mode execution", () => { value: 2, }); }, 30_000); + + test("WebAssembly memory past the heap limit fails only its own script", async () => { + // 1 GiB of WebAssembly memory against the default 128 MiB limit. + const result = await vm.javascript.evaluate( + `(() => { + const held = []; + for (let i = 0; i < 16; i++) { + const memory = new WebAssembly.Memory({ initial: 1024 }); + new Uint8Array(memory.buffer).fill(1); + held.push(memory); + } + return held.length; + })()`, + { timeoutMs: 10_000 }, + ); + + expect(result).toMatchObject({ + outcome: "failed", + error: { + code: "execution_heap_limit_exceeded", + message: expect.stringContaining("limits.jsRuntime.v8HeapLimitMb"), + }, + }); + await expect + .poll(() => limitWarnings) + .toContain("v8_external_memory_bytes"); + expect( + await vm.javascript.evaluate( + "new WebAssembly.Memory({ initial: 512 }).buffer.byteLength", + ), + ).toMatchObject({ outcome: "succeeded", value: 32 * 1024 * 1024 }); + }, 30_000); });