diff --git a/Cargo.lock b/Cargo.lock index fdaa928aed..4117b73f8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ dependencies = [ "agentos-native-sidecar-core", "agentos-runtime", "agentos-sidecar-protocol", + "agentos-v8-runtime", "agentos-vfs", "agentos-vfs-core", "agentos-vm-config", diff --git a/crates/native-sidecar/Cargo.toml b/crates/native-sidecar/Cargo.toml index 120a51a87c..4fca8a80ad 100644 --- a/crates/native-sidecar/Cargo.toml +++ b/crates/native-sidecar/Cargo.toml @@ -21,6 +21,7 @@ agentos-sidecar-protocol = { workspace = true } agentos-native-sidecar-core = { workspace = true } agentos-runtime = { workspace = true } agentos-execution = { workspace = true } +agentos-v8-runtime = { workspace = true } agentos-vm-config = { workspace = true } agentos-vfs = { workspace = true } async-trait = "0.1" diff --git a/crates/native-sidecar/src/lib.rs b/crates/native-sidecar/src/lib.rs index fa357a142d..d604c6fb43 100644 --- a/crates/native-sidecar/src/lib.rs +++ b/crates/native-sidecar/src/lib.rs @@ -16,6 +16,7 @@ pub(crate) mod json_rpc; pub(crate) mod language_execution; pub mod limits; pub(crate) mod metadata; +pub(crate) mod native_heap; pub(crate) mod ownership_coordinator; pub mod package_projection; pub(crate) mod plugins; diff --git a/crates/native-sidecar/src/native_heap.rs b/crates/native-sidecar/src/native_heap.rs new file mode 100644 index 0000000000..548160dae3 --- /dev/null +++ b/crates/native-sidecar/src/native_heap.rs @@ -0,0 +1,51 @@ +//! Return native heap memory to the OS after VM disposal. +//! +//! V8 isolates allocate through glibc malloc, which keeps freed arena pages +//! mapped after an isolate is destroyed. A disposed VM therefore leaves a high +//! RSS watermark unless the sidecar asks glibc to release free pages. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use agentos_runtime::{BlockingJobError, RuntimeContext, TaskClass}; + +/// Coalescing window. Every disposal inside the window shares one trim, so VM +/// churn causes at most one trim per window across the whole process. +const TRIM_COALESCE_WINDOW: Duration = Duration::from_secs(1); + +static TRIM_SCHEDULED: AtomicBool = AtomicBool::new(false); + +/// Schedule one coalesced native heap trim on the shared blocking executor. +/// +/// A no-op unless the sidecar runs on Linux with glibc. +pub(crate) fn schedule_trim_after_vm_disposal(runtime: &RuntimeContext) { + if !cfg!(all(target_os = "linux", target_env = "gnu")) { + return; + } + if TRIM_SCHEDULED.swap(true, Ordering::AcqRel) { + return; + } + let blocking = runtime.blocking().clone(); + let spawned = runtime.spawn(TaskClass::Timer, async move { + tokio::time::sleep(TRIM_COALESCE_WINDOW).await; + // Clear before the trim so a disposal that finishes during the trim + // schedules a later one instead of being absorbed by this one. + TRIM_SCHEDULED.store(false, Ordering::Release); + match blocking + .run(0, agentos_v8_runtime::native_heap::release_free_native_heap) + .await + { + Ok(_) => {} + Err(BlockingJobError::ShuttingDown) => { + tracing::debug!("skipped native heap trim: sidecar runtime is shutting down"); + } + Err(error) => { + tracing::warn!(%error, "ERR_AGENTOS_NATIVE_HEAP_TRIM: native heap trim failed"); + } + } + }); + if let Err(error) = spawned { + TRIM_SCHEDULED.store(false, Ordering::Release); + tracing::warn!(%error, "ERR_AGENTOS_NATIVE_HEAP_TRIM: failed to schedule native heap trim"); + } +} diff --git a/crates/native-sidecar/src/service.rs b/crates/native-sidecar/src/service.rs index 84bcf3327f..517d154b79 100644 --- a/crates/native-sidecar/src/service.rs +++ b/crates/native-sidecar/src/service.rs @@ -1654,7 +1654,8 @@ where /// reset) erroring out with `?` can never strand these maps for the rest of /// the process lifetime (H1). This also reclaims extension output buffers, /// which was previously removed only on a successful handoff and leaked on VM - /// or session disposal (M6). + /// or session disposal (M6). Finally, it schedules a coalesced native heap + /// trim so the disposed VM's freed V8 memory returns to the OS. pub(crate) fn reclaim_vm_tracking(&mut self, session_id: &str, vm_id: &str) { self.prune_extension_vm_resource(vm_id); self.extension_process_output_buffers @@ -1662,6 +1663,9 @@ where if let Some(session) = self.sessions.get_mut(session_id) { session.vm_ids.remove(vm_id); } + if let Some(runtime_context) = self.runtime_context.as_ref() { + crate::native_heap::schedule_trim_after_vm_disposal(runtime_context); + } } /// Probe buffered extension process output without retaining the sidecar diff --git a/crates/native-sidecar/tests/service.rs b/crates/native-sidecar/tests/service.rs index 2f6a045c62..743afc0eca 100644 --- a/crates/native-sidecar/tests/service.rs +++ b/crates/native-sidecar/tests/service.rs @@ -30,6 +30,8 @@ mod limits; #[allow(dead_code)] #[path = "../src/metadata/mod.rs"] mod metadata; +#[path = "../src/native_heap.rs"] +mod native_heap; #[allow(dead_code)] #[path = "../src/package_projection.rs"] mod package_projection; diff --git a/crates/v8-runtime/src/lib.rs b/crates/v8-runtime/src/lib.rs index f788f0dfd1..2ecf34dcee 100644 --- a/crates/v8-runtime/src/lib.rs +++ b/crates/v8-runtime/src/lib.rs @@ -5,6 +5,7 @@ pub mod host_call; pub mod ipc; pub mod ipc_binary; pub mod isolate; +pub mod native_heap; pub mod runtime_protocol; pub mod session; pub mod snapshot; diff --git a/crates/v8-runtime/src/native_heap.rs b/crates/v8-runtime/src/native_heap.rs new file mode 100644 index 0000000000..d68032885d --- /dev/null +++ b/crates/v8-runtime/src/native_heap.rs @@ -0,0 +1,18 @@ +//! Native heap maintenance for memory freed by V8 and other C/C++ code. + +/// Return free glibc heap pages to the OS. +/// +/// V8 isolates and other native code allocate through glibc malloc, which keeps +/// freed arena memory mapped after an isolate is destroyed. Returns whether +/// any memory was released. On other platforms and C libraries this is a no-op. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn release_free_native_heap() -> bool { + // SAFETY: `malloc_trim` has no preconditions; it takes the allocator's own + // arena locks and only releases pages that are already free. + unsafe { libc::malloc_trim(0) != 0 } +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn release_free_native_heap() -> bool { + false +} diff --git a/docs/content/docs/resource-limits.mdx b/docs/content/docs/resource-limits.mdx index b7eb6b4a7a..c087b15d58 100644 --- a/docs/content/docs/resource-limits.mdx +++ b/docs/content/docs/resource-limits.mdx @@ -71,6 +71,18 @@ Library VMs share a sidecar process by default. Limits that apply to the whole process, such as `runtime.executor.maxActiveVms`, are set when creating a dedicated sidecar with `AgentOs.createSidecar()`, as in the example above. +### Sidecar memory + +On Linux with glibc, the sidecar returns memory freed by a disposed VM's V8 +isolates to the OS shortly after disposal. + +The sidecar also keeps a pool of up to four pre-started V8 isolates so that +JavaScript and Python executions start faster. The pool stays resident while +the sidecar runs, at about 20 MiB per isolate. To cap it, set +`AGENTOS_V8_WARM_ISOLATES` to a value from `0` to `4` in the environment that +starts the sidecar. `0` disables the pool and trades memory for slower +execution starts. + ## Embedded API Limits apply to VMs created with `AgentOs.create()` and sidecars created with