Skip to content

Commit 418ac77

Browse files
committed
feat(runtime): make the guest-execution admission ceiling operator-tunable
The process-wide V8 executor slot cap was derived from available_parallelism() and had no override path in any shipped build: every runtime.* config value came from RuntimeConfig::default(). Its own limit error told operators to "raise runtime.executor.maxActiveVms", a knob nothing could set. The cap admits concurrently running guest executions, not VMs. Every live guest process holds one slot for its whole lifetime, so a shell and the command it waits on need two, and a fleet of parallel agents exhausts it well before any per-VM cap. CPU count is the wrong unit: the slot costs one OS thread and one thread-affine V8 isolate, and an agent parked on a network read burns no CPU while holding one. - Read AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS at sidecar startup, before the process topology is fixed. Missing, non-numeric, zero, or above-ceiling values fail startup with a typed error instead of being clamped. - Default to a fixed 64 rather than the host core count, so admitted concurrency no longer varies per machine, with a hard ceiling of 1024. - Rename the knob, config field, and limit error to say guest executions, and point the error at a variable an operator can actually set. - Log the effective ceiling at startup so the admitted value is observable before an execution is rejected for exceeding it. Kept process-scoped rather than exposed as a client wire field: one sidecar process is shared by every VM and connection it hosts, so no single tenant may rewrite the ceiling for its neighbours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0182QmdSp9xAUey53LViPA8B
1 parent 42bedb3 commit 418ac77

8 files changed

Lines changed: 284 additions & 28 deletions

File tree

