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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/native-sidecar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/native-sidecar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions crates/native-sidecar/src/native_heap.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
6 changes: 5 additions & 1 deletion crates/native-sidecar/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1654,14 +1654,18 @@ 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
.retain(|(buffer_vm_id, _process_id), _| buffer_vm_id != vm_id);
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
Expand Down
2 changes: 2 additions & 0 deletions crates/native-sidecar/tests/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions crates/v8-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions crates/v8-runtime/src/native_heap.rs
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions docs/content/docs/resource-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading