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
58 changes: 57 additions & 1 deletion crates/native-sidecar/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ use nix::fcntl::{fcntl, FcntlArg};

const CONTROL_FD: i32 = 3;

fn parse_runtime_config(
mut args: impl Iterator<Item = String>,
) -> Result<agentos_runtime::RuntimeConfig, String> {
let mut config = agentos_runtime::RuntimeConfig::default();
while let Some(argument) = args.next() {
let value = if argument == "--max-active-vms" {
args.next()
.ok_or_else(|| String::from("--max-active-vms requires a positive integer"))?
} else if let Some(value) = argument.strip_prefix("--max-active-vms=") {
value.to_owned()
} else {
return Err(format!("unknown agentOS sidecar argument: {argument}"));
};
let maximum = value.parse::<usize>().map_err(|_| {
format!("--max-active-vms must be a positive integer, received {value:?}")
})?;
if maximum == 0 {
return Err(String::from(
"--max-active-vms must be greater than zero when configured",
));
}
config.max_active_vm_executors = Some(maximum);
}
config.validate().map_err(|error| error.to_string())?;
Ok(config)
}

fn main() {
// Default to WARN so near-limit / backpressure warnings actually surface
// (they were swallowed at ERROR-only); operators can tune via AGENTOS_LOG
Expand All @@ -25,13 +52,42 @@ fn main() {
);
std::process::exit(1);
}
let runtime_config = match parse_runtime_config(std::env::args().skip(1)) {
Ok(config) => config,
Err(error) => {
tracing::error!(%error, "invalid agentOS sidecar configuration");
std::process::exit(1);
}
};
// SAFETY: the process launch contract reserves fd 3 for the inherited
// response/control socket and transfers its sole ownership to the sidecar.
// The fcntl probe above establishes that the descriptor is open before it
// is adopted.
let control_fd = unsafe { OwnedFd::from_raw_fd(CONTROL_FD) };
if let Err(error) = agentos_native_sidecar::stdio::run(control_fd) {
if let Err(error) =
agentos_native_sidecar::stdio::run_with_runtime_config(control_fd, runtime_config)
{
tracing::error!(?error, "agentos-native-sidecar startup failed");
std::process::exit(1);
}
}

#[cfg(test)]
mod tests {
use super::parse_runtime_config;

#[test]
fn runtime_executor_limit_is_uncapped_by_default_and_configurable() {
let default = parse_runtime_config(std::iter::empty()).expect("parse default config");
assert_eq!(default.max_active_vm_executors, None);

let configured =
parse_runtime_config([String::from("--max-active-vms"), String::from("7")].into_iter())
.expect("parse configured executor limit");
assert_eq!(configured.max_active_vm_executors, Some(7));

let error = parse_runtime_config([String::from("--max-active-vms=0")].into_iter())
.expect_err("zero executor limit must fail");
assert!(error.contains("greater than zero"));
}
}
20 changes: 19 additions & 1 deletion crates/native-sidecar/src/stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,13 @@ pub fn run(control_fd: OwnedFd) -> Result<(), Box<dyn Error>> {
run_with_extensions(Vec::new(), control_fd)
}

pub fn run_with_runtime_config(
control_fd: OwnedFd,
runtime: agentos_runtime::RuntimeConfig,
) -> Result<(), Box<dyn Error>> {
run_with_optional_control_and_runtime(Vec::new(), Some(control_fd), Some(runtime))
}

