diff --git a/crates/bridge/src/queue_tracker.rs b/crates/bridge/src/queue_tracker.rs index 7ef9130f9e..f8abb3f327 100644 --- a/crates/bridge/src/queue_tracker.rs +++ b/crates/bridge/src/queue_tracker.rs @@ -95,6 +95,8 @@ pub enum TrackedLimit { VmRecursiveFsDepth, VmRecursiveFsEntries, V8HeapBytes, + V8ArrayBufferBytes, + V8ExternalMemoryBytes, V8CpuTimeMs, V8WallClockMs, WasmFuelMs, @@ -132,6 +134,8 @@ impl TrackedLimit { TrackedLimit::VmRecursiveFsDepth => "vm_recursive_fs_depth", 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", @@ -166,7 +170,10 @@ impl TrackedLimit { | TrackedLimit::VmInodes | TrackedLimit::VmRecursiveFsDepth | TrackedLimit::VmRecursiveFsEntries => LimitCategory::Resource, - TrackedLimit::V8HeapBytes | TrackedLimit::WasmMemoryBytes => LimitCategory::Memory, + 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/bridge.rs b/crates/v8-runtime/src/bridge.rs index 2f46e12228..a5d78913b6 100644 --- a/crates/v8-runtime/src/bridge.rs +++ b/crates/v8-runtime/src/bridge.rs @@ -535,23 +535,8 @@ fn cbor_to_v8_inner<'s>( ciborium::Value::Text(s) => Ok(v8::String::new(scope, s) .ok_or_else(|| "CBOR decode failed to allocate string".to_string())? .into()), - ciborium::Value::Bytes(b) => { - let len = b.len(); - let ab = v8::ArrayBuffer::new(scope, len); - if len > 0 { - let bs = ab.get_backing_store(); - unsafe { - std::ptr::copy_nonoverlapping( - b.as_ptr(), - bs.data().unwrap().as_ptr() as *mut u8, - len, - ); - } - } - Ok(v8::Uint8Array::new(scope, ab, 0, len) - .ok_or_else(|| "CBOR decode failed to allocate byte array".to_string())? - .into()) - } + ciborium::Value::Bytes(b) => raw_bytes_to_uint8array(scope, b) + .ok_or_else(|| "CBOR decode failed to allocate byte array".to_string()), ciborium::Value::Array(items) => { cbor_container_item_count("array", items.len())?; let arr = v8::Array::new(scope, items.len() as i32); @@ -580,14 +565,13 @@ fn raw_bytes_to_uint8array<'s>( scope: &mut v8::HandleScope<'s>, bytes: &[u8], ) -> Option> { + // Host data uses a Rust-owned backing store. `v8::ArrayBuffer::new` draws + // from the isolate's ArrayBuffer budget and fatal-aborts the whole process + // when that budget is spent, so a guest that fills it could then crash the + // sidecar by reading a file. let len = bytes.len(); - let ab = v8::ArrayBuffer::new(scope, len); - if len > 0 { - let bs = ab.get_backing_store(); - unsafe { - std::ptr::copy_nonoverlapping(bytes.as_ptr(), bs.data()?.as_ptr() as *mut u8, len); - } - } + let backing_store = v8::ArrayBuffer::new_backing_store_from_vec(bytes.to_vec()); + let ab = v8::ArrayBuffer::with_backing_store(scope, &backing_store.make_shared()); v8::Uint8Array::new(scope, ab, 0, len).map(Into::into) } 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 cd8375a3df..1acbcc4612 100644 --- a/crates/v8-runtime/src/isolate.rs +++ b/crates/v8-runtime/src/isolate.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::ffi::c_void; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Mutex, Once}; use std::thread; @@ -268,22 +268,237 @@ pub fn install_heap_limit_guard(isolate: &mut v8::OwnedIsolate) { isolate.add_near_heap_limit_callback(near_heap_limit_callback, data); } -/// Create a new V8 isolate with an optional heap limit in MB. `None` applies the -/// bounded-by-default cap (`DEFAULT_HEAP_LIMIT_MB`) — an isolate is NEVER created -/// with an unbounded heap, so a guest heap bomb terminates its own isolate rather -/// than fatal-aborting the shared process. -pub fn create_isolate(heap_limit_mb: Option) -> v8::OwnedIsolate { - prepare_current_thread(); +/// Byte budget for one isolate's ArrayBuffer backing stores. +/// +/// `ArrayBuffer`, typed array, `Buffer`, and `SharedArrayBuffer` memory lives +/// outside the V8 heap, so `heap_limits` does not bound it. Without a budget one +/// guest can grow the shared sidecar until the host OOM-kills every VM on it. +/// Past the budget the allocator returns null; V8 then collects garbage, +/// retries, and finally throws `RangeError: Array buffer allocation failed`, the +/// same error Node reports when an allocation fails. +/// +/// V8 allocates WebAssembly memory and resizable buffers through its page +/// allocator instead, so this budget does not count them. +struct ArrayBufferBudget { + used: AtomicUsize, + limit: usize, + warned: AtomicBool, +} + +impl ArrayBufferBudget { + fn reserve(&self, len: usize) -> bool { + let reserved = self + .used + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(len).filter(|next| *next <= self.limit) + }) + .is_ok(); + // V8 retries a refused allocation after each GC, so warn once per + // isolate instead of once per attempt. + if !reserved && !self.warned.swap(true, Ordering::Relaxed) { + warn_limit_exhausted( + TrackedLimit::V8ArrayBufferBytes, + self.used.load(Ordering::Relaxed).saturating_add(len), + self.limit, + ); + } + reserved + } + + fn release(&self, len: usize) { + self.used.fetch_sub(len, Ordering::AcqRel); + } +} + +// V8 calls these from the isolate thread and from its GC threads. They must not +// call back into V8. +unsafe extern "C" fn budget_allocate(budget: &ArrayBufferBudget, len: usize) -> *mut c_void { + if !budget.reserve(len) { + return std::ptr::null_mut(); + } + let data = unsafe { libc::calloc(len.max(1), 1) }; + if data.is_null() { + budget.release(len); + } + data +} + +unsafe extern "C" fn budget_allocate_uninitialized( + budget: &ArrayBufferBudget, + len: usize, +) -> *mut c_void { + if !budget.reserve(len) { + return std::ptr::null_mut(); + } + let data = unsafe { libc::malloc(len.max(1)) }; + if data.is_null() { + budget.release(len); + } + data +} + +unsafe extern "C" fn budget_free(budget: &ArrayBufferBudget, data: *mut c_void, len: usize) { + unsafe { libc::free(data) }; + budget.release(len); +} + +unsafe extern "C" fn budget_reallocate( + budget: &ArrayBufferBudget, + data: *mut c_void, + old_len: usize, + new_len: usize, +) -> *mut c_void { + if new_len > old_len && !budget.reserve(new_len - old_len) { + return std::ptr::null_mut(); + } + let resized = unsafe { libc::realloc(data, new_len.max(1)) }; + if resized.is_null() { + if new_len > old_len { + budget.release(new_len - old_len); + } + } else if new_len < old_len { + budget.release(old_len - new_len); + } + resized +} + +unsafe extern "C" fn budget_drop(budget: *const ArrayBufferBudget) { + drop(unsafe { Arc::from_raw(budget) }); +} + +static ARRAY_BUFFER_BUDGET_VTABLE: v8::RustAllocatorVtable = + v8::RustAllocatorVtable { + allocate: budget_allocate, + allocate_uninitialized: budget_allocate_uninitialized, + free: budget_free, + reallocate: budget_reallocate, + drop: budget_drop, + }; + +/// 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 mut params = v8::CreateParams::default(); let limit_bytes = (limit as usize) * 1024 * 1024; - params = params.heap_limits(0, limit_bytes); + let budget = Arc::new(ArrayBufferBudget { + used: AtomicUsize::new(0), + limit: limit_bytes, + warned: AtomicBool::new(false), + }); + // 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(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 +/// bounded-by-default cap (`DEFAULT_HEAP_LIMIT_MB`) — an isolate is NEVER created +/// with an unbounded heap, so a guest heap bomb terminates its own isolate rather +/// than fatal-aborting the shared process. +pub fn create_isolate(heap_limit_mb: Option) -> v8::OwnedIsolate { + prepare_current_thread(); + 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. /// /// rusty_v8 130.0.7 embeds a V8 13.0-era process-wide WebAssembly code pointer 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 cb228d1fde..813ab3a7a8 100644 --- a/crates/v8-runtime/src/snapshot.rs +++ b/crates/v8-runtime/src/snapshot.rs @@ -580,20 +580,13 @@ where init_v8_platform(); crate::isolate::prepare_current_thread(); - // `None` applies the bounded-by-default cap (`DEFAULT_HEAP_LIMIT_MB`), same as - // the fresh-isolate path — a snapshot-restored isolate is never unbounded. - let limit = heap_limit_mb.unwrap_or(crate::isolate::DEFAULT_HEAP_LIMIT_MB); - let limit_bytes = (limit as usize) * 1024 * 1024; - let params = v8::CreateParams::default() - .snapshot_blob(blob) - .external_references(&**external_refs()) - .heap_limits(0, limit_bytes); - 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 b8b2b6f6ad..81fb82ae42 100644 --- a/docs/content/docs/resource-limits.mdx +++ b/docs/content/docs/resource-limits.mdx @@ -79,6 +79,27 @@ file content is base64-encoded, so one `writeFile` or `writeFiles` call carries at most about 12 MiB of binary data. To move larger files, split them into several files or mount a host directory. +### Isolate memory + +Each V8 isolate that runs guest JavaScript or Python has two memory caps of the +same size: one for the V8 heap and one for `ArrayBuffer` memory, which also +backs typed arrays, `Buffer`, and `SharedArrayBuffer`. The size is +`limits.jsRuntime.v8HeapLimitMb` for JavaScript (128 MiB by default) and +`limits.python.maxOldSpaceMb` for Python. A script that goes past the heap cap +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. + +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 On Linux with glibc, the sidecar returns memory freed by a disposed VM's V8 diff --git a/packages/core/tests/code-mode-execution.test.ts b/packages/core/tests/code-mode-execution.test.ts index b342458936..ed86e737de 100644 --- a/packages/core/tests/code-mode-execution.test.ts +++ b/packages/core/tests/code-mode-execution.test.ts @@ -20,10 +20,12 @@ describe("code mode execution", () => { let callStarts: number[] = []; let inFlight = 0; let maxInFlight = 0; + const limitWarnings: string[] = []; beforeAll(async () => { vm = await AgentOs.create({ permissions: ALLOW_ALL_VM_PERMISSIONS, + onLimitWarning: (warning) => limitWarnings.push(warning.limit), hostFunctions: { tools: { wait: { @@ -121,4 +123,97 @@ describe("code mode execution", () => { value: 2, }); }, 30_000); + + test("buffer memory past the heap limit fails the allocation, not the VM", async () => { + // 1 GiB of typed arrays against the default 128 MiB limit. + const result = await vm.javascript.evaluate( + `(() => { + const held = []; + try { + for (let i = 0; i < 16; i++) held.push(new Uint8Array(64 * 1024 * 1024).fill(1)); + } catch (error) { + return \`\${error.name}: \${error.message}\`; + } + return "held 1 GiB"; + })()`, + { timeoutMs: 10_000 }, + ); + + expect(result).toMatchObject({ + outcome: "succeeded", + value: "RangeError: Array buffer allocation failed", + }); + await expect.poll(() => limitWarnings).toContain("v8_array_buffer_bytes"); + // The script's buffers are released, so the same VM can allocate again. + expect( + await vm.javascript.evaluate( + "new Uint8Array(64 * 1024 * 1024).fill(1).length", + ), + ).toMatchObject({ outcome: "succeeded", value: 64 * 1024 * 1024 }); + }, 30_000); + + test("reading a file with no buffer memory left fails the read, not the sidecar", async () => { + await vm.writeFile( + "/tmp/host-bytes.bin", + new Uint8Array(1024 * 1024).fill(7), + ); + + const result = await vm.javascript.evaluate( + `(() => { + const held = []; + for (let size = 16 * 1024 * 1024; size >= 256; size >>= 1) { + try { + for (;;) held.push(new Uint8Array(size)); + } catch {} + } + try { + return require("node:fs").readFileSync("/tmp/host-bytes.bin").length; + } catch (error) { + return \`\${error.name}: \${error.message}\`; + } + })()`, + { timeoutMs: 20_000 }, + ); + + expect(result).toMatchObject({ + outcome: "succeeded", + value: "RangeError: Array buffer allocation failed", + }); + expect(await vm.javascript.evaluate("1 + 1")).toMatchObject({ + outcome: "succeeded", + 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); });