crates/native-sidecar/src/stdio.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -747,10 +747,22 @@ fn run_with_optional_control(
747747
extensions: Vec<Box<dyn Extension>>,
748748
control_fd: Option<OwnedFd>,
749749
) -> Result<(), Box<dyn Error>> {
750-
let config = NativeSidecarConfig {
750+
let mut config = NativeSidecarConfig {
751751
compile_cache_root: Some(default_compile_cache_root()),
752752
..NativeSidecarConfig::default()
753753
};
754+
// Operator overrides must land before `SidecarRuntime::process` fixes the
755+
// process topology: the first caller's config is the one the whole process
756+
// keeps, and a later differing config is a typed error, not a re-configure.
757+
config.runtime.apply_env_overrides()?;
758+
// The admitted ceiling is fixed for the life of the process and shared by
759+
// every VM it hosts, so make the effective value observable at startup
760+
// rather than only when an execution is rejected for exceeding it.
761+
tracing::info!(
762+
max_active_guest_executions = config.runtime.max_active_guest_executions,
763+
env = agentos_runtime::MAX_ACTIVE_GUEST_EXECUTIONS_ENV,
764+
"guest execution admission ceiling"
765+
);
754766
let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime)?;
755767
let runtime_context = runtime.context();
756768
// Initialize the embedded V8 runtime + platform now, on the long-lived main

crates/native-sidecar/tests/fixtures/limits-inventory.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1658,6 +1658,19 @@
16581658
"rationale": "Default for the configured process-wide protocol queue bound.",
16591659
"wired": "RuntimeConfig.protocol.max_process_events"
16601660
},
1661+
{
1662+
"name": "DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS",
1663+
"path": "crates/runtime/src/lib.rs",
1664+
"class": "policy",
1665+
"rationale": "Default process-wide cap on concurrently running guest executions; each slot owns one OS thread and one V8 isolate.",
1666+
"wired": "RuntimeConfig.max_active_guest_executions"
1667+
},
1668+
{
1669+
"name": "MAX_ACTIVE_GUEST_EXECUTIONS_CEILING",
1670+
"path": "crates/runtime/src/lib.rs",
1671+
"class": "invariant",
1672+
"rationale": "Hard ceiling for the AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS operator override; admission stays bounded whatever an operator requests."
1673+
},
16611674
{
16621675
"name": "DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS",
16631676
"path": "crates/runtime/src/lib.rs",

crates/runtime/src/lib.rs

Lines changed: 155 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,38 @@ const DEFAULT_MAX_PROCESS_HTTP2_EVENT_BYTES: usize = 512 * 1024 * 1024;
6969
const DEFAULT_TASK_POLL_WATCHDOG_MS: u64 = 100;
7070
const DEFAULT_MAX_TERMINAL_TASK_REPORTS: usize = 4_096;
7171
const DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS: u64 = 5_000;
72+
73+
/// Process-wide ceiling on concurrently running guest executions.
74+
///
75+
/// Each admitted execution owns one OS thread and one V8 isolate (thread-affine,
76+
/// so it can never be multiplexed onto a shared pool) capped at
77+
/// `DEFAULT_HEAP_LIMIT_MB`. The binding constraint is therefore threads and
78+
/// memory, NOT CPU: an agent parked on a network read, or a shell blocked in
79+
/// `waitpid`, burns no CPU and still holds its slot for the whole life of the
80+
/// guest process. Deriving this from `available_parallelism()` made the ceiling
81+
/// depend on the host's core count and silently rejected ordinary workloads —
82+
/// a shell plus the command it waits on already needs two slots.
83+
///
84+
/// A fixed default keeps the admitted concurrency identical on every host.
85+
/// Raise it with `AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS`.
86+
pub const DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS: usize = 64;
87+
88+
/// Hard ceiling for `AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS`.
89+
///
90+
/// Admission stays bounded regardless of what an operator asks for: at this
91+
/// ceiling the process still reserves 1024 OS threads and isolates, which is
92+
/// past the point where a host is thread- and memory-bound. Requests above it
93+
/// are a typed configuration error, never a silent clamp.
94+
pub const MAX_ACTIVE_GUEST_EXECUTIONS_CEILING: usize = 1_024;
95+
96+
/// Operator override for [`DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS`].
97+
///
98+
/// Read once, by the process entrypoint, before any VM exists. The value is
99+
/// process topology (see [`SidecarRuntime::process`]) and is deliberately not a
100+
/// client wire field: one sidecar process is shared by every VM and connection,
101+
/// so no single tenant may rewrite it for its neighbours.
102+
pub const MAX_ACTIVE_GUEST_EXECUTIONS_ENV: &str = "AGENTOS_MAX_ACTIVE_GUEST_EXECUTIONS";
103+
72104
pub const DEFAULT_PROTOCOL_MAX_INGRESS_FRAMES: usize = 128;
73105
pub const DEFAULT_PROTOCOL_MAX_INGRESS_BYTES: usize = 64 * 1024 * 1024;
74106
pub const DEFAULT_PROTOCOL_MAX_CONTROL_FRAMES: usize = 1_024;
@@ -417,7 +449,10 @@ impl RuntimeResourceConfig {
417449
#[derive(Clone, Debug, PartialEq, Eq)]
418450
pub struct RuntimeConfig {
419451
pub worker_threads: usize,
420-
pub max_active_vm_executors: usize,
452+
/// Process-wide cap on concurrently running guest executions (JavaScript,
453+
/// TypeScript, Python, and WASM alike — every live guest process holds one).
454+
/// See [`DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS`].
455+
pub max_active_guest_executions: usize,
421456
pub vm_executor_teardown_timeout_ms: u64,
422457
pub blocking_worker_threads: usize,
423458
pub max_blocking_jobs: usize,
@@ -438,7 +473,7 @@ impl Default for RuntimeConfig {
438473
.unwrap_or(1);
439474
Self {
440475
worker_threads: available.clamp(1, 4),
441-
max_active_vm_executors: available.max(1),
476+
max_active_guest_executions: DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS,
442477
vm_executor_teardown_timeout_ms: DEFAULT_VM_EXECUTOR_TEARDOWN_TIMEOUT_MS,
443478
blocking_worker_threads: available.clamp(1, 4),
444479
max_blocking_jobs: DEFAULT_MAX_BLOCKING_JOBS,
@@ -455,12 +490,46 @@ impl Default for RuntimeConfig {
455490
}
456491

457492
impl RuntimeConfig {
493+
/// Apply operator overrides from the process environment.
494+
///
495+
/// Call this from the process entrypoint, before [`SidecarRuntime::process`]
496+
/// fixes the topology. A present-but-unusable value is a hard, typed error
497+
/// naming the variable and its bounds: an operator who asked for a specific
498+
/// admission ceiling must never silently get a different one.
499+
pub fn apply_env_overrides(&mut self) -> Result<(), RuntimeBuildError> {
500+
self.apply_env_overrides_from(|key| std::env::var(key).ok())
501+
}
502+
503+
/// Testable core of [`apply_env_overrides`]. `read` resolves a variable
504+
/// name to its value, mirroring `std::env::var(..).ok()`.
505+
pub fn apply_env_overrides_from(
506+
&mut self,
507+
read: impl Fn(&str) -> Option<String>,
508+
) -> Result<(), RuntimeBuildError> {
509+
let Some(raw) = read(MAX_ACTIVE_GUEST_EXECUTIONS_ENV) else {
510+
return Ok(());
511+
};
512+
let value = raw.trim();
513+
let parsed: usize = value.parse().map_err(|_| {
514+
RuntimeBuildError(format!(
515+
"ERR_AGENTOS_RUNTIME_CONFIG: {MAX_ACTIVE_GUEST_EXECUTIONS_ENV} must be an integer between 1 and {MAX_ACTIVE_GUEST_EXECUTIONS_CEILING}, got {value:?}"
516+
))
517+
})?;
518+
if parsed == 0 || parsed > MAX_ACTIVE_GUEST_EXECUTIONS_CEILING {
519+
return Err(RuntimeBuildError(format!(
520+
"ERR_AGENTOS_RUNTIME_CONFIG: {MAX_ACTIVE_GUEST_EXECUTIONS_ENV} must be between 1 and {MAX_ACTIVE_GUEST_EXECUTIONS_CEILING}, got {parsed}"
521+
)));
522+
}
523+
self.max_active_guest_executions = parsed;
524+
Ok(())
525+
}
526+
458527
pub fn validate(&self) -> Result<(), RuntimeBuildError> {
459528
for (field, value) in [
460529
("runtime.workerThreads", self.worker_threads),
461530
(
462-
"runtime.executor.maxActiveVms",
463-
self.max_active_vm_executors,
531+
"runtime.executor.maxActiveGuestExecutions",
532+
self.max_active_guest_executions,
464533
),
465534
(
466535
"runtime.blocking.workerThreads",
@@ -1114,7 +1183,7 @@ pub struct RuntimeContext {
11141183
fairness: FairWorkBroker,
11151184
terminal_failure: Arc<Mutex<Option<TaskTerminalReport>>>,
11161185
task_poll_watchdog: Duration,
1117-
max_active_vm_executors: usize,
1186+
max_active_guest_executions: usize,
11181187
vm_executor_teardown_timeout: Duration,
11191188
blocking_job_timeout: Duration,
11201189
admission_open: Arc<AtomicBool>,
@@ -1145,8 +1214,8 @@ impl RuntimeContext {
11451214
&self.metrics
11461215
}
11471216

1148-
pub fn max_active_vm_executors(&self) -> usize {
1149-
self.max_active_vm_executors
1217+
pub fn max_active_guest_executions(&self) -> usize {
1218+
self.max_active_guest_executions
11501219
}
11511220

11521221
pub fn vm_executor_teardown_timeout(&self) -> Duration {
@@ -1257,7 +1326,7 @@ impl RuntimeContext {
12571326
fairness: self.fairness.clone(),
12581327
terminal_failure: Arc::new(Mutex::new(None)),
12591328
task_poll_watchdog: self.task_poll_watchdog,
1260-
max_active_vm_executors: self.max_active_vm_executors,
1329+
max_active_guest_executions: self.max_active_guest_executions,
12611330
vm_executor_teardown_timeout: self.vm_executor_teardown_timeout,
12621331
blocking_job_timeout: self.blocking_job_timeout,
12631332
admission_open,
@@ -1535,7 +1604,7 @@ impl SidecarRuntime {
15351604
fairness,
15361605
terminal_failure: Arc::new(Mutex::new(None)),
15371606
task_poll_watchdog: Duration::from_millis(config.task_poll_watchdog_ms),
1538-
max_active_vm_executors: config.max_active_vm_executors,
1607+
max_active_guest_executions: config.max_active_guest_executions,
15391608
vm_executor_teardown_timeout: Duration::from_millis(
15401609
config.vm_executor_teardown_timeout_ms,
15411610
),
@@ -1598,6 +1667,78 @@ impl SidecarRuntime {
15981667
mod tests {
15991668
use super::*;
16001669

1670+
fn env_override(value: Option<&str>) -> Result<RuntimeConfig, RuntimeBuildError> {
1671+
let mut config = RuntimeConfig::default();
1672+
let value = value.map(str::to_owned);
1673+
config.apply_env_overrides_from(|key| {
1674+
(key == MAX_ACTIVE_GUEST_EXECUTIONS_ENV)
1675+
.then(|| value.clone())
1676+
.flatten()
1677+
})?;
1678+
Ok(config)
1679+
}
1680+
1681+
#[test]
1682+
fn default_guest_execution_ceiling_does_not_depend_on_host_cpu_count() {
1683+
// A CPU-derived ceiling rejected ordinary workloads on small hosts and
1684+
// made admitted concurrency differ per machine. Guest executions are
1685+
// bounded by threads and memory, not by cores.
1686+
assert_eq!(
1687+
RuntimeConfig::default().max_active_guest_executions,
1688+
DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS
1689+
);
1690+
assert!(
1691+
DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS >= 2,
1692+
"a shell and the command it waits on already need two slots"
1693+
);
1694+
}
1695+
1696+
#[test]
1697+
fn absent_guest_execution_override_keeps_the_default() {
1698+
let config = env_override(None).expect("absent override is not an error");
1699+
assert_eq!(
1700+
config.max_active_guest_executions,
1701+
DEFAULT_MAX_ACTIVE_GUEST_EXECUTIONS
1702+
);
1703+
}
1704+
1705+
#[test]
1706+
fn guest_execution_override_applies_and_stays_valid() {
1707+
let config = env_override(Some(" 128 ")).expect("surrounding whitespace is accepted");
1708+
assert_eq!(config.max_active_guest_executions, 128);
1709+
config.validate().expect("override must stay valid");
1710+
}
1711+
1712+
#[test]
1713+
fn unusable_guest_execution_override_is_a_typed_error() {
1714+
// An operator who asked for a specific ceiling must never silently get a
1715+
// different one: no clamping, no falling back to the default.
1716+
for value in ["", "many", "0", "-1", "1.5"] {
1717+
let error = env_override(Some(value)).expect_err("unusable override must be rejected");
1718+
assert!(
1719+
error.to_string().contains(MAX_ACTIVE_GUEST_EXECUTIONS_ENV),
1720+
"error must name the variable: {error}"
1721+
);
1722+
}
1723+
1724+
let above_ceiling = MAX_ACTIVE_GUEST_EXECUTIONS_CEILING + 1;
1725+
let error = env_override(Some(&above_ceiling.to_string()))
1726+
.expect_err("a request above the hard ceiling must be rejected");
1727+
assert!(
1728+
error
1729+
.to_string()
1730+
.contains(&MAX_ACTIVE_GUEST_EXECUTIONS_CEILING.to_string()),
1731+
"error must name the ceiling: {error}"
1732+
);
1733+
1734+
let at_ceiling = env_override(Some(&MAX_ACTIVE_GUEST_EXECUTIONS_CEILING.to_string()))
1735+
.expect("the ceiling itself is admissible");
1736+
assert_eq!(
1737+
at_ceiling.max_active_guest_executions,
1738+
MAX_ACTIVE_GUEST_EXECUTIONS_CEILING
1739+
);
1740+
}
1741+
16011742
#[test]
16021743
fn process_runtime_bounds_every_resource_class_by_default() {
16031744
let runtime = SidecarRuntime::build(RuntimeConfig::default()).expect("build runtime");
@@ -1650,12 +1791,14 @@ mod tests {
16501791
.contains("runtime.tasks.maxTerminalReports"));
16511792

16521793
let error = RuntimeConfig {
1653-
max_active_vm_executors: 0,
1794+
max_active_guest_executions: 0,
16541795
..RuntimeConfig::default()
16551796
}
16561797
.validate()
1657-
.expect_err("zero VM executor capacity must be rejected");
1658-
assert!(error.to_string().contains("runtime.executor.maxActiveVms"));
1798+
.expect_err("zero guest-execution capacity must be rejected");
1799+
assert!(error
1800+
.to_string()
1801+
.contains("runtime.executor.maxActiveGuestExecutions"));
16591802

16601803
let error = RuntimeConfig {
16611804
vm_executor_teardown_timeout_ms: 0,

crates/v8-runtime/src/embedded_runtime.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl EmbeddedV8Runtime {
6363
// without immediately evicting each other.
6464
let snapshot_cache = Arc::new(SnapshotCache::new(8));
6565
let call_id_router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
66-
let configured_max_concurrency = runtime.max_active_vm_executors();
66+
let configured_max_concurrency = runtime.max_active_guest_executions();
6767
let executor_teardown_timeout = runtime.vm_executor_teardown_timeout();
6868
let session_mgr = Arc::new(Mutex::new(SessionManager::new(
6969
max_concurrency.unwrap_or(configured_max_concurrency),
@@ -682,7 +682,7 @@ pub fn spawn_embedded_runtime_ipc(
682682
let shutdown_stream = host_stream.try_clone()?;
683683
let alive = Arc::new(AtomicBool::new(true));
684684
let alive_for_thread = Arc::clone(&alive);
685-
let max_concurrency = max_concurrency.unwrap_or_else(|| runtime.max_active_vm_executors());
685+
let max_concurrency = max_concurrency.unwrap_or_else(|| runtime.max_active_guest_executions());
686686

687687
// AGENTOS_THREAD_SITE: embedded-v8-dispatch
688688
let join_handle = thread::Builder::new()
@@ -1175,7 +1175,7 @@ mod tests {
11751175
.lock()
11761176
.expect("embedded runtime codec test lock poisoned");
11771177
let mut config = agentos_runtime::RuntimeConfig {
1178-
max_active_vm_executors: 2,
1178+
max_active_guest_executions: 2,
11791179
vm_executor_teardown_timeout_ms: 31,
11801180
..agentos_runtime::RuntimeConfig::default()
11811181
};

crates/v8-runtime/src/session.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,12 +1062,15 @@ impl SessionSlotPermit {
10621062
metrics: RuntimeMetrics,
10631063
) -> Result<Self, String> {
10641064
let (lock, _) = &**control;
1065-
let mut active = lock
1066-
.lock()
1067-
.map_err(|_| String::from("ERR_AGENTOS_VM_EXECUTOR_POISONED: slot lock poisoned"))?;
1065+
let mut active = lock.lock().map_err(|_| {
1066+
String::from("ERR_AGENTOS_GUEST_EXECUTION_POISONED: slot lock poisoned")
1067+
})?;
10681068
if *active >= maximum {
10691069
return Err(format!(
1070-
"ERR_AGENTOS_VM_EXECUTOR_LIMIT: active V8 executors reached limit of {maximum}; raise runtime.executor.maxActiveVms"
1070+
"ERR_AGENTOS_GUEST_EXECUTION_LIMIT: concurrently running guest executions reached the process limit of {maximum}; \
1071+
every live guest process (JavaScript, TypeScript, Python, or WASM command) holds one slot for its whole lifetime, \
1072+
so a parent and the child it waits on need two. Raise runtime.executor.maxActiveGuestExecutions by setting {} on the sidecar process.",
1073+
agentos_runtime::MAX_ACTIVE_GUEST_EXECUTIONS_ENV
10711074
));
10721075
}
10731076
*active += 1;
@@ -1090,10 +1093,12 @@ impl Drop for SessionSlotPermit {
10901093
cvar.notify_all();
10911094
}
10921095
Ok(_) => eprintln!(
1093-
"ERR_AGENTOS_VM_EXECUTOR_ACCOUNTING_UNDERFLOW: executor permit released at zero"
1096+
"ERR_AGENTOS_GUEST_EXECUTION_ACCOUNTING_UNDERFLOW: executor permit released at zero"
10941097
),
10951098
Err(_) => {
1096-
eprintln!("ERR_AGENTOS_VM_EXECUTOR_POISONED: executor permit could not be released")
1099+
eprintln!(
1100+
"ERR_AGENTOS_GUEST_EXECUTION_POISONED: executor permit could not be released"
1101+
)
10971102
}
10981103
}
10991104
}
@@ -4005,7 +4010,7 @@ mod tests {
40054010
return;
40064011
}
40074012
let mut config = agentos_runtime::RuntimeConfig {
4008-
max_active_vm_executors: 3,
4013+
max_active_guest_executions: 3,
40094014
vm_executor_teardown_timeout_ms: 23,
40104015
..agentos_runtime::RuntimeConfig::default()
40114016
};
@@ -4016,7 +4021,7 @@ mod tests {
40164021
let (event_tx, _event_rx) = crossbeam_channel::unbounded();
40174022
let router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
40184023
let mut manager = SessionManager::new(
4019-
runtime.max_active_vm_executors(),
4024+
runtime.max_active_guest_executions(),
40204025
event_tx,
40214026
router,
40224027
Arc::new(SnapshotCache::new(1)),
@@ -4169,7 +4174,12 @@ mod tests {
41694174
let error = mgr
41704175
.create_session("s3".into(), None, None, None)
41714176
.expect_err("third executor must be rejected before thread creation");
4172-
assert!(error.contains("ERR_AGENTOS_VM_EXECUTOR_LIMIT"));
4177+
assert!(error.contains("ERR_AGENTOS_GUEST_EXECUTION_LIMIT"));
4178+
// The limit error must name a knob an operator can actually set.
4179+
assert!(
4180+
error.contains(agentos_runtime::MAX_ACTIVE_GUEST_EXECUTIONS_ENV),
4181+
"limit error must say how to raise it: {error}"
4182+
);
41734183

41744184
// Allow threads to acquire slots
41754185
std::thread::sleep(std::time::Duration::from_millis(300));
@@ -4251,7 +4261,7 @@ mod tests {
42514261
let error = mgr
42524262
.create_session("two-phase".into(), None, None, None)
42534263
.expect_err("old generation must retain its executor permit");
4254-
assert!(error.contains("ERR_AGENTOS_VM_EXECUTOR_LIMIT"));
4264+
assert!(error.contains("ERR_AGENTOS_GUEST_EXECUTION_LIMIT"));
42554265
first_shutdown.finish();
42564266

42574267
mgr.create_session("two-phase".into(), None, None, None)

0 commit comments

Comments
 (0)