pub fn run_combined() -> Result<(), Box<dyn Error>> {
run_combined_with_extensions(Vec::new())
}
Expand All @@ -1407,10 +1414,21 @@ fn run_with_optional_control(
extensions: Vec<Box<dyn Extension>>,
control_fd: Option<OwnedFd>,
) -> Result<(), Box<dyn Error>> {
let config = NativeSidecarConfig {
run_with_optional_control_and_runtime(extensions, control_fd, None)
}

fn run_with_optional_control_and_runtime(
extensions: Vec<Box<dyn Extension>>,
control_fd: Option<OwnedFd>,
runtime: Option<agentos_runtime::RuntimeConfig>,
) -> Result<(), Box<dyn Error>> {
let mut config = NativeSidecarConfig {
compile_cache_root: Some(default_compile_cache_root()),
..NativeSidecarConfig::default()
};
if let Some(runtime) = runtime {
config.runtime = runtime;
}
let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime)?;
let runtime_context = runtime.context();
// Initialize the embedded V8 runtime + platform now, on the long-lived main
Expand Down
22 changes: 13 additions & 9 deletions crates/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,10 @@ impl RuntimeResourceConfig {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeConfig {
pub worker_threads: usize,
pub max_active_vm_executors: usize,
/// Optional process-wide ceiling for concurrently active V8 executors.
/// `None` leaves executor admission uncapped; CPU availability only sizes
/// the trusted worker pools.
pub max_active_vm_executors: Option<usize>,
pub vm_executor_teardown_timeout_ms: u64,
pub blocking_worker_threads: usize,
pub max_blocking_jobs: usize,
Expand All @@ -480,7 +483,7 @@ impl Default for RuntimeConfig {
.unwrap_or(1);
Self {
worker_threads: available.clamp(1, 4),
max_active_vm_executors: available.max(1),
max_active_vm_executors: None,
vm_executor_teardown_timeout_ms: DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS,
blocking_worker_threads: available.clamp(1, 4),
max_blocking_jobs: DEFAULT_MAX_BLOCKING_JOBS,
Expand All @@ -500,10 +503,6 @@ impl RuntimeConfig {
pub fn validate(&self) -> Result<(), RuntimeBuildError> {
for (field, value) in [
("runtime.workerThreads", self.worker_threads),
(
"runtime.executor.maxActiveVms",
self.max_active_vm_executors,
),
(
"runtime.blocking.workerThreads",
self.blocking_worker_threads,
Expand Down Expand Up @@ -711,6 +710,11 @@ impl RuntimeConfig {
)));
}
}
if self.max_active_vm_executors == Some(0) {
return Err(RuntimeBuildError(String::from(
"ERR_AGENTOS_RUNTIME_CONFIG: runtime.executor.maxActiveVms must be greater than zero when configured",
)));
}
if self.task_poll_watchdog_ms == 0 {
return Err(RuntimeBuildError(String::from(
"ERR_AGENTOS_RUNTIME_CONFIG: runtime.watchdog.taskPollMs must be greater than zero",
Expand Down Expand Up @@ -1254,7 +1258,7 @@ pub struct RuntimeContext {
fairness: FairWorkBroker,
terminal_failure: Arc<Mutex<Option<TaskTerminalReport>>>,
task_poll_watchdog: Duration,
max_active_vm_executors: usize,
max_active_vm_executors: Option<usize>,
vm_executor_teardown_timeout: Duration,
blocking_job_timeout: Duration,
admission_open: Arc<AtomicBool>,
Expand Down Expand Up @@ -1285,7 +1289,7 @@ impl RuntimeContext {
&self.metrics
}

pub fn max_active_vm_executors(&self) -> usize {
pub fn max_active_vm_executors(&self) -> Option<usize> {
self.max_active_vm_executors
}

Expand Down Expand Up @@ -1790,7 +1794,7 @@ mod tests {
.contains("runtime.tasks.maxTerminalReports"));

let error = RuntimeConfig {
max_active_vm_executors: 0,
max_active_vm_executors: Some(0),
..RuntimeConfig::default()
}
.validate()
Expand Down
14 changes: 7 additions & 7 deletions crates/v8-runtime/src/embedded_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ impl EmbeddedV8Runtime {
let configured_max_concurrency = runtime.max_active_vm_executors();
let executor_teardown_timeout = runtime.vm_executor_teardown_timeout();
let session_mgr = Arc::new(Mutex::new(SessionManager::new(
max_concurrency.unwrap_or(configured_max_concurrency),
max_concurrency.or(configured_max_concurrency),
crate::session::RuntimeEventSender::closed(),
call_id_router,
Arc::clone(&snapshot_cache),
Expand Down Expand Up @@ -682,7 +682,7 @@ pub fn spawn_embedded_runtime_ipc(
let shutdown_stream = host_stream.try_clone()?;
let alive = Arc::new(AtomicBool::new(true));
let alive_for_thread = Arc::clone(&alive);
let max_concurrency = max_concurrency.unwrap_or_else(|| runtime.max_active_vm_executors());
let max_concurrency = max_concurrency.or_else(|| runtime.max_active_vm_executors());

// AGENTOS_THREAD_SITE: embedded-v8-dispatch
let join_handle = thread::Builder::new()
Expand All @@ -706,7 +706,7 @@ pub fn spawn_embedded_runtime_ipc(

fn run_embedded_runtime(
stream: UnixStream,
max_concurrency: usize,
max_concurrency: Option<usize>,
runtime: agentos_runtime::RuntimeContext,
) {
// Keep bridge-only, agent-SDK, and wasm-runner userland variants warm
Expand Down Expand Up @@ -1175,7 +1175,7 @@ mod tests {
.lock()
.expect("embedded runtime codec test lock poisoned");
let mut config = agentos_runtime::RuntimeConfig {
max_active_vm_executors: 2,
max_active_vm_executors: Some(2),
vm_executor_teardown_timeout_ms: 31,
..agentos_runtime::RuntimeConfig::default()
};
Expand All @@ -1192,7 +1192,7 @@ mod tests {
.lock()
.expect("session manager")
.max_concurrency(),
2
Some(2)
);
assert_eq!(runtime.executor_teardown_timeout, Duration::from_millis(31));
let (_receiver, registration) = runtime
Expand Down Expand Up @@ -1357,7 +1357,7 @@ mod tests {
let call_id_router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
let runtime = test_runtime_context();
let session_mgr = Arc::new(Mutex::new(SessionManager::new(
1,
Some(1),
event_tx,
Arc::clone(&call_id_router),
Arc::clone(&snapshot_cache),
Expand Down Expand Up @@ -1808,7 +1808,7 @@ mod tests {
fn test_session_manager() -> Arc<Mutex<SessionManager>> {
let (event_tx, _event_rx) = crossbeam_channel::bounded::<RuntimeEventEnvelope>(1);
Arc::new(Mutex::new(SessionManager::new(
1,
Some(1),
event_tx,
Arc::new(BridgeCallRegistry::with_default_limit()),
Arc::new(SnapshotCache::new(1)),
Expand Down
21 changes: 11 additions & 10 deletions crates/v8-runtime/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,14 +1058,15 @@ struct SessionSlotPermit {
impl SessionSlotPermit {
fn try_acquire(
control: &SlotControl,
maximum: usize,
maximum: Option<usize>,
metrics: RuntimeMetrics,
) -> Result<Self, String> {
let (lock, _) = &**control;
let mut active = lock
.lock()
.map_err(|_| String::from("ERR_AGENTOS_VM_EXECUTOR_POISONED: slot lock poisoned"))?;
if *active >= maximum {
if maximum.is_some_and(|maximum| *active >= maximum) {
let maximum = maximum.expect("checked as present");
return Err(format!(
"ERR_AGENTOS_VM_EXECUTOR_LIMIT: active V8 executors reached limit of {maximum}; raise runtime.executor.maxActiveVms"
));
Expand Down Expand Up @@ -1222,7 +1223,7 @@ pub struct SessionManager {
/// thread itself retains the concurrency permit, so a successor cannot
/// consume capacity that is still running untrusted code.
quarantined: Vec<QuarantinedSession>,
max_concurrency: usize,
max_concurrency: Option<usize>,
slot_control: SlotControl,
/// Typed runtime event sender shared across session threads.
event_tx: RuntimeEventSender,
Expand Down Expand Up @@ -1251,7 +1252,7 @@ struct QuarantinedSession {

impl SessionManager {
pub fn new(
max_concurrency: usize,
max_concurrency: Option<usize>,
event_tx: impl Into<RuntimeEventSender>,
call_id_router: CallIdRouter,
snapshot_cache: Arc<SnapshotCache>,
Expand All @@ -1273,7 +1274,7 @@ impl SessionManager {
}

#[cfg(test)]
pub(crate) fn max_concurrency(&self) -> usize {
pub(crate) fn max_concurrency(&self) -> Option<usize> {
self.max_concurrency
}

Expand Down Expand Up @@ -3943,7 +3944,7 @@ mod tests {
agentos_runtime::SidecarRuntime::process(&agentos_runtime::RuntimeConfig::default())
.expect("create test process runtime")
.context();
let manager = SessionManager::new(max, tx, router, snap_cache, runtime);
let manager = SessionManager::new(Some(max), tx, router, snap_cache, runtime);
(manager, _rx)
}

Expand All @@ -3959,9 +3960,9 @@ mod tests {
let control: SlotControl = Arc::new((Mutex::new(0), Condvar::new()));
let metrics = RuntimeMetrics::new();

let first = SessionSlotPermit::try_acquire(&control, 2, metrics.clone())
let first = SessionSlotPermit::try_acquire(&control, Some(2), metrics.clone())
.expect("acquire first VM executor");
let second = SessionSlotPermit::try_acquire(&control, 2, metrics.clone())
let second = SessionSlotPermit::try_acquire(&control, Some(2), metrics.clone())
.expect("acquire second VM executor");
let active = metrics.snapshot().executors[ExecutorMetricClass::Vm.index()].active;
assert_eq!(active.current, 2);
Expand Down Expand Up @@ -4005,7 +4006,7 @@ mod tests {
return;
}
let mut config = agentos_runtime::RuntimeConfig {
max_active_vm_executors: 3,
max_active_vm_executors: Some(3),
vm_executor_teardown_timeout_ms: 23,
..agentos_runtime::RuntimeConfig::default()
};
Expand All @@ -4023,7 +4024,7 @@ mod tests {
runtime,
);

assert_eq!(manager.max_concurrency, 3);
assert_eq!(manager.max_concurrency, Some(3));
assert_eq!(manager.executor_teardown_timeout, Duration::from_millis(23));
manager
.create_session("configured-bounds".into(), None, None, None)
Expand Down
22 changes: 22 additions & 0 deletions crates/v8-runtime/tests/embedded_runtime_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ fn embedded_runtime(max_concurrency: usize) -> io::Result<EmbeddedV8Runtime> {
EmbeddedV8Runtime::new(Some(max_concurrency), process_runtime_context()?)
}

fn default_embedded_runtime() -> io::Result<EmbeddedV8Runtime> {
EmbeddedV8Runtime::new(None, process_runtime_context()?)
}

fn vm_runtime_context(session_id: &str) -> io::Result<agentos_runtime::RuntimeContext> {
use agentos_runtime::accounting::{ResourceClass, ResourceLedger, ResourceLimit};

Expand Down Expand Up @@ -539,6 +543,23 @@ fn assert_overload_rejects_before_thread_and_recovers_after_release() -> io::Res
Ok(())
}

fn assert_default_executor_admission_is_uncapped() -> io::Result<()> {
let runtime = Arc::new(default_embedded_runtime()?);
let first = next_session_id();
let second = next_session_id();
let _first_receiver = register_and_create_session(&runtime, &first)?;
let _second_receiver = register_and_create_session(&runtime, &second)?;
assert_eq!(runtime.active_slot_count(), 2);

for session_id in [&first, &second] {
runtime.dispatch(RuntimeCommand::DestroySession {
session_id: session_id.clone(),
})?;
runtime.unregister_session(session_id);
}
Ok(())
}

fn assert_shared_runtime_handles_share_concurrency_quota() -> io::Result<()> {
let runtime = Arc::new(embedded_runtime(3)?);
let clients = (0..4)
Expand Down Expand Up @@ -1024,6 +1045,7 @@ fn embedded_runtime_session_consolidated_behaviors() -> io::Result<()> {
assert_snapshot_rebuild_on_bridge_change()?;
assert_execute_rejects_oversized_bridge_code()?;
assert_direct_zero_cpu_time_limit_disables_timeout()?;
assert_default_executor_admission_is_uncapped()?;
assert_overload_rejects_before_thread_and_recovers_after_release()?;
assert_shared_runtime_handles_share_concurrency_quota()?;
assert_sync_bridge_response_bypasses_stream_event_flood()?;
Expand Down
5 changes: 3 additions & 2 deletions docs/content/docs/architecture/javascript-executor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ Guest V8 execution is deliberately different:
the only non-V8 platform thread that enters that isolate.
- Synchronous guest JavaScript or a synchronous bridge wait can block that
executor, but cannot occupy a Tokio worker or another VM's executor.
- The number of active and warm executor threads is bounded separately from
socket and task counts.
- Operators can cap active executor threads separately from socket and task
counts with `runtime.executor.maxActiveVms`. Executor admission is uncapped
by default and is not derived from the sidecar's reported CPU count.

There is therefore no "Tokio task running a Node.js process." Trusted I/O runs
as Tokio tasks; untrusted JavaScript runs on a V8 executor thread.
Expand Down
5 changes: 5 additions & 0 deletions docs/content/docs/resource-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Every agentOS VM runs with **per-VM resource and runtime caps**. These caps cont

Set caps on the `limits` object in the `agentOS` config. Limits are grouped by subsystem (`resources`, `process`, `jsRuntime`, `python`, `wasm`, and more). Omitted limits keep their secure default.

V8 executor admission is process-wide rather than per-VM. It is uncapped by
default and does not derive a ceiling from the sidecar's reported CPU count.
Operators can set `runtime.executor.maxActiveVms` when creating a sidecar to
enforce an explicit concurrent-executor ceiling.

<CodeSnippet file="examples/resource-limits/server.ts" />

## Available caps
Expand Down
Loading
Loading