From a9b3735f3a1f89865ede2c887da4acac78a555ec Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 4 Aug 2026 15:24:19 -0700 Subject: [PATCH] refactor(native-sidecar): partition VM and event ownership --- crates/execution/src/javascript.rs | 7 + crates/execution/src/python.rs | 4 + crates/execution/src/wasm.rs | 6 + crates/kernel/src/command_registry.rs | 32 + crates/native-sidecar/src/bindings.rs | 185 +- .../src/execution/child_process.rs | 8029 +++++++++++++---- .../src/execution/coordinator.rs | 798 +- .../src/execution/javascript/http.rs | 1372 +++ .../src/execution/javascript/mod.rs | 6 +- .../src/execution/javascript/rpc.rs | 49 + crates/native-sidecar/src/execution/launch.rs | 847 +- crates/native-sidecar/src/execution/mod.rs | 21 +- .../src/execution/network/udp.rs | 192 +- .../native-sidecar/src/execution/process.rs | 26 +- .../src/execution/process_events.rs | 1169 ++- .../src/execution/python/mod.rs | 4 + .../src/execution/python/rpc.rs | 357 +- .../src/execution/python/sockets.rs | 841 +- .../src/execution/python/subprocess.rs | 383 +- .../native-sidecar/src/execution/signals.rs | 224 +- crates/native-sidecar/src/extension.rs | 318 +- .../native-sidecar/src/extension_services.rs | 1914 ++++ crates/native-sidecar/src/filesystem.rs | 150 +- .../native-sidecar/src/language_execution.rs | 865 +- crates/native-sidecar/src/lib.rs | 6 +- .../src/ownership_coordinator.rs | 2335 +++++ .../src/process_event_broker.rs | 1419 +++ crates/native-sidecar/src/service.rs | 3163 ++++++- crates/native-sidecar/src/state.rs | 384 +- crates/native-sidecar/src/vm.rs | 2502 +++-- 30 files changed, 23231 insertions(+), 4377 deletions(-) create mode 100644 crates/native-sidecar/src/extension_services.rs create mode 100644 crates/native-sidecar/src/ownership_coordinator.rs create mode 100644 crates/native-sidecar/src/process_event_broker.rs diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index 49890565de..6eb863fa28 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -1856,6 +1856,13 @@ impl JavascriptExecution { self.exited.load(Ordering::Acquire) } + /// Whether the runtime event bridge has already made another event + /// durable for this execution. This is a non-consuming readiness probe; + /// callers still own bounded draining and ordering. + pub fn has_pending_events(&self) -> bool { + !self.events.is_empty() + } + /// Run another sidecar-managed operation in this execution's retained V8 /// context. Public clients submit semantic language requests; only the /// sidecar calls this executor primitive. diff --git a/crates/execution/src/python.rs b/crates/execution/src/python.rs index 2cb9eada5b..cb51d6d7f7 100644 --- a/crates/execution/src/python.rs +++ b/crates/execution/src/python.rs @@ -520,6 +520,10 @@ impl PythonExecution { self.inner.uses_shared_v8_runtime() } + pub fn has_pending_events(&self) -> bool { + self.inner.has_pending_events() + } + /// Run another sidecar-managed operation in the retained Pyodide /// interpreter owned by this execution. pub fn execute_retained(&mut self, source: String) -> Result<(), PythonExecutionError> { diff --git a/crates/execution/src/wasm.rs b/crates/execution/src/wasm.rs index e295592c9b..31054d0ca7 100644 --- a/crates/execution/src/wasm.rs +++ b/crates/execution/src/wasm.rs @@ -503,6 +503,12 @@ impl WasmExecution { self.inner.uses_shared_v8_runtime() } + pub fn has_pending_events(&self) -> bool { + !self.pending_events.is_empty() + || !self.internal_sync_rpc.pending_events.is_empty() + || self.inner.has_pending_events() + } + pub fn start_prepared(&mut self) -> Result<(), WasmExecutionError> { self.inner.start_prepared().map_err(map_javascript_error)?; self.execution_started_at = Instant::now(); diff --git a/crates/kernel/src/command_registry.rs b/crates/kernel/src/command_registry.rs index a2b4d1dbe5..54efbde4b2 100644 --- a/crates/kernel/src/command_registry.rs +++ b/crates/kernel/src/command_registry.rs @@ -53,6 +53,13 @@ impl CommandRegistry { pub fn register(&mut self, driver: CommandDriver) -> VfsResult<()> { driver.validate_commands()?; + // Registering one logical driver is replacement, not an append. This is + // required for transactional registries (for example host callbacks): + // rolling a driver back to its previous command set must make aliases + // introduced by the failed update unresolvable. + self.commands + .retain(|_, existing| existing.name() != driver.name()); + for command in &driver.commands { if let Some(existing) = self.commands.get(command) { self.warnings.push(format!( @@ -142,3 +149,28 @@ fn validate_command_name(command: &str) -> VfsResult<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registering_same_driver_replaces_its_command_set() { + let mut registry = CommandRegistry::new(); + registry + .register(CommandDriver::new("bindings", ["old", "temporary"])) + .expect("register initial driver"); + registry + .register(CommandDriver::new("bindings", ["old"])) + .expect("replace driver commands"); + + assert_eq!( + registry.resolve("old").map(CommandDriver::name), + Some("bindings") + ); + assert!( + registry.resolve("temporary").is_none(), + "aliases removed by a driver refresh must not remain executable" + ); + } +} diff --git a/crates/native-sidecar/src/bindings.rs b/crates/native-sidecar/src/bindings.rs index 6ba4acc312..92d9bf50c1 100644 --- a/crates/native-sidecar/src/bindings.rs +++ b/crates/native-sidecar/src/bindings.rs @@ -3,7 +3,7 @@ use crate::protocol::{ RequestFrame, ResponsePayload, }; use crate::service::{kernel_error, normalize_path, DispatchResult}; -use crate::state::{BridgeError, VmState, BINDING_DRIVER_NAME}; +use crate::state::{BridgeError, SharedBridge, VmHandle, VmState, BINDING_DRIVER_NAME}; use crate::{NativeSidecar, NativeSidecarBridge, SidecarError}; use agentos_kernel::command_registry::CommandDriver; use agentos_native_sidecar_core::bindings::{ @@ -24,6 +24,7 @@ pub(crate) use agentos_native_sidecar_core::bindings::{ use agentos_native_sidecar_core::permissions::{ allow_all_policy, deny_all_policy, evaluate_permissions_policy, }; +use agentos_native_sidecar_core::respond as shared_respond; use agentos_vm_config::PermissionMode; use serde_json::{json, Map, Number, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -52,73 +53,135 @@ pub(crate) fn register_host_callbacks( sidecar: &mut NativeSidecar, request: &RequestFrame, payload: RegisterHostCallbacksRequest, -) -> Result +) -> impl std::future::Future> + 'static where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - let (connection_id, session_id, vm_id) = sidecar.vm_scope_for(&request.ownership)?; - sidecar.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - validate_bindings_registration(&payload)?; - - let registered_name = payload.name.clone(); - let (original_permissions, original_bindings, original_command_guest_paths) = { - let vm = sidecar.vms.get(&vm_id).expect("owned VM should exist"); - ( - vm.configuration.permissions.clone(), - vm.bindings.clone(), - vm.command_guest_paths.clone(), - ) - }; - sidecar - .bridge - .set_vm_permissions(&vm_id, &allow_all_policy())?; - let registration_result = (|| -> Result<_, SidecarError> { - let vm = sidecar.vms.get_mut(&vm_id).expect("owned VM should exist"); - ensure_collection_name_available(&vm.bindings, ®istered_name)?; - ensure_command_aliases_available(&vm.bindings, &payload)?; - ensure_binding_registry_capacity(&vm.bindings, &payload)?; - vm.bindings.insert(registered_name.clone(), payload); - refresh_binding_registry(vm)?; - Ok::<_, SidecarError>(binding_command_names(vm).len() as u32) - })(); - let command_count = match registration_result { - Ok(result) => { - sidecar - .bridge - .set_vm_permissions(&vm_id, &original_permissions)?; - result - } - Err(error) => { - let vm = sidecar.vms.get_mut(&vm_id).expect("owned VM should exist"); - vm.bindings = original_bindings; - vm.command_guest_paths = original_command_guest_paths; - match sidecar.bridge.restore_vm_permissions_fail_closed( - &vm_id, - &original_permissions, - "binding collection registration rollback", - &error, - ) { - Ok(()) => return Err(error), - Err(rollback_error) => { - vm.configuration.permissions = deny_all_policy(); - return Err(rollback_error); + let input = sidecar.prepare_owned_vm_route(request); + let bridge = sidecar.bridge.clone(); + async move { + let input = input?; + validate_bindings_registration(&payload)?; + + let registered_name = payload.name.clone(); + let (original_permissions, original_bindings, original_command_guest_paths) = input + .vm + .try_read("snapshot host callback registration", |vm| { + ( + vm.configuration.permissions.clone(), + vm.bindings.clone(), + vm.command_guest_paths.clone(), + ) + })?; + bridge.set_vm_permissions(&input.vm_id, &allow_all_policy())?; + let registration_result = input.vm.try_command("register host callbacks", |vm| { + ensure_collection_name_available(&vm.bindings, ®istered_name)?; + ensure_command_aliases_available(&vm.bindings, &payload)?; + ensure_binding_registry_capacity(&vm.bindings, &payload)?; + vm.bindings.insert(registered_name.clone(), payload); + refresh_binding_registry(vm)?; + Ok(binding_command_names(vm).len() as u32) + }); + let command_count = match registration_result { + Ok(result) => { + if let Err(restore_error) = + bridge.set_vm_permissions(&input.vm_id, &original_permissions) + { + if let Err(rollback_error) = rollback_host_callback_registration( + &input.vm, + &bridge, + &input.vm_id, + &original_permissions, + original_bindings, + original_command_guest_paths, + &restore_error, + ) { + return Err(rollback_error); + } + return Err(restore_error); } + result } - } - }; + Err(error) => { + match rollback_host_callback_registration( + &input.vm, + &bridge, + &input.vm_id, + &original_permissions, + original_bindings, + original_command_guest_paths, + &error, + ) { + Ok(()) => return Err(error), + Err(rollback_error) => return Err(rollback_error), + } + } + }; - Ok(DispatchResult { - response: sidecar.respond( - request, - ResponsePayload::HostCallbacksRegistered(HostCallbacksRegisteredResponse { - registration: registered_name, - command_count, - }), - ), - events: Vec::new(), - }) + Ok(DispatchResult { + response: shared_respond( + &input.request, + ResponsePayload::HostCallbacksRegistered(HostCallbacksRegisteredResponse { + registration: registered_name, + command_count, + }), + ), + events: Vec::new(), + }) + } +} + +fn rollback_host_callback_registration( + vm: &VmHandle, + bridge: &SharedBridge, + vm_id: &str, + original_permissions: &agentos_vm_config::PermissionsPolicy, + original_bindings: BTreeMap, + original_command_guest_paths: BTreeMap, + operation_error: &SidecarError, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + // These two rollback legs are independent. In particular, a failed VM + // registry refresh must never skip restoring (or fail-closing) the trusted + // bridge permission policy. + let state_rollback = vm.try_command("rollback host callback registration", |vm| { + vm.bindings = original_bindings; + vm.command_guest_paths = original_command_guest_paths; + refresh_binding_registry(vm) + }); + let permission_rollback = bridge.restore_vm_permissions_fail_closed( + vm_id, + original_permissions, + "binding collection registration rollback", + operation_error, + ); + + match (state_rollback, permission_rollback) { + (Ok(()), Ok(())) => Ok(()), + (Err(state_error), Ok(())) => Err(SidecarError::InvalidState(format!( + "binding collection registration state rollback failed after {operation_error}: {state_error}; original permissions were restored" + ))), + (Ok(()), Err(permission_error)) => { + vm.try_command("record fail-closed binding permissions", |vm| { + vm.configuration.permissions = deny_all_policy(); + Ok(()) + })?; + Err(permission_error) + } + (Err(state_error), Err(permission_error)) => { + let fail_closed_state = vm.try_command("record fail-closed binding permissions", |vm| { + vm.configuration.permissions = deny_all_policy(); + Ok(()) + }); + Err(SidecarError::InvalidState(format!( + "binding collection registration rollback failed after {operation_error}: state rollback: {state_error}; permission rollback: {permission_error}; fail-closed VM state: {fail_closed_state:?}" + ))) + } + } } fn refresh_binding_registry(vm: &mut VmState) -> Result<(), SidecarError> { diff --git a/crates/native-sidecar/src/execution/child_process.rs b/crates/native-sidecar/src/execution/child_process.rs index a29ae4d45c..ca09c5af60 100644 --- a/crates/native-sidecar/src/execution/child_process.rs +++ b/crates/native-sidecar/src/execution/child_process.rs @@ -2,6 +2,345 @@ use super::*; const SYNTHETIC_V8_TERMINATION_STDERR: &[u8] = b"Error: Execution terminated\n"; +type OwnedChildExecutionStart = + Pin> + 'static>>; + +fn settle_owned_javascript_process_event_target( + target: OwnedJavascriptEventService, + response: Result, +) -> Pin> + 'static>> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + Box::pin(async move { + let result = settle_owned_fetch_sync_rpc::( + &target.vm, + &target.process_id, + &target.child_path, + target.request.clone(), + response, + ) + .await; + drop(target); + result + }) +} + +/// A request-owned view of one VM. Child-process startup captures this view +/// before it leaves the protocol dispatcher, so the startup future never +/// retains `NativeSidecar` while still preserving the old short-borrow call +/// sites during the staged migration. +#[derive(Clone)] +struct OwnedChildProcessVm { + vm_id: String, + handle: VmHandle, +} + +struct PendingOwnedChildSpawn { + vm: VmHandle, + kernel_handle: Option, + execution: Option, +} + +impl PendingOwnedChildSpawn { + fn new(vm: VmHandle, kernel_handle: KernelProcessHandle) -> Self { + Self { + vm, + kernel_handle: Some(kernel_handle), + execution: None, + } + } + + fn set_execution(&mut self, execution: ActiveExecution) { + self.execution = Some(execution); + } + + fn take_execution(&mut self) -> ActiveExecution { + self.execution + .take() + .expect("owned child startup completed before registration") + } + + fn disarm(&mut self) { + self.kernel_handle = None; + self.execution = None; + } +} + +impl Drop for PendingOwnedChildSpawn { + fn drop(&mut self) { + let Some(kernel_handle) = self.kernel_handle.take() else { + return; + }; + let mut execution = self.execution.take(); + if let Err(error) = self + .vm + .try_command("roll back cancelled child startup", |vm| { + rollback_unregistered_spawn_child( + &mut vm.kernel, + &kernel_handle, + execution.as_mut(), + "owned child_process.spawn", + ); + Ok(()) + }) + { + tracing::error!( + %error, + pid = kernel_handle.pid(), + "ERR_AGENTOS_CHILD_PROCESS_ROLLBACK: cancelled child startup could not be rolled back" + ); + } + } +} + +/// Rolls back a child that has been published into its caller's process tree +/// but whose higher-level spawnSync/subprocess completion registration has not +/// committed yet. +pub(super) struct PendingOwnedRegisteredChild { + vm: VmHandle, + process_id: String, + parent_path: Vec, + child_process_id: Option, +} + +impl PendingOwnedRegisteredChild { + pub(super) fn new( + vm: VmHandle, + process_id: String, + parent_path: Vec, + child_process_id: String, + ) -> Self { + Self { + vm, + process_id, + parent_path, + child_process_id: Some(child_process_id), + } + } + + pub(super) fn disarm(&mut self) { + self.child_process_id = None; + } +} + +impl Drop for PendingOwnedRegisteredChild { + fn drop(&mut self) { + let Some(child_process_id) = self.child_process_id.take() else { + return; + }; + let process_id = self.process_id.clone(); + let parent_path = self.parent_path.clone(); + if let Err(error) = self + .vm + .try_command("roll back registered child startup", |vm| { + let Some(root) = vm.active_processes.get_mut(&process_id) else { + return Ok(()); + }; + let mut parent = root; + for segment in &parent_path { + let Some(child) = parent.child_processes.get_mut(segment) else { + return Ok(()); + }; + parent = child; + } + parent.pending_child_process_sync.remove(&child_process_id); + let Some(mut child) = parent.child_processes.remove(&child_process_id) else { + return Ok(()); + }; + terminate_child_process_tree( + &mut vm.kernel, + &mut child, + &vm.kernel_socket_readiness, + &vm.unix_address_registry, + ); + child.kernel_handle.finish(1); + let _ = vm.kernel.wait_and_reap(child.kernel_pid); + vm.signal_states.remove(&child_process_id); + Ok(()) + }) + { + tracing::error!( + %error, + process_id, + child_process_id, + "ERR_AGENTOS_CHILD_PROCESS_ROLLBACK: failed to clean up a partially registered child" + ); + } + } +} + +impl OwnedChildProcessVm { + fn get(&self, vm_id: &str) -> Option> { + (self.vm_id == vm_id).then(|| self.handle.borrow()) + } + + fn get_mut(&self, vm_id: &str) -> Option> { + (self.vm_id == vm_id).then(|| self.handle.borrow_mut()) + } +} + +fn resolve_owned_javascript_child_process_with_shebang( + vms: &OwnedChildProcessVm, + vm_id: &str, + parent_env: &BTreeMap, + parent_guest_cwd: &str, + parent_host_cwd: &Path, + request: &mut JavascriptChildProcessSpawnRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + const MAX_SHEBANG_REDIRECTS: usize = 4; + + let mut resolved = { + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + NativeSidecar::::resolve_javascript_child_process_execution_with_mode( + &vm, + parent_env, + parent_guest_cwd, + parent_host_cwd, + request, + false, + None, + )? + }; + for redirects in 0..=MAX_SHEBANG_REDIRECTS { + let redirected = { + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + rewrite_javascript_shebang_request(&mut vm, &resolved, request)? + }; + if !redirected { + return Ok(resolved); + } + if redirects == MAX_SHEBANG_REDIRECTS { + return Err(SidecarError::Execution(format!( + "ELOOP: exceeded {MAX_SHEBANG_REDIRECTS} shebang redirects" + ))); + } + resolved = { + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + NativeSidecar::::resolve_javascript_child_process_execution_with_mode( + &vm, + parent_env, + parent_guest_cwd, + parent_host_cwd, + request, + false, + None, + )? + }; + } + Ok(resolved) +} + +pub(crate) struct OwnedChildBridgeEventService { + ownership: OwnershipScope, + vm_id: String, + root_process_id: String, + parent_path: Vec, + event_type: &'static str, + payload: Value, + vm: VmHandle, + deadline: Instant, + _relay: Option, + _reservation: Option, +} + +struct ChildBridgeRelayLease { + in_flight: Arc, + process_event_notify: Arc, +} + +impl ChildBridgeRelayLease { + fn acquire( + in_flight: Arc, + process_event_notify: Arc, + child_process_id: &str, + ) -> Result { + in_flight + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_CHILD_BRIDGE_RELAY_OVERLAP: child {child_process_id} already owns an in-flight parent relay" + )) + })?; + Ok(Self { + in_flight, + process_event_notify, + }) + } +} + +impl Drop for ChildBridgeRelayLease { + fn drop(&mut self) { + if self.in_flight.swap(false, Ordering::AcqRel) { + // The source child's later stdout/stderr/exit events stayed + // durably queued while this exact relay owned ordering. Rearm the + // central pump only after delivery, failure, or cancellation has + // released that ownership. + self.process_event_notify.notify_one(); + } + } +} + +struct ClaimedDescendantBridgeEvent { + event: Value, + reservation: Option, +} + +impl OwnedChildBridgeEventService { + pub(crate) fn ownership(&self) -> &OwnershipScope { + &self.ownership + } +} + +pub(crate) async fn service_owned_child_bridge_event( + target: OwnedChildBridgeEventService, +) -> Result<(), SidecarError> { + let mut retry_delay = Duration::from_millis(2); + let mut capacity_warning_emitted = false; + loop { + let result = target + .vm + .try_read("deliver owned child bridge event", |vm| { + let Some(root) = vm.active_processes.get(&target.root_process_id) else { + return Ok(()); + }; + let mut parent = root; + for child_id in &target.parent_path { + let Some(child) = parent.child_processes.get(child_id) else { + return Ok(()); + }; + parent = child; + } + parent + .execution + .send_javascript_stream_event(target.event_type, target.payload.clone()) + })?; + match result { + Ok(()) => return Ok(()), + Err(SidecarError::Execution(message)) + if message.contains("ERR_AGENTOS_SESSION_COMMAND_LIMIT") => + { + if !capacity_warning_emitted && Instant::now() >= target.deadline { + tracing::warn!( + vm_id = %target.vm_id, + process_id = %target.root_process_id, + "ERR_AGENTOS_SESSION_COMMAND_LIMIT: child bridge event remains durably leased while runtime command capacity is unavailable" + ); + capacity_warning_emitted = true; + } + tokio::time::sleep(retry_delay).await; + retry_delay = retry_delay.saturating_mul(2).min(Duration::from_millis(50)); + } + Err(error) => return Err(error), + } + } +} + #[derive(Debug)] pub(super) enum TransferredHostNetSocket { Tcp { @@ -2300,9 +2639,14 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - pub(crate) async fn pump_child_process_events( + pub(crate) fn pump_child_process_events_nowait( &mut self, vm_id: &str, + javascript_services: &mut Vec, + python_services: &mut Vec, + python_socket_completions: &mut Vec, + child_bridge_services: &mut Vec, + max_service_claims: usize, ) -> Result { let root_process_ids = self .vms @@ -2319,14 +2663,17 @@ where { continue; } - let mut child_paths = Vec::new(); - if let Some(root) = self + let child_paths = self .vms .get(vm_id) - .and_then(|vm| vm.active_processes.get(&process_id)) - { - Self::collect_attached_child_paths(root, &mut Vec::new(), &mut child_paths); - } + .map(|vm| { + let mut child_paths = Vec::new(); + if let Some(root) = vm.active_processes.get(&process_id) { + Self::collect_attached_child_paths(root, &mut Vec::new(), &mut child_paths); + } + child_paths + }) + .unwrap_or_default(); child_candidates.extend( child_paths .into_iter() @@ -2343,7 +2690,7 @@ where .map(|vm| vm.attached_child_event_cursor % child_candidates.len()) .unwrap_or_default(); child_candidates.rotate_left(start); - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { vm.attached_child_event_cursor = (start + 1) % child_candidates.len(); } @@ -2353,11 +2700,20 @@ where let mut work = 0usize; let mut child_work = vec![0usize; child_candidates.len()]; let mut yielded = false; - let mut delivery_backpressured = false; loop { let mut emitted_this_round = false; for (candidate_index, (process_id, child_path)) in child_candidates.iter().enumerate() { + if javascript_services + .len() + .saturating_add(python_services.len()) + .saturating_add(python_socket_completions.len()) + .saturating_add(child_bridge_services.len()) + >= max_service_claims + { + yielded = true; + break; + } if work >= vm_work_limit { yielded = true; break; @@ -2375,6 +2731,19 @@ where .map(String::as_str) .collect::>(); + let bridge_relay_in_flight = self.vms.get(vm_id).is_some_and(|vm| { + vm.active_processes + .get(process_id) + .and_then(|root| Self::active_process_by_path(root, &parent_path)) + .and_then(|parent| parent.child_processes.get(&child_process_id)) + .is_some_and(|child| { + child.child_bridge_relay_in_flight.load(Ordering::Acquire) + }) + }); + if bridge_relay_in_flight { + continue; + } + // Deadline and capacity wakes must service the child's parked // synchronous RPC even when a standalone WASM parent owns // output delivery through child_process.poll. @@ -2392,9 +2761,13 @@ where let parent_is_pull_driven_wasm = self .vms .get(vm_id) - .and_then(|vm| vm.active_processes.get(process_id)) - .and_then(|root| Self::active_process_by_path(root, &parent_path)) - .is_some_and(|parent| parent.runtime == GuestRuntimeKind::WebAssembly); + .map(|vm| { + vm.active_processes + .get(process_id) + .and_then(|root| Self::active_process_by_path(root, &parent_path)) + .is_some_and(|parent| parent.runtime == GuestRuntimeKind::WebAssembly) + }) + .unwrap_or(false); if parent_is_pull_driven_wasm { continue; } @@ -2405,21 +2778,22 @@ where &child_process_id, )?; - let event = match self - .poll_descendant_javascript_child_process( - vm_id, - process_id, - &parent_path, - &child_process_id, - 0, - false, - ) - .await - { + let claimed = match self.poll_descendant_javascript_child_process_nowait( + vm_id, + process_id, + &parent_path, + &child_process_id, + false, + javascript_services, + python_services, + python_socket_completions, + None, + ) { Ok(event) => event, Err(error) if is_javascript_child_process_gone_error(&error) => continue, Err(error) => return Err(error), }; + let ClaimedDescendantBridgeEvent { event, reservation } = claimed; if event.is_null() { continue; } @@ -2429,9 +2803,10 @@ where &parent_path, &child_process_id, event, + reservation, + child_bridge_services, )? { yielded = true; - delivery_backpressured = true; break; } emitted_any = true; @@ -2444,24 +2819,52 @@ where } } - if delivery_backpressured { - // The parent V8 lane is bounded and deliberately nonblocking: a - // blocking send here can deadlock when that isolate is waiting on - // a synchronous sidecar RPC. Retry after a short gap so the - // isolate can drain its lane and the stdio loop can continue - // servicing control requests. An immediate self-notification - // hot-loops this pump and can starve session/new for seconds. - let notify = Arc::clone(&self.process_event_notify); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(2)).await; - notify.notify_one(); - }); - } else if yielded { + if yielded { self.process_event_notify.notify_one(); } Ok(emitted_any) } + pub(crate) async fn pump_child_process_events( + &mut self, + vm_id: &str, + ) -> Result { + let mut javascript_services = Vec::new(); + let mut python_services = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge_services = Vec::new(); + let max_service_claims = self.config.runtime.protocol.max_process_events.max(1); + let emitted = self.pump_child_process_events_nowait( + vm_id, + &mut javascript_services, + &mut python_services, + &mut python_socket_completions, + &mut child_bridge_services, + max_service_claims, + )?; + for target in javascript_services { + let service = self.prepare_owned_javascript_process_event_service(target); + service.await?; + } + for target in python_services { + let service = prepare_owned_python_process_event_service(self, target); + service.await?; + } + for target in python_socket_completions { + let (_ownership, vm_id, process_id, child_path, vm, responder, completion, reservation) = + target.into_parts(); + let _reservation = reservation; + service_owned_python_socket_connect_completion( + vm, vm_id, process_id, child_path, responder, completion, + ) + .await?; + } + for target in child_bridge_services { + service_owned_child_bridge_event(target).await?; + } + Ok(emitted) + } + fn expire_child_process_sync_if_needed( &mut self, vm_id: &str, @@ -2470,7 +2873,7 @@ where child_process_id: &str, ) -> Result<(), SidecarError> { let signal = { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; let Some(root) = vm.active_processes.get_mut(process_id) else { @@ -2513,6 +2916,8 @@ where parent_path: &[&str], child_process_id: &str, event: Value, + reservation: Option, + child_bridge_services: &mut Vec, ) -> Result { let event_type = event .get("type") @@ -2529,7 +2934,7 @@ where let mut kill_for_buffer = false; let mut direct_delivery = None; let completion = { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(true); }; let Some(root) = vm.active_processes.get_mut(process_id) else { @@ -2594,49 +2999,102 @@ where }), _ => return Ok(true), }; - direct_delivery = Some(parent.execution.send_javascript_stream_event( - match event_type { - "stdout" => "child_stdout", - "stderr" => "child_stderr", - _ => "child_exit", - }, - payload, - )); + let bridge_event_type = match event_type { + "stdout" => "child_stdout", + "stderr" => "child_stderr", + _ => "child_exit", + }; + let delivery = parent + .execution + .send_javascript_stream_event(bridge_event_type, payload.clone()); + direct_delivery = Some((bridge_event_type, payload, delivery)); None } }; - if let Some(delivery) = direct_delivery { + if let Some((bridge_event_type, payload, delivery)) = direct_delivery { match delivery { - Ok(()) => return Ok(true), + Ok(()) => { + tracing::trace!( + vm_id, + process_id, + child_process_id, + event_type = bridge_event_type, + "child bridge event admitted to parent V8 session" + ); + return Ok(true); + } Err(SidecarError::Execution(message)) if message.contains("ERR_AGENTOS_SESSION_COMMAND_LIMIT") => { - let retry_event = match event_type { - "stdout" => ActiveExecutionEvent::Stdout(chunk.unwrap_or_default()), - "stderr" => ActiveExecutionEvent::Stderr(chunk.unwrap_or_default()), - "exit" => ActiveExecutionEvent::Exited( - event - .get("exitCode") - .and_then(Value::as_i64) - .map(|value| value as i32) - .unwrap_or(1), - ), - _ => return Ok(true), + tracing::trace!( + vm_id, + process_id, + child_process_id, + event_type = bridge_event_type, + "child bridge event retained for parent V8 session capacity" + ); + let relay = if bridge_event_type == "child_exit" { + None + } else { + let in_flight = self + .vms + .get(vm_id) + .and_then(|vm| { + vm.active_processes + .get(process_id) + .and_then(|root| { + Self::active_process_by_path(root, parent_path) + }) + .and_then(|parent| { + parent.child_processes.get(child_process_id) + }) + .map(|child| Arc::clone(&child.child_bridge_relay_in_flight)) + }) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_CHILD_BRIDGE_RELAY_SOURCE_MISSING: child {child_process_id} disappeared while retaining {bridge_event_type} delivery" + )) + })?; + Some(ChildBridgeRelayLease::acquire( + in_flight, + Arc::clone(&self.process_event_notify), + child_process_id, + )?) }; - let Some(child) = self - .vms - .get_mut(vm_id) - .and_then(|vm| vm.active_processes.get_mut(process_id)) - .and_then(|root| Self::active_process_by_path_mut(root, parent_path)) - .and_then(|parent| parent.child_processes.get_mut(child_process_id)) + let Some((connection_id, session_id, deadline, vm)) = + self.vms.get(vm_id).and_then(|state| { + self.vms.handle(vm_id).map(|handle| { + ( + state.connection_id.clone(), + state.session_id.clone(), + Instant::now() + + Duration::from_millis( + state.limits.reactor.operation_deadline_ms, + ), + handle, + ) + }) + }) else { return Ok(true); }; - child.requeue_pending_execution_event(PolledExecutionEvent::unreserved( - retry_event, - ))?; - return Ok(false); + child_bridge_services.push(OwnedChildBridgeEventService { + ownership: OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id: vm_id.to_owned(), + root_process_id: process_id.to_owned(), + parent_path: parent_path + .iter() + .map(|segment| (*segment).to_owned()) + .collect(), + event_type: bridge_event_type, + payload, + vm, + deadline, + _relay: relay, + _reservation: reservation, + }); + return Ok(true); } Err(error) => return Err(error), } @@ -2674,10 +3132,12 @@ where ); } } - PendingChildProcessSyncCompletion::Python { request_id } => { - self.respond_python_rpc( - vm_id, - process_id, + PendingChildProcessSyncCompletion::Python { + request_id, + responder, + } => { + respond_owned_python_rpc( + &responder, request_id, Ok(PythonVfsRpcResponsePayload::SubprocessRun { exit_code, @@ -2692,9 +3152,14 @@ where Ok(true) } - pub(super) async fn pump_detached_child_process_events( + pub(crate) fn pump_detached_child_process_events_nowait( &mut self, vm_id: &str, + javascript_services: &mut Vec, + python_services: &mut Vec, + python_socket_completions: &mut Vec, + child_bridge_services: &mut Vec, + max_service_claims: usize, ) -> Result { let mut detached_process_ids = self .vms @@ -2715,7 +3180,7 @@ where .map(|vm| vm.detached_child_event_cursor % detached_process_ids.len()) .unwrap_or_default(); detached_process_ids.rotate_left(start); - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { vm.detached_child_event_cursor = (start + 1) % detached_process_ids.len(); } let vm_work_limit = self.config.runtime.fairness.vm_quantum_operations; @@ -2728,19 +3193,37 @@ where yielded = true; break; } + if javascript_services + .len() + .saturating_add(python_services.len()) + .saturating_add(python_socket_completions.len()) + .saturating_add(child_bridge_services.len()) + >= max_service_claims + { + yielded = true; + break; + } let mut detached_work = 0usize; - let Some((root_process_id, child_path)) = self - .vms - .get(vm_id) - .and_then(|vm| Self::resolve_detached_child_process_path(vm, &detached_process_id)) - else { - if let Some(vm) = self.vms.get_mut(vm_id) { + let Some((root_process_id, child_path)) = self.vms.get(vm_id).and_then(|vm| { + Self::resolve_detached_child_process_path(&vm, &detached_process_id) + }) else { + if let Some(mut vm) = self.vms.get_mut(vm_id) { vm.detached_child_processes.remove(&detached_process_id); } continue; }; if child_path.is_empty() { loop { + if javascript_services + .len() + .saturating_add(python_services.len()) + .saturating_add(python_socket_completions.len()) + .saturating_add(child_bridge_services.len()) + >= max_service_claims + { + yielded = true; + break; + } if work >= vm_work_limit || detached_work >= child_work_limit { yielded = true; break; @@ -2750,7 +3233,7 @@ where RecoverClosedChannel, } let poll_result = { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { break; }; let Some(process) = vm.active_processes.get_mut(&root_process_id) else { @@ -2759,7 +3242,7 @@ where if let Some(event) = process.lease_pending_execution_event() { ProcessPollResult::Event(Box::new(Some(event))) } else { - match process.poll_execution_event(Duration::ZERO).await { + match process.try_poll_execution_event() { Ok(event) => ProcessPollResult::Event(Box::new(event)), Err(SidecarError::Execution(message)) if (process.runtime == GuestRuntimeKind::JavaScript @@ -2807,26 +3290,43 @@ where connection_id, session_id, vm_id: vm_id.to_owned(), + child_path: Vec::new(), process_id: detached_process_id.clone(), event: ActiveExecutionEvent::Stdout(chunk), }; if let Err(error) = self.check_pending_process_event_capacity(&envelope) { - if let Some(process) = self - .vms - .get_mut(vm_id) - .and_then(|vm| vm.active_processes.get_mut(&root_process_id)) - { - process.requeue_pending_execution_event( - PolledExecutionEvent { - event: envelope.event, - reservation, - }, - )?; + if let Some(mut vm) = self.vms.get_mut(vm_id) { + if let Some(process) = + vm.active_processes.get_mut(&root_process_id) + { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } + } + return Err(error); + } + if let Err((error, envelope)) = + self.try_queue_pending_process_event(envelope) + { + if let Some(mut vm) = self.vms.get_mut(vm_id) { + if let Some(process) = + vm.active_processes.get_mut(&root_process_id) + { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } } return Err(error); } - self.queue_pending_process_event(envelope)?; drop(reservation); emitted_any = true; } @@ -2835,26 +3335,43 @@ where connection_id, session_id, vm_id: vm_id.to_owned(), + child_path: Vec::new(), process_id: detached_process_id.clone(), event: ActiveExecutionEvent::Stderr(chunk), }; if let Err(error) = self.check_pending_process_event_capacity(&envelope) { - if let Some(process) = self - .vms - .get_mut(vm_id) - .and_then(|vm| vm.active_processes.get_mut(&root_process_id)) - { - process.requeue_pending_execution_event( - PolledExecutionEvent { - event: envelope.event, - reservation, - }, - )?; + if let Some(mut vm) = self.vms.get_mut(vm_id) { + if let Some(process) = + vm.active_processes.get_mut(&root_process_id) + { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } + } + return Err(error); + } + if let Err((error, envelope)) = + self.try_queue_pending_process_event(envelope) + { + if let Some(mut vm) = self.vms.get_mut(vm_id) { + if let Some(process) = + vm.active_processes.get_mut(&root_process_id) + { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } } return Err(error); } - self.queue_pending_process_event(envelope)?; drop(reservation); emitted_any = true; } @@ -2863,41 +3380,73 @@ where connection_id, session_id, vm_id: vm_id.to_owned(), + child_path: Vec::new(), process_id: detached_process_id.clone(), event: ActiveExecutionEvent::Exited(exit_code), }; if let Err(error) = self.check_pending_process_event_capacity(&envelope) { - if let Some(process) = self - .vms - .get_mut(vm_id) - .and_then(|vm| vm.active_processes.get_mut(&root_process_id)) - { - process.requeue_pending_execution_event( - PolledExecutionEvent { - event: envelope.event, - reservation, - }, - )?; - } + if let Some(mut vm) = self.vms.get_mut(vm_id) { + if let Some(process) = + vm.active_processes.get_mut(&root_process_id) + { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } + } return Err(error); } - if let Some(vm) = self.vms.get_mut(vm_id) { - vm.detached_child_processes.remove(&detached_process_id); + if let Err((error, envelope)) = + self.try_queue_pending_process_event(envelope) + { + if let Some(mut vm) = self.vms.get_mut(vm_id) { + if let Some(process) = + vm.active_processes.get_mut(&root_process_id) + { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } + } + return Err(error); } - self.queue_pending_process_event(envelope)?; drop(reservation); + if let Some(mut vm) = self.vms.get_mut(vm_id) { + vm.detached_child_processes.remove(&detached_process_id); + } emitted_any = true; break; } ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => { - drop(reservation); - self.handle_javascript_sync_rpc_request( - vm_id, - &root_process_id, + let Some((connection_id, session_id, vm)) = + self.vms.get(vm_id).and_then(|state| { + self.vms.handle(vm_id).map(|handle| { + ( + state.connection_id.clone(), + state.session_id.clone(), + handle, + ) + }) + }) + else { + break; + }; + javascript_services.push(OwnedJavascriptEventService::new( + OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id.to_owned(), + root_process_id.clone(), + Vec::new(), + vm, request, - ) - .await?; + reservation, + )); } ActiveExecutionEvent::JavascriptSyncRpcCompletion(completion) => { drop(reservation); @@ -2908,24 +3457,75 @@ where )?; } ActiveExecutionEvent::PythonVfsRpcRequest(request) => { - drop(reservation); - self.handle_python_vfs_rpc_request(vm_id, &root_process_id, *request) - .await?; + let Some((connection_id, session_id, vm)) = + self.vms.get(vm_id).and_then(|state| { + self.vms.handle(vm_id).map(|handle| { + ( + state.connection_id.clone(), + state.session_id.clone(), + handle, + ) + }) + }) + else { + break; + }; + let responder = vm.try_read( + "claim detached Python process-event service", + |state| { + state + .active_processes + .get(&root_process_id) + .map(|process| process.execution.python_vfs_rpc_responder()) + }, + )?; + let Some(responder) = responder else { + break; + }; + python_services.push(OwnedPythonEventService::new( + OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id.to_owned(), + root_process_id.clone(), + Vec::new(), + vm, + responder?, + *request, + reservation, + )); } ActiveExecutionEvent::PythonSocketConnectCompletion(completion) => { - drop(reservation); - self.handle_python_socket_connect_completion( - vm_id, - &root_process_id, - *completion, - )?; + let Some(vm) = self.vms.handle(vm_id) else { + break; + }; + let responder = + vm.try_read("claim detached Python socket completion", |state| { + state + .active_processes + .get(&root_process_id) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { + break; + }; + python_socket_completions.push( + OwnedPythonSocketCompletionService::new( + OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id.to_owned(), + root_process_id.clone(), + Vec::new(), + vm, + responder?, + *completion, + reservation, + ), + ); } ActiveExecutionEvent::SignalState { signal, registration, } => { drop(reservation); - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { vm.signal_states .entry(root_process_id.clone()) .or_default() @@ -2944,43 +3544,58 @@ where let child_process_id = child_path.last().expect("child path cannot be empty"); loop { + if javascript_services + .len() + .saturating_add(python_services.len()) + .saturating_add(python_socket_completions.len()) + .saturating_add(child_bridge_services.len()) + >= max_service_claims + { + yielded = true; + break; + } if work >= vm_work_limit || detached_work >= child_work_limit { yielded = true; break; } - let event = match self - .poll_descendant_javascript_child_process( - vm_id, - &root_process_id, - &parent_path, - child_process_id, - 0, - false, - ) - .await - { + let claimed = match self.poll_descendant_javascript_child_process_nowait( + vm_id, + &root_process_id, + &parent_path, + child_process_id, + false, + javascript_services, + python_services, + python_socket_completions, + Some(&detached_process_id), + ) { Ok(event) => event, Err(SidecarError::InvalidState(message)) if message.contains("unknown child process") || message.contains("unknown child process path") => { - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { vm.detached_child_processes.remove(&detached_process_id); } break; } Err(error) if is_javascript_child_process_gone_error(&error) => { - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { vm.detached_child_processes.remove(&detached_process_id); } break; } Err(error) => return Err(error), }; + let ClaimedDescendantBridgeEvent { event, reservation } = claimed; let Some(event_type) = event.get("type").and_then(Value::as_str) else { break; }; + let public_event_queued = event + .get("publicEventQueued") + .and_then(Value::as_bool) + .unwrap_or(false); work += 1; detached_work += 1; let Some((connection_id, session_id)) = self @@ -2996,6 +3611,7 @@ where connection_id: connection_id.clone(), session_id: session_id.clone(), vm_id: vm_id.to_owned(), + child_path: Vec::new(), process_id: detached_process_id.clone(), event: ActiveExecutionEvent::Stdout(javascript_sync_rpc_bytes_arg( &[event.get("data").cloned().unwrap_or(Value::Null)], @@ -3007,6 +3623,7 @@ where connection_id: connection_id.clone(), session_id: session_id.clone(), vm_id: vm_id.to_owned(), + child_path: Vec::new(), process_id: detached_process_id.clone(), event: ActiveExecutionEvent::Stderr(javascript_sync_rpc_bytes_arg( &[event.get("data").cloned().unwrap_or(Value::Null)], @@ -3014,34 +3631,47 @@ where "detached child_process stderr", )?), }), - "exit" => { - if let Some(vm) = self.vms.get_mut(vm_id) { - vm.detached_child_processes.remove(&detached_process_id); - } - Some(ProcessEventEnvelope { - connection_id, - session_id, - vm_id: vm_id.to_owned(), - process_id: detached_process_id.clone(), - event: ActiveExecutionEvent::Exited( - event - .get("exitCode") - .and_then(Value::as_i64) - .map(|value| value as i32) - .unwrap_or(1), - ), - }) - } + "exit" if !public_event_queued => Some(ProcessEventEnvelope { + connection_id, + session_id, + vm_id: vm_id.to_owned(), + child_path: Vec::new(), + process_id: detached_process_id.clone(), + event: ActiveExecutionEvent::Exited( + event + .get("exitCode") + .and_then(Value::as_i64) + .map(|value| value as i32) + .unwrap_or(1), + ), + }), + "exit" => None, _ => None, }; - let Some(envelope) = envelope else { + if envelope.is_none() && !public_event_queued { break; - }; - self.queue_pending_process_event(envelope)?; + } + if let Some(envelope) = envelope { + if let Err((error, envelope)) = self.try_queue_pending_process_event(envelope) { + self.requeue_descendant_claimed_event( + vm_id, + &root_process_id, + &parent_path, + child_process_id, + envelope.event, + reservation, + )?; + return Err(error); + } + } + drop(reservation); emitted_any = true; if event_type == "exit" { + if let Some(mut vm) = self.vms.get_mut(vm_id) { + vm.detached_child_processes.remove(&detached_process_id); + } break; } } @@ -3052,6 +3682,45 @@ where } Ok(emitted_any) } + + pub(super) async fn pump_detached_child_process_events( + &mut self, + vm_id: &str, + ) -> Result { + let mut javascript_services = Vec::new(); + let mut python_services = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge_services = Vec::new(); + let emitted = self.pump_detached_child_process_events_nowait( + vm_id, + &mut javascript_services, + &mut python_services, + &mut python_socket_completions, + &mut child_bridge_services, + self.config.runtime.protocol.max_process_events.max(1), + )?; + for target in javascript_services { + let service = self.prepare_owned_javascript_process_event_service(target); + service.await?; + } + for target in python_services { + let service = prepare_owned_python_process_event_service(self, target); + service.await?; + } + for target in python_socket_completions { + let (_ownership, vm_id, process_id, child_path, vm, responder, completion, reservation) = + target.into_parts(); + let _reservation = reservation; + service_owned_python_socket_connect_completion( + vm, vm_id, process_id, child_path, responder, completion, + ) + .await?; + } + for target in child_bridge_services { + service_owned_child_bridge_event(target).await?; + } + Ok(emitted) + } pub(crate) fn drain_queued_descendant_javascript_child_process_events( &mut self, vm_id: &str, @@ -3061,32 +3730,43 @@ where if child_path.is_empty() { return Ok(()); } - let target_process_id = Self::child_process_path_label(process_id, child_path); - let mut child_capacity = self - .vms - .get(vm_id) - .and_then(|vm| vm.active_processes.get(process_id)) - .and_then(|root| descendant_pending_execution_event_capacity(root, child_path)); + let mut child_capacity = self.vms.get(vm_id).and_then(|vm| { + vm.active_processes + .get(process_id) + .and_then(|root| descendant_pending_execution_event_capacity(root, child_path)) + }); let mut deferred = VecDeque::new(); + let mut released_public_capacity = false; while let Some(envelope) = self.pending_process_events.pop_front() { - if envelope.vm_id == vm_id && envelope.process_id == target_process_id { + if envelope.vm_id == vm_id + && envelope.process_id == process_id + && envelope + .child_path + .iter() + .map(String::as_str) + .eq(child_path.iter().copied()) + { if matches!(child_capacity, Some(0)) { self.pending_process_events.push_front(envelope); while let Some(deferred_envelope) = deferred.pop_back() { self.pending_process_events.push_front(deferred_envelope); } self.observe_pending_process_event_depth(); + if released_public_capacity { + self.rearm_deferred_process_event_after_capacity_release(); + } return Err(process_event_queue_overflow_error( self.config.runtime.protocol.max_process_events, )); } - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { if let Some(root) = vm.active_processes.get_mut(process_id) { if let Some(child) = Self::active_process_by_path_mut(root, child_path) { match child.try_queue_pending_execution_envelope(envelope) { Ok(()) => { child_capacity = child_capacity.map(|capacity| capacity - 1); + released_public_capacity = true; continue; } Err((error, envelope)) => { @@ -3095,6 +3775,9 @@ where self.pending_process_events.push_front(deferred_envelope); } self.observe_pending_process_event_depth(); + if released_public_capacity { + self.rearm_deferred_process_event_after_capacity_release(); + } return Err(error); } } @@ -3106,6 +3789,9 @@ where } self.pending_process_events = deferred; self.observe_pending_process_event_depth(); + if released_public_capacity { + self.rearm_deferred_process_event_after_capacity_release(); + } let mut queued = VecDeque::new(); { @@ -3134,8 +3820,15 @@ where } } while let Some(envelope) = queued.pop_front() { - if envelope.vm_id == vm_id && envelope.process_id == target_process_id { - if let Some(vm) = self.vms.get_mut(vm_id) { + if envelope.vm_id == vm_id + && envelope.process_id == process_id + && envelope + .child_path + .iter() + .map(String::as_str) + .eq(child_path.iter().copied()) + { + if let Some(mut vm) = self.vms.get_mut(vm_id) { if let Some(root) = vm.active_processes.get_mut(process_id) { if let Some(child) = Self::active_process_by_path_mut(root, child_path) { match child.try_queue_pending_execution_envelope(envelope) { @@ -3164,14 +3857,13 @@ where #[allow(dead_code)] pub(crate) fn resolve_javascript_child_process_execution( - &self, vm: &VmState, parent_env: &BTreeMap, parent_guest_cwd: &str, parent_host_cwd: &Path, request: &JavascriptChildProcessSpawnRequest, ) -> Result { - self.resolve_javascript_child_process_execution_with_mode( + Self::resolve_javascript_child_process_execution_with_mode( vm, parent_env, parent_guest_cwd, @@ -3186,7 +3878,6 @@ where // are distinct security inputs, not interchangeable options. #[allow(clippy::too_many_arguments)] pub(crate) fn resolve_javascript_child_process_execution_with_mode( - &self, vm: &VmState, parent_env: &BTreeMap, parent_guest_cwd: &str, @@ -3600,8 +4291,8 @@ where let mut resolved = { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - self.resolve_javascript_child_process_execution_with_mode( - vm, + Self::resolve_javascript_child_process_execution_with_mode( + &vm, parent_env, parent_guest_cwd, parent_host_cwd, @@ -3613,11 +4304,11 @@ where for redirects in 0..=MAX_SHEBANG_REDIRECTS { let redirected = { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - rewrite_javascript_shebang_request(vm, &resolved, request)? + rewrite_javascript_shebang_request(&mut vm, &resolved, request)? }; if !redirected { return Ok(resolved); @@ -3629,8 +4320,8 @@ where } resolved = { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - self.resolve_javascript_child_process_execution_with_mode( - vm, + Self::resolve_javascript_child_process_execution_with_mode( + &vm, parent_env, parent_guest_cwd, parent_host_cwd, @@ -3669,18 +4360,19 @@ where }) }; if let Some((host_cwd, guest_cwd, sync_root_shadow)) = parent_sync_roots { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - sync_process_host_roots_to_kernel(vm, &host_cwd, &guest_cwd, sync_root_shadow)?; + sync_process_host_roots_to_kernel(&mut vm, &host_cwd, &guest_cwd, sync_root_shadow)?; } let prepared_host_net_fds = { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - let current_network_counts = vm_spawn_host_net_resource_counts(vm); + let mut vm = &mut *vm; + let current_network_counts = vm_spawn_host_net_resource_counts(&mut vm); let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); let parent = active_processes .get_mut(process_id) @@ -3715,7 +4407,7 @@ where .unwrap_or_else(|| parent.guest_cwd.clone()); (parent.kernel_pid, initial_cwd) }; - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; @@ -3743,11 +4435,11 @@ where .ok_or_else(|| missing_process_error(vm_id, process_id))? .guest_cwd .clone(); - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - resolve_posix_spawn_program(vm, &parent_guest_cwd, &mut request)?; + resolve_posix_spawn_program(&mut vm, &parent_guest_cwd, &mut request)?; } let total_start = Instant::now(); let process_event_capacity = self.config.runtime.protocol.max_process_events; @@ -3775,8 +4467,8 @@ where )? } else { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - self.resolve_javascript_child_process_execution_with_mode( - vm, + Self::resolve_javascript_child_process_execution_with_mode( + &vm, &parent_env, &parent_guest_cwd, &parent_host_cwd, @@ -3787,11 +4479,11 @@ where }; apply_child_process_argv0(&mut resolved, request.options.argv0.as_deref()); { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - stage_agentos_package_command(vm, &mut resolved)?; + stage_agentos_package_command(&mut vm, &mut resolved)?; } tracing::debug!( vm_id, @@ -3816,7 +4508,7 @@ where } record_execute_phase("child_process_resolve_execution", phase_start.elapsed()); let (parent_kernel_pid, child_process_id) = { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; @@ -3827,11 +4519,12 @@ where (process.kernel_pid, process.allocate_child_process_id()) }; let sidecar_requests = self.sidecar_requests.clone(); - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - enforce_resolved_wasm_execute_dac(vm, parent_kernel_pid, &resolved)?; + let execution_engines = vm.execution_engines.clone(); + enforce_resolved_wasm_execute_dac(&mut vm, parent_kernel_pid, &resolved)?; let vm_pending_stdin_bytes_budget = Arc::clone(&vm.pending_stdin_bytes_budget); let vm_pending_event_bytes_budget = Arc::clone(&vm.pending_event_bytes_budget); let phase_start = Instant::now(); @@ -3844,7 +4537,7 @@ where direct_posix_stdin, ) = if resolved.binding_command { let binding_resolution = resolve_binding_command( - vm, + &mut vm, &resolved.command, &resolved.execution_args, Some(&resolved.guest_cwd), @@ -4046,25 +4739,27 @@ where let execution = match resolved.runtime { GuestRuntimeKind::JavaScript => { + let mut javascript_engine = + execution_engines.javascript("start JavaScript child process")?; execution_env.extend(sanitize_javascript_child_process_internal_bootstrap_env( &request.options.internal_bootstrap_env, )); execution_env .insert(String::from("AGENTOS_KEEP_STDIN_OPEN"), String::from("1")); let launch_entrypoint = resolve_agentos_package_javascript_launch_entrypoint( - vm, + &mut vm, &mut execution_env, ) .unwrap_or_else(|| resolved.entrypoint.clone()); let inline_code = load_javascript_entrypoint_source( - vm, + &mut vm, &resolved.host_cwd, &launch_entrypoint, &execution_env, ); - prepare_javascript_shadow(vm, &resolved, &execution_env)?; + prepare_javascript_shadow(&mut vm, &resolved, &execution_env)?; - let built_reader = build_module_reader(vm, &resolved); + let built_reader = build_module_reader(&mut vm, &resolved); let guest_reader = built_reader.clone().map(|reader| { Box::new(crate::plugins::host_dir::SessionModuleReader::new(reader)) as Box @@ -4072,21 +4767,17 @@ where let module_reader = built_reader .map(|reader| Box::new(reader) as Box); let context = - self.javascript_engine - .create_context(CreateJavascriptContextRequest { - vm_id: vm_id.to_owned(), - bootstrap_module: None, - compile_cache_root: Some( - self.cache_root.join("node-compile-cache"), - ), - }); + javascript_engine.create_context(CreateJavascriptContextRequest { + vm_id: vm_id.to_owned(), + bootstrap_module: None, + compile_cache_root: Some(self.cache_root.join("node-compile-cache")), + }); let context_id = context.context_id; - let execution_result = self - .javascript_engine + let execution_result = javascript_engine .start_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { guest_runtime: guest_runtime_identity( - vm, + &mut vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), ), @@ -4098,7 +4789,7 @@ where argv0: request.options.argv0.clone(), env: execution_env, cwd: resolved.host_cwd.clone(), - limits: javascript_execution_limits(vm), + limits: javascript_execution_limits(&mut vm), inline_code, wasm_module_bytes: None, }, @@ -4106,11 +4797,13 @@ where guest_reader, vm.runtime_context.clone(), ); - self.javascript_engine.dispose_context(&context_id); + javascript_engine.dispose_context(&context_id); let execution = execution_result.map_err(javascript_error)?; ActiveExecution::Javascript(execution) } GuestRuntimeKind::WebAssembly => { + let mut wasm_engine = + execution_engines.wasm("start WebAssembly child process")?; // These values configure the trusted WASM runner, not // the guest-visible Linux environment. execution_env.extend(sanitize_javascript_child_process_internal_bootstrap_env( @@ -4118,19 +4811,18 @@ where )); execution_env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1")); execution_env.insert(String::from(WASM_EXEC_COMMIT_RPC_ENV), String::from("1")); - let wasm_limits = wasm_execution_limits(vm); + let wasm_limits = wasm_execution_limits(&mut vm); let wasm_guest_runtime = guest_runtime_identity( - vm, + &mut vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), ); - let context = self.wasm_engine.create_context(CreateWasmContextRequest { + let context = wasm_engine.create_context(CreateWasmContextRequest { vm_id: vm_id.to_owned(), module_path: Some(resolved.entrypoint.clone()), }); let context_id = context.context_id; - let execution_result = self - .wasm_engine + let execution_result = wasm_engine .start_execution_with_runtime_async( StartWasmExecutionRequest { vm_id: vm_id.to_owned(), @@ -4149,11 +4841,13 @@ where vm.runtime_context.clone(), ) .await; - self.wasm_engine.dispose_context(&context_id); + wasm_engine.dispose_context(&context_id); let execution = execution_result.map_err(wasm_error)?; ActiveExecution::Wasm(Box::new(execution)) } GuestRuntimeKind::Python => { + let mut python_engine = + execution_engines.python("start Python child process")?; // Nested `python` child_process: set up the Pyodide context the // same way the top-level execute path does, so a guest shell or // node parent can spawn `python` exactly like `node`. @@ -4162,8 +4856,7 @@ where } else { python_file_entrypoint(&resolved.entrypoint) }; - let pyodide_dist_path = self - .python_engine + let pyodide_dist_path = python_engine .bundled_pyodide_dist_path_for_vm(vm_id) .map_err(python_error)?; let pyodide_cache_path = pyodide_dist_path @@ -4199,15 +4892,12 @@ where &pyodide_cache_path, false, ); - let context = self - .python_engine - .create_context(CreatePythonContextRequest { - vm_id: vm_id.to_owned(), - pyodide_dist_path, - }); + let context = python_engine.create_context(CreatePythonContextRequest { + vm_id: vm_id.to_owned(), + pyodide_dist_path, + }); let context_id = context.context_id; - let execution_result = self - .python_engine + let execution_result = python_engine .start_execution_with_runtime_async( StartPythonExecutionRequest { vm_id: vm_id.to_owned(), @@ -4216,9 +4906,9 @@ where file_path: python_file_path, env: execution_env, cwd: resolved.host_cwd.clone(), - limits: python_execution_limits(vm), + limits: python_execution_limits(&mut vm), guest_runtime: guest_runtime_identity( - vm, + &mut vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), ), @@ -4226,7 +4916,7 @@ where vm.runtime_context.clone(), ) .await; - self.python_engine.dispose_context(&context_id); + python_engine.dispose_context(&context_id); let execution = execution_result.map_err(python_error)?; ActiveExecution::Python(execution) } @@ -4339,7 +5029,7 @@ where })) } - fn child_process_sync_max_buffer( + pub(crate) fn child_process_sync_max_buffer( process: &ActiveProcess, requested: Option, ) -> Result { @@ -4393,7 +5083,7 @@ where .clone() .unwrap_or_else(|| String::from("SIGTERM")); let spawned = self - .spawn_javascript_child_process(vm_id, process_id, request) + .spawn_descendant_javascript_child_process(vm_id, process_id, &[], request) .await?; let child_process_id = spawned .get("childId") @@ -4420,7 +5110,7 @@ where self.close_javascript_child_process_stdin(vm_id, process_id, &child_process_id)?; let (runtime, notify) = { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; @@ -4493,8 +5183,42 @@ where vm_id: &str, root_process_id: &str, process_path: &[&str], + request: JavascriptChildProcessSpawnRequest, + ) -> Result<(), SidecarError> { + let handle = self + .vms + .handle(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + let vms = OwnedChildProcessVm { + vm_id: vm_id.to_owned(), + handle, + }; + #[cfg(test)] + let fail_after_commit = std::mem::take(&mut self.fail_next_exec_start_after_commit); + #[cfg(not(test))] + let fail_after_commit = false; + Self::exec_javascript_process_image_owned( + &vms, + &self.cache_root, + fail_after_commit, + vm_id, + root_process_id, + process_path, + request, + ) + } + + fn exec_javascript_process_image_owned( + vms: &OwnedChildProcessVm, + cache_root: &Path, + fail_after_commit: bool, + vm_id: &str, + root_process_id: &str, + process_path: &[&str], mut request: JavascriptChildProcessSpawnRequest, ) -> Result<(), SidecarError> { + #[cfg(not(test))] + let _ = fail_after_commit; if request.options.executable_fd.is_some() { return Err(SidecarError::InvalidState(String::from( "EINVAL: executableFd is only valid for process.exec_fd_image_commit", @@ -4514,7 +5238,7 @@ where current_runtime, direct_posix_stdin, ) = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let root = vm .active_processes .get(root_process_id) @@ -4556,10 +5280,7 @@ where }; let literal_exec_path = request.command.clone(); request.command = { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; vm.kernel .validate_executable_path(&literal_exec_path, &guest_cwd) .map_err(kernel_error)? @@ -4569,9 +5290,9 @@ where request.options.detached = false; let mut resolved = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - self.resolve_javascript_child_process_execution_with_mode( - vm, + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + Self::resolve_javascript_child_process_execution_with_mode( + &vm, &BTreeMap::new(), &guest_cwd, &host_cwd, @@ -4591,10 +5312,7 @@ where && current_runtime == GuestRuntimeKind::WebAssembly && resolved.runtime == GuestRuntimeKind::WebAssembly { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; // The runner has already compiled the replacement before it asks // the sidecar to commit. Recursively validate the original image // and every `#!` interpreter in the kernel so scripts retain @@ -4604,18 +5322,12 @@ where .validate_wasm_exec_image(&request.command, &guest_cwd) .map_err(kernel_error)?; } else { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - validate_exact_exec_image_format(vm, &request.command, &resolved.runtime)?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + validate_exact_exec_image_format(&mut vm, &request.command, &resolved.runtime)?; } { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - stage_agentos_package_command(vm, &mut resolved)?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + stage_agentos_package_command(&mut vm, &mut resolved)?; } // Keep guest-visible envp separate from executor bootstrap variables // added during resolution. Both local and separate-runtime exec paths @@ -4631,10 +5343,7 @@ where literal_exec_path ))); } - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; // execve's envp is a complete replacement. `resolved.env` also // contains host/runtime control variables injected while locating // the module; those must never leak into the process's Linux-visible @@ -4692,10 +5401,9 @@ where return Ok(()); } - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = &mut *vm; + let execution_engines = vm.execution_engines.clone(); let mut execution_env = resolved.env.clone(); execution_env.insert( String::from(EXECUTION_SANDBOX_ROOT_ENV), @@ -4703,6 +5411,8 @@ where ); let replacement = match resolved.runtime { GuestRuntimeKind::JavaScript => { + let mut javascript_engine = + execution_engines.javascript("prepare JavaScript exec replacement")?; execution_env.extend(sanitize_javascript_child_process_internal_bootstrap_env( &request.options.internal_bootstrap_env, )); @@ -4718,37 +5428,36 @@ where } else { execution_env.remove("AGENTOS_FORWARD_KERNEL_STDIN_RPC"); } - let launch_entrypoint = - resolve_agentos_package_javascript_launch_entrypoint(vm, &mut execution_env) - .unwrap_or_else(|| resolved.entrypoint.clone()); + let launch_entrypoint = resolve_agentos_package_javascript_launch_entrypoint( + &mut vm, + &mut execution_env, + ) + .unwrap_or_else(|| resolved.entrypoint.clone()); let inline_code = load_javascript_entrypoint_source( - vm, + &mut vm, &resolved.host_cwd, &launch_entrypoint, &execution_env, ); - prepare_javascript_shadow(vm, &resolved, &execution_env)?; - let built_reader = build_module_reader(vm, &resolved); + prepare_javascript_shadow(&mut vm, &resolved, &execution_env)?; + let built_reader = build_module_reader(&mut vm, &resolved); let guest_reader = built_reader.clone().map(|reader| { Box::new(crate::plugins::host_dir::SessionModuleReader::new(reader)) as Box }); let module_reader = built_reader.map(|reader| Box::new(reader) as Box); - let context = - self.javascript_engine - .create_context(CreateJavascriptContextRequest { - vm_id: vm_id.to_owned(), - bootstrap_module: None, - compile_cache_root: Some(self.cache_root.join("node-compile-cache")), - }); + let context = javascript_engine.create_context(CreateJavascriptContextRequest { + vm_id: vm_id.to_owned(), + bootstrap_module: None, + compile_cache_root: Some(cache_root.join("node-compile-cache")), + }); let context_id = context.context_id; - let replacement_result = self - .javascript_engine + let replacement_result = javascript_engine .prepare_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { guest_runtime: guest_runtime_identity( - vm, + &mut vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), ), @@ -4760,7 +5469,7 @@ where argv0: request.options.argv0.clone(), env: execution_env, cwd: resolved.host_cwd.clone(), - limits: javascript_execution_limits(vm), + limits: javascript_execution_limits(&mut vm), inline_code, wasm_module_bytes: None, }, @@ -4768,51 +5477,52 @@ where guest_reader, vm.runtime_context.clone(), ); - self.javascript_engine.dispose_context(&context_id); + javascript_engine.dispose_context(&context_id); ActiveExecution::Javascript(replacement_result.map_err(javascript_error)?) } GuestRuntimeKind::WebAssembly => { + let mut wasm_engine = + execution_engines.wasm("prepare WebAssembly exec replacement")?; execution_env.extend(sanitize_javascript_child_process_internal_bootstrap_env( &request.options.internal_bootstrap_env, )); execution_env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1")); execution_env.insert(String::from(WASM_EXEC_COMMIT_RPC_ENV), String::from("1")); - let context = self.wasm_engine.create_context(CreateWasmContextRequest { + let context = wasm_engine.create_context(CreateWasmContextRequest { vm_id: vm_id.to_owned(), module_path: Some(resolved.entrypoint.clone()), }); let context_id = context.context_id; - let replacement_result = - self.wasm_engine - .prepare_execution(StartWasmExecutionRequest { - vm_id: vm_id.to_owned(), - context_id: context_id.clone(), - argv: resolved.process_args.clone(), - env: execution_env, - cwd: resolved.host_cwd.clone(), - permission_tier: execution_wasm_permission_tier( - resolved - .wasm_permission_tier - .unwrap_or(WasmPermissionTier::Full), - ), - limits: wasm_execution_limits(vm), - guest_runtime: guest_runtime_identity( - vm, - Some(u64::from(kernel_pid)), - Some(u64::from(parent_kernel_pid)), - ), - }); - self.wasm_engine.dispose_context(&context_id); + let replacement_result = wasm_engine.prepare_execution(StartWasmExecutionRequest { + vm_id: vm_id.to_owned(), + context_id: context_id.clone(), + argv: resolved.process_args.clone(), + env: execution_env, + cwd: resolved.host_cwd.clone(), + permission_tier: execution_wasm_permission_tier( + resolved + .wasm_permission_tier + .unwrap_or(WasmPermissionTier::Full), + ), + limits: wasm_execution_limits(&mut vm), + guest_runtime: guest_runtime_identity( + &mut vm, + Some(u64::from(kernel_pid)), + Some(u64::from(parent_kernel_pid)), + ), + }); + wasm_engine.dispose_context(&context_id); ActiveExecution::Wasm(Box::new(replacement_result.map_err(wasm_error)?)) } GuestRuntimeKind::Python => { + let mut python_engine = + execution_engines.python("prepare Python exec replacement")?; let python_file_path = if execution_env.contains_key("AGENTOS_PYTHON_ARGV") { execution_env.get("AGENTOS_PYTHON_FILE").map(PathBuf::from) } else { python_file_entrypoint(&resolved.entrypoint) }; - let pyodide_dist_path = self - .python_engine + let pyodide_dist_path = python_engine .bundled_pyodide_dist_path_for_vm(vm_id) .map_err(python_error)?; let pyodide_cache_path = pyodide_dist_path @@ -4848,30 +5558,27 @@ where &pyodide_cache_path, false, ); - let context = self - .python_engine - .create_context(CreatePythonContextRequest { - vm_id: vm_id.to_owned(), - pyodide_dist_path, - }); + let context = python_engine.create_context(CreatePythonContextRequest { + vm_id: vm_id.to_owned(), + pyodide_dist_path, + }); let context_id = context.context_id; let replacement_result = - self.python_engine - .prepare_execution(StartPythonExecutionRequest { - vm_id: vm_id.to_owned(), - context_id: context_id.clone(), - code: resolved.entrypoint.clone(), - file_path: python_file_path, - env: execution_env, - cwd: resolved.host_cwd.clone(), - limits: python_execution_limits(vm), - guest_runtime: guest_runtime_identity( - vm, - Some(u64::from(kernel_pid)), - Some(u64::from(parent_kernel_pid)), - ), - }); - self.python_engine.dispose_context(&context_id); + python_engine.prepare_execution(StartPythonExecutionRequest { + vm_id: vm_id.to_owned(), + context_id: context_id.clone(), + code: resolved.entrypoint.clone(), + file_path: python_file_path, + env: execution_env, + cwd: resolved.host_cwd.clone(), + limits: python_execution_limits(&mut vm), + guest_runtime: guest_runtime_identity( + &mut vm, + Some(u64::from(kernel_pid)), + Some(u64::from(parent_kernel_pid)), + ), + }); + python_engine.dispose_context(&context_id); ActiveExecution::Python(replacement_result.map_err(python_error)?) } }; @@ -4958,8 +5665,7 @@ where // after both kernel-visible process state and sidecar-owned descriptors // and event targets point at the replacement image. #[cfg(test)] - let replacement_start_error = if std::mem::take(&mut self.fail_next_exec_start_after_commit) - { + let replacement_start_error = if fail_after_commit { Some(SidecarError::Execution(String::from( "injected post-commit execve start failure", ))) @@ -5029,6 +5735,29 @@ where root_process_id: &str, process_path: &[&str], request: JavascriptChildProcessSpawnRequest, + ) -> Result<(), SidecarError> { + let handle = self + .vms + .handle(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + Self::commit_wasm_fd_process_image_owned( + &OwnedChildProcessVm { + vm_id: vm_id.to_owned(), + handle, + }, + vm_id, + root_process_id, + process_path, + request, + ) + } + + fn commit_wasm_fd_process_image_owned( + vms: &OwnedChildProcessVm, + vm_id: &str, + root_process_id: &str, + process_path: &[&str], + request: JavascriptChildProcessSpawnRequest, ) -> Result<(), SidecarError> { if !request.options.local_replacement || request.options.executable_fd.is_none() { return Err(SidecarError::InvalidState(String::from( @@ -5042,7 +5771,7 @@ where } let (kernel_pid, retained_internal_fds) = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let root = vm .active_processes .get(root_process_id) @@ -5078,10 +5807,7 @@ where argv.extend(request.args); let replacement_guest_env = request.options.env; - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; vm.kernel .exec_process_retaining_internal_fds( EXECUTION_DRIVER_NAME, @@ -5120,166 +5846,222 @@ where Ok(()) } - async fn spawn_descendant_javascript_child_process( - &mut self, + pub(crate) fn spawn_descendant_javascript_child_process( + &self, vm_id: &str, process_id: &str, current_process_path: &[&str], - mut request: JavascriptChildProcessSpawnRequest, - ) -> Result { - let spawn_attributes = javascript_spawn_attributes(&request.options)?; - let requested_pgid = spawn_attributes.process_group; - let current_process_label = - Self::child_process_path_label(process_id, current_process_path); - let parent_sync_roots = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - let root = vm - .active_processes - .get(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path(root, current_process_path).ok_or_else(|| { - SidecarError::InvalidState(format!( - "unknown child process path {current_process_label} during nested spawn" - )) - })?; - (parent.host_write_dirty_recursive() - || !parent.clean_host_writes_are_observable_recursive()) - .then(|| { - ( - parent.host_cwd.clone(), - parent.guest_cwd.clone(), - parent.runtime != GuestRuntimeKind::JavaScript, - ) - }) + request: JavascriptChildProcessSpawnRequest, + ) -> Pin> + 'static>> { + let vm_id = vm_id.to_owned(); + let process_id = process_id.to_owned(); + let current_process_path = current_process_path + .iter() + .map(|segment| (*segment).to_owned()) + .collect::>(); + let Some(handle) = self.vms.handle(&vm_id) else { + return Box::pin(async move { Err(missing_vm_error(&vm_id)) }); }; - if let Some((host_cwd, guest_cwd, sync_root_shadow)) = parent_sync_roots { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - sync_process_host_roots_to_kernel(vm, &host_cwd, &guest_cwd, sync_root_shadow)?; - } - let prepared_host_net_fds = { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - let current_network_counts = vm_spawn_host_net_resource_counts(vm); - let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); - let root = active_processes - .get_mut(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| { - SidecarError::InvalidState(format!( - "unknown child process path {} during host-network fd inheritance", - Self::child_process_path_label(process_id, current_process_path) - )) - })?; - prepare_spawn_host_net_fds( - kernel, - parent, - current_network_counts, - &request.options.spawn_host_net_fds, - &request.options.spawn_fd_mappings, - &request.options.spawn_file_actions, - )? + let process_event_capacity = self.config.runtime.protocol.max_process_events; + let sidecar_requests = SharedSidecarRequestClient::clone(&self.sidecar_requests); + let process_event_notify = Arc::clone(&self.process_event_notify); + let cache_root = PathBuf::clone(&self.cache_root); + Self::build_owned_descendant_javascript_child_process_spawn( + handle, + vm_id, + process_id, + current_process_path, + request, + process_event_capacity, + sidecar_requests, + process_event_notify, + cache_root, + ) + } + + pub(crate) fn build_owned_descendant_javascript_child_process_spawn( + handle: VmHandle, + vm_id: String, + process_id: String, + current_process_path: Vec, + request: JavascriptChildProcessSpawnRequest, + process_event_capacity: usize, + sidecar_requests: SharedSidecarRequestClient, + process_event_notify: Arc, + cache_root: PathBuf, + ) -> Pin> + 'static>> { + let vms = OwnedChildProcessVm { + vm_id: vm_id.clone(), + handle, }; - let prepared_spawn_actions = if !prepared_host_net_fds.kernel_actions.is_empty() { - let (parent_pid, parent_cwd) = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + Box::pin(async move { + let vm_id = vm_id.as_str(); + let process_id = process_id.as_str(); + let current_process_path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let current_process_path = current_process_path.as_slice(); + let mut request = request; + let spawn_attributes = javascript_spawn_attributes(&request.options)?; + let requested_pgid = spawn_attributes.process_group; + let current_process_label = + NativeSidecar::::child_process_path_label(process_id, current_process_path); + let parent_sync_roots = { + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let root = vm .active_processes .get(process_id) .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path(root, current_process_path).ok_or_else(|| { + let parent = NativeSidecar::::active_process_by_path(root, current_process_path) + .ok_or_else(|| { SidecarError::InvalidState(format!( - "unknown child process path {} during spawn file actions", - Self::child_process_path_label(process_id, current_process_path) - )) + "unknown child process path {current_process_label} during nested spawn" + )) })?; - let initial_cwd = request - .options - .cwd - .as_deref() - .map(|cwd| { - if cwd.starts_with('/') { - normalize_path(cwd) - } else { - normalize_path(&format!("{}/{cwd}", parent.guest_cwd)) - } - }) - .unwrap_or_else(|| parent.guest_cwd.clone()); - (parent.kernel_pid, initial_cwd) + (parent.host_write_dirty_recursive() + || !parent.clean_host_writes_are_observable_recursive()) + .then(|| { + ( + parent.host_cwd.clone(), + parent.guest_cwd.clone(), + parent.runtime != GuestRuntimeKind::JavaScript, + ) + }) }; - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - Some(preapply_posix_spawn_file_actions( - &mut vm.kernel, - parent_pid, - &parent_cwd, - requested_pgid, - &request.options.spawn_fd_mappings, - &prepared_host_net_fds.kernel_actions, - )?) - } else { - None - }; - if let Some(prepared) = prepared_spawn_actions.as_ref() { - request.options.cwd = Some(prepared.cwd.clone()); - } - { - let parent_guest_cwd = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + if let Some((host_cwd, guest_cwd, sync_root_shadow)) = parent_sync_roots { + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + sync_process_host_roots_to_kernel( + &mut vm, + &host_cwd, + &guest_cwd, + sync_root_shadow, + )?; + } + let prepared_host_net_fds = { + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let mut vm = &mut *vm; + let current_network_counts = vm_spawn_host_net_resource_counts(&mut vm); + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let root = active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + NativeSidecar::::active_process_by_path_mut(root, current_process_path) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown child process path {} during host-network fd inheritance", + NativeSidecar::::child_process_path_label( + process_id, + current_process_path + ) + )) + })?; + prepare_spawn_host_net_fds( + kernel, + parent, + current_network_counts, + &request.options.spawn_host_net_fds, + &request.options.spawn_fd_mappings, + &request.options.spawn_file_actions, + )? + }; + let prepared_spawn_actions = if !prepared_host_net_fds.kernel_actions.is_empty() { + let (parent_pid, parent_cwd) = { + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let root = vm + .active_processes + .get(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + NativeSidecar::::active_process_by_path(root, current_process_path) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown child process path {} during spawn file actions", + NativeSidecar::::child_process_path_label( + process_id, + current_process_path + ) + )) + })?; + let initial_cwd = request + .options + .cwd + .as_deref() + .map(|cwd| { + if cwd.starts_with('/') { + normalize_path(cwd) + } else { + normalize_path(&format!("{}/{cwd}", parent.guest_cwd)) + } + }) + .unwrap_or_else(|| parent.guest_cwd.clone()); + (parent.kernel_pid, initial_cwd) + }; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + Some(preapply_posix_spawn_file_actions( + &mut vm.kernel, + parent_pid, + &parent_cwd, + requested_pgid, + &request.options.spawn_fd_mappings, + &prepared_host_net_fds.kernel_actions, + )?) + } else { + None + }; + if let Some(prepared) = prepared_spawn_actions.as_ref() { + request.options.cwd = Some(prepared.cwd.clone()); + } + { + let parent_guest_cwd = { + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let root = vm + .active_processes + .get(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + NativeSidecar::::active_process_by_path(root, current_process_path) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown child process path {} during program resolution", + NativeSidecar::::child_process_path_label( + process_id, + current_process_path + ) + )) + })? + .guest_cwd + .clone() + }; + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + resolve_posix_spawn_program(&mut vm, &parent_guest_cwd, &mut request)?; + } + let total_start = Instant::now(); + let phase_start = Instant::now(); + let (parent_env, parent_guest_cwd, parent_host_cwd, parent_kernel_pid) = { + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let root = vm .active_processes .get(process_id) .ok_or_else(|| missing_process_error(vm_id, process_id))?; - Self::active_process_by_path(root, current_process_path) + let parent = NativeSidecar::::active_process_by_path(root, current_process_path) .ok_or_else(|| { SidecarError::InvalidState(format!( - "unknown child process path {} during program resolution", - Self::child_process_path_label(process_id, current_process_path) - )) - })? - .guest_cwd - .clone() - }; - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - resolve_posix_spawn_program(vm, &parent_guest_cwd, &mut request)?; - } - let total_start = Instant::now(); - let process_event_capacity = self.config.runtime.protocol.max_process_events; - let phase_start = Instant::now(); - let (parent_env, parent_guest_cwd, parent_host_cwd, parent_kernel_pid) = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - let root = vm - .active_processes - .get(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path(root, current_process_path).ok_or_else(|| { - SidecarError::InvalidState(format!( "unknown child process path {current_process_label} during nested spawn" )) - })?; - ( - parent.env.clone(), - parent.guest_cwd.clone(), - parent.host_cwd.clone(), - parent.kernel_pid, - ) - }; - let mut resolved = - if !request.options.spawn_exact_path && request.options.spawn_search_path.is_none() { - self.resolve_javascript_child_process_with_shebang( + })?; + ( + parent.env.clone(), + parent.guest_cwd.clone(), + parent.host_cwd.clone(), + parent.kernel_pid, + ) + }; + let mut resolved = if !request.options.spawn_exact_path + && request.options.spawn_search_path.is_none() + { + resolve_owned_javascript_child_process_with_shebang::( + &vms, vm_id, &parent_env, &parent_guest_cwd, @@ -5287,9 +6069,9 @@ where &mut request, )? } else { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - self.resolve_javascript_child_process_execution_with_mode( - vm, + let vm = vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + NativeSidecar::::resolve_javascript_child_process_execution_with_mode( + &vm, &parent_env, &parent_guest_cwd, &parent_host_cwd, @@ -5298,66 +6080,60 @@ where request.options.spawn_search_path.as_deref(), )? }; - apply_child_process_argv0(&mut resolved, request.options.argv0.as_deref()); - { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - stage_agentos_package_command(vm, &mut resolved)?; - } - tracing::debug!( - vm_id, - process_id, - parent = %current_process_label, - command = %resolved.command, - runtime = ?resolved.runtime, - entrypoint = %resolved.entrypoint, - execution_args = ?resolved.execution_args, - parent_guest_cwd = %parent_guest_cwd, - requested_cwd = ?request.options.cwd, - guest_cwd = %resolved.guest_cwd, - host_cwd = %resolved.host_cwd.display(), - "resolved nested JavaScript child process" - ); - let resolved = resolved; - if prepared_host_net_fds.inherited_fd_count() != 0 - && (resolved.runtime != GuestRuntimeKind::WebAssembly || resolved.binding_command) - { - return Err(SidecarError::InvalidState(String::from( - "ENOTSUP: inherited host-network fds require a WebAssembly child runtime", - ))); - } - record_execute_phase("child_process_resolve_execution", phase_start.elapsed()); - let sidecar_requests = self.sidecar_requests.clone(); - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - enforce_resolved_wasm_execute_dac(vm, parent_kernel_pid, &resolved)?; - let vm_pending_stdin_bytes_budget = Arc::clone(&vm.pending_stdin_bytes_budget); - let vm_pending_event_bytes_budget = Arc::clone(&vm.pending_event_bytes_budget); - let phase_start = Instant::now(); - let child_process_id = { - let root = vm - .active_processes - .get_mut(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| { - SidecarError::InvalidState(format!( + apply_child_process_argv0(&mut resolved, request.options.argv0.as_deref()); + { + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + stage_agentos_package_command(&mut vm, &mut resolved)?; + } + tracing::debug!( + vm_id, + process_id, + parent = %current_process_label, + command = %resolved.command, + runtime = ?resolved.runtime, + entrypoint = %resolved.entrypoint, + execution_args = ?resolved.execution_args, + parent_guest_cwd = %parent_guest_cwd, + requested_cwd = ?request.options.cwd, + guest_cwd = %resolved.guest_cwd, + host_cwd = %resolved.host_cwd.display(), + "resolved nested JavaScript child process" + ); + let resolved = resolved; + if prepared_host_net_fds.inherited_fd_count() != 0 + && (resolved.runtime != GuestRuntimeKind::WebAssembly || resolved.binding_command) + { + return Err(SidecarError::InvalidState(String::from( + "ENOTSUP: inherited host-network fds require a WebAssembly child runtime", + ))); + } + record_execute_phase("child_process_resolve_execution", phase_start.elapsed()); + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let execution_engines = vm.execution_engines.clone(); + enforce_resolved_wasm_execute_dac(&mut vm, parent_kernel_pid, &resolved)?; + let vm_pending_stdin_bytes_budget = Arc::clone(&vm.pending_stdin_bytes_budget); + let vm_pending_event_bytes_budget = Arc::clone(&vm.pending_event_bytes_budget); + let phase_start = Instant::now(); + let child_process_id = { + let root = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + NativeSidecar::::active_process_by_path_mut(root, current_process_path) + .ok_or_else(|| { + SidecarError::InvalidState(format!( "unknown child process path {current_process_label} during nested spawn" )) - })?; - parent.allocate_child_process_id() - }; - let mut child_path = current_process_path.to_vec(); - child_path.push(child_process_id.as_str()); - let mut pending_kernel_handle = None; - let spawn_result = (async { + })?; + parent.allocate_child_process_id() + }; + let mut child_path = current_process_path.to_vec(); + child_path.push(child_process_id.as_str()); + let mut pending_kernel_handle: Option; let spawned = if resolved.binding_command { let binding_resolution = resolve_binding_command( - vm, + &mut vm, &resolved.command, &resolved.execution_args, Some(&resolved.guest_cwd), @@ -5406,7 +6182,7 @@ where )?; pending_kernel_handle = Some(kernel_handle.clone()); let binding_execution = BindingExecution::with_event_notify( - Arc::clone(&self.process_event_notify), + Arc::clone(&process_event_notify), process_event_capacity, ) .with_vm_pending_event_bytes_budget(Arc::clone(&vm_pending_event_bytes_budget)); @@ -5438,7 +6214,8 @@ where ( kernel_pid, kernel_handle, - ActiveExecution::Binding(binding_execution), + Box::pin(async move { Ok(ActiveExecution::Binding(binding_execution)) }) + as OwnedChildExecutionStart, None, 0, false, @@ -5560,8 +6337,10 @@ where String::from(EXECUTION_SANDBOX_ROOT_ENV), normalize_host_path(&vm.cwd).to_string_lossy().into_owned(), ); - let execution = match resolved.runtime { + let execution_start: OwnedChildExecutionStart = match resolved.runtime { GuestRuntimeKind::JavaScript => { + let mut javascript_engine = execution_engines + .javascript("start nested JavaScript child process")?; execution_env.extend( sanitize_javascript_child_process_internal_bootstrap_env( &request.options.internal_bootstrap_env, @@ -5580,19 +6359,19 @@ where } let launch_entrypoint = resolve_agentos_package_javascript_launch_entrypoint( - vm, + &mut vm, &mut execution_env, ) .unwrap_or_else(|| resolved.entrypoint.clone()); let inline_code = load_javascript_entrypoint_source( - vm, + &mut vm, &resolved.host_cwd, &launch_entrypoint, &execution_env, ); - prepare_javascript_shadow(vm, &resolved, &execution_env)?; + prepare_javascript_shadow(&mut vm, &resolved, &execution_env)?; - let built_reader = build_module_reader(vm, &resolved); + let built_reader = build_module_reader(&mut vm, &resolved); let guest_reader = built_reader.clone().map(|reader| { Box::new(crate::plugins::host_dir::SessionModuleReader::new(reader)) as Box @@ -5600,21 +6379,17 @@ where let module_reader = built_reader .map(|reader| Box::new(reader) as Box); let context = - self.javascript_engine - .create_context(CreateJavascriptContextRequest { - vm_id: vm_id.to_owned(), - bootstrap_module: None, - compile_cache_root: Some( - self.cache_root.join("node-compile-cache"), - ), - }); + javascript_engine.create_context(CreateJavascriptContextRequest { + vm_id: vm_id.to_owned(), + bootstrap_module: None, + compile_cache_root: Some(cache_root.join("node-compile-cache")), + }); let context_id = context.context_id; - let execution_result = self - .javascript_engine + let execution_result = javascript_engine .start_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { guest_runtime: guest_runtime_identity( - vm, + &mut vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), ), @@ -5626,7 +6401,7 @@ where argv0: request.options.argv0.clone(), env: execution_env, cwd: resolved.host_cwd.clone(), - limits: javascript_execution_limits(vm), + limits: javascript_execution_limits(&mut vm), inline_code, wasm_module_bytes: None, }, @@ -5634,9 +6409,9 @@ where guest_reader, vm.runtime_context.clone(), ); - self.javascript_engine.dispose_context(&context_id); + javascript_engine.dispose_context(&context_id); let execution = execution_result.map_err(javascript_error)?; - ActiveExecution::Javascript(execution) + Box::pin(async move { Ok(ActiveExecution::Javascript(execution)) }) } GuestRuntimeKind::WebAssembly => { execution_env.extend( @@ -5648,41 +6423,51 @@ where .insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1")); execution_env .insert(String::from(WASM_EXEC_COMMIT_RPC_ENV), String::from("1")); - let wasm_limits = wasm_execution_limits(vm); + let wasm_limits = wasm_execution_limits(&mut vm); let wasm_guest_runtime = guest_runtime_identity( - vm, + &mut vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), ); - let context = self.wasm_engine.create_context(CreateWasmContextRequest { + let start_request = StartWasmExecutionRequest { vm_id: vm_id.to_owned(), - module_path: Some(resolved.entrypoint.clone()), - }); - let context_id = context.context_id; - let execution_result = self - .wasm_engine - .start_execution_with_runtime_async( - StartWasmExecutionRequest { - vm_id: vm_id.to_owned(), - context_id: context_id.clone(), - argv: resolved.process_args.clone(), - env: execution_env, - cwd: resolved.host_cwd.clone(), - permission_tier: execution_wasm_permission_tier( - resolved - .wasm_permission_tier - .unwrap_or(WasmPermissionTier::Full), - ), - limits: wasm_limits, - guest_runtime: wasm_guest_runtime, - }, - vm.runtime_context.clone(), - ) - .await; - self.wasm_engine.dispose_context(&context_id); - let execution = execution_result.map_err(wasm_error)?; - ActiveExecution::Wasm(Box::new(execution)) - } + context_id: String::new(), + argv: resolved.process_args.clone(), + env: execution_env, + cwd: resolved.host_cwd.clone(), + permission_tier: execution_wasm_permission_tier( + resolved + .wasm_permission_tier + .unwrap_or(WasmPermissionTier::Full), + ), + limits: wasm_limits, + guest_runtime: wasm_guest_runtime, + }; + let module_path = resolved.entrypoint.clone(); + let runtime_context = vm.runtime_context.clone(); + let execution_engines = execution_engines.clone(); + Box::pin(async move { + let mut wasm_engine = + execution_engines.wasm("start nested WebAssembly child process")?; + let context = wasm_engine.create_context(CreateWasmContextRequest { + vm_id: start_request.vm_id.clone(), + module_path: Some(module_path), + }); + let context_id = context.context_id; + let execution_result = wasm_engine + .start_execution_with_runtime_async( + StartWasmExecutionRequest { + context_id: context_id.clone(), + ..start_request + }, + runtime_context, + ) + .await; + wasm_engine.dispose_context(&context_id); + let execution = execution_result.map_err(wasm_error)?; + Ok(ActiveExecution::Wasm(Box::new(execution))) + }) + } GuestRuntimeKind::Python => { // Nested `python` child_process: set up the Pyodide context the // same way the top-level execute path does, so a guest shell or @@ -5693,73 +6478,85 @@ where } else { python_file_entrypoint(&resolved.entrypoint) }; - let pyodide_dist_path = self - .python_engine - .bundled_pyodide_dist_path_for_vm(vm_id) - .map_err(python_error)?; - let pyodide_cache_path = pyodide_dist_path - .parent() - .and_then(Path::parent) - .unwrap_or(pyodide_dist_path.as_path()) - .join("pyodide-package-cache"); - add_runtime_guest_path_mapping( - &mut execution_env, - PYTHON_PYODIDE_GUEST_ROOT, - &pyodide_dist_path, - ); - add_runtime_guest_path_mapping( - &mut execution_env, - PYTHON_PYODIDE_CACHE_GUEST_ROOT, - &pyodide_cache_path, - ); - add_runtime_host_access_path( - &mut execution_env, - "AGENTOS_EXTRA_FS_READ_PATHS", - &pyodide_dist_path, - true, - ); - add_runtime_host_access_path( - &mut execution_env, - "AGENTOS_EXTRA_FS_READ_PATHS", - &pyodide_cache_path, - true, - ); - add_runtime_host_access_path( - &mut execution_env, - "AGENTOS_EXTRA_FS_WRITE_PATHS", - &pyodide_cache_path, - false, + let python_limits = python_execution_limits(&mut vm); + let python_guest_runtime = guest_runtime_identity( + &mut vm, + Some(u64::from(kernel_pid)), + Some(u64::from(parent_kernel_pid)), ); - let context = - self.python_engine - .create_context(CreatePythonContextRequest { - vm_id: vm_id.to_owned(), + let runtime_context = vm.runtime_context.clone(); + let execution_engines = execution_engines.clone(); + let start_vm_id = vm_id.to_owned(); + let code = resolved.entrypoint.clone(); + let cwd = resolved.host_cwd.clone(); + Box::pin(async move { + let mut python_engine = + execution_engines.python("start nested Python child process")?; + let pyodide_dist_path = python_engine + .bundled_pyodide_dist_path_for_vm_async( + &start_vm_id, + &runtime_context, + ) + .await + .map_err(python_error)?; + let pyodide_cache_path = pyodide_dist_path + .parent() + .and_then(Path::parent) + .unwrap_or(pyodide_dist_path.as_path()) + .join("pyodide-package-cache"); + add_runtime_guest_path_mapping( + &mut execution_env, + PYTHON_PYODIDE_GUEST_ROOT, + &pyodide_dist_path, + ); + add_runtime_guest_path_mapping( + &mut execution_env, + PYTHON_PYODIDE_CACHE_GUEST_ROOT, + &pyodide_cache_path, + ); + add_runtime_host_access_path( + &mut execution_env, + "AGENTOS_EXTRA_FS_READ_PATHS", + &pyodide_dist_path, + true, + ); + add_runtime_host_access_path( + &mut execution_env, + "AGENTOS_EXTRA_FS_READ_PATHS", + &pyodide_cache_path, + true, + ); + add_runtime_host_access_path( + &mut execution_env, + "AGENTOS_EXTRA_FS_WRITE_PATHS", + &pyodide_cache_path, + false, + ); + let context = + python_engine.create_context(CreatePythonContextRequest { + vm_id: start_vm_id.clone(), pyodide_dist_path, }); - let context_id = context.context_id; - let execution_result = self - .python_engine - .start_execution_with_runtime_async( - StartPythonExecutionRequest { - vm_id: vm_id.to_owned(), - context_id: context_id.clone(), - code: resolved.entrypoint.clone(), - file_path: python_file_path, - env: execution_env, - cwd: resolved.host_cwd.clone(), - limits: python_execution_limits(vm), - guest_runtime: guest_runtime_identity( - vm, - Some(u64::from(kernel_pid)), - Some(u64::from(parent_kernel_pid)), - ), - }, - vm.runtime_context.clone(), - ) - .await; - self.python_engine.dispose_context(&context_id); - let execution = execution_result.map_err(python_error)?; - ActiveExecution::Python(execution) + let context_id = context.context_id; + let execution_result = python_engine + .start_execution_with_runtime_async( + StartPythonExecutionRequest { + vm_id: start_vm_id, + context_id: context_id.clone(), + code, + file_path: python_file_path, + env: execution_env, + cwd, + limits: python_limits, + guest_runtime: python_guest_runtime, + }, + runtime_context, + ) + .await; + python_engine.dispose_context(&context_id); + let execution = execution_result.map_err(python_error)?; + Ok(ActiveExecution::Python(execution)) + }) } }; let kernel_stdin_writer_fd = if posix_spawn_controls_stdin { @@ -5780,849 +6577,1989 @@ where ( kernel_pid, kernel_handle, - execution, + execution_start, kernel_stdin_writer_fd, kernel_stdin_reader_fd, posix_spawn_controls_stdin, ) }; - Ok::<_, SidecarError>(spawned) - }) - .await; - let ( - kernel_pid, - kernel_handle, - mut execution, - kernel_stdin_writer_fd, - kernel_stdin_reader_fd, - direct_posix_stdin, - ) = match spawn_result { - Ok(spawned) => spawned, - Err(error) => { - if let Some(process) = pending_kernel_handle.take() { - rollback_unregistered_spawn_child( - &mut vm.kernel, - &process, - None, - "nested child_process.spawn", - ); - } - return Err(error); - } - }; - record_execute_phase( - "child_process_spawn_and_start_execution", - phase_start.elapsed(), - ); + let ( + kernel_pid, + kernel_handle, + execution_start, + kernel_stdin_writer_fd, + kernel_stdin_reader_fd, + direct_posix_stdin, + ) = spawned; + let mut pending_spawn = + PendingOwnedChildSpawn::new(vms.handle.clone(), kernel_handle.clone()); + pending_kernel_handle.take(); + drop(vm); + let execution = execution_start.await?; + pending_spawn.set_execution(execution); + let mut vm = vms.get_mut(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let mut execution = pending_spawn.take_execution(); + record_execute_phase( + "child_process_spawn_and_start_execution", + phase_start.elapsed(), + ); - let phase_start = Instant::now(); - let child_fd1_is_tty = vm - .kernel - .isatty(EXECUTION_DRIVER_NAME, kernel_pid, 1) - .unwrap_or(false); - let child_process_group = match vm.kernel.getpgid(EXECUTION_DRIVER_NAME, kernel_pid) { - Ok(process_group) => process_group, - Err(error) => { - if let Some(process) = pending_kernel_handle.take() { - rollback_unregistered_spawn_child( - &mut vm.kernel, - &process, - Some(&mut execution), - "nested child_process.spawn", - ); + let phase_start = Instant::now(); + let child_fd1_is_tty = vm + .kernel + .isatty(EXECUTION_DRIVER_NAME, kernel_pid, 1) + .unwrap_or(false); + let child_process_group = match vm.kernel.getpgid(EXECUTION_DRIVER_NAME, kernel_pid) { + Ok(process_group) => process_group, + Err(error) => { + if let Some(process) = pending_kernel_handle.take() { + rollback_unregistered_spawn_child( + &mut vm.kernel, + &process, + Some(&mut execution), + "nested child_process.spawn", + ); + } + return Err(kernel_error(error)); } - return Err(kernel_error(error)); - } - }; - let process_event_limits = vm.limits.process.clone(); - let shadow_root = normalize_host_path(&vm.cwd); - let root = match vm.active_processes.get_mut(process_id) { - Some(root) => root, - None => { - let error = missing_process_error(vm_id, process_id); - if let Some(child) = pending_kernel_handle.take() { - rollback_unregistered_spawn_child( - &mut vm.kernel, - &child, - Some(&mut execution), - "nested child_process.spawn", - ); + }; + let process_event_limits = vm.limits.process.clone(); + let shadow_root = normalize_host_path(&vm.cwd); + let root = match vm.active_processes.get_mut(process_id) { + Some(root) => root, + None => { + let error = missing_process_error(vm_id, process_id); + if let Some(child) = pending_kernel_handle.take() { + rollback_unregistered_spawn_child( + &mut vm.kernel, + &child, + Some(&mut execution), + "nested child_process.spawn", + ); + } + return Err(error); } - return Err(error); - } - }; - let parent = match Self::active_process_by_path_mut(root, current_process_path) { - Some(parent) => parent, - None => { - let error = SidecarError::InvalidState(format!( + }; + let parent = + match NativeSidecar::::active_process_by_path_mut(root, current_process_path) { + Some(parent) => parent, + None => { + let error = SidecarError::InvalidState(format!( "unknown child process path {current_process_label} during nested spawn" )); - if let Some(child) = pending_kernel_handle.take() { - rollback_unregistered_spawn_child( - &mut vm.kernel, - &child, - Some(&mut execution), - "nested child_process.spawn", - ); + if let Some(child) = pending_kernel_handle.take() { + rollback_unregistered_spawn_child( + &mut vm.kernel, + &child, + Some(&mut execution), + "nested child_process.spawn", + ); + } + return Err(error); + } + }; + let inherited_tty_master_owner = if child_fd1_is_tty { + parent + .tty_master_fd + .map(|master_fd| (parent.kernel_pid, master_fd)) + .or(parent.tty_master_owner) + } else { + None + }; + pending_kernel_handle.take(); + parent.child_processes.insert( + child_process_id.clone(), + ActiveProcess::new( + kernel_pid, + kernel_handle, + parent.runtime_context.clone(), + parent.limits.clone(), + process_event_capacity, + resolved.runtime, + execution, + ) + .with_event_notify(Arc::clone(&process_event_notify)) + .with_process_event_limits(&process_event_limits) + .with_vm_pending_byte_budgets( + Arc::clone(&vm_pending_stdin_bytes_budget), + Arc::clone(&vm_pending_event_bytes_budget), + ) + .with_detached(request.options.detached) + .with_guest_cwd(resolved.guest_cwd.clone()) + .with_env(resolved.env.clone()) + .with_shadow_root(shadow_root) + .with_host_cwd(resolved.host_cwd.clone()), + ); + pending_spawn.disarm(); + { + let child = parent + .child_processes + .get_mut(&child_process_id) + .expect("inserted nested child exists during spawn registration"); + child.tty_master_owner = inherited_tty_master_owner; + child.direct_posix_stdin = direct_posix_stdin; + child.kernel_stdin_reader_fd = kernel_stdin_reader_fd; + if let Some(kernel_stdin_writer_fd) = kernel_stdin_writer_fd { + child.kernel_stdin_writer_fd = Some(kernel_stdin_writer_fd); } - return Err(error); - } - }; - let inherited_tty_master_owner = if child_fd1_is_tty { - parent - .tty_master_fd - .map(|master_fd| (parent.kernel_pid, master_fd)) - .or(parent.tty_master_owner) - } else { - None - }; - pending_kernel_handle.take(); - parent.child_processes.insert( - child_process_id.clone(), - ActiveProcess::new( - kernel_pid, - kernel_handle, - parent.runtime_context.clone(), - parent.limits.clone(), - process_event_capacity, - resolved.runtime, - execution, - ) - .with_event_notify(Arc::clone(&self.process_event_notify)) - .with_process_event_limits(&process_event_limits) - .with_vm_pending_byte_budgets( - Arc::clone(&vm_pending_stdin_bytes_budget), - Arc::clone(&vm_pending_event_bytes_budget), - ) - .with_detached(request.options.detached) - .with_guest_cwd(resolved.guest_cwd.clone()) - .with_env(resolved.env.clone()) - .with_shadow_root(shadow_root) - .with_host_cwd(resolved.host_cwd.clone()), - ); - { - let child = parent - .child_processes - .get_mut(&child_process_id) - .expect("inserted nested child exists during spawn registration"); - child.tty_master_owner = inherited_tty_master_owner; - child.direct_posix_stdin = direct_posix_stdin; - child.kernel_stdin_reader_fd = kernel_stdin_reader_fd; - if let Some(kernel_stdin_writer_fd) = kernel_stdin_writer_fd { - child.kernel_stdin_writer_fd = Some(kernel_stdin_writer_fd); + prepared_host_net_fds.install(child); } - prepared_host_net_fds.install(child); - } - record_execute_phase("child_process_register", phase_start.elapsed()); - record_execute_phase("child_process_spawn_total", total_start.elapsed()); - Ok(json!({ - "childId": child_process_id, - "pid": kernel_pid, - "pgid": child_process_group, - "directPosixStdin": direct_posix_stdin, - "command": resolved.command, - "args": resolved.process_args, - })) - } - - #[cfg(test)] - #[allow(dead_code)] - pub(crate) async fn spawn_descendant_javascript_child_process_for_test( - &mut self, - vm_id: &str, - process_id: &str, - current_process_path: &[&str], - request: JavascriptChildProcessSpawnRequest, - ) -> Result { - self.spawn_descendant_javascript_child_process( - vm_id, - process_id, - current_process_path, - request, - ) - .await + record_execute_phase("child_process_register", phase_start.elapsed()); + record_execute_phase("child_process_spawn_total", total_start.elapsed()); + Ok(json!({ + "childId": child_process_id, + "pid": kernel_pid, + "pgid": child_process_group, + "directPosixStdin": direct_posix_stdin, + "command": resolved.command, + "args": resolved.process_args, + })) + }) } - async fn defer_descendant_javascript_child_process_sync( - &mut self, - vm_id: &str, - process_id: &str, - current_process_path: &[&str], + fn prepare_owned_descendant_javascript_child_process_sync( + &self, + vm: VmHandle, + vm_id: String, + process_id: String, + current_process_path: Vec, request: JavascriptChildProcessSpawnRequest, max_buffer: Option, - ) -> Result { - let max_buffer = { - let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - let root = vm - .active_processes - .get(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path(root, current_process_path).ok_or_else(|| { + ) -> Pin< + Box> + 'static>, + > { + let sync_input_value = request.options.input.clone(); + let timeout_ms = request.options.timeout; + let kill_signal = request.options.kill_signal.clone(); + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let spawn = + self.spawn_descendant_javascript_child_process(&vm_id, &process_id, &path, request); + + Box::pin(async move { + let (max_buffer, runtime, notify) = + vm.try_command("prepare owned JavaScript spawnSync", |vm| { + let root = vm + .active_processes + .get(&process_id) + .ok_or_else(|| missing_process_error(&vm_id, &process_id))?; + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let parent = Self::active_process_by_path(root, &path).ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown process path {} during owned spawnSync", + Self::child_process_path_label(&process_id, &path) + )) + })?; + Ok(( + Self::child_process_sync_max_buffer(parent, max_buffer)?, + parent.runtime_context.clone(), + Arc::clone(&parent.process_event_notify), + )) + })?; + let sync_input = javascript_child_process_sync_input_bytes(sync_input_value.as_ref())?; + let deadline = + timeout_ms.map(|timeout_ms| Instant::now() + Duration::from_millis(timeout_ms)); + let timeout_signal = kill_signal.unwrap_or_else(|| String::from("SIGTERM")); + let spawned = spawn.await?; + let child_process_id = spawned + .get("childId") + .and_then(Value::as_str) + .ok_or_else(|| { SidecarError::InvalidState(String::from( - "unknown child process path during nested spawnSync", + "owned child_process.spawn_sync response is missing childId", + )) + })? + .to_owned(); + let pid = spawned + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "owned child_process.spawn_sync response is missing a valid pid", )) })?; - Self::child_process_sync_max_buffer(parent, max_buffer)? - }; - let sync_input = javascript_child_process_sync_input_bytes(request.options.input.as_ref())?; - let deadline = request - .options - .timeout - .map(|timeout_ms| Instant::now() + Duration::from_millis(timeout_ms)); - let timeout_signal = request - .options - .kill_signal - .clone() - .unwrap_or_else(|| String::from("SIGTERM")); - let spawned = self - .spawn_descendant_javascript_child_process( - vm_id, - process_id, - current_process_path, - request, - ) - .await?; - let child_process_id = spawned - .get("childId") - .and_then(Value::as_str) - .ok_or_else(|| { - SidecarError::InvalidState(String::from( - "child_process.spawn_sync response is missing childId", - )) - })? - .to_owned(); - let pid = spawned - .get("pid") - .and_then(Value::as_u64) - .and_then(|pid| u32::try_from(pid).ok()) - .ok_or_else(|| { - SidecarError::InvalidState(String::from( - "child_process.spawn_sync response is missing a valid pid", - )) - })?; + let mut pending_child = PendingOwnedRegisteredChild::new( + vm.clone(), + process_id.clone(), + current_process_path.clone(), + child_process_id.clone(), + ); - if let Some(input) = sync_input.as_deref() { - self.write_descendant_javascript_child_process_stdin( - vm_id, - process_id, - current_process_path, - &child_process_id, - input, - )?; - } - self.close_descendant_javascript_child_process_stdin( - vm_id, - process_id, - current_process_path, - &child_process_id, - )?; - - let (respond_to, receiver) = tokio::sync::oneshot::channel(); - let (runtime, notify) = { - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - let root = vm - .active_processes - .get_mut(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let parent = - Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "unknown child process path during nested spawnSync", - )) - })?; - parent.pending_child_process_sync.insert( - child_process_id, - PendingChildProcessSync { - pid, - stdout: Vec::new(), - stderr: Vec::new(), - max_buffer, - deadline, - timeout_signal, - kill_sent: false, - timed_out: false, - max_buffer_exceeded: false, - completion: PendingChildProcessSyncCompletion::Javascript(respond_to), - }, - ); - ( - parent.runtime_context.clone(), - Arc::clone(&parent.process_event_notify), - ) - }; - if let Some(deadline) = deadline { - let delay = deadline.saturating_duration_since(Instant::now()); - runtime - .spawn(agentos_runtime::TaskClass::Timer, async move { - tokio::time::sleep(delay).await; - notify.notify_one(); + let response = vm + .try_command("initialize owned JavaScript spawnSync child", |vm| { + let VmState { + kernel, + active_processes, + .. + } = vm; + let root = active_processes + .get_mut(&process_id) + .ok_or_else(|| missing_process_error(&vm_id, &process_id))?; + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let parent = + Self::active_process_by_path_mut(root, &path).ok_or_else(|| { + SidecarError::InvalidState(format!( + "unknown process path {} during owned spawnSync publish", + Self::child_process_path_label(&process_id, &path) + )) + })?; + let child = parent + .child_processes + .get_mut(&child_process_id) + .ok_or_else(|| { + javascript_child_process_gone_error( + &process_id, + &[child_process_id.as_str()], + ) + })?; + if let Some(input) = sync_input.as_deref() { + if let Err(error) = child.execution.write_stdin(input) { + if !is_broken_pipe_error(&error) { + return Err(error); + } + } + write_kernel_process_stdin(kernel, child, input)?; + } + child.execution.close_stdin()?; + close_kernel_process_stdin(kernel, child)?; + let (respond_to, receiver) = tokio::sync::oneshot::channel(); + parent.pending_child_process_sync.insert( + child_process_id.clone(), + PendingChildProcessSync { + pid, + stdout: Vec::new(), + stderr: Vec::new(), + max_buffer, + deadline, + timeout_signal, + kill_sent: false, + timed_out: false, + max_buffer_exceeded: false, + completion: PendingChildProcessSyncCompletion::Javascript(respond_to), + }, + ); + Ok(receiver) }) - .map_err(SidecarError::from)?; - } - Ok(JavascriptSyncRpcServiceResponse::Deferred { - receiver, - timeout: None, - task_class: agentos_runtime::TaskClass::Vm, + .and_then(|receiver| { + if let Some(deadline) = deadline { + let delay = deadline.saturating_duration_since(Instant::now()); + runtime + .spawn(agentos_runtime::TaskClass::Timer, async move { + tokio::time::sleep(delay).await; + notify.notify_one(); + }) + .map_err(SidecarError::from)?; + } + Ok(JavascriptSyncRpcServiceResponse::Deferred { + receiver, + timeout: None, + task_class: agentos_runtime::TaskClass::Vm, + }) + }); + if response.is_ok() { + pending_child.disarm(); + } + response }) } - async fn handle_descendant_javascript_child_process_rpc( + /// Prepare the path-aware service for a claimed JavaScript process RPC. + /// Root and descendant targets share this dispatcher; an empty path means + /// the root process. Preparation only parses or clones bounded inputs. + /// Every process mutation happens after the owned future is polled, so the + /// protocol engine never performs guest work or retains `NativeSidecar` + /// while the operation is pending. + pub(crate) fn prepare_owned_javascript_process_event_service( &mut self, - vm_id: &str, - process_id: &str, - current_process_path: &[&str], - request: &JavascriptSyncRpcRequest, - ) -> Result { - match request.method.as_str() { + target: OwnedJavascriptEventService, + ) -> Pin> + 'static>> { + let bridge = self.bridge.clone(); + match target.request.method.as_str() { "child_process.spawn" => { - let Some(vm) = self.vms.get(vm_id) else { - return Ok(Value::Null.into()); + let payload = target + .vm + .try_read("prepare owned child_process.spawn", |vm| { + parse_javascript_child_process_spawn_request(vm, &target.request.args) + .map(|parsed| parsed.0) + }); + let spawn = match payload { + Ok(Ok(payload)) => { + let path = target + .child_path + .iter() + .map(String::as_str) + .collect::>(); + Some(self.spawn_descendant_javascript_child_process( + &target.vm_id, + &target.process_id, + &path, + payload, + )) + } + Ok(Err(error)) | Err(error) => { + return settle_owned_javascript_process_event_target::( + target, + Err(error), + ) + } }; - let (payload, _) = parse_javascript_child_process_spawn_request(vm, &request.args)?; - self.spawn_descendant_javascript_child_process( - vm_id, - process_id, - current_process_path, - payload, - ) - .await - .map(Into::into) + let vm = target.vm.clone(); + let process_id = target.process_id.clone(); + let child_path = target.child_path.clone(); + let request = target.request.clone(); + return Box::pin(async move { + let response = spawn + .expect("owned spawn future prepared") + .await + .map(Into::into); + let result = settle_owned_fetch_sync_rpc::( + &vm, + &process_id, + &child_path, + request, + response, + ) + .await; + drop(target); + result + }); } "child_process.spawn_sync" => { - let Some(vm) = self.vms.get(vm_id) else { - return Ok(Value::Null.into()); + let payload = target + .vm + .try_read("prepare owned child_process.spawn_sync", |vm| { + parse_javascript_child_process_spawn_request(vm, &target.request.args) + }); + let (payload, max_buffer) = match payload { + Ok(Ok(payload)) => payload, + Ok(Err(error)) | Err(error) => { + return settle_owned_javascript_process_event_target::( + target, + Err(error), + ) + } }; - let (payload, max_buffer) = - parse_javascript_child_process_spawn_request(vm, &request.args)?; - self.defer_descendant_javascript_child_process_sync( - vm_id, - process_id, - current_process_path, + let service = self.prepare_owned_descendant_javascript_child_process_sync( + target.vm.clone(), + target.vm_id.clone(), + target.process_id.clone(), + target.child_path.clone(), payload, max_buffer, - ) - .await - } - "child_process.poll" => { - let child_process_id = - javascript_sync_rpc_arg_str(&request.args, 0, "child_process.poll child id")?; - let wait_ms = javascript_sync_rpc_arg_u64_optional( - &request.args, - 1, - "child_process.poll wait ms", - )? - .unwrap_or_default(); - Box::pin(self.poll_descendant_javascript_child_process( - vm_id, - process_id, - current_process_path, - child_process_id, - wait_ms, - false, - )) - .await - .map(Into::into) + ); + let vm = target.vm.clone(); + let process_id = target.process_id.clone(); + let child_path = target.child_path.clone(); + let request = target.request.clone(); + return Box::pin(async move { + let response = service.await; + let result = settle_owned_fetch_sync_rpc::( + &vm, + &process_id, + &child_path, + request, + response, + ) + .await; + drop(target); + result + }); } - "child_process.write_stdin" => { - let child_process_id = javascript_sync_rpc_arg_str( - &request.args, + _ => {} + } + + let special = matches!( + target.request.method.as_str(), + "child_process.poll" + | "child_process.write_stdin" + | "child_process.close_stdin" + | "child_process.kill" + | "process.exec_fd_image_commit" + | "process.exec" + | "process.signal_state" + | "process.kill" + ); + if !special { + return Box::pin(async move { + let result = service_owned_javascript_sync_rpc_request( + &bridge, + &target.vm_id, + &target.vm, + &target.process_id, + &target.child_path, + target.request.clone(), + ) + .await; + drop(target); + result + }); + } + + let cache_root = self.cache_root.clone(); + let process_event_notify = Arc::clone(&self.process_event_notify); + #[cfg(test)] + let fail_after_commit = target.request.method == "process.exec" + && std::mem::take(&mut self.fail_next_exec_start_after_commit); + #[cfg(not(test))] + let fail_after_commit = false; + Box::pin(async move { + let path = target + .child_path + .iter() + .map(String::as_str) + .collect::>(); + let owned_vm = OwnedChildProcessVm { + vm_id: target.vm_id.clone(), + handle: target.vm.clone(), + }; + let mut omit_exec_response = false; + let response: Result = match target + .request + .method + .as_str() + { + "child_process.poll" => { + let child_process_id = javascript_sync_rpc_arg_str( + &target.request.args, + 0, + "child_process.poll child id", + ); + match child_process_id { + Ok(child_process_id) => { + let wait_ms = javascript_sync_rpc_arg_u64_optional( + &target.request.args, + 1, + "child_process.poll wait ms", + ) + .map(|wait| wait.unwrap_or_default()); + match wait_ms { + Ok(wait_ms) => { + Self::poll_owned_descendant_javascript_child_process( + &bridge, + &target.vm, + &target.vm_id, + &target.process_id, + &target.child_path, + child_process_id, + wait_ms, + Arc::clone(&process_event_notify), + ) + .await + .map(Into::into) + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + } + } + "child_process.write_stdin" => javascript_sync_rpc_arg_str( + &target.request.args, 0, "child_process.write_stdin child id", - )?; - let chunk = javascript_sync_rpc_bytes_arg( - &request.args, - 1, - "child_process.write_stdin chunk", - )?; - self.write_descendant_javascript_child_process_stdin( - vm_id, - process_id, - current_process_path, - child_process_id, - &chunk, ) - .map(|()| Value::Null.into()) - } - "child_process.close_stdin" => { - let child_process_id = javascript_sync_rpc_arg_str( - &request.args, + .and_then(|child_process_id| { + javascript_sync_rpc_bytes_arg( + &target.request.args, + 1, + "child_process.write_stdin chunk", + ) + .and_then(|chunk| { + Self::write_descendant_javascript_child_process_stdin_owned( + &target.vm, + &target.process_id, + &target.child_path, + child_process_id, + &chunk, + ) + }) + }) + .map(|()| Value::Null.into()), + "child_process.close_stdin" => javascript_sync_rpc_arg_str( + &target.request.args, 0, "child_process.close_stdin child id", - )?; - self.close_descendant_javascript_child_process_stdin( - vm_id, - process_id, - current_process_path, - child_process_id, ) - .map(|()| Value::Null.into()) - } - "child_process.kill" => { - let child_process_id = - javascript_sync_rpc_arg_str(&request.args, 0, "child_process.kill child id")?; - let signal = - javascript_sync_rpc_arg_str(&request.args, 1, "child_process.kill signal")?; - self.kill_descendant_javascript_child_process( - vm_id, - process_id, - current_process_path, - child_process_id, - signal, + .and_then(|child_process_id| { + Self::close_descendant_javascript_child_process_stdin_owned( + &target.vm, + &target.process_id, + &target.child_path, + child_process_id, + ) + }) + .map(|()| Value::Null.into()), + "child_process.kill" => javascript_sync_rpc_arg_str( + &target.request.args, + 0, + "child_process.kill child id", ) - .map(|()| Value::Null.into()) + .and_then(|child_process_id| { + let signal = javascript_sync_rpc_arg_str( + &target.request.args, + 1, + "child_process.kill signal", + )?; + Self::kill_descendant_javascript_child_process_owned( + &bridge, + &target.vm, + &target.vm_id, + &target.process_id, + &target.child_path, + child_process_id, + signal, + &process_event_notify, + ) + }) + .map(|()| Value::Null.into()), + "process.exec_fd_image_commit" => target + .vm + .try_read("parse owned fd image commit", |vm| { + parse_javascript_child_process_spawn_request(vm, &target.request.args) + .map(|parsed| parsed.0) + }) + .and_then(|payload| payload) + .and_then(|payload| { + Self::commit_wasm_fd_process_image_owned( + &owned_vm, + &target.vm_id, + &target.process_id, + &path, + payload, + ) + }) + .map(|()| json!({ "committed": true }).into()), + "process.exec" => { + let parsed = target.vm.try_read("parse owned process exec", |vm| { + parse_javascript_child_process_spawn_request(vm, &target.request.args) + .map(|parsed| parsed.0) + }); + match parsed.and_then(|payload| payload) { + Ok(payload) => { + let local_replacement = payload.options.local_replacement; + match Self::exec_javascript_process_image_owned( + &owned_vm, + &cache_root, + fail_after_commit, + &target.vm_id, + &target.process_id, + &path, + payload, + ) { + Ok(()) => { + omit_exec_response = !local_replacement; + Ok(if local_replacement { + json!({ "committed": true }).into() + } else { + Value::Null.into() + }) + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + } + } + "process.signal_state" => parse_process_signal_state_request(&target.request.args) + .map_err(|error| SidecarError::InvalidState(error.to_string())) + .and_then(|(signal, registration)| { + target + .vm + .try_command("apply owned process signal state", |vm| { + let signal_key = + Self::child_process_signal_key(&target.process_id, &path) + .to_owned(); + apply_process_signal_state_update( + &mut vm.signal_states, + &signal_key, + signal, + registration, + ); + Ok(()) + }) + }) + .map(|()| Value::Null.into()), + "process.kill" => Self::handle_owned_process_kill_rpc( + &bridge, + &target.vm, + &target.vm_id, + &target.process_id, + &target.child_path, + &target.request, + ) + .map(Into::into), + _ => unreachable!("special JavaScript process RPC was classified above"), + }; + + // A successful non-local exec destroys the requesting image. It is + // the one path where replying would target code that no longer + // exists, so completion is intentionally response-free. + if omit_exec_response && response.is_ok() { + drop(target); + return Ok(()); } - _ => Err(SidecarError::InvalidState(format!( - "unsupported nested child process RPC method {}", - request.method - ))), - } + let result = settle_owned_fetch_sync_rpc::( + &target.vm, + &target.process_id, + &target.child_path, + target.request.clone(), + response, + ) + .await; + drop(target); + result + }) } - /// Deferred servicing for a child's blocking kernel read, poll, or stdio - /// write inside the child-event pump. Each operation is probed without - /// blocking the sidecar actor; an unavailable write is parked by reply - /// token and retried after the parent consumes pipe data. The pump loop - /// re-checks the parked RPC every iteration. Returns false when the RPC - /// must use the normal inline path (local JS stdin or shared TTY output). - fn service_child_kernel_wait_rpc( - &mut self, + async fn poll_owned_descendant_javascript_child_process( + bridge: &SharedBridge, + vm: &VmHandle, vm_id: &str, process_id: &str, - current_process_path: &[&str], + current_process_path: &[String], child_process_id: &str, - request: &JavascriptSyncRpcRequest, - ) -> Result { - let event_notify = Arc::clone(&self.process_event_notify); - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(true); - }; - let operation_deadline_ms = vm.limits.reactor.operation_deadline_ms; - let runtime = vm.runtime_context.clone(); - let kernel = &mut vm.kernel; - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(true); - }; - let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { - return Ok(true); - }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Ok(true); - }; - if request.method == "__kernel_stdio_write" || request.method == "process.fd_write" { - if request.method == "__kernel_stdio_write" && child.tty_master_owner.is_some() { - return Ok(false); - } - let now = Instant::now(); - let (deadline, arm_deadline_wake) = match &child.deferred_kernel_wait_rpc { - Some((parked, parked_deadline)) if parked.id == request.id => ( - parked_deadline.unwrap_or(now + Duration::from_millis(operation_deadline_ms)), - false, - ), - _ => (now + Duration::from_millis(operation_deadline_ms), true), - }; - let response = if request.method == "__kernel_stdio_write" { - service_javascript_kernel_stdio_write_sync_rpc(kernel, child, request) - } else { - service_javascript_kernel_fd_write_sync_rpc(kernel, child, request) + wait_ms: u64, + process_event_notify: Arc, + ) -> Result { + // Polling is readiness-driven by the process supervisor. The legacy + // wait argument remains wire-compatible but must never park this + // request or retain VM state. + let _ = wait_ms; + let mut child_path = current_process_path.to_vec(); + child_path.push(child_process_id.to_owned()); + loop { + let polled = vm.try_command("poll owned descendant process", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let root = state + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = Self::active_process_by_path_mut(root, &path).ok_or_else(|| { + javascript_child_process_gone_error( + process_id, + &child_path.iter().map(String::as_str).collect::>(), + ) + })?; + let child = parent + .child_processes + .get_mut(child_process_id) + .ok_or_else(|| { + javascript_child_process_gone_error( + process_id, + &child_path.iter().map(String::as_str).collect::>(), + ) + })?; + if let Some(event) = child.lease_pending_execution_event() { + return Ok(Some(event)); + } + match child.try_poll_execution_event() { + Ok(event) => Ok(event), + Err(SidecarError::Execution(message)) + if (child.runtime == GuestRuntimeKind::JavaScript + && closed_javascript_event_channel(&message)) + || (child.runtime == GuestRuntimeKind::Python + && closed_python_event_channel(&message)) + || (child.runtime == GuestRuntimeKind::WebAssembly + && closed_wasm_event_channel(&message)) => + { + Ok(None) + } + Err(error) => Err(error), + } + })?; + let Some(PolledExecutionEvent { event, reservation }) = polled else { + return Ok(Value::Null); }; - match response { - Ok(response) => { - child.clear_deferred_kernel_wait_rpc(); - child - .execution - .respond_javascript_sync_rpc_response(request.id, response.into()) - .or_else(ignore_stale_javascript_sync_rpc_response)?; + match event { + ActiveExecutionEvent::Stdout(chunk) => { + return Ok(json!({ + "type": "stdout", + "data": javascript_sync_rpc_bytes_value(&chunk), + })); } - Err(error) - if javascript_sync_rpc_error_code(&error) == "EAGAIN" && now >= deadline => + ActiveExecutionEvent::Stderr(chunk) + if chunk.as_slice() == SYNTHETIC_V8_TERMINATION_STDERR => { - child.clear_deferred_kernel_wait_rpc(); - child - .execution - .respond_javascript_sync_rpc_error( - request.id, - "ETIMEDOUT", - format!( - "pipe write exceeded limits.reactor.operationDeadlineMs ({operation_deadline_ms} ms); raise that limit for slower readers" - ), - ) - .or_else(ignore_stale_javascript_sync_rpc_response)?; + drop(reservation); + continue; } - Err(error) if javascript_sync_rpc_error_code(&error) == "EAGAIN" => { - if arm_deadline_wake { - let delay = deadline.saturating_duration_since(now); - child.clear_deferred_kernel_wait_rpc(); - child.deferred_kernel_wait_rpc = Some((request.clone(), Some(deadline))); - let timer = runtime.spawn(agentos_runtime::TaskClass::Timer, async move { - tokio::time::sleep(delay).await; - event_notify.notify_one(); - }); - match timer { - Ok(timer) => { - child.deferred_child_write_timer = Some(timer); - } - Err(agentos_runtime::TaskSpawnError::ResourceLimit(limit)) => { - child.clear_deferred_kernel_wait_rpc(); - child - .execution - .respond_javascript_sync_rpc_error( - request.id, - "ERR_AGENTOS_RESOURCE_LIMIT", - crate::state::guest_limit_message(&limit), + ActiveExecutionEvent::Stderr(chunk) => { + return Ok(json!({ + "type": "stderr", + "data": javascript_sync_rpc_bytes_value(&chunk), + })); + } + ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => { + if request.method == "process.signal_state" { + let response = parse_process_signal_state_request(&request.args) + .map_err(|error| SidecarError::InvalidState(error.to_string())) + .and_then(|(signal, registration)| { + vm.try_command("apply nested owned process signal state", |state| { + let path = + child_path.iter().map(String::as_str).collect::>(); + let signal_key = + Self::child_process_signal_key(process_id, &path) + .to_owned(); + apply_process_signal_state_update( + &mut state.signal_states, + &signal_key, + signal, + registration, + ); + Ok(()) + }) + }) + .map(|()| Value::Null.into()); + settle_owned_fetch_sync_rpc::( + vm, + process_id, + &child_path, + request, + response, + ) + .await?; + drop(reservation); + continue; + } + if matches!( + request.method.as_str(), + "child_process.spawn" + | "child_process.spawn_sync" + | "child_process.poll" + | "child_process.write_stdin" + | "child_process.close_stdin" + | "child_process.kill" + | "process.exec_fd_image_commit" + | "process.exec" + | "process.signal_state" + | "process.kill" + ) { + vm.try_command("requeue nested special process RPC", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let root = state + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + Self::active_process_by_path_mut(root, &path).ok_or_else(|| { + javascript_child_process_gone_error( + process_id, + &child_path.iter().map(String::as_str).collect::>(), ) - .or_else(ignore_stale_javascript_sync_rpc_response)?; - } - Err( - error @ agentos_runtime::TaskSpawnError::AdmissionClosed { .. }, - ) => { - child.clear_deferred_kernel_wait_rpc(); - child - .execution - .respond_javascript_sync_rpc_error( - request.id, - "ERR_AGENTOS_TASK_ADMISSION_CLOSED", - error.to_string(), + })?; + let child = parent + .child_processes + .get_mut(child_process_id) + .ok_or_else(|| { + javascript_child_process_gone_error( + process_id, + &child_path.iter().map(String::as_str).collect::>(), ) - .or_else(ignore_stale_javascript_sync_rpc_response)?; + })?; + child.queue_pending_polled_execution_event(PolledExecutionEvent { + event: ActiveExecutionEvent::JavascriptSyncRpcRequest(request), + reservation, + }) + })?; + process_event_notify.notify_one(); + return Ok(Value::Null); + } + service_owned_javascript_sync_rpc_request( + bridge, + vm_id, + vm, + process_id, + &child_path, + request, + ) + .await?; + drop(reservation); + } + ActiveExecutionEvent::Exited(exit_code) => { + let (signal_name, core_dumped) = + vm.try_command("finish owned descendant process exit", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let parent = state + .active_processes + .get_mut(process_id) + .and_then(|root| Self::active_process_by_path_mut(root, &path)); + let Some(parent) = parent else { + return Ok((None, false)); + }; + let Some(mut child) = parent.child_processes.remove(child_process_id) + else { + return Ok((None, false)); + }; + let signal_name = child + .exit_signal + .take() + .and_then(canonical_signal_name) + .map(str::to_owned); + let core_dumped = child.exit_core_dumped; + let label = Self::child_process_path_label( + process_id, + &child_path.iter().map(String::as_str).collect::>(), + ); + let detached = Self::adopt_detached_child_processes(&label, &mut child); + if child.host_write_dirty_recursive() + || !child.clean_host_writes_are_observable_recursive() + { + sync_process_host_writes_to_kernel(state, &child)?; } - } - } else { - child.deferred_kernel_wait_rpc = Some((request.clone(), Some(deadline))); + release_inherited_child_raw_mode(&mut state.kernel, &child)?; + terminate_child_process_tree( + &mut state.kernel, + &mut child, + &state.kernel_socket_readiness, + &state.unix_address_registry, + ); + child.kernel_handle.finish(exit_code); + let _ = state.kernel.wait_and_reap(child.kernel_pid); + state.signal_states.remove(child_process_id); + for (detached_id, detached_child) in detached { + state.detached_child_processes.insert(detached_id.clone()); + state.active_processes.insert(detached_id, detached_child); + } + Ok((signal_name, core_dumped)) + })?; + let mut payload = Map::new(); + payload.insert(String::from("type"), Value::String(String::from("exit"))); + payload.insert(String::from("exitCode"), Value::from(exit_code)); + payload.insert(String::from("coreDumped"), Value::from(core_dumped)); + if let Some(signal_name) = signal_name { + payload.insert(String::from("signal"), Value::String(signal_name)); } + drop(reservation); + return Ok(Value::Object(payload)); } - Err(error) => { - child.clear_deferred_kernel_wait_rpc(); - tracing::warn!( - method = %request.method, - error = %error, - "child JavaScript sync RPC failed" - ); - child - .execution - .respond_javascript_sync_rpc_error( - request.id, - javascript_sync_rpc_error_code(&error), - javascript_sync_rpc_error_message(&error), - ) - .or_else(ignore_stale_javascript_sync_rpc_response)?; + other => { + vm.try_command("requeue supervisor-owned child event", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let root = state + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + Self::active_process_by_path_mut(root, &path).ok_or_else(|| { + javascript_child_process_gone_error( + process_id, + &child_path.iter().map(String::as_str).collect::>(), + ) + })?; + let child = parent + .child_processes + .get_mut(child_process_id) + .ok_or_else(|| { + javascript_child_process_gone_error( + process_id, + &child_path.iter().map(String::as_str).collect::>(), + ) + })?; + child.queue_pending_polled_execution_event(PolledExecutionEvent { + event: other, + reservation, + }) + })?; + process_event_notify.notify_one(); + return Ok(Value::Null); } } - return Ok(true); - } - if request.method == "__kernel_stdin_read" - && matches!(child.execution, ActiveExecution::Javascript(_)) - && child.tty_master_fd.is_none() - && !child.direct_posix_stdin - && child.kernel_stdin_writer_fd.is_some() - { - return Ok(false); - } - if request.method == "process.fd_read" { - let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "fd_read fd")?; - let stat = kernel - .fd_stat(EXECUTION_DRIVER_NAME, child.kernel_pid, fd) - .map_err(kernel_error)?; - if matches!( - stat.filetype, - agentos_kernel::fd_table::FILETYPE_REGULAR_FILE - | agentos_kernel::fd_table::FILETYPE_DIRECTORY - | agentos_kernel::fd_table::FILETYPE_SYMBOLIC_LINK - ) { - // Ordinary VFS descriptors are immediately serviced by the - // normal RPC handler. Parking them behind poll readiness can - // deadlock a nested command before it produces pipeline data. - return Ok(false); - } } - // The child draining its stdin pipe is what frees capacity for queued - // host bytes and eventually delivers a deferred close/EOF. - flush_pending_kernel_stdin(kernel, child)?; - let now = Instant::now(); - let requested_timeout_ms = match request.method.as_str() { - "__kernel_stdin_read" => parse_kernel_stdin_read_args(request)?.1, - "__kernel_poll" => { - let timeout_ms = parse_kernel_poll_args(request)?.1; - (timeout_ms >= 0).then_some(timeout_ms as u64) - } - "process.fd_read" => Some( - javascript_sync_rpc_arg_u64_optional(&request.args, 2, "fd_read timeout ms")? - .unwrap_or(DEFAULT_KERNEL_STDIN_READ_TIMEOUT_MS), - ), - _ => return Ok(false), - }; - let deadline = match &child.deferred_kernel_wait_rpc { - Some((parked, parked_deadline)) if parked.id == request.id => *parked_deadline, - _ => requested_timeout_ms.map(|timeout_ms| now + Duration::from_millis(timeout_ms)), - }; - let kernel_pid = child.kernel_pid; - let mut fd_read = None; - let probe = match request.method.as_str() { - "__kernel_stdin_read" => { - let (max_bytes, _) = parse_kernel_stdin_read_args(request)?; - kernel_stdin_read_response( - kernel, - kernel_pid, - child.kernel_stdin_reader_fd, - max_bytes, - Duration::ZERO, - ) - .map(|value| (value, true)) + } + + fn write_descendant_javascript_child_process_stdin_owned( + vm: &VmHandle, + process_id: &str, + current_process_path: &[String], + child_process_id: &str, + chunk: &[u8], + ) -> Result<(), SidecarError> { + vm.try_command("write owned descendant stdin", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let mut child_path = path.clone(); + child_path.push(child_process_id); + let VmState { + kernel, + active_processes, + .. + } = state; + let root = active_processes + .get_mut(process_id) + .ok_or_else(|| javascript_child_process_gone_error(process_id, &child_path))?; + let parent = Self::active_process_by_path_mut(root, &path) + .ok_or_else(|| javascript_child_process_gone_error(process_id, &child_path))?; + let child = parent + .child_processes + .get_mut(child_process_id) + .ok_or_else(|| javascript_child_process_gone_error(process_id, &child_path))?; + if let Err(error) = child.execution.write_stdin(chunk) { + if is_broken_pipe_error(&error) { + return Ok(()); + } + return Err(error); } - "__kernel_poll" => { - let (fd_requests, _) = parse_kernel_poll_args(request)?; - kernel_poll_response(kernel, kernel_pid, &fd_requests, 0).map(|value| (value, true)) - } - "process.fd_read" => { - let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "fd_read fd")?; - let length = usize::try_from(javascript_sync_rpc_arg_u64( - &request.args, - 1, - "fd_read length", - )?) - .map_err(|_| SidecarError::InvalidState("fd_read length is too large".into()))?; - fd_read = Some((fd, length)); - kernel - .poll_fds( - EXECUTION_DRIVER_NAME, - kernel_pid, - vec![PollFd::new(fd, POLLIN)], - 0, - ) - .map(|result| (Value::Null, result.ready_count > 0)) - .map_err(kernel_error) - } - _ => unreachable!("unsupported deferred kernel wait method"), - }; - let (probe, fd_read_ready) = match probe { - Ok(probe) => probe, - Err(error) => { - child.clear_deferred_kernel_wait_rpc(); - child - .execution - .respond_javascript_sync_rpc_error( - request.id, - javascript_sync_rpc_error_code(&error), - javascript_sync_rpc_error_message(&error), - ) - .or_else(ignore_stale_javascript_sync_rpc_response)?; - return Ok(true); - } - }; - let ready = match request.method.as_str() { - "__kernel_stdin_read" => !probe.is_null(), - "__kernel_poll" => probe.get("readyCount").and_then(Value::as_u64).unwrap_or(0) > 0, - "process.fd_read" => fd_read_ready, - _ => unreachable!("unsupported deferred kernel wait method"), - }; - if ready - || requested_timeout_ms == Some(0) - || deadline.is_some_and(|deadline| now >= deadline) - { - child.clear_deferred_kernel_wait_rpc(); - if let Some((fd, length)) = fd_read { - // Claim before the destructive read. A stale reply token must - // not consume bytes intended for a later read on this fd. - let claimed = child - .execution - .claim_javascript_sync_rpc_response(request.id)?; - if !claimed { - return Ok(true); - } - let read_result = kernel.fd_read_with_timeout_result( - EXECUTION_DRIVER_NAME, - kernel_pid, - fd, - length, - Some(Duration::ZERO), - ); - match read_result { - Ok(Some(bytes)) => child - .execution - .respond_claimed_javascript_sync_rpc_success( - request.id, - javascript_sync_rpc_bytes_value(&bytes), - )?, - Ok(None) => child - .execution - .respond_claimed_javascript_sync_rpc_success( - request.id, - javascript_sync_rpc_bytes_value(&[]), - )?, - Err(error) => { - let error = kernel_error(error); - child.execution.respond_claimed_javascript_sync_rpc_error( - request.id, - javascript_sync_rpc_error_code(&error), - error.to_string(), - )?; - } - } - return Ok(true); - } - child - .execution - .respond_javascript_sync_rpc_response(request.id, probe.into()) - .or_else(ignore_stale_javascript_sync_rpc_response)?; - return Ok(true); - } - child.deferred_kernel_wait_rpc = Some((request.clone(), deadline)); - Ok(true) + write_kernel_process_stdin(kernel, child, chunk) + }) } - /// Service `__kernel_stdio_write` for a process writing to the SHARED - /// terminal (`tty_master_owner` set): write through the process's own PTY - /// slave (line discipline applies), then drain the master and surface the - /// drained bytes as the OWNER's ordered output stream — the single - /// host-facing path. No child stdout event is queued, so nothing gets - /// relayed (and re-rendered) by the parent shell. - pub(crate) fn service_shared_tty_stdio_write( - &mut self, - vm_id: &str, - writer_kernel_pid: u32, - owner: (u32, u32), - request: &JavascriptSyncRpcRequest, - ) -> Result { - let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "__kernel_stdio_write fd")?; - let chunk = javascript_sync_rpc_bytes_arg(&request.args, 1, "__kernel_stdio_write chunk")?; - if fd != 1 && fd != 2 { - return Err(SidecarError::InvalidState(format!( - "__kernel_stdio_write only supports fd 1/2, got {fd}" - ))); - } - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(json!(chunk.len())); - }; - let written = if fd == 1 { - vm.kernel - .write_process_stdout(EXECUTION_DRIVER_NAME, writer_kernel_pid, &chunk) - .map_err(kernel_error)? - } else { - vm.kernel - .write_process_stderr(EXECUTION_DRIVER_NAME, writer_kernel_pid, &chunk) - .map_err(kernel_error)? - }; - let (owner_pid, master_fd) = owner; - let mut drained: Vec = Vec::new(); - loop { - match vm.kernel.fd_read_with_timeout_result( - EXECUTION_DRIVER_NAME, - owner_pid, - master_fd, - MAX_PTY_BUFFER_BYTES, - Some(Duration::ZERO), - ) { - Ok(Some(bytes)) if !bytes.is_empty() => drained.extend(bytes), - Ok(_) => break, - Err(error) if error.code() == "EAGAIN" => break, - Err(error) => return Err(kernel_error(error)), - } - } - if !drained.is_empty() { - if let Some(owner_process) = vm - .active_processes - .values_mut() - .find(|process| process.kernel_pid == owner_pid) - { - owner_process - .queue_pending_execution_event(ActiveExecutionEvent::Stdout(drained))?; - } - } - Ok(json!(written)) + fn close_descendant_javascript_child_process_stdin_owned( + vm: &VmHandle, + process_id: &str, + current_process_path: &[String], + child_process_id: &str, + ) -> Result<(), SidecarError> { + vm.try_command("close owned descendant stdin", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let mut child_path = path.clone(); + child_path.push(child_process_id); + let VmState { + kernel, + active_processes, + .. + } = state; + let root = active_processes + .get_mut(process_id) + .ok_or_else(|| javascript_child_process_gone_error(process_id, &child_path))?; + let parent = Self::active_process_by_path_mut(root, &path) + .ok_or_else(|| javascript_child_process_gone_error(process_id, &child_path))?; + let child = parent + .child_processes + .get_mut(child_process_id) + .ok_or_else(|| javascript_child_process_gone_error(process_id, &child_path))?; + child.execution.close_stdin()?; + close_kernel_process_stdin(kernel, child) + }) } - /// Re-check a child's parked kernel-wait RPC (see - /// `service_child_kernel_wait_rpc`); called once per pump-loop iteration. - fn recheck_child_deferred_kernel_wait_rpc( - &mut self, + fn kill_descendant_javascript_child_process_owned( + bridge: &SharedBridge, + vm: &VmHandle, vm_id: &str, process_id: &str, - current_process_path: &[&str], + current_process_path: &[String], child_process_id: &str, + signal_name: &str, + process_event_notify: &Arc, ) -> Result<(), SidecarError> { - let parked = { - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(()); - }; - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(()); + let signal = parse_signal(signal_name)?; + let audit = vm.try_command("kill owned descendant process", |state| { + let path = current_process_path + .iter() + .map(String::as_str) + .collect::>(); + let mut signal_path = path.clone(); + signal_path.push(child_process_id); + let signal_key = Self::child_process_signal_key(process_id, &signal_path).to_owned(); + let registration = state + .signal_states + .get(&signal_key) + .and_then(|handlers| handlers.get(&(signal as u32))) + .cloned(); + let VmState { + kernel, + active_processes, + .. + } = state; + let Some(root) = active_processes.get_mut(process_id) else { + return Ok(None); }; - let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { - return Ok(()); + let Some(parent) = Self::active_process_by_path_mut(root, &path) else { + return Ok(None); }; + let source_pid = parent.kernel_pid; let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Ok(()); + return Ok(None); }; - child - .deferred_kernel_wait_rpc - .as_ref() - .map(|(request, _)| request.clone()) - }; - if let Some(request) = parked { - let _ = self.service_child_kernel_wait_rpc( - vm_id, - process_id, - current_process_path, - child_process_id, - &request, + terminate_tracked_child_process_for_signal( + kernel, + child, + signal, + registration.as_ref(), )?; + Ok(Some((source_pid, child.kernel_pid))) + })?; + if let Some((source_pid, target_pid)) = audit { + let child_label = if current_process_path.is_empty() { + child_process_id.to_owned() + } else { + format!("{}/{}", current_process_path.join("/"), child_process_id) + }; + emit_security_audit_event( + bridge, + vm_id, + "security.process.kill", + audit_fields([ + (String::from("source"), String::from("guest_child_process")), + (String::from("source_pid"), source_pid.to_string()), + (String::from("target_pid"), target_pid.to_string()), + (String::from("process_id"), process_id.to_owned()), + (String::from("child_process_id"), child_label), + (String::from("signal"), signal_name.to_owned()), + ]), + ); + process_event_notify.notify_one(); } Ok(()) } - async fn poll_descendant_javascript_child_process( - &mut self, + fn handle_owned_process_kill_rpc( + bridge: &SharedBridge, + vm: &VmHandle, vm_id: &str, process_id: &str, - current_process_path: &[&str], - child_process_id: &str, - wait_ms: u64, - preserve_pull_owned_events: bool, + source_path: &[String], + request: &JavascriptSyncRpcRequest, ) -> Result { - let mut child_path = current_process_path.to_vec(); - child_path.push(child_process_id); - let child_gone_error = || javascript_child_process_gone_error(process_id, &child_path); - // `wait_ms` remains on the compatibility RPC surface for WASM/native - // callers, but the sidecar never parks while servicing it. Runtime - // event producers wake the shared process pump instead. - let _ = wait_ms; - - loop { - self.drain_queued_descendant_javascript_child_process_events( - vm_id, - process_id, - &child_path, - )?; - self.recheck_child_deferred_kernel_wait_rpc( - vm_id, - process_id, - current_process_path, - child_process_id, - )?; - enum ChildPollResult { - Event(Box>), - RecoverRuntimeExit, - Timeout, - } - let poll_result = { - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(Value::Null); - }; - let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) - else { - return Err(child_gone_error()); - }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Err(child_gone_error()); - }; - if let Some(event) = child.lease_pending_execution_event() { - ChildPollResult::Event(Box::new(Some(event))) - } else { - match child.try_poll_execution_event() { + let target_pid = javascript_sync_rpc_arg_i32(&request.args, 0, "process.kill target pid")?; + let signal_name = javascript_sync_rpc_arg_str(&request.args, 1, "process.kill signal")?; + let signal = parse_signal(signal_name)?; + if signal == 0 { + vm.try_command("probe owned process pid", |state| { + state + .kernel + .signal_process(EXECUTION_DRIVER_NAME, target_pid, 0) + .map_err(kernel_error) + })?; + return Ok(Value::Null); + } + let source_pid = vm.try_read("locate owned process.kill source", |state| { + let root = state.active_processes.get(process_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "ESRCH: unknown process {process_id} during process.kill" + )) + })?; + let path = source_path.iter().map(String::as_str).collect::>(); + Self::active_process_by_path(root, &path) + .map(|source| source.kernel_pid) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "ESRCH: unknown source process {}", + Self::child_process_path_label(process_id, &path) + )) + }) + })??; + if target_pid < 0 { + let pgid = target_pid.unsigned_abs(); + let members = vm.try_read("list owned process group", |state| { + state + .kernel + .list_processes() + .into_iter() + .filter(|(_, info)| info.pgid == pgid && info.status != ProcessStatus::Exited) + .map(|(pid, _)| pid) + .collect::>() + })?; + if members.is_empty() { + return Err(SidecarError::InvalidState(format!( + "ESRCH: no such process group {pgid}" + ))); + } + for member in members.iter().copied().filter(|pid| *pid != source_pid) { + match signal_vm_kernel_pid_owned(bridge, vm, vm_id, member, signal_name) { + Ok(()) => {} + Err(error) if error.to_string().contains("ESRCH") => {} + Err(error) => return Err(error), + } + } + if members.contains(&source_pid) { + if !matches!( + canonical_signal_name(signal), + Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG") + ) { + vm.try_command("apply owned process-group self signal", |state| { + let VmState { + kernel, + active_processes, + .. + } = state; + let root = active_processes.get_mut(process_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "ESRCH: unknown process {process_id} during process.kill" + )) + })?; + let path = source_path.iter().map(String::as_str).collect::>(); + let source = + Self::active_process_by_path_mut(root, &path).ok_or_else(|| { + SidecarError::InvalidState(String::from( + "ESRCH: process.kill source disappeared", + )) + })?; + apply_active_process_default_signal(kernel, source, signal) + })?; + } + return Ok(json!({ "self": true, "action": "default" })); + } + return Ok(Value::Null); + } + let target_kernel_pid = u32::try_from(target_pid).map_err(|_| { + SidecarError::InvalidState(format!("EINVAL: invalid process pid {target_pid}")) + })?; + let located = vm.try_read("classify owned process.kill target", |state| { + state.active_processes.iter().find_map(|(root_id, root)| { + Self::active_process_path_by_kernel_pid(root, target_kernel_pid).map(|path| { + let signal_key = path.last().map(String::as_str).unwrap_or(root_id); + let action = state + .signal_states + .get(signal_key) + .and_then(|handlers| handlers.get(&(signal as u32))) + .map(|registration| registration.action.clone()); + (root_id.clone(), path, action) + }) + }) + })?; + signal_vm_kernel_pid_owned(bridge, vm, vm_id, target_kernel_pid, signal_name)?; + let Some((target_root_id, target_path, registration)) = located else { + return Ok(Value::Null); + }; + if source_pid == target_kernel_pid { + return Ok(json!({ "self": true, "action": "default" })); + } + let action = match registration { + Some(SignalDispositionAction::Ignore) => "ignore", + Some(SignalDispositionAction::User) => "user", + Some(SignalDispositionAction::Default) | None + if matches!( + canonical_signal_name(signal), + Some("SIGWINCH" | "SIGCHLD" | "SIGURG") + ) => + { + "ignore" + } + Some(SignalDispositionAction::Default) | None => "default", + }; + let path = target_path.iter().map(String::as_str).collect::>(); + let target_path_label = Self::child_process_path_label(&target_root_id, &path); + Ok(json!({ + "self": false, + "action": action, + "signal": signal_name, + "number": signal, + "targetProcessPath": target_path_label, + })) + } + + #[cfg(test)] + #[allow(dead_code)] + pub(crate) async fn spawn_descendant_javascript_child_process_for_test( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + request: JavascriptChildProcessSpawnRequest, + ) -> Result { + self.spawn_descendant_javascript_child_process( + vm_id, + process_id, + current_process_path, + request, + ) + .await + } + + async fn defer_descendant_javascript_child_process_sync( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + request: JavascriptChildProcessSpawnRequest, + max_buffer: Option, + ) -> Result { + let max_buffer = { + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let root = vm + .active_processes + .get(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + Self::active_process_by_path(root, current_process_path).ok_or_else(|| { + SidecarError::InvalidState(String::from( + "unknown child process path during nested spawnSync", + )) + })?; + Self::child_process_sync_max_buffer(parent, max_buffer)? + }; + let sync_input = javascript_child_process_sync_input_bytes(request.options.input.as_ref())?; + let deadline = request + .options + .timeout + .map(|timeout_ms| Instant::now() + Duration::from_millis(timeout_ms)); + let timeout_signal = request + .options + .kill_signal + .clone() + .unwrap_or_else(|| String::from("SIGTERM")); + let spawned = self + .spawn_descendant_javascript_child_process( + vm_id, + process_id, + current_process_path, + request, + ) + .await?; + let child_process_id = spawned + .get("childId") + .and_then(Value::as_str) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "child_process.spawn_sync response is missing childId", + )) + })? + .to_owned(); + let pid = spawned + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "child_process.spawn_sync response is missing a valid pid", + )) + })?; + + if let Some(input) = sync_input.as_deref() { + self.write_descendant_javascript_child_process_stdin( + vm_id, + process_id, + current_process_path, + &child_process_id, + input, + )?; + } + self.close_descendant_javascript_child_process_stdin( + vm_id, + process_id, + current_process_path, + &child_process_id, + )?; + + let (respond_to, receiver) = tokio::sync::oneshot::channel(); + let (runtime, notify) = { + let mut vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + let root = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let parent = + Self::active_process_by_path_mut(root, current_process_path).ok_or_else(|| { + SidecarError::InvalidState(String::from( + "unknown child process path during nested spawnSync", + )) + })?; + parent.pending_child_process_sync.insert( + child_process_id, + PendingChildProcessSync { + pid, + stdout: Vec::new(), + stderr: Vec::new(), + max_buffer, + deadline, + timeout_signal, + kill_sent: false, + timed_out: false, + max_buffer_exceeded: false, + completion: PendingChildProcessSyncCompletion::Javascript(respond_to), + }, + ); + ( + parent.runtime_context.clone(), + Arc::clone(&parent.process_event_notify), + ) + }; + if let Some(deadline) = deadline { + let delay = deadline.saturating_duration_since(Instant::now()); + runtime + .spawn(agentos_runtime::TaskClass::Timer, async move { + tokio::time::sleep(delay).await; + notify.notify_one(); + }) + .map_err(SidecarError::from)?; + } + Ok(JavascriptSyncRpcServiceResponse::Deferred { + receiver, + timeout: None, + task_class: agentos_runtime::TaskClass::Vm, + }) + } + + async fn handle_descendant_javascript_child_process_rpc( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + request: &JavascriptSyncRpcRequest, + ) -> Result { + match request.method.as_str() { + "child_process.spawn" => { + let payload = { + let Some(vm) = self.vms.get(vm_id) else { + return Ok(Value::Null.into()); + }; + parse_javascript_child_process_spawn_request(&vm, &request.args)?.0 + }; + self.spawn_descendant_javascript_child_process( + vm_id, + process_id, + current_process_path, + payload, + ) + .await + .map(Into::into) + } + "child_process.spawn_sync" => { + let (payload, max_buffer) = { + let Some(vm) = self.vms.get(vm_id) else { + return Ok(Value::Null.into()); + }; + parse_javascript_child_process_spawn_request(&vm, &request.args)? + }; + self.defer_descendant_javascript_child_process_sync( + vm_id, + process_id, + current_process_path, + payload, + max_buffer, + ) + .await + } + "child_process.poll" => { + let child_process_id = + javascript_sync_rpc_arg_str(&request.args, 0, "child_process.poll child id")?; + let wait_ms = javascript_sync_rpc_arg_u64_optional( + &request.args, + 1, + "child_process.poll wait ms", + )? + .unwrap_or_default(); + Box::pin(self.poll_descendant_javascript_child_process( + vm_id, + process_id, + current_process_path, + child_process_id, + wait_ms, + false, + None, + None, + None, + None, + None, + )) + .await + .map(Into::into) + } + "child_process.write_stdin" => { + let child_process_id = javascript_sync_rpc_arg_str( + &request.args, + 0, + "child_process.write_stdin child id", + )?; + let chunk = javascript_sync_rpc_bytes_arg( + &request.args, + 1, + "child_process.write_stdin chunk", + )?; + self.write_descendant_javascript_child_process_stdin( + vm_id, + process_id, + current_process_path, + child_process_id, + &chunk, + ) + .map(|()| Value::Null.into()) + } + "child_process.close_stdin" => { + let child_process_id = javascript_sync_rpc_arg_str( + &request.args, + 0, + "child_process.close_stdin child id", + )?; + self.close_descendant_javascript_child_process_stdin( + vm_id, + process_id, + current_process_path, + child_process_id, + ) + .map(|()| Value::Null.into()) + } + "child_process.kill" => { + let child_process_id = + javascript_sync_rpc_arg_str(&request.args, 0, "child_process.kill child id")?; + let signal = + javascript_sync_rpc_arg_str(&request.args, 1, "child_process.kill signal")?; + self.kill_descendant_javascript_child_process( + vm_id, + process_id, + current_process_path, + child_process_id, + signal, + ) + .map(|()| Value::Null.into()) + } + _ => Err(SidecarError::InvalidState(format!( + "unsupported nested child process RPC method {}", + request.method + ))), + } + } + + /// Deferred servicing for a child's blocking kernel read, poll, or stdio + /// write inside the child-event pump. Each operation is probed without + /// blocking the sidecar actor; an unavailable write is parked by reply + /// token and retried after the parent consumes pipe data. The pump loop + /// re-checks the parked RPC every iteration. Returns false when the RPC + /// must use the normal inline path (local JS stdin or shared TTY output). + fn service_child_kernel_wait_rpc( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + request: &JavascriptSyncRpcRequest, + ) -> Result { + let event_notify = Arc::clone(&self.process_event_notify); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(true); + }; + let vm = &mut *vm; + let operation_deadline_ms = vm.limits.reactor.operation_deadline_ms; + let runtime = vm.runtime_context.clone(); + let kernel = &mut vm.kernel; + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(true); + }; + let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { + return Ok(true); + }; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Ok(true); + }; + if request.method == "__kernel_stdio_write" || request.method == "process.fd_write" { + if request.method == "__kernel_stdio_write" && child.tty_master_owner.is_some() { + return Ok(false); + } + let now = Instant::now(); + let (deadline, arm_deadline_wake) = match &child.deferred_kernel_wait_rpc { + Some((parked, parked_deadline)) if parked.id == request.id => ( + parked_deadline.unwrap_or(now + Duration::from_millis(operation_deadline_ms)), + false, + ), + _ => (now + Duration::from_millis(operation_deadline_ms), true), + }; + let response = if request.method == "__kernel_stdio_write" { + service_javascript_kernel_stdio_write_sync_rpc(kernel, child, request) + } else { + service_javascript_kernel_fd_write_sync_rpc(kernel, child, request) + }; + match response { + Ok(response) => { + child.clear_deferred_kernel_wait_rpc(); + child + .execution + .respond_javascript_sync_rpc_response(request.id, response.into()) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + } + Err(error) + if javascript_sync_rpc_error_code(&error) == "EAGAIN" && now >= deadline => + { + child.clear_deferred_kernel_wait_rpc(); + child + .execution + .respond_javascript_sync_rpc_error( + request.id, + "ETIMEDOUT", + format!( + "pipe write exceeded limits.reactor.operationDeadlineMs ({operation_deadline_ms} ms); raise that limit for slower readers" + ), + ) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + } + Err(error) if javascript_sync_rpc_error_code(&error) == "EAGAIN" => { + if arm_deadline_wake { + let delay = deadline.saturating_duration_since(now); + child.clear_deferred_kernel_wait_rpc(); + child.deferred_kernel_wait_rpc = Some((request.clone(), Some(deadline))); + let timer = runtime.spawn(agentos_runtime::TaskClass::Timer, async move { + tokio::time::sleep(delay).await; + event_notify.notify_one(); + }); + match timer { + Ok(timer) => { + child.deferred_child_write_timer = Some(timer); + } + Err(agentos_runtime::TaskSpawnError::ResourceLimit(limit)) => { + child.clear_deferred_kernel_wait_rpc(); + child + .execution + .respond_javascript_sync_rpc_error( + request.id, + "ERR_AGENTOS_RESOURCE_LIMIT", + crate::state::guest_limit_message(&limit), + ) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + } + Err( + error @ agentos_runtime::TaskSpawnError::AdmissionClosed { .. }, + ) => { + child.clear_deferred_kernel_wait_rpc(); + child + .execution + .respond_javascript_sync_rpc_error( + request.id, + "ERR_AGENTOS_TASK_ADMISSION_CLOSED", + error.to_string(), + ) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + } + } + } else { + child.deferred_kernel_wait_rpc = Some((request.clone(), Some(deadline))); + } + } + Err(error) => { + child.clear_deferred_kernel_wait_rpc(); + tracing::warn!( + method = %request.method, + error = %error, + "child JavaScript sync RPC failed" + ); + child + .execution + .respond_javascript_sync_rpc_error( + request.id, + javascript_sync_rpc_error_code(&error), + javascript_sync_rpc_error_message(&error), + ) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + } + } + return Ok(true); + } + if request.method == "__kernel_stdin_read" + && matches!(child.execution, ActiveExecution::Javascript(_)) + && child.tty_master_fd.is_none() + && !child.direct_posix_stdin + && child.kernel_stdin_writer_fd.is_some() + { + return Ok(false); + } + if request.method == "process.fd_read" { + let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "fd_read fd")?; + let stat = kernel + .fd_stat(EXECUTION_DRIVER_NAME, child.kernel_pid, fd) + .map_err(kernel_error)?; + if matches!( + stat.filetype, + agentos_kernel::fd_table::FILETYPE_REGULAR_FILE + | agentos_kernel::fd_table::FILETYPE_DIRECTORY + | agentos_kernel::fd_table::FILETYPE_SYMBOLIC_LINK + ) { + // Ordinary VFS descriptors are immediately serviced by the + // normal RPC handler. Parking them behind poll readiness can + // deadlock a nested command before it produces pipeline data. + return Ok(false); + } + } + // The child draining its stdin pipe is what frees capacity for queued + // host bytes and eventually delivers a deferred close/EOF. + flush_pending_kernel_stdin(kernel, child)?; + let now = Instant::now(); + let requested_timeout_ms = match request.method.as_str() { + "__kernel_stdin_read" => parse_kernel_stdin_read_args(request)?.1, + "__kernel_poll" => { + let timeout_ms = parse_kernel_poll_args(request)?.1; + (timeout_ms >= 0).then_some(timeout_ms as u64) + } + "process.fd_read" => Some( + javascript_sync_rpc_arg_u64_optional(&request.args, 2, "fd_read timeout ms")? + .unwrap_or(DEFAULT_KERNEL_STDIN_READ_TIMEOUT_MS), + ), + _ => return Ok(false), + }; + let deadline = match &child.deferred_kernel_wait_rpc { + Some((parked, parked_deadline)) if parked.id == request.id => *parked_deadline, + _ => requested_timeout_ms.map(|timeout_ms| now + Duration::from_millis(timeout_ms)), + }; + let kernel_pid = child.kernel_pid; + let mut fd_read = None; + let probe = match request.method.as_str() { + "__kernel_stdin_read" => { + let (max_bytes, _) = parse_kernel_stdin_read_args(request)?; + kernel_stdin_read_response( + kernel, + kernel_pid, + child.kernel_stdin_reader_fd, + max_bytes, + Duration::ZERO, + ) + .map(|value| (value, true)) + } + "__kernel_poll" => { + let (fd_requests, _) = parse_kernel_poll_args(request)?; + kernel_poll_response(kernel, kernel_pid, &fd_requests, 0).map(|value| (value, true)) + } + "process.fd_read" => { + let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "fd_read fd")?; + let length = usize::try_from(javascript_sync_rpc_arg_u64( + &request.args, + 1, + "fd_read length", + )?) + .map_err(|_| SidecarError::InvalidState("fd_read length is too large".into()))?; + fd_read = Some((fd, length)); + kernel + .poll_fds( + EXECUTION_DRIVER_NAME, + kernel_pid, + vec![PollFd::new(fd, POLLIN)], + 0, + ) + .map(|result| (Value::Null, result.ready_count > 0)) + .map_err(kernel_error) + } + _ => unreachable!("unsupported deferred kernel wait method"), + }; + let (probe, fd_read_ready) = match probe { + Ok(probe) => probe, + Err(error) => { + child.clear_deferred_kernel_wait_rpc(); + child + .execution + .respond_javascript_sync_rpc_error( + request.id, + javascript_sync_rpc_error_code(&error), + javascript_sync_rpc_error_message(&error), + ) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + return Ok(true); + } + }; + let ready = match request.method.as_str() { + "__kernel_stdin_read" => !probe.is_null(), + "__kernel_poll" => probe.get("readyCount").and_then(Value::as_u64).unwrap_or(0) > 0, + "process.fd_read" => fd_read_ready, + _ => unreachable!("unsupported deferred kernel wait method"), + }; + if ready + || requested_timeout_ms == Some(0) + || deadline.is_some_and(|deadline| now >= deadline) + { + child.clear_deferred_kernel_wait_rpc(); + if let Some((fd, length)) = fd_read { + // Claim before the destructive read. A stale reply token must + // not consume bytes intended for a later read on this fd. + let claimed = child + .execution + .claim_javascript_sync_rpc_response(request.id)?; + if !claimed { + return Ok(true); + } + let read_result = kernel.fd_read_with_timeout_result( + EXECUTION_DRIVER_NAME, + kernel_pid, + fd, + length, + Some(Duration::ZERO), + ); + match read_result { + Ok(Some(bytes)) => child + .execution + .respond_claimed_javascript_sync_rpc_success( + request.id, + javascript_sync_rpc_bytes_value(&bytes), + )?, + Ok(None) => child + .execution + .respond_claimed_javascript_sync_rpc_success( + request.id, + javascript_sync_rpc_bytes_value(&[]), + )?, + Err(error) => { + let error = kernel_error(error); + child.execution.respond_claimed_javascript_sync_rpc_error( + request.id, + javascript_sync_rpc_error_code(&error), + error.to_string(), + )?; + } + } + return Ok(true); + } + child + .execution + .respond_javascript_sync_rpc_response(request.id, probe.into()) + .or_else(ignore_stale_javascript_sync_rpc_response)?; + return Ok(true); + } + child.deferred_kernel_wait_rpc = Some((request.clone(), deadline)); + Ok(true) + } + + /// Service `__kernel_stdio_write` for a process writing to the SHARED + /// terminal (`tty_master_owner` set): write through the process's own PTY + /// slave (line discipline applies), then drain the master and surface the + /// drained bytes as the OWNER's ordered output stream — the single + /// host-facing path. No child stdout event is queued, so nothing gets + /// relayed (and re-rendered) by the parent shell. + pub(crate) fn service_shared_tty_stdio_write( + &mut self, + vm_id: &str, + writer_kernel_pid: u32, + owner: (u32, u32), + request: &JavascriptSyncRpcRequest, + ) -> Result { + let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "__kernel_stdio_write fd")?; + let chunk = javascript_sync_rpc_bytes_arg(&request.args, 1, "__kernel_stdio_write chunk")?; + if fd != 1 && fd != 2 { + return Err(SidecarError::InvalidState(format!( + "__kernel_stdio_write only supports fd 1/2, got {fd}" + ))); + } + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(json!(chunk.len())); + }; + let written = if fd == 1 { + vm.kernel + .write_process_stdout(EXECUTION_DRIVER_NAME, writer_kernel_pid, &chunk) + .map_err(kernel_error)? + } else { + vm.kernel + .write_process_stderr(EXECUTION_DRIVER_NAME, writer_kernel_pid, &chunk) + .map_err(kernel_error)? + }; + let (owner_pid, master_fd) = owner; + let mut drained: Vec = Vec::new(); + loop { + match vm.kernel.fd_read_with_timeout_result( + EXECUTION_DRIVER_NAME, + owner_pid, + master_fd, + MAX_PTY_BUFFER_BYTES, + Some(Duration::ZERO), + ) { + Ok(Some(bytes)) if !bytes.is_empty() => drained.extend(bytes), + Ok(_) => break, + Err(error) if error.code() == "EAGAIN" => break, + Err(error) => return Err(kernel_error(error)), + } + } + if !drained.is_empty() { + if let Some(owner_process) = vm + .active_processes + .values_mut() + .find(|process| process.kernel_pid == owner_pid) + { + owner_process + .queue_pending_execution_event(ActiveExecutionEvent::Stdout(drained))?; + } + } + Ok(json!(written)) + } + + /// Re-check a child's parked kernel-wait RPC (see + /// `service_child_kernel_wait_rpc`); called once per pump-loop iteration. + fn recheck_child_deferred_kernel_wait_rpc( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + ) -> Result<(), SidecarError> { + let parked = { + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(()); + }; + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(()); + }; + let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { + return Ok(()); + }; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Ok(()); + }; + child + .deferred_kernel_wait_rpc + .as_ref() + .map(|(request, _)| request.clone()) + }; + if let Some(request) = parked { + let _ = self.service_child_kernel_wait_rpc( + vm_id, + process_id, + current_process_path, + child_process_id, + &request, + )?; + } + Ok(()) + } + + fn check_descendant_public_process_event_capacity( + &self, + vm_id: &str, + public_process_id: &str, + event: ActiveExecutionEvent, + ) -> Result<(), SidecarError> { + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + self.check_pending_process_event_capacity(&ProcessEventEnvelope { + connection_id: vm.connection_id.clone(), + session_id: vm.session_id.clone(), + vm_id: vm_id.to_owned(), + child_path: Vec::new(), + process_id: public_process_id.to_owned(), + event, + }) + } + + fn requeue_descendant_claimed_event( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + event: ActiveExecutionEvent, + reservation: Option, + ) -> Result<(), SidecarError> { + let mut vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + let parent = Self::descendant_parent_process_mut(&mut vm, process_id, current_process_path) + .ok_or_else(|| javascript_child_process_gone_error(process_id, current_process_path))?; + let child = parent + .child_processes + .get_mut(child_process_id) + .ok_or_else(|| { + let mut child_path = current_process_path.to_vec(); + child_path.push(child_process_id); + javascript_child_process_gone_error(process_id, &child_path) + })?; + child.requeue_pending_execution_event(PolledExecutionEvent { event, reservation }) + } + + fn poll_descendant_javascript_child_process_nowait( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + preserve_pull_owned_events: bool, + owned_javascript_services: &mut Vec, + owned_python_services: &mut Vec, + owned_python_socket_completions: &mut Vec, + public_process_id: Option<&str>, + ) -> Result { + let mut claimed_reservation = None; + let mut future = Box::pin(self.poll_descendant_javascript_child_process( + vm_id, + process_id, + current_process_path, + child_process_id, + 0, + preserve_pull_owned_events, + Some(owned_javascript_services), + Some(owned_python_services), + Some(owned_python_socket_completions), + Some(&mut claimed_reservation), + public_process_id, + )); + let mut context = Context::from_waker(Waker::noop()); + let poll = future.as_mut().poll(&mut context); + drop(future); + match poll { + Poll::Ready(result) => result.map(|event| ClaimedDescendantBridgeEvent { + event, + reservation: claimed_reservation, + }), + Poll::Pending => Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_CHILD_EVENT_TURN_SUSPENDED: bounded child event claim unexpectedly suspended", + ))), + } + } + + async fn poll_descendant_javascript_child_process( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + wait_ms: u64, + preserve_pull_owned_events: bool, + mut owned_javascript_services: Option<&mut Vec>, + mut owned_python_services: Option<&mut Vec>, + mut owned_python_socket_completions: Option<&mut Vec>, + mut claimed_bridge_reservation: Option<&mut Option>, + public_process_id: Option<&str>, + ) -> Result { + let mut child_path = current_process_path.to_vec(); + child_path.push(child_process_id); + let child_gone_error = || javascript_child_process_gone_error(process_id, &child_path); + // `wait_ms` remains on the compatibility RPC surface for WASM/native + // callers, but the sidecar never parks while servicing it. Runtime + // event producers wake the shared process pump instead. + let _ = wait_ms; + + loop { + self.drain_queued_descendant_javascript_child_process_events( + vm_id, + process_id, + &child_path, + )?; + self.recheck_child_deferred_kernel_wait_rpc( + vm_id, + process_id, + current_process_path, + child_process_id, + )?; + enum ChildPollResult { + Event(Box>), + RecoverRuntimeExit, + Timeout, + } + let poll_result = { + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(Value::Null); + }; + let Some(parent) = + Self::descendant_parent_process_mut(&mut vm, process_id, current_process_path) + else { + return Err(child_gone_error()); + }; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Err(child_gone_error()); + }; + if let Some(event) = child.lease_pending_execution_event() { + ChildPollResult::Event(Box::new(Some(event))) + } else { + match child.try_poll_execution_event() { Ok(Some(event)) => ChildPollResult::Event(Box::new(Some(event))), Ok(None) => ChildPollResult::Timeout, Err(SidecarError::Execution(message)) @@ -6656,18 +8593,21 @@ where return Ok(Value::Null); }; - let PolledExecutionEvent { event, reservation } = event; + let PolledExecutionEvent { + event, + mut reservation, + } = event; let synthetic_signal_termination = matches!( &event, ActiveExecutionEvent::Stderr(chunk) if chunk.as_slice() == SYNTHETIC_V8_TERMINATION_STDERR - ) && self - .vms - .get(vm_id) - .and_then(|vm| vm.active_processes.get(process_id)) - .and_then(|root| Self::active_process_by_path(root, current_process_path)) - .and_then(|parent| parent.child_processes.get(child_process_id)) - .is_some_and(|child| child.exit_signal.is_some()); + ) && self.vms.get(vm_id).is_some_and(|vm| { + vm.active_processes + .get(process_id) + .and_then(|root| Self::active_process_by_path(root, current_process_path)) + .and_then(|parent| parent.child_processes.get(child_process_id)) + .is_some_and(|child| child.exit_signal.is_some()) + }); if synthetic_signal_termination { // The following exit event carries the authoritative signal status. drop(reservation); @@ -6681,11 +8621,11 @@ where | ActiveExecutionEvent::Exited(_) ) { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) + Self::descendant_parent_process_mut(&mut vm, process_id, current_process_path) else { return Ok(Value::Null); }; @@ -6700,12 +8640,64 @@ where } match event { ActiveExecutionEvent::Stdout(chunk) => { + if let Some(public_process_id) = public_process_id { + if let Err(error) = self.check_descendant_public_process_event_capacity( + vm_id, + public_process_id, + ActiveExecutionEvent::Stdout(chunk.clone()), + ) { + tracing::debug!( + %error, + vm_id, + process_id = public_process_id, + "detached descendant stdout remains leased at producer capacity" + ); + self.requeue_descendant_claimed_event( + vm_id, + process_id, + current_process_path, + child_process_id, + ActiveExecutionEvent::Stdout(chunk), + reservation, + )?; + return Ok(Value::Null); + } + } + if let Some(slot) = claimed_bridge_reservation.as_deref_mut() { + *slot = reservation.take(); + } return Ok(json!({ "type": "stdout", "data": javascript_sync_rpc_bytes_value(&chunk), })); } ActiveExecutionEvent::Stderr(chunk) => { + if let Some(public_process_id) = public_process_id { + if let Err(error) = self.check_descendant_public_process_event_capacity( + vm_id, + public_process_id, + ActiveExecutionEvent::Stderr(chunk.clone()), + ) { + tracing::debug!( + %error, + vm_id, + process_id = public_process_id, + "detached descendant stderr remains leased at producer capacity" + ); + self.requeue_descendant_claimed_event( + vm_id, + process_id, + current_process_path, + child_process_id, + ActiveExecutionEvent::Stderr(chunk), + reservation, + )?; + return Ok(Value::Null); + } + } + if let Some(slot) = claimed_bridge_reservation.as_deref_mut() { + *slot = reservation.take(); + } return Ok(json!({ "type": "stderr", "data": javascript_sync_rpc_bytes_value(&chunk), @@ -6714,11 +8706,11 @@ where ActiveExecutionEvent::Exited(mut exit_code) => { let cleanup_start = Instant::now(); let had_trailing_events = { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let Some(parent) = Self::descendant_parent_process_mut( - vm, + &mut vm, process_id, current_process_path, ) else { @@ -6745,7 +8737,7 @@ where // while a trailing event remains behind it. child.queue_pending_polled_execution_event(PolledExecutionEvent { event: ActiveExecutionEvent::Exited(exit_code), - reservation, + reservation: reservation.take(), })?; true } else { @@ -6756,16 +8748,40 @@ where continue; } + if let Some(public_process_id) = public_process_id { + if let Err(error) = self.check_descendant_public_process_event_capacity( + vm_id, + public_process_id, + ActiveExecutionEvent::Exited(exit_code), + ) { + tracing::debug!( + %error, + vm_id, + process_id = public_process_id, + "detached descendant exit remains leased before cleanup" + ); + self.requeue_descendant_claimed_event( + vm_id, + process_id, + current_process_path, + child_process_id, + ActiveExecutionEvent::Exited(exit_code), + reservation, + )?; + return Ok(Value::Null); + } + } + // The native wait status is authoritative for whether the // runner exited normally or was terminated by a signal. // Never infer a signal from 128+N: a program can // legitimately call exit(137). { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let Some(parent) = Self::descendant_parent_process_mut( - vm, + &mut vm, process_id, current_process_path, ) else { @@ -6786,14 +8802,55 @@ where } } + // Detached descendant exits cross two ownership domains: + // the child tree and the public process-event queue. Make + // the public handoff durable before removing or tearing + // down the child. If admission changed after the earlier + // capacity probe, put the exact claimed exit (including + // its retained-byte reservation) back on the child. + let public_event_queued = if let Some(public_process_id) = public_process_id { + let Some((connection_id, session_id)) = self + .vms + .get(vm_id) + .map(|vm| (vm.connection_id.clone(), vm.session_id.clone())) + else { + return Ok(Value::Null); + }; + let envelope = ProcessEventEnvelope { + connection_id, + session_id, + vm_id: vm_id.to_owned(), + child_path: Vec::new(), + process_id: public_process_id.to_owned(), + event: ActiveExecutionEvent::Exited(exit_code), + }; + if let Err((error, envelope)) = + self.try_queue_pending_process_event(envelope) + { + self.requeue_descendant_claimed_event( + vm_id, + process_id, + current_process_path, + child_process_id, + envelope.event, + reservation.take(), + )?; + return Err(error); + } + drop(reservation.take()); + true + } else { + false + }; + let parent_signal_key = Self::child_process_signal_key(process_id, current_process_path); - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let (signal_name, core_dumped) = { let Some(parent) = Self::descendant_parent_process_mut( - vm, + &mut vm, process_id, current_process_path, ) else { @@ -6817,9 +8874,11 @@ where parent_is_wasm, should_signal_parent, ) = { - let Some(parent) = - Self::descendant_parent_process(vm, process_id, current_process_path) - else { + let Some(parent) = Self::descendant_parent_process( + &mut vm, + process_id, + current_process_path, + ) else { return Ok(Value::Null); }; ( @@ -6840,9 +8899,11 @@ where }), ) }; - let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) - else { + let Some(parent) = Self::descendant_parent_process_mut( + &mut vm, + process_id, + current_process_path, + ) else { return Ok(Value::Null); }; let Some(mut child) = parent.child_processes.remove(child_process_id) else { @@ -6861,7 +8922,7 @@ where if child.host_write_dirty_recursive() || !child.clean_host_writes_are_observable_recursive() { - sync_process_host_writes_to_kernel(vm, &child)?; + sync_process_host_writes_to_kernel(&mut vm, &child)?; } release_inherited_child_raw_mode(&mut vm.kernel, &child)?; let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); @@ -6884,7 +8945,7 @@ where if should_signal_parent { if parent_is_wasm { let Some(parent) = Self::descendant_parent_process_mut( - vm, + &mut vm, process_id, current_process_path, ) else { @@ -6901,16 +8962,50 @@ where payload.insert(String::from("type"), Value::String(String::from("exit"))); payload.insert(String::from("exitCode"), Value::from(exit_code)); payload.insert(String::from("coreDumped"), Value::from(core_dumped)); + if public_event_queued { + payload.insert(String::from("publicEventQueued"), Value::Bool(true)); + } if let Some(signal_name) = signal_name { payload.insert(String::from("signal"), Value::String(signal_name)); } + if let Some(slot) = claimed_bridge_reservation.as_deref_mut() { + *slot = reservation.take(); + } record_execute_phase("child_process_exit_cleanup", cleanup_start.elapsed()); return Ok(Value::Object(payload)); } ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => { - drop(reservation); let mut current_child_path = current_process_path.to_vec(); current_child_path.push(child_process_id); + if let Some(services) = owned_javascript_services.as_deref_mut() { + let Some((connection_id, session_id, vm)) = + self.vms.get(vm_id).and_then(|state| { + self.vms.handle(vm_id).map(|handle| { + ( + state.connection_id.clone(), + state.session_id.clone(), + handle, + ) + }) + }) + else { + return Ok(Value::Null); + }; + services.push(OwnedJavascriptEventService::new( + OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id.to_owned(), + process_id.to_owned(), + current_child_path + .iter() + .map(|segment| (*segment).to_owned()) + .collect(), + vm, + request, + reservation.take(), + )); + return Ok(Value::Null); + } + drop(reservation); let kernel_wait_request = { let Some(vm) = self.vms.get(vm_id) else { return Ok(Value::Null); @@ -6936,21 +9031,21 @@ where &kernel_wait_request, )? { if javascript_sync_rpc_may_make_fd_writable(&kernel_wait_request) { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; - Self::wake_ready_deferred_fd_writes(vm)?; + Self::wake_ready_deferred_fd_writes(&mut vm)?; } - let parked = self - .vms - .get(vm_id) - .and_then(|vm| vm.active_processes.get(process_id)) - .and_then(|root| { - Self::active_process_by_path(root, current_process_path) - }) - .and_then(|parent| parent.child_processes.get(child_process_id)) - .and_then(|child| child.deferred_kernel_wait_rpc.as_ref()) - .is_some_and(|(parked, _)| parked.id == kernel_wait_request.id); + let parked = self.vms.get(vm_id).is_some_and(|vm| { + vm.active_processes + .get(process_id) + .and_then(|root| { + Self::active_process_by_path(root, current_process_path) + }) + .and_then(|parent| parent.child_processes.get(child_process_id)) + .and_then(|child| child.deferred_kernel_wait_rpc.as_ref()) + .is_some_and(|(parked, _)| parked.id == kernel_wait_request.id) + }); if parked { // The execution keeps exposing the unresolved // sync request until it receives a reply. Yield @@ -6965,7 +9060,7 @@ where } if request.method == "__kernel_stdio_write" { let shared_tty = { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let Some(root) = vm.active_processes.get_mut(process_id) else { @@ -6992,7 +9087,7 @@ where owner, &request, ); - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let Some(root) = vm.active_processes.get_mut(process_id) else { @@ -7029,7 +9124,7 @@ where let Some(vm) = self.vms.get(vm_id) else { return Ok(Value::Null); }; - parse_javascript_child_process_spawn_request(vm, &request.args)?.0 + parse_javascript_child_process_spawn_request(&vm, &request.args)?.0 }; self.commit_wasm_fd_process_image( vm_id, @@ -7043,7 +9138,7 @@ where let Some(vm) = self.vms.get(vm_id) else { return Ok(Value::Null); }; - parse_javascript_child_process_spawn_request(vm, &request.args)?.0 + parse_javascript_child_process_spawn_request(&vm, &request.args)?.0 }; let local_replacement = payload.options.local_replacement; match self.exec_javascript_process_image( @@ -7062,7 +9157,7 @@ where let (signal, registration) = parse_process_signal_state_request(&request.args) .map_err(|error| SidecarError::InvalidState(error.to_string()))?; - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let signal_key = @@ -7093,10 +9188,11 @@ where ) .await } else { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; - let socket_paths = build_javascript_socket_path_context(vm)?; + let mut vm = &mut *vm; + let socket_paths = build_javascript_socket_path_context(&mut vm)?; let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); let capabilities = vm.capabilities.clone(); let Some(root) = vm.active_processes.get_mut(process_id) else { @@ -7139,8 +9235,11 @@ where let sender = self.process_event_sender.clone(); let event_notify = Arc::clone(&self.process_event_notify); let envelope_vm_id = vm_id.to_owned(); - let envelope_process_id = - Self::child_process_path_label(process_id, ¤t_child_path); + let envelope_process_id = process_id.to_owned(); + let envelope_child_path = current_child_path + .iter() + .map(|segment| (*segment).to_owned()) + .collect::>(); let request_id = request.id; let method = request.method.clone(); runtime @@ -7179,6 +9278,7 @@ where connection_id, session_id, vm_id: envelope_vm_id, + child_path: envelope_child_path, process_id: envelope_process_id, event: ActiveExecutionEvent::JavascriptSyncRpcCompletion( crate::state::JavascriptSyncRpcCompletion { @@ -7204,24 +9304,26 @@ where }; if response.is_ok() && javascript_sync_rpc_may_make_fd_readable(&request) { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; - Self::wake_ready_deferred_fd_reads(vm)?; + Self::wake_ready_deferred_fd_reads(&mut vm)?; } if response.is_ok() && javascript_sync_rpc_may_make_fd_writable(&request) { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; - Self::wake_ready_deferred_fd_writes(vm)?; + Self::wake_ready_deferred_fd_writes(&mut vm)?; } - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; - let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) - else { + let Some(parent) = Self::descendant_parent_process_mut( + &mut vm, + process_id, + current_process_path, + ) else { return Ok(Value::Null); }; let Some(child) = parent.child_processes.get_mut(child_process_id) else { @@ -7273,95 +9375,130 @@ where } } ActiveExecutionEvent::PythonVfsRpcRequest(request) => { - drop(reservation); - // The kernel-VFS bridge is wired for top-level Python - // executions; a nested Python child (spawned by a JS/Python - // parent) cannot service VFS RPCs through this child-event - // path. Respond with a recoverable error instead of aborting - // the child, so its runner falls back to the in-isolate FS - // for the nested process — top-level Python keeps the full - // VFS root. - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(Value::Null); + let Some(services) = owned_python_services.as_deref_mut() else { + return Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_PYTHON_EVENT_OWNERSHIP: nested Python VFS RPC must be claimed by the owned process-event supervisor", + ))); }; - let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) + let current_child_path = child_path + .iter() + .map(|segment| (*segment).to_owned()) + .collect::>(); + let Some((connection_id, session_id, vm)) = + self.vms.get(vm_id).and_then(|state| { + self.vms.handle(vm_id).map(|handle| { + ( + state.connection_id.clone(), + state.session_id.clone(), + handle, + ) + }) + }) else { return Ok(Value::Null); }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { + let responder = + vm.try_read("claim attached Python process-event service", |state| { + let root = state.active_processes.get(process_id)?; + let path = current_child_path + .iter() + .map(String::as_str) + .collect::>(); + Self::active_process_by_path(root, &path) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { return Ok(Value::Null); }; - // Best-effort: deliver the "unavailable" error so the child's - // pending VFS RPC resolves and its runner falls back to the - // in-isolate FS. A stale child is recoverable, but the failed - // settlement remains host-visible for lifecycle diagnosis. - if let Err(error) = child.execution.respond_python_vfs_rpc_error( - request.id, - "ERR_AGENTOS_PYTHON_VFS_UNAVAILABLE", - "python VFS is not available for nested child processes", - ) { - eprintln!( - "ERR_AGENTOS_PYTHON_VFS_RESPONSE: nested child response {} failed: {error}", - request.id - ); - } - } - ActiveExecutionEvent::PythonSocketConnectCompletion(_) => { - drop(reservation); - eprintln!( - "ERR_AGENTOS_PYTHON_SOCKET_COMPLETION_ROUTE: nested Python TCP completion reached a child execution queue" - ); + services.push(OwnedPythonEventService::new( + OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id.to_owned(), + process_id.to_owned(), + current_child_path, + vm, + responder?, + *request, + reservation.take(), + )); + return Ok(Value::Null); } - ActiveExecutionEvent::JavascriptSyncRpcCompletion(completion) => { - drop(reservation); - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(Value::Null); + ActiveExecutionEvent::PythonSocketConnectCompletion(completion) => { + let Some(services) = owned_python_socket_completions.as_deref_mut() else { + return Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_PYTHON_SOCKET_COMPLETION_OWNERSHIP: nested completion must be claimed by the owned process-event supervisor", + ))); }; - let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); - let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) + let current_child_path = child_path + .iter() + .map(|segment| (*segment).to_owned()) + .collect::>(); + let Some((connection_id, session_id, vm)) = + self.vms.get(vm_id).and_then(|state| { + self.vms.handle(vm_id).map(|handle| { + ( + state.connection_id.clone(), + state.session_id.clone(), + handle, + ) + }) + }) else { return Ok(Value::Null); }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { + let responder = + vm.try_read("claim attached Python socket completion", |state| { + let root = state.active_processes.get(process_id)?; + let path = current_child_path + .iter() + .map(String::as_str) + .collect::>(); + Self::active_process_by_path(root, &path) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { return Ok(Value::Null); }; - let connected = child - .pending_javascript_net_connects - .remove(&completion.request_id); - let completion_result = match (completion.result, connected) { - (Ok(_), Some(connected)) => { - finalize_javascript_net_connect(child, &kernel_readiness, connected) - .map_err(|error| crate::state::DeferredRpcError { - code: javascript_sync_rpc_error_code(&error), - message: javascript_sync_rpc_error_message(&error), - }) - } - (result @ Err(_), Some(connected)) => { - restore_pending_bound_unix_connect(child, &connected)?; - result - } - (result, None) => result, + services.push(OwnedPythonSocketCompletionService::new( + OwnershipScope::vm(connection_id, session_id, vm_id), + vm_id.to_owned(), + process_id.to_owned(), + current_child_path, + vm, + responder?, + *completion, + reservation.take(), + )); + return Ok(Value::Null); + } + ActiveExecutionEvent::JavascriptSyncRpcCompletion(completion) => { + drop(reservation); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(Value::Null); + }; + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let Some(parent) = Self::descendant_parent_process_mut( + &mut vm, + process_id, + current_process_path, + ) else { + return Ok(Value::Null); }; - let result = match completion_result { - Ok(value) => child - .execution - .respond_javascript_sync_rpc_success(completion.request_id, value), - Err(error) => child.execution.respond_javascript_sync_rpc_error( - completion.request_id, - error.code, - error.message, - ), + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Ok(Value::Null); }; - result.or_else(ignore_stale_javascript_sync_rpc_response)?; + settle_javascript_sync_rpc_completion( + child, + &kernel_readiness, + completion.request_id, + completion.result, + )?; } ActiveExecutionEvent::SignalState { signal, registration, } => { drop(reservation); - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(Value::Null); }; let signal_key = @@ -7398,103 +9535,469 @@ where ) = { let mut child_path = current_process_path.to_vec(); child_path.push(child_process_id); - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(None); }; let Some(parent) = - Self::descendant_parent_process_mut(vm, process_id, current_process_path) + Self::descendant_parent_process_mut(&mut vm, process_id, current_process_path) else { return Err(javascript_child_process_gone_error(process_id, &child_path)); }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + ( + parent.kernel_pid, + child.kernel_pid, + child.execution.child_pid(), + child.runtime.clone(), + child.execution.uses_shared_v8_runtime(), + ) + }; + if child_runtime != GuestRuntimeKind::JavaScript + && child_runtime != GuestRuntimeKind::Python + && child_runtime != GuestRuntimeKind::WebAssembly + { + return Ok(None); + } + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(None); + }; + if let Some(process_info) = vm.kernel.list_processes().get(&child_kernel_pid) { + if process_info.status == ProcessStatus::Exited { + return Ok(Some(ActiveExecutionEvent::Exited( + process_info.exit_code.unwrap_or(0), + ))); + } + } + if let Some(wait_result) = vm + .kernel + .waitpid_with_options( + EXECUTION_DRIVER_NAME, + parent_kernel_pid, + child_kernel_pid as i32, + WaitPidFlags::WNOHANG, + ) + .map_err(kernel_error)? + { + return Ok(Some(ActiveExecutionEvent::Exited(wait_result.status))); + } + + if !child_shared_runtime && child_runtime_pid != 0 { + match runtime_child_exit_status(child_runtime_pid)? { + RuntimeChildStatusObservation::Exited(status) => { + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(None); + }; + let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) + else { + return Ok(None); + }; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Ok(None); + }; + child.exit_signal = status.signal; + child.exit_core_dumped = status.core_dumped; + return Ok(Some(ActiveExecutionEvent::Exited(status.status))); + } + RuntimeChildStatusObservation::Running => {} + RuntimeChildStatusObservation::NotWaitable => { + return Err(SidecarError::Execution(format!( + "ECHILD: guest runtime process {child_runtime_pid} exited without an observable wait status" + ))); + } + } + } + Ok(None) + } + + fn write_descendant_javascript_child_process_stdin( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + chunk: &[u8], + ) -> Result<(), SidecarError> { + let mut child_path = current_process_path.to_vec(); + child_path.push(child_process_id); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + let vm = &mut *vm; + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + if let Err(error) = child.execution.write_stdin(chunk) { + if is_broken_pipe_error(&error) { + return Ok(()); + } + return Err(error); + } + write_kernel_process_stdin(&mut vm.kernel, child, chunk) + } + + fn close_descendant_javascript_child_process_stdin( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + ) -> Result<(), SidecarError> { + let mut child_path = current_process_path.to_vec(); + child_path.push(child_process_id); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + let vm = &mut *vm; + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { + return Err(javascript_child_process_gone_error(process_id, &child_path)); + }; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return missing_javascript_child_cleanup_result( + parent.next_child_process_id, + child_process_id, + "stdin close", + ); + }; + child.execution.close_stdin()?; + close_kernel_process_stdin(&mut vm.kernel, child) + } + + fn kill_descendant_javascript_child_process( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + signal: &str, + ) -> Result<(), SidecarError> { + let signal_name = signal.to_owned(); + let signal = parse_signal(signal)?; + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(()); + }; + let vm = &mut *vm; + let registration = vm + .signal_states + .get(child_process_id) + .and_then(|handlers| handlers.get(&(signal as u32))) + .cloned(); + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(()); + }; + let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { + return Ok(()); + }; + let source_pid = parent.kernel_pid; + let Some(child) = parent.child_processes.get_mut(child_process_id) else { + return Ok(()); + }; + terminate_tracked_child_process_for_signal( + &mut vm.kernel, + child, + signal, + registration.as_ref(), + )?; + let child_process_label = if current_process_path.is_empty() { + child_process_id.to_owned() + } else { + format!("{}/{}", current_process_path.join("/"), child_process_id) + }; + emit_security_audit_event( + &self.bridge, + vm_id, + "security.process.kill", + audit_fields([ + (String::from("source"), String::from("guest_child_process")), + (String::from("source_pid"), source_pid.to_string()), + (String::from("target_pid"), child.kernel_pid.to_string()), + (String::from("process_id"), process_id.to_owned()), + (String::from("child_process_id"), child_process_label), + (String::from("signal"), signal_name), + ]), + ); + self.process_event_notify.notify_one(); + Ok(()) + } + + fn handle_descendant_process_kill_rpc( + &mut self, + vm_id: &str, + process_id: &str, + current_process_path: &[&str], + child_process_id: &str, + request: &JavascriptSyncRpcRequest, + ) -> Result { + let target_pid = javascript_sync_rpc_arg_i32(&request.args, 0, "process.kill target pid")?; + let signal_name = javascript_sync_rpc_arg_str(&request.args, 1, "process.kill signal")?; + let signal = parse_signal(signal_name)?; + + let mut source_path = current_process_path.to_vec(); + source_path.push(child_process_id); + + if signal != 0 && target_pid < 0 { + let pgid = target_pid.unsigned_abs(); + let caller_kernel_pid = { + let Some(vm) = self.vms.get(vm_id) else { + return Err(SidecarError::InvalidState(String::from( + "ESRCH: unknown VM during process.kill", + ))); + }; + let Some(root) = vm.active_processes.get(process_id) else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: unknown process {process_id} during process.kill", + ))); + }; + let Some(source) = Self::active_process_by_path(root, &source_path) else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: unknown child process {child_process_id} during process.kill", + ))); + }; + source.kernel_pid + }; + let caller_is_member = + self.signal_vm_process_group(vm_id, caller_kernel_pid, pgid, signal_name)?; + if !caller_is_member { + return Ok(Value::Null); + } + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(Value::Null); + }; + let vm = &mut *vm; + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(Value::Null); + }; + let Some(source) = Self::active_process_by_path_mut(root, &source_path) else { + return Ok(Value::Null); + }; + if !matches!( + canonical_signal_name(signal), + Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG") + ) { + apply_active_process_default_signal(&mut vm.kernel, source, signal)?; + } + return Ok(json!({ + "self": true, + "action": "default", + })); + } + + let Some(vm) = self.vms.get_mut(vm_id) else { + return Err(SidecarError::InvalidState(String::from( + "ESRCH: unknown VM during process.kill", + ))); + }; + + if signal == 0 { + vm.kernel + .signal_process(EXECUTION_DRIVER_NAME, target_pid, signal) + .map_err(kernel_error)?; + return Ok(Value::Null); + } + + let target_kernel_pid = u32::try_from(target_pid).map_err(|_| { + SidecarError::InvalidState(format!("EINVAL: invalid process pid {target_pid}")) + })?; + let (source_pid, located_target_path) = { + let Some(root) = vm.active_processes.get(process_id) else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: unknown process {process_id} during process.kill", + ))); + }; + let Some(source) = Self::active_process_by_path(root, &source_path) else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: unknown child process {child_process_id} during process.kill", + ))); }; + vm.kernel + .signal_process(EXECUTION_DRIVER_NAME, target_pid, 0) + .map_err(kernel_error)?; ( - parent.kernel_pid, - child.kernel_pid, - child.execution.child_pid(), - child.runtime.clone(), - child.execution.uses_shared_v8_runtime(), + source.kernel_pid, + Self::active_process_path_by_kernel_pid(root, target_kernel_pid), ) }; - if child_runtime != GuestRuntimeKind::JavaScript - && child_runtime != GuestRuntimeKind::Python - && child_runtime != GuestRuntimeKind::WebAssembly - { - return Ok(None); - } - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(None); + drop(vm); + let Some(target_path) = located_target_path else { + // The target is alive but not part of this root's process tree. + // Resolve it VM-wide so cross-tree pids and untracked kernel + // processes still receive the signal. + self.signal_vm_kernel_pid(vm_id, target_kernel_pid, signal_name)?; + return Ok(Value::Null); }; - if let Some(process_info) = vm.kernel.list_processes().get(&child_kernel_pid) { - if process_info.status == ProcessStatus::Exited { - return Ok(Some(ActiveExecutionEvent::Exited( - process_info.exit_code.unwrap_or(0), - ))); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Err(SidecarError::InvalidState(String::from( + "ESRCH: unknown VM during process.kill", + ))); + }; + let vm = &mut *vm; + + if source_pid == target_kernel_pid { + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(Value::Null); + }; + let Some(source) = Self::active_process_by_path_mut(root, &source_path) else { + return Ok(Value::Null); + }; + if !matches!( + canonical_signal_name(signal), + Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG") + ) { + apply_active_process_default_signal(&mut vm.kernel, source, signal)?; } - } - if let Some(wait_result) = vm - .kernel - .waitpid_with_options( - EXECUTION_DRIVER_NAME, - parent_kernel_pid, - child_kernel_pid as i32, - WaitPidFlags::WNOHANG, - ) - .map_err(kernel_error)? - { - return Ok(Some(ActiveExecutionEvent::Exited(wait_result.status))); + return Ok(json!({ + "self": true, + "action": "default", + })); } - if !child_shared_runtime && child_runtime_pid != 0 { - match runtime_child_exit_status(child_runtime_pid)? { - RuntimeChildStatusObservation::Exited(status) => { - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(None); - }; - let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) - else { - return Ok(None); - }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Ok(None); - }; - child.exit_signal = status.signal; - child.exit_core_dumped = status.core_dumped; - return Ok(Some(ActiveExecutionEvent::Exited(status.status))); - } - RuntimeChildStatusObservation::Running => {} - RuntimeChildStatusObservation::NotWaitable => { - return Err(SidecarError::Execution(format!( - "ECHILD: guest runtime process {child_runtime_pid} exited without an observable wait status" + let signal_key = target_path.last().map(String::as_str).unwrap_or(process_id); + let registration = vm + .signal_states + .get(signal_key) + .and_then(|handlers| handlers.get(&(signal as u32))) + .cloned(); + + let action = match registration + .as_ref() + .map(|registration| ®istration.action) + { + Some(SignalDispositionAction::Ignore) => "ignore", + Some(SignalDispositionAction::User) => { + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(Value::Null); + }; + let Some(target) = Self::active_process_by_owned_path_mut(root, &target_path) + else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: unknown process pid {target_pid}" + ))); + }; + if matches!(&target.execution, ActiveExecution::Wasm(execution) if execution.uses_shared_v8_runtime()) + { + target.queue_pending_wasm_signal(signal)?; + } else if let Some(session) = target.execution.javascript_v8_session_handle().filter( + |_| matches!(&target.execution, ActiveExecution::Javascript(execution) if execution.uses_shared_v8_runtime()), + ) { + dispatch_v8_session_signal(session, signal); + } else if !dispatch_v8_process_signal(target, signal)? { + return Err(SidecarError::InvalidState(format!( + "unsupported guest signal delivery for pid {target_pid}" ))); } + "user" } - } - Ok(None) + Some(SignalDispositionAction::Default) | None + if matches!( + canonical_signal_name(signal), + Some("SIGWINCH" | "SIGCHLD" | "SIGURG") + ) => + { + "ignore" + } + Some(SignalDispositionAction::Default) | None => { + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(Value::Null); + }; + let Some(target) = Self::active_process_by_owned_path_mut(root, &target_path) + else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: unknown process pid {target_pid}" + ))); + }; + apply_active_process_default_signal(&mut vm.kernel, target, signal)?; + "default" + } + }; + + let target_path_label = Self::child_process_path_label( + process_id, + &target_path.iter().map(String::as_str).collect::>(), + ); + emit_security_audit_event( + &self.bridge, + vm_id, + "security.process.kill", + audit_fields([ + (String::from("source"), String::from("guest_process")), + (String::from("source_pid"), source_pid.to_string()), + (String::from("target_pid"), target_pid.to_string()), + (String::from("process_id"), process_id.to_owned()), + ( + String::from("target_process_path"), + target_path_label.clone(), + ), + (String::from("signal"), signal_name.to_owned()), + ]), + ); + + Ok(json!({ + "self": false, + "action": action, + "signal": signal_name, + "number": signal, + "targetProcessPath": target_path_label, + })) } - fn write_descendant_javascript_child_process_stdin( + pub(crate) async fn poll_javascript_child_process( + &mut self, + vm_id: &str, + process_id: &str, + child_process_id: &str, + wait_ms: u64, + ) -> Result { + self.poll_descendant_javascript_child_process( + vm_id, + process_id, + &[], + child_process_id, + wait_ms, + false, + None, + None, + None, + None, + None, + ) + .await + } + + pub(crate) fn write_javascript_child_process_stdin( &mut self, vm_id: &str, process_id: &str, - current_process_path: &[&str], child_process_id: &str, chunk: &[u8], ) -> Result<(), SidecarError> { - let mut child_path = current_process_path.to_vec(); - child_path.push(child_process_id); - let Some(vm) = self.vms.get_mut(vm_id) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); - }; - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); - }; - let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Err(javascript_child_process_gone_error( + process_id, + &[child_process_id], + )); }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); + let vm = &mut *vm; + let Some(child) = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))? + .child_processes + .get_mut(child_process_id) + else { + return Err(javascript_child_process_gone_error( + process_id, + &[child_process_id], + )); }; if let Err(error) = child.execution.write_stdin(chunk) { if is_broken_pipe_error(&error) { @@ -7505,27 +10008,26 @@ where write_kernel_process_stdin(&mut vm.kernel, child, chunk) } - fn close_descendant_javascript_child_process_stdin( + pub(crate) fn close_javascript_child_process_stdin( &mut self, vm_id: &str, process_id: &str, - current_process_path: &[&str], child_process_id: &str, - ) -> Result<(), SidecarError> { - let mut child_path = current_process_path.to_vec(); - child_path.push(child_process_id); - let Some(vm) = self.vms.get_mut(vm_id) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); - }; - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); - }; - let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { - return Err(javascript_child_process_gone_error(process_id, &child_path)); + ) -> Result<(), SidecarError> { + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Err(javascript_child_process_gone_error( + process_id, + &[child_process_id], + )); }; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { + let vm = &mut *vm; + let process = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let Some(child) = process.child_processes.get_mut(child_process_id) else { return missing_javascript_child_cleanup_result( - parent.next_child_process_id, + process.next_child_process_id, child_process_id, "stdin close", ); @@ -7534,33 +10036,38 @@ where close_kernel_process_stdin(&mut vm.kernel, child) } - fn kill_descendant_javascript_child_process( + pub(crate) fn kill_javascript_child_process( &mut self, vm_id: &str, process_id: &str, - current_process_path: &[&str], child_process_id: &str, signal: &str, ) -> Result<(), SidecarError> { let signal_name = signal.to_owned(); let signal = parse_signal(signal)?; - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; + let vm = &mut *vm; let registration = vm .signal_states .get(child_process_id) .and_then(|handlers| handlers.get(&(signal as u32))) .cloned(); - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(()); - }; - let Some(parent) = Self::active_process_by_path_mut(root, current_process_path) else { - return Ok(()); - }; - let source_pid = parent.kernel_pid; - let Some(child) = parent.child_processes.get_mut(child_process_id) else { - return Ok(()); + let process = vm + .active_processes + .get_mut(process_id) + .ok_or_else(|| missing_process_error(vm_id, process_id))?; + let source_pid = process.kernel_pid; + let Some(child) = process.child_processes.get_mut(child_process_id) else { + // Child IDs are monotonically allocated per parent. An allocated + // ID that is no longer present was already reaped (or rolled back), + // so cleanup kills are idempotent without hiding never-issued IDs. + return missing_javascript_child_cleanup_result( + process.next_child_process_id, + child_process_id, + "kill", + ); }; terminate_tracked_child_process_for_signal( &mut vm.kernel, @@ -7568,11 +10075,6 @@ where signal, registration.as_ref(), )?; - let child_process_label = if current_process_path.is_empty() { - child_process_id.to_owned() - } else { - format!("{}/{}", current_process_path.join("/"), child_process_id) - }; emit_security_audit_event( &self.bridge, vm_id, @@ -7582,365 +10084,1924 @@ where (String::from("source_pid"), source_pid.to_string()), (String::from("target_pid"), child.kernel_pid.to_string()), (String::from("process_id"), process_id.to_owned()), - (String::from("child_process_id"), child_process_label), + ( + String::from("child_process_id"), + child_process_id.to_owned(), + ), (String::from("signal"), signal_name), ]), ); self.process_event_notify.notify_one(); Ok(()) } +} + +#[cfg(test)] +mod child_event_claim_tests { + use super::*; + use crate::protocol::{RequestFrame, RequestPayload, ResponsePayload}; + use crate::request_operations::OperationCancellation; + use crate::state::{ConnectionState, SessionState}; + use crate::stdio::LocalBridge; + use crate::NativeSidecarConfig; + + async fn sidecar_with_test_vm( + max_process_events: usize, + ) -> (NativeSidecar, String) { + sidecar_with_test_vm_and_handle_command_limit(max_process_events, None).await + } + + async fn sidecar_with_test_vm_and_handle_command_limit( + max_process_events: usize, + max_handle_commands: Option, + ) -> (NativeSidecar, String) { + let config = NativeSidecarConfig::default(); + let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime) + .expect("child claim test runtime"); + let mut sidecar = NativeSidecar::with_config_extensions_and_runtime( + LocalBridge::default(), + config, + Vec::new(), + runtime.context(), + ) + .expect("child claim test sidecar"); + // The process runtime is a singleton and every test must construct it + // from one identical topology. This limit belongs to the sidecar-owned + // public event queue, so tighten the fixture after runtime acquisition. + sidecar.config.runtime.protocol.max_process_events = max_process_events; + let connection_id = "child-claim-connection"; + let session_id = "child-claim-session"; + sidecar.connections.insert( + connection_id.to_owned(), + ConnectionState { + auth_token: String::new(), + sessions: BTreeSet::from([session_id.to_owned()]), + }, + ); + sidecar.sessions.insert( + session_id.to_owned(), + SessionState { + connection_id: connection_id.to_owned(), + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: BTreeMap::new(), + vm_ids: BTreeSet::new(), + }, + ); + let mut create = crate::protocol::CreateVmRequest::legacy_test_config( + crate::protocol::GuestRuntimeKind::JavaScript, + std::collections::HashMap::new(), + Default::default(), + Some(crate::wire::PermissionsPolicy::allow_all()), + ); + if let Some(max_handle_commands) = max_handle_commands { + let mut vm_config: agentos_vm_config::CreateVmConfig = + serde_json::from_str(&create.config).expect("decode child claim VM config"); + let reactor = vm_config + .limits + .get_or_insert_default() + .reactor + .get_or_insert_default(); + reactor.max_handle_commands = Some(max_handle_commands); + reactor.per_handle_operation_quantum = Some(max_handle_commands); + create.config = + serde_json::to_string(&vm_config).expect("encode child claim VM config"); + } + let request = RequestFrame::new( + 1, + OwnershipScope::session(connection_id, session_id), + RequestPayload::CreateVm(create), + ); + let RequestPayload::CreateVm(payload) = request.payload.clone() else { + unreachable!("child claim fixture request is create VM"); + }; + let dispatch = sidecar + .create_vm(&request, payload) + .await + .expect("create child claim VM"); + let ResponsePayload::VmCreated(created) = dispatch.response.payload else { + panic!("child claim VM creation returned another response"); + }; + (sidecar, created.vm_id) + } + + fn javascript_process( + vm_id: &str, + vm: &mut VmState, + label: &str, + source: &str, + event_notify: Arc, + ) -> ActiveProcess { + let kernel_handle = vm + .kernel + .create_virtual_process( + EXECUTION_DRIVER_NAME, + EXECUTION_DRIVER_NAME, + JAVASCRIPT_COMMAND, + vec![String::from(JAVASCRIPT_COMMAND)], + VirtualProcessOptions { + env: vm.guest_env.clone(), + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("create {label} kernel process: {error}")); + let kernel_pid = kernel_handle.pid(); + let execution_engines = vm.execution_engines.clone(); + let runtime_context = vm.runtime_context.clone(); + let limits = javascript_execution_limits(vm); + let guest_runtime = guest_runtime_identity(vm, Some(u64::from(kernel_pid)), None); + let cwd = vm.host_cwd.clone(); + let mut javascript_engine = execution_engines + .javascript("start child event claim test JavaScript") + .expect("borrow child event claim JavaScript engine"); + let context = javascript_engine.create_context(CreateJavascriptContextRequest { + vm_id: vm_id.to_owned(), + bootstrap_module: None, + compile_cache_root: None, + }); + let context_id = context.context_id.clone(); + let execution = javascript_engine + .start_execution_with_module_reader_and_runtime( + StartJavascriptExecutionRequest { + guest_runtime, + vm_id: vm_id.to_owned(), + context_id, + argv: vec![String::from("-e")], + argv0: None, + env: vm.guest_env.clone(), + cwd, + limits, + inline_code: Some(source.to_owned()), + wasm_module_bytes: None, + }, + None, + None, + runtime_context.clone(), + ) + .unwrap_or_else(|error| panic!("start {label} JavaScript process: {error}")); + javascript_engine.dispose_context(&context.context_id); + ActiveProcess::new( + kernel_pid, + kernel_handle, + runtime_context, + vm.limits.clone(), + agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS, + GuestRuntimeKind::JavaScript, + ActiveExecution::Javascript(execution), + ) + .with_event_notify(event_notify) + } + + fn binding_process(vm: &mut VmState, label: &str, parent_pid: Option) -> ActiveProcess { + let kernel_handle = vm + .kernel + .create_virtual_process( + EXECUTION_DRIVER_NAME, + EXECUTION_DRIVER_NAME, + JAVASCRIPT_COMMAND, + vec![String::from(JAVASCRIPT_COMMAND)], + VirtualProcessOptions { + parent_pid, + env: vm.guest_env.clone(), + ..Default::default() + }, + ) + .unwrap_or_else(|error| panic!("create {label} kernel process: {error}")); + ActiveProcess::new( + kernel_handle.pid(), + kernel_handle, + vm.runtime_context.clone(), + vm.limits.clone(), + agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS, + GuestRuntimeKind::JavaScript, + ActiveExecution::Binding(BindingExecution::default()), + ) + } + + fn rpc(id: u64) -> ActiveExecutionEvent { + ActiveExecutionEvent::JavascriptSyncRpcRequest(JavascriptSyncRpcRequest { + id, + method: String::from("fs.stat"), + args: Vec::new(), + raw_bytes_args: Default::default(), + }) + } + + fn owned_rpc_target( + sidecar: &NativeSidecar, + vm_id: &str, + process_id: &str, + child_path: Vec, + request: JavascriptSyncRpcRequest, + ) -> OwnedJavascriptEventService { + OwnedJavascriptEventService::new( + OwnershipScope::vm("child-claim-connection", "child-claim-session", vm_id), + vm_id.to_owned(), + process_id.to_owned(), + child_path, + sidecar.vms.handle(vm_id).expect("owned RPC VM handle"), + request, + None, + ) + } + + fn signal_state_rpc(id: u64, signal: u32, action: &str) -> JavascriptSyncRpcRequest { + JavascriptSyncRpcRequest { + id, + method: String::from("process.signal_state"), + args: vec![ + Value::from(signal), + Value::from(action), + Value::from("[]"), + Value::from(0), + ], + raw_bytes_args: Default::default(), + } + } + + fn bound_kernel_udp_pair( + sidecar: &NativeSidecar, + vm_id: &str, + process_id: &str, + ) -> (String, String, SocketAddr, Arc) { + let bridge = sidecar.bridge.clone(); + let mut vm = sidecar.vms.get_mut(vm_id).expect("kernel UDP test VM"); + let socket_paths = + build_javascript_socket_path_context(&vm).expect("kernel UDP socket path context"); + let dns = vm.dns.clone(); + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let capabilities = vm.capabilities.clone(); + let VmState { + kernel, + active_processes, + .. + } = &mut *vm; + let process = active_processes + .get_mut(process_id) + .expect("kernel UDP test process"); + + let mut create = |id| { + let response = + service_javascript_dgram_sync_rpc(JavascriptDgramSyncRpcServiceRequest { + bridge: &bridge, + kernel, + vm_id, + dns: &dns, + socket_paths: &socket_paths, + process, + kernel_readiness: Arc::clone(&kernel_readiness), + sync_request: &JavascriptSyncRpcRequest { + id, + method: String::from("dgram.createSocket"), + args: vec![json!({ "type": "udp4" })], + raw_bytes_args: Default::default(), + }, + capabilities: capabilities.clone(), + }) + .expect("create kernel UDP socket"); + let JavascriptSyncRpcServiceResponse::Json(value) = response else { + panic!("kernel UDP create returned a deferred response"); + }; + value + .get("socketId") + .and_then(Value::as_str) + .expect("kernel UDP socket id") + .to_owned() + }; + let receiver_id = create(100); + let sender_id = create(101); + let pid = process.kernel_pid; + let receiver_addr = process + .udp_sockets + .get_mut(&receiver_id) + .expect("kernel UDP receiver") + .bind(kernel, pid, Some("127.0.0.1"), 43_120, &socket_paths) + .expect("bind kernel UDP receiver"); + process + .udp_sockets + .get_mut(&sender_id) + .expect("kernel UDP sender") + .bind(kernel, pid, Some("127.0.0.1"), 43_121, &socket_paths) + .expect("bind kernel UDP sender"); + let notify = Arc::clone( + &process + .udp_sockets + .get(&receiver_id) + .expect("kernel UDP receiver") + .read_event_notify, + ); + (receiver_id, sender_id, receiver_addr, notify) + } + + async fn consume_notify_permit(notify: &Arc) { + let _ = tokio::time::timeout(Duration::from_millis(5), notify.notified()).await; + } + + async fn assert_owned_deferred_tcp_connect_settles( + child_path: Vec, + listener: Option, + port: u16, + expect_socket: bool, + ) { + let (sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS).await; + let process_id = String::from("owned-connect-root"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("owned connect VM"); + vm.create_loopback_exempt_ports.insert(port); + let mut root = binding_process(&mut vm, "owned connect root", None); + if let Some(child_id) = child_path.first() { + let child = + binding_process(&mut vm, "owned connect descendant", Some(root.kernel_pid)); + root.child_processes.insert(child_id.clone(), child); + } + vm.active_processes.insert(process_id.clone(), root); + } + + let request_id = 104; + let vm = sidecar.vms.handle(&vm_id).expect("owned connect VM handle"); + let request = JavascriptSyncRpcRequest { + id: request_id, + method: String::from("net.connect"), + args: vec![json!({ "host": "127.0.0.1", "port": port })], + raw_bytes_args: Default::default(), + }; + let error = service_owned_javascript_sync_rpc_request::( + &sidecar.bridge, + &vm_id, + &vm, + &process_id, + &child_path, + request, + ) + .await + .expect_err("binding fixture cannot accept a JavaScript RPC response"); + assert!(error.to_string().contains("JavaScript sync RPC")); + + vm.try_read("verify owned deferred connect settlement", |state| { + let root = state + .active_processes + .get(&process_id) + .expect("owned connect root remains active"); + let path = child_path.iter().map(String::as_str).collect::>(); + let process = NativeSidecar::::active_process_by_path(root, &path) + .expect("owned connect target remains active"); + assert!(process.pending_javascript_net_connects.is_empty()); + assert_eq!(process.tcp_sockets.len(), usize::from(expect_socket)); + }) + .expect("inspect owned deferred connect settlement"); + drop(listener); + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_root_deferred_tcp_connect_commits_before_reply() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind owned root connect listener"); + let port = listener + .local_addr() + .expect("owned root listener address") + .port(); + assert_owned_deferred_tcp_connect_settles(Vec::new(), Some(listener), port, true).await; + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_descendant_deferred_tcp_connect_commits_before_reply() { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind owned descendant connect listener"); + let port = listener + .local_addr() + .expect("owned descendant listener address") + .port(); + assert_owned_deferred_tcp_connect_settles( + vec![String::from("child")], + Some(listener), + port, + true, + ) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_deferred_tcp_connect_error_clears_pending_state() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("reserve refused connect port"); + let port = listener + .local_addr() + .expect("reserved connect address") + .port(); + drop(listener); + assert_owned_deferred_tcp_connect_settles(Vec::new(), None, port, false).await; + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_kernel_udp_poll_wakes_for_arrival_and_restores_socket() { + tokio::task::LocalSet::new() + .run_until(async { + let (sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let process_id = String::from("owned-udp-root"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("owned UDP VM"); + let root = binding_process(&mut vm, "owned UDP root", None); + vm.active_processes.insert(process_id.clone(), root); + } + let (receiver_id, sender_id, receiver_addr, notify) = + bound_kernel_udp_pair(&sidecar, &vm_id, &process_id); + consume_notify_permit(¬ify).await; + + let vm = sidecar.vms.handle(&vm_id).expect("owned UDP VM handle"); + let request = JavascriptSyncRpcRequest { + id: 102, + method: String::from("dgram.poll"), + args: vec![Value::from(receiver_id.clone()), Value::from(500)], + raw_bytes_args: Default::default(), + }; + let mut poll = Box::pin(service_owned_javascript_sync_rpc_request::( + &sidecar.bridge, + &vm_id, + &vm, + &process_id, + &[], + request, + )); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!(poll.as_mut().poll(&mut context), Poll::Pending)); + assert!(!vm + .try_read("verify UDP socket detached while waiting", |vm| vm + .active_processes + .get(&process_id) + .is_some_and(|process| process + .udp_sockets + .contains_key(&receiver_id))) + .expect("verify UDP socket detached while waiting")); + vm.try_command("send kernel UDP test datagram", |vm| { + let VmState { + kernel, + active_processes, + .. + } = vm; + let process = active_processes + .get_mut(&process_id) + .expect("kernel UDP sender process"); + let sender = process + .udp_sockets + .get(&sender_id) + .expect("kernel UDP sender"); + kernel + .socket_send_to_inet_loopback( + EXECUTION_DRIVER_NAME, + process.kernel_pid, + sender.kernel_socket_id.expect("sender kernel socket"), + agentos_kernel::socket_table::InetSocketAddress::new( + receiver_addr.ip().to_string(), + receiver_addr.port(), + ), + b"owned-wake", + ) + .map(|_| ()) + .map_err(kernel_error) + }) + .expect("send kernel UDP test datagram"); + let error = poll + .await + .expect_err("binding test execution cannot accept JavaScript RPC replies"); + assert!(error.to_string().contains("JavaScript sync RPC")); + assert!(vm + .try_read("verify restored UDP socket", |vm| vm + .active_processes + .get(&process_id) + .is_some_and(|process| process + .udp_sockets + .contains_key(&receiver_id))) + .expect("verify restored UDP socket")); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn cancelling_owned_udp_poll_restores_detached_socket() { + tokio::task::LocalSet::new() + .run_until(async { + let (sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let process_id = String::from("owned-udp-cancel-root"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("owned UDP cancel VM"); + let root = binding_process(&mut vm, "owned UDP cancel root", None); + vm.active_processes.insert(process_id.clone(), root); + } + let (receiver_id, _sender_id, _receiver_addr, notify) = + bound_kernel_udp_pair(&sidecar, &vm_id, &process_id); + consume_notify_permit(¬ify).await; + let vm = sidecar + .vms + .handle(&vm_id) + .expect("owned UDP cancel VM handle"); + let request = JavascriptSyncRpcRequest { + id: 103, + method: String::from("dgram.poll"), + args: vec![Value::from(receiver_id.clone()), Value::from(500)], + raw_bytes_args: Default::default(), + }; + let mut poll = Box::pin(service_owned_javascript_dgram_poll::( + &vm, + &process_id, + &[], + &request, + )); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!(poll.as_mut().poll(&mut context), Poll::Pending)); + assert!(!vm + .try_read("verify detached UDP socket", |vm| vm + .active_processes + .get(&process_id) + .is_some_and(|process| process + .udp_sockets + .contains_key(&receiver_id))) + .expect("verify detached UDP socket")); + drop(poll); + assert!(vm + .try_read("verify cancelled UDP socket restore", |vm| vm + .active_processes + .get(&process_id) + .is_some_and(|process| process + .udp_sockets + .contains_key(&receiver_id))) + .expect("verify cancelled UDP socket restore")); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_special_dispatch_is_path_aware_for_root_and_descendant() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("owned-special-root"); + let child_id = String::from("owned-special-child"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("owned special VM"); + let mut root = binding_process(&mut vm, "owned special root", None); + let child = + binding_process(&mut vm, "owned special child", Some(root.kernel_pid)); + root.child_processes.insert(child_id.clone(), child); + vm.active_processes.insert(root_id.clone(), root); + } + + let root_target = owned_rpc_target( + &sidecar, + &vm_id, + &root_id, + Vec::new(), + signal_state_rpc(1, libc::SIGUSR1 as u32, "ignore"), + ); + let root_service = + sidecar.prepare_owned_javascript_process_event_service(root_target); + let _ = root_service.await; + + let child_target = owned_rpc_target( + &sidecar, + &vm_id, + &root_id, + vec![child_id.clone()], + signal_state_rpc(2, libc::SIGUSR2 as u32, "user"), + ); + let child_service = + sidecar.prepare_owned_javascript_process_event_service(child_target); + let _ = child_service.await; + + let vm = sidecar.vms.get(&vm_id).expect("owned special VM"); + assert!(vm + .signal_states + .get(&root_id) + .is_some_and(|handlers| handlers.contains_key(&(libc::SIGUSR1 as u32)))); + assert!(vm + .signal_states + .get(&child_id) + .is_some_and(|handlers| handlers.contains_key(&(libc::SIGUSR2 as u32)))); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn all_root_and_descendant_special_methods_are_side_effect_free_until_polled() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("owned-special-cancel-root"); + let child_id = String::from("owned-special-cancel-child"); + let root_target_child = String::from("root-target-child"); + let descendant_target_child = String::from("descendant-target-child"); + let (root_target_pid, descendant_target_pid) = { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("special cancel VM"); + let mut root = binding_process(&mut vm, "special cancel root", None); + let root_pid = root.kernel_pid; + let root_child = + binding_process(&mut vm, "root target child", Some(root.kernel_pid)); + let mut descendant = binding_process( + &mut vm, + "special cancel descendant", + Some(root.kernel_pid), + ); + let descendant_pid = descendant.kernel_pid; + let descendant_child = binding_process( + &mut vm, + "descendant target child", + Some(descendant.kernel_pid), + ); + root.child_processes + .insert(root_target_child.clone(), root_child); + descendant + .child_processes + .insert(descendant_target_child.clone(), descendant_child); + root.child_processes.insert(child_id.clone(), descendant); + vm.active_processes.insert(root_id.clone(), root); + (root_pid, descendant_pid) + }; + + for (path, target_child, target_pid) in [ + (Vec::new(), root_target_child.clone(), root_target_pid), + ( + vec![child_id.clone()], + descendant_target_child.clone(), + descendant_target_pid, + ), + ] { + let requests = vec![ + JavascriptSyncRpcRequest { + id: 100, + method: String::from("child_process.spawn"), + args: vec![ + Value::from("/bin/echo"), + Value::from("[]"), + Value::from("{}"), + ], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 101, + method: String::from("child_process.spawn_sync"), + args: vec![ + Value::from("/bin/echo"), + Value::from("[]"), + Value::from("{}"), + ], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 102, + method: String::from("child_process.poll"), + args: vec![Value::from(target_child.clone()), Value::from(0)], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 103, + method: String::from("child_process.write_stdin"), + args: vec![ + Value::from(target_child.clone()), + javascript_sync_rpc_bytes_value(b"must-not-write"), + ], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 104, + method: String::from("child_process.close_stdin"), + args: vec![Value::from(target_child.clone())], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 105, + method: String::from("child_process.kill"), + args: vec![Value::from(target_child.clone()), Value::from("SIGTERM")], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 106, + method: String::from("process.exec_fd_image_commit"), + args: vec![ + Value::from("/bin/echo"), + Value::from("[]"), + Value::from("{}"), + ], + raw_bytes_args: Default::default(), + }, + JavascriptSyncRpcRequest { + id: 107, + method: String::from("process.exec"), + args: vec![ + Value::from("/bin/echo"), + Value::from("[]"), + Value::from("{}"), + ], + raw_bytes_args: Default::default(), + }, + signal_state_rpc(108, libc::SIGUSR1 as u32, "ignore"), + JavascriptSyncRpcRequest { + id: 109, + method: String::from("process.kill"), + args: vec![Value::from(target_pid), Value::from("SIGTERM")], + raw_bytes_args: Default::default(), + }, + ]; + for request in requests { + let target = + owned_rpc_target(&sidecar, &vm_id, &root_id, path.clone(), request); + let service = + sidecar.prepare_owned_javascript_process_event_service(target); + drop(service); + } + } + + let vm = sidecar.vms.get(&vm_id).expect("special cancel VM"); + assert!(vm.signal_states.is_empty()); + let root = vm + .active_processes + .get(&root_id) + .expect("special cancel root"); + assert!(root.child_processes.contains_key(&root_target_child)); + let descendant = root + .child_processes + .get(&child_id) + .expect("special cancel descendant"); + assert!(descendant + .child_processes + .contains_key(&descendant_target_child)); + assert!(vm + .kernel + .list_processes() + .get(&root_target_pid) + .is_some_and(|process| process.status != ProcessStatus::Exited)); + assert!(vm + .kernel + .list_processes() + .get(&descendant_target_pid) + .is_some_and(|process| process.status != ProcessStatus::Exited)); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_child_poll_extracts_nested_internal_rpc_before_settling_parent() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("owned-poll-root"); + let child_id = String::from("owned-poll-child"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("owned poll VM"); + let mut root = binding_process(&mut vm, "owned poll root", None); + let mut child = + binding_process(&mut vm, "owned poll child", Some(root.kernel_pid)); + child + .queue_pending_execution_event( + ActiveExecutionEvent::JavascriptSyncRpcRequest(signal_state_rpc( + 11, + libc::SIGTERM as u32, + "ignore", + )), + ) + .expect("queue nested child RPC"); + root.child_processes.insert(child_id.clone(), child); + vm.active_processes.insert(root_id.clone(), root); + } + + let poll_target = owned_rpc_target( + &sidecar, + &vm_id, + &root_id, + Vec::new(), + JavascriptSyncRpcRequest { + id: 12, + method: String::from("child_process.poll"), + args: vec![Value::from(child_id.clone()), Value::from(0)], + raw_bytes_args: Default::default(), + }, + ); + let service = sidecar.prepare_owned_javascript_process_event_service(poll_target); + let _ = service.await; + + let vm = sidecar.vms.get(&vm_id).expect("owned poll VM"); + assert!(vm + .signal_states + .get(&child_id) + .is_some_and(|handlers| handlers.contains_key(&(libc::SIGTERM as u32)))); + let child = vm + .active_processes + .get(&root_id) + .and_then(|root| root.child_processes.get(&child_id)) + .expect("owned poll child"); + assert!( + child.pending_execution_events.is_empty(), + "nested RPC must be durably claimed and serviced, not requeued" + ); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn attached_and_detached_claims_are_exactly_once_and_capacity_bounded() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let (root_id, child_id, detached_id) = ( + "root".to_owned(), + "child-1".to_owned(), + "detached".to_owned(), + ); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + let mut root = binding_process(&mut vm, "root", None); + let mut child = binding_process(&mut vm, "child", Some(root.kernel_pid)); + child + .queue_pending_execution_event(rpc(1)) + .expect("queue child RPC"); + root.child_processes.insert(child_id.clone(), child); + let mut detached = binding_process(&mut vm, "detached", None); + detached + .queue_pending_execution_event(rpc(3)) + .expect("queue detached RPC"); + vm.active_processes.insert(root_id.clone(), root); + vm.active_processes.insert(detached_id.clone(), detached); + vm.detached_child_processes.insert(detached_id.clone()); + } + + let mut javascript = Vec::new(); + let mut python = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge = Vec::new(); + sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("claim attached child RPC"); + assert_eq!(javascript.len(), 1); + assert_eq!(javascript[0].process_id, root_id); + assert_eq!(javascript[0].child_path, vec![child_id.clone()]); + + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + vm.active_processes + .get_mut(&root_id) + .and_then(|root| root.child_processes.get_mut(&child_id)) + .expect("attached child") + .queue_pending_execution_event(rpc(2)) + .expect("queue second child RPC"); + } + sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("capacity-saturated attached turn"); + assert_eq!(javascript.len(), 1, "capacity must not over-claim"); + + javascript.clear(); + sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("claim second attached RPC"); + assert_eq!(javascript.len(), 1); + assert_eq!(javascript[0].request.id, 2); + + javascript.clear(); + sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("claim detached child RPC"); + assert_eq!(javascript.len(), 1); + assert_eq!(javascript[0].process_id, detached_id); + assert!(javascript[0].child_path.is_empty()); + + javascript.clear(); + sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("repeat detached turn"); + assert!( + javascript.is_empty(), + "a claimed event must have one consumer" + ); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn root_public_event_rearm_reaches_terminal_after_exact_256_update_quantum() { + tokio::task::LocalSet::new() + .run_until(async { + const UPDATE_COUNT: usize = 256; + const EVENT_COUNT: usize = UPDATE_COUNT + 2; + let (mut sidecar, vm_id) = sidecar_with_test_vm(EVENT_COUNT + 1).await; + let root_id = String::from("root-exact-quantum"); + let process_event_notify = Arc::clone(&sidecar.process_event_notify); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("exact-quantum VM"); + let mut root = binding_process(&mut vm, "exact-quantum root", None) + .with_event_notify(Arc::clone(&process_event_notify)); + root.pending_execution_event_count_limit = EVENT_COUNT; + for index in 0..UPDATE_COUNT { + root.queue_pending_execution_event(ActiveExecutionEvent::Stdout( + format!("update:{index}\n").into_bytes(), + )) + .expect("queue exact-quantum update"); + } + root.queue_pending_execution_event(ActiveExecutionEvent::Stdout( + b"tool-result:42\n".to_vec(), + )) + .expect("queue tool result after exact quantum"); + root.queue_pending_execution_event(ActiveExecutionEvent::Stdout( + b"terminal-response\n".to_vec(), + )) + .expect("queue terminal response after exact quantum"); + vm.active_processes.insert(root_id.clone(), root); + } + + let ownership = + OwnershipScope::vm("child-claim-connection", "child-claim-session", &vm_id); + let target = crate::process_event_broker::ProcessEventTarget::new( + "child-claim-connection", + "child-claim-session", + &vm_id, + &root_id, + ) + .expect("exact-quantum process target"); + sidecar + .process_event_broker + .claim_target(&ownership, target.clone()) + .expect("claim exact-quantum target"); + let waiter = sidecar + .process_event_broker + .register_waiter(&ownership, target, OperationCancellation::new()) + .expect("register exact-quantum waiter"); + + // Every producer write above coalesced into one stored permit. + // Consume it so all 258 pump turns, including the two events + // after the exact update quantum, depend only on continuation + // rearming by the bounded root drain. + tokio::time::timeout(Duration::from_millis(50), process_event_notify.notified()) + .await + .expect("initial coalesced root event edge"); + + for index in 0..EVENT_COUNT { + let turn = sidecar + .pump_process_events_nowait(&ownership, EVENT_COUNT) + .expect("bounded exact-quantum root turn"); + assert!(turn.emitted_any); + drop(turn); + + let event = tokio::time::timeout(Duration::from_millis(50), waiter.next()) + .await + .expect("exact-quantum broker delivery deadline") + .expect("exact-quantum broker delivery"); + let ActiveExecutionEvent::Stdout(bytes) = event.event else { + panic!("expected exact-quantum stdout event"); + }; + let expected = match index { + 0..UPDATE_COUNT => format!("update:{index}\n"), + UPDATE_COUNT => String::from("tool-result:42\n"), + _ => String::from("terminal-response\n"), + }; + assert_eq!(bytes, expected.as_bytes()); + + if index + 1 < EVENT_COUNT { + tokio::time::timeout( + Duration::from_millis(50), + process_event_notify.notified(), + ) + .await + .expect("remaining root source must preserve a continuation edge"); + } + } + + let empty = sidecar + .pump_process_events_nowait(&ownership, EVENT_COUNT) + .expect("empty exact-quantum follow-up turn"); + assert!(!empty.emitted_any); + assert!( + tokio::time::timeout( + Duration::from_millis(10), + process_event_notify.notified(), + ) + .await + .is_err(), + "an empty root follow-up turn must not hot-rearm itself" + ); + }) + .await; + } - fn handle_descendant_process_kill_rpc( - &mut self, - vm_id: &str, - process_id: &str, - current_process_path: &[&str], - child_process_id: &str, - request: &JavascriptSyncRpcRequest, - ) -> Result { - let target_pid = javascript_sync_rpc_arg_i32(&request.args, 0, "process.kill target pid")?; - let signal_name = javascript_sync_rpc_arg_str(&request.args, 1, "process.kill signal")?; - let signal = parse_signal(signal_name)?; + #[tokio::test(flavor = "current_thread")] + async fn root_service_claim_capacity_rearms_without_a_new_producer_edge() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("root-capacity"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + let mut root = binding_process(&mut vm, "root capacity", None); + for request_id in 1..=3 { + root.queue_pending_execution_event(rpc(request_id)) + .expect("queue root RPC"); + } + vm.active_processes.insert(root_id.clone(), root); + } - let mut source_path = current_process_path.to_vec(); - source_path.push(child_process_id); + // All three producers have already fired. Consume their one + // coalesced permit so every later turn depends on pump rearm. + sidecar.process_event_notify.notify_one(); + tokio::time::timeout( + Duration::from_millis(50), + sidecar.process_event_notify.notified(), + ) + .await + .expect("initial coalesced producer edge"); + let ownership = + OwnershipScope::session("child-claim-connection", "child-claim-session"); + + for expected_request_id in 1..=3 { + let turn = sidecar + .pump_process_events_nowait(&ownership, 1) + .expect("bounded root service claim turn"); + assert_eq!(turn.javascript_services.len(), 1); + assert_eq!(turn.javascript_services[0].request.id, expected_request_id); + drop(turn); + tokio::time::timeout( + Duration::from_millis(50), + sidecar.process_event_notify.notified(), + ) + .await + .expect("capacity-limited turn must preserve a continuation edge"); + } - if signal != 0 && target_pid < 0 { - let pgid = target_pid.unsigned_abs(); - let caller_kernel_pid = { - let Some(vm) = self.vms.get(vm_id) else { - return Err(SidecarError::InvalidState(String::from( - "ESRCH: unknown VM during process.kill", - ))); + let empty = sidecar + .pump_process_events_nowait(&ownership, 1) + .expect("empty follow-up turn"); + assert!(!empty.emitted_any); + assert!(empty.javascript_services.is_empty()); + assert!( + tokio::time::timeout( + Duration::from_millis(10), + sidecar.process_event_notify.notified(), + ) + .await + .is_err(), + "an empty turn must not hot-rearm itself" + ); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn javascript_executor_source_rearms_after_its_coalesced_notify_is_consumed() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("root-javascript-source-rearm"); + let process_event_notify = Arc::clone(&sidecar.process_event_notify); + consume_notify_permit(&process_event_notify).await; + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("JavaScript source VM"); + let root = javascript_process( + &vm_id, + &mut vm, + "JavaScript source rearm root", + r#" +console.log("executor-source-one"); +console.log("executor-source-two"); +"#, + Arc::clone(&process_event_notify), + ); + vm.active_processes.insert(root_id.clone(), root); + } + + let ownership = + OwnershipScope::vm("child-claim-connection", "child-claim-session", &vm_id); + let target = crate::process_event_broker::ProcessEventTarget::new( + "child-claim-connection", + "child-claim-session", + &vm_id, + &root_id, + ) + .expect("JavaScript source target"); + sidecar + .process_event_broker + .claim_target(&ownership, target.clone()) + .expect("claim JavaScript source target"); + let waiter = sidecar + .process_event_broker + .register_waiter(&ownership, target, OperationCancellation::new()) + .expect("register JavaScript source waiter"); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let complete = sidecar.vms.get(&vm_id).is_some_and(|vm| { + vm.active_processes.get(&root_id).is_some_and(|root| { + root.execution.has_exited() + && root.execution.has_pending_events() + && root.pending_execution_events.is_empty() + }) + }); + if complete { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + }) + .await + .expect("JavaScript executor source fills before deadline"); + + // The event bridge called notify for each source event, but the + // broker coalesced them into one permit. Consume that sole edge + // before polling so continuation depends on the executor's + // receiver, not ActiveProcess's local staging deque. + tokio::time::timeout(Duration::from_millis(50), process_event_notify.notified()) + .await + .expect("coalesced JavaScript source edge"); + assert!( + tokio::time::timeout( + Duration::from_millis(10), + process_event_notify.notified(), + ) + .await + .is_err(), + "the executor source must begin with only one coalesced permit" + ); + + let first_turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("first JavaScript executor-source turn"); + assert!(first_turn.emitted_any); + drop(first_turn); + let first = tokio::time::timeout(Duration::from_millis(50), waiter.next()) + .await + .expect("first JavaScript source delivery deadline") + .expect("first JavaScript source delivery"); + let ActiveExecutionEvent::Stdout(first) = first.event else { + panic!("expected first JavaScript executor stdout event"); }; - let Some(root) = vm.active_processes.get(process_id) else { - return Err(SidecarError::InvalidState(format!( - "ESRCH: unknown process {process_id} during process.kill", - ))); + assert_eq!(first, b"executor-source-one\n"); + assert!(sidecar.vms.get(&vm_id).is_some_and(|vm| { + vm.active_processes.get(&root_id).is_some_and(|root| { + root.pending_execution_events.is_empty() + && root.execution.has_pending_events() + }) + })); + + tokio::time::timeout(Duration::from_millis(50), process_event_notify.notified()) + .await + .expect( + "remaining JavaScript receiver events must restore a continuation edge", + ); + let second_turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("second JavaScript executor-source turn"); + assert!(second_turn.emitted_any); + drop(second_turn); + let second = tokio::time::timeout(Duration::from_millis(50), waiter.next()) + .await + .expect("second JavaScript source delivery deadline") + .expect("second JavaScript source delivery"); + let ActiveExecutionEvent::Stdout(second) = second.event else { + panic!("expected second JavaScript executor stdout event"); }; - let Some(source) = Self::active_process_by_path(root, &source_path) else { - return Err(SidecarError::InvalidState(format!( - "ESRCH: unknown child process {child_process_id} during process.kill", - ))); + assert_eq!(second, b"executor-source-two\n"); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn saturated_child_bridge_relay_preserves_later_exit_until_release() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("root-relay-order"); + let child_id = String::from("child-relay-order"); + let relay_in_flight = { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child relay VM"); + let mut root = binding_process(&mut vm, "relay parent", None); + let mut child = binding_process(&mut vm, "relay child", Some(root.kernel_pid)); + child + .queue_pending_execution_event(ActiveExecutionEvent::Exited(0)) + .expect("queue exit behind saturated stdout relay"); + let relay_in_flight = Arc::clone(&child.child_bridge_relay_in_flight); + root.child_processes.insert(child_id.clone(), child); + vm.active_processes.insert(root_id.clone(), root); + relay_in_flight }; - source.kernel_pid - }; - let caller_is_member = - self.signal_vm_process_group(vm_id, caller_kernel_pid, pgid, signal_name)?; - if !caller_is_member { - return Ok(Value::Null); - } - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(Value::Null); - }; - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(Value::Null); - }; - let Some(source) = Self::active_process_by_path_mut(root, &source_path) else { - return Ok(Value::Null); - }; - if !matches!( - canonical_signal_name(signal), - Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG") - ) { - apply_active_process_default_signal(&mut vm.kernel, source, signal)?; - } - return Ok(json!({ - "self": true, - "action": "default", - })); - } - let Some(vm) = self.vms.get_mut(vm_id) else { - return Err(SidecarError::InvalidState(String::from( - "ESRCH: unknown VM during process.kill", - ))); - }; + // Model a stdout event that already left the child queue after + // the parent V8 command lane reported capacity. Its owned + // relay keeps the exact payload while the source exit remains + // durably queued behind it. + let relay_notify = Arc::new(tokio::sync::Notify::new()); + let relay = ChildBridgeRelayLease::acquire( + Arc::clone(&relay_in_flight), + Arc::clone(&relay_notify), + &child_id, + ) + .expect("acquire saturated stdout relay"); + let target = OwnedChildBridgeEventService { + ownership: OwnershipScope::vm( + "child-claim-connection", + "child-claim-session", + &vm_id, + ), + vm_id: vm_id.clone(), + root_process_id: root_id.clone(), + parent_path: Vec::new(), + event_type: "child_stdout", + payload: json!({ + "sessionId": child_id, + "dataBase64": "b3JkZXJlZC1zdGRvdXQ=", + }), + vm: sidecar.vms.handle(&vm_id).expect("child relay VM handle"), + deadline: Instant::now() + Duration::from_secs(1), + _relay: Some(relay), + _reservation: None, + }; - if signal == 0 { - vm.kernel - .signal_process(EXECUTION_DRIVER_NAME, target_pid, signal) - .map_err(kernel_error)?; - return Ok(Value::Null); - } + let mut javascript = Vec::new(); + let mut python = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge = Vec::new(); + assert!( + !sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 256, + ) + .expect("blocked relay pump turn"), + "exit must not overtake the owned stdout relay" + ); + assert!( + sidecar.vms.get(&vm_id).is_some_and(|vm| { + vm.active_processes + .get(&root_id) + .and_then(|root| root.child_processes.get(&child_id)) + .is_some_and(|child| { + matches!( + child.pending_execution_events.front(), + Some(ActiveExecutionEvent::Exited(0)) + ) + }) + }), + "the source exit must remain durable while stdout retries" + ); - let target_kernel_pid = u32::try_from(target_pid).map_err(|_| { - SidecarError::InvalidState(format!("EINVAL: invalid process pid {target_pid}")) - })?; - let (source_pid, located_target_path) = { - let Some(root) = vm.active_processes.get(process_id) else { - return Err(SidecarError::InvalidState(format!( - "ESRCH: unknown process {process_id} during process.kill", - ))); - }; - let Some(source) = Self::active_process_by_path(root, &source_path) else { - return Err(SidecarError::InvalidState(format!( - "ESRCH: unknown child process {child_process_id} during process.kill", - ))); - }; - vm.kernel - .signal_process(EXECUTION_DRIVER_NAME, target_pid, 0) - .map_err(kernel_error)?; - ( - source.kernel_pid, - Self::active_process_path_by_kernel_pid(root, target_kernel_pid), - ) - }; - let Some(target_path) = located_target_path else { - // The target is alive but not part of this root's process tree. - // Resolve it VM-wide so cross-tree pids and untracked kernel - // processes still receive the signal. - self.signal_vm_kernel_pid(vm_id, target_kernel_pid, signal_name)?; - return Ok(Value::Null); - }; - let Some(vm) = self.vms.get_mut(vm_id) else { - return Err(SidecarError::InvalidState(String::from( - "ESRCH: unknown VM during process.kill", - ))); - }; + drop(target); + tokio::time::timeout(Duration::from_millis(50), relay_notify.notified()) + .await + .expect("relay release must rearm the process pump"); + assert!(!relay_in_flight.load(Ordering::Acquire)); + + let error = sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 256, + ) + .expect_err("binding parent cannot accept the released V8 exit event"); + assert!( + error.to_string().contains( + "only embedded V8 executions can receive JavaScript stream events" + ), + "unexpected released exit error: {error}" + ); + assert!( + sidecar.vms.get(&vm_id).is_some_and(|vm| { + vm.active_processes + .get(&root_id) + .is_some_and(|root| !root.child_processes.contains_key(&child_id)) + }), + "the exit may be claimed only after the stdout relay releases" + ); + }) + .await; + } - if source_pid == target_kernel_pid { - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(Value::Null); - }; - let Some(source) = Self::active_process_by_path_mut(root, &source_path) else { - return Ok(Value::Null); - }; - if !matches!( - canonical_signal_name(signal), - Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG") - ) { - apply_active_process_default_signal(&mut vm.kernel, source, signal)?; - } - return Ok(json!({ - "self": true, - "action": "default", - })); - } + #[tokio::test(flavor = "current_thread")] + async fn saturated_production_child_relay_preserves_v8_stream_lifecycle_order() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = sidecar_with_test_vm_and_handle_command_limit( + agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS, + Some(1), + ) + .await; + let root_id = String::from("root-production-relay-order"); + let child_id = String::from("child-production-relay-order"); + let process_event_notify = Arc::clone(&sidecar.process_event_notify); + consume_notify_permit(&process_event_notify).await; + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("production relay VM"); + let root = javascript_process( + &vm_id, + &mut vm, + "production relay parent", + r#" +const { spawn } = require("node:child_process"); + +const events = []; +const child = spawn("/synthetic-production-relay-child", []); +child.stdout.on("data", (chunk) => events.push(`stdout:${String(chunk)}`)); +child.stdout.on("end", () => events.push("end")); +child.on("exit", () => events.push("exit")); +child.on("close", () => { + events.push("close"); + setTimeout(() => console.log(`relay-order:${JSON.stringify(events)}`), 0); +}); +console.log("relay-ready"); +"#, + Arc::clone(&process_event_notify), + ); + vm.active_processes.insert(root_id.clone(), root); + } - let signal_key = target_path.last().map(String::as_str).unwrap_or(process_id); - let registration = vm - .signal_states - .get(signal_key) - .and_then(|handlers| handlers.get(&(signal as u32))) - .cloned(); + let ownership = + OwnershipScope::vm("child-claim-connection", "child-claim-session", &vm_id); + let target = crate::process_event_broker::ProcessEventTarget::new( + "child-claim-connection", + "child-claim-session", + &vm_id, + &root_id, + ) + .expect("production relay target"); + sidecar + .process_event_broker + .claim_target(&ownership, target.clone()) + .expect("claim production relay target"); + let waiter = sidecar + .process_event_broker + .register_waiter(&ownership, target, OperationCancellation::new()) + .expect("register production relay waiter"); + + let spawn_target = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let _ = tokio::time::timeout( + Duration::from_millis(20), + process_event_notify.notified(), + ) + .await; + let mut turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("claim production child spawn"); + assert!(turn.child_bridge_services.is_empty()); + if let Some(target) = turn.javascript_services.pop() { + break target; + } + } + }) + .await + .expect("production child spawn request deadline"); + assert_eq!(spawn_target.request.method, "child_process.spawn"); + let spawn_request_id = spawn_target.request.id; + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("production relay VM"); + let parent_pid = vm + .active_processes + .get(&root_id) + .expect("production relay parent") + .kernel_pid; + let child = + binding_process(&mut vm, "production relay child", Some(parent_pid)) + .with_event_notify(Arc::clone(&process_event_notify)); + let child_pid = child.kernel_pid; + let root = vm + .active_processes + .get_mut(&root_id) + .expect("production relay parent"); + assert!(root + .execution + .claim_javascript_sync_rpc_response(spawn_request_id) + .expect("claim production child spawn response")); + root.child_processes.insert(child_id.clone(), child); + root.execution + .respond_claimed_javascript_sync_rpc_success( + spawn_request_id, + json!({ "childId": child_id, "pid": child_pid }), + ) + .expect("respond to production child spawn"); + } + drop(spawn_target); - let action = match registration - .as_ref() - .map(|registration| ®istration.action) - { - Some(SignalDispositionAction::Ignore) => "ignore", - Some(SignalDispositionAction::User) => { - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(Value::Null); - }; - let Some(target) = Self::active_process_by_owned_path_mut(root, &target_path) - else { - return Err(SidecarError::InvalidState(format!( - "ESRCH: unknown process pid {target_pid}" - ))); - }; - if matches!(&target.execution, ActiveExecution::Wasm(execution) if execution.uses_shared_v8_runtime()) + let ready = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let _ = tokio::time::timeout( + Duration::from_millis(20), + process_event_notify.notified(), + ) + .await; + let turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("pump production relay parent ready event"); + assert!(turn.javascript_services.is_empty()); + drop(turn); + if let Ok(Ok(event)) = + tokio::time::timeout(Duration::from_millis(5), waiter.next()).await + { + if let ActiveExecutionEvent::Stdout(bytes) = event.event { + break bytes; + } + } + } + }) + .await + .expect("production relay parent ready deadline"); + assert_eq!(ready, b"relay-ready\n"); + + { + let vm = sidecar.vms.get(&vm_id).expect("production relay VM"); + let root = vm + .active_processes + .get(&root_id) + .expect("production relay parent"); + root.execution + .pause() + .expect("pause production relay parent"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + { + let vm = sidecar.vms.get(&vm_id).expect("production relay VM"); + let root = vm + .active_processes + .get(&root_id) + .expect("production relay parent"); + root.execution + .send_javascript_stream_event("relay_capacity_filler", Value::Null) + .expect("fill one-slot production parent command lane"); + let full = root + .execution + .send_javascript_stream_event("relay_capacity_probe", Value::Null) + .expect_err("paused one-slot parent command lane must be full"); + assert!(full + .to_string() + .contains("ERR_AGENTOS_SESSION_COMMAND_LIMIT")); + } + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("production relay VM"); + let child = vm + .active_processes + .get_mut(&root_id) + .and_then(|root| root.child_processes.get_mut(&child_id)) + .expect("production relay child"); + child + .queue_pending_execution_event(ActiveExecutionEvent::Stdout( + b"ordered-stdout".to_vec(), + )) + .expect("queue production relay stdout"); + child + .queue_pending_execution_event(ActiveExecutionEvent::Exited(0)) + .expect("queue production relay exit"); + } + + let mut saturated_turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("pump saturated production child stdout"); + assert_eq!(saturated_turn.child_bridge_services.len(), 1); + let stdout_relay = saturated_turn.child_bridge_services.pop().unwrap(); + assert_eq!(stdout_relay.event_type, "child_stdout"); + assert!(sidecar.vms.get(&vm_id).is_some_and(|vm| { + vm.active_processes + .get(&root_id) + .and_then(|root| root.child_processes.get(&child_id)) + .is_some_and(|child| { + child.child_bridge_relay_in_flight.load(Ordering::Acquire) + && matches!( + child.pending_execution_events.front(), + Some(ActiveExecutionEvent::Exited(0)) + ) + }) + })); + + let stdout_delivery = + tokio::task::spawn_local(service_owned_child_bridge_event(stdout_relay)); + tokio::task::yield_now().await; + assert!(!stdout_delivery.is_finished()); + { + let vm = sidecar.vms.get(&vm_id).expect("production relay VM"); + vm.active_processes + .get(&root_id) + .expect("production relay parent") + .execution + .resume() + .expect("resume production relay parent"); + } + tokio::time::timeout(Duration::from_secs(2), stdout_delivery) + .await + .expect("production stdout relay deadline") + .expect("join production stdout relay") + .expect("deliver production stdout relay"); + + let mut exit_turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("pump production child exit after stdout relay"); + assert!(exit_turn.javascript_services.is_empty()); + if let Some(exit_relay) = exit_turn.child_bridge_services.pop() { + assert_eq!(exit_relay.event_type, "child_exit"); + tokio::time::timeout( + Duration::from_secs(2), + service_owned_child_bridge_event(exit_relay), + ) + .await + .expect("production exit relay deadline") + .expect("deliver production exit relay"); + } + + let report = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let _ = tokio::time::timeout( + Duration::from_millis(20), + process_event_notify.notified(), + ) + .await; + let turn = sidecar + .pump_process_events_nowait(&ownership, 8) + .expect("pump production relay order report"); + assert!(turn.javascript_services.is_empty()); + assert!(turn.child_bridge_services.is_empty()); + drop(turn); + if let Ok(Ok(event)) = + tokio::time::timeout(Duration::from_millis(5), waiter.next()).await + { + if let ActiveExecutionEvent::Stdout(bytes) = event.event { + break bytes; + } + } + } + }) + .await + .expect("production relay order report deadline"); + assert_eq!( + report, + b"relay-order:[\"stdout:ordered-stdout\",\"end\",\"exit\",\"close\"]\n" + ); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn attached_and_detached_output_exit_progress_and_bridge_failure_are_observed() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = + sidecar_with_test_vm(agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS) + .await; + let root_id = String::from("root-output"); + let child_id = String::from("child-output"); + let (sync_tx, sync_rx) = tokio::sync::oneshot::channel(); { - target.queue_pending_wasm_signal(signal)?; - } else if let Some(session) = target.execution.javascript_v8_session_handle().filter( - |_| matches!(&target.execution, ActiveExecution::Javascript(execution) if execution.uses_shared_v8_runtime()), - ) { - dispatch_v8_session_signal(session, signal); - } else if !dispatch_v8_process_signal(target, signal)? { - return Err(SidecarError::InvalidState(format!( - "unsupported guest signal delivery for pid {target_pid}" - ))); + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + let mut root = binding_process(&mut vm, "root output", None); + let mut child = binding_process(&mut vm, "child output", Some(root.kernel_pid)); + let child_pid = child.kernel_pid; + child + .queue_pending_execution_event(ActiveExecutionEvent::Stdout( + b"attached-output".to_vec(), + )) + .expect("queue attached output"); + child + .queue_pending_execution_event(ActiveExecutionEvent::Exited(0)) + .expect("queue attached exit"); + root.pending_child_process_sync.insert( + child_id.clone(), + PendingChildProcessSync { + pid: child_pid, + stdout: Vec::new(), + stderr: Vec::new(), + max_buffer: 1024, + deadline: None, + timeout_signal: String::from("SIGTERM"), + kill_sent: false, + timed_out: false, + max_buffer_exceeded: false, + completion: PendingChildProcessSyncCompletion::Javascript(sync_tx), + }, + ); + root.child_processes.insert(child_id.clone(), child); + vm.active_processes.insert(root_id.clone(), root); } - "user" - } - Some(SignalDispositionAction::Default) | None - if matches!( - canonical_signal_name(signal), - Some("SIGWINCH" | "SIGCHLD" | "SIGURG") - ) => - { - "ignore" - } - Some(SignalDispositionAction::Default) | None => { - let Some(root) = vm.active_processes.get_mut(process_id) else { - return Ok(Value::Null); - }; - let Some(target) = Self::active_process_by_owned_path_mut(root, &target_path) - else { - return Err(SidecarError::InvalidState(format!( - "ESRCH: unknown process pid {target_pid}" - ))); - }; - apply_active_process_default_signal(&mut vm.kernel, target, signal)?; - "default" - } - }; - let target_path_label = Self::child_process_path_label( - process_id, - &target_path.iter().map(String::as_str).collect::>(), - ); - emit_security_audit_event( - &self.bridge, - vm_id, - "security.process.kill", - audit_fields([ - (String::from("source"), String::from("guest_process")), - (String::from("source_pid"), source_pid.to_string()), - (String::from("target_pid"), target_pid.to_string()), - (String::from("process_id"), process_id.to_owned()), - ( - String::from("target_process_path"), - target_path_label.clone(), - ), - (String::from("signal"), signal_name.to_owned()), - ]), - ); + let mut javascript = Vec::new(); + let mut python = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge = Vec::new(); + assert!(sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("pump attached output and exit")); + let sync_result = sync_rx + .await + .expect("attached sync completion channel") + .expect("attached sync completion result"); + assert_eq!( + sync_result.get("stdout").and_then(Value::as_str), + Some("attached-output") + ); + assert_eq!(sync_result.get("code").and_then(Value::as_i64), Some(0)); + assert!( + sidecar + .vms + .get(&vm_id) + .and_then(|vm| { + vm.active_processes + .get(&root_id) + .map(|root| !root.child_processes.contains_key(&child_id)) + }) + .unwrap_or(false), + "attached child cleanup must complete with its exit delivery" + ); - Ok(json!({ - "self": false, - "action": action, - "signal": signal_name, - "number": signal, - "targetProcessPath": target_path_label, - })) - } + let detached_id = String::from("detached-output"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + let mut detached = + binding_process(&mut vm, "detached output", None).with_detached(true); + detached + .queue_pending_execution_event(ActiveExecutionEvent::Stdout( + b"detached-output".to_vec(), + )) + .expect("queue detached output"); + detached + .queue_pending_execution_event(ActiveExecutionEvent::Exited(7)) + .expect("queue detached exit"); + vm.active_processes.insert(detached_id.clone(), detached); + vm.detached_child_processes.insert(detached_id.clone()); + } + assert!(sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("pump detached output and exit")); + let detached_events = sidecar + .pending_process_events + .iter() + .filter(|envelope| envelope.process_id == detached_id) + .map(|envelope| &envelope.event) + .collect::>(); + assert!(matches!( + detached_events.as_slice(), + [ActiveExecutionEvent::Stdout(bytes), ActiveExecutionEvent::Exited(7)] + if bytes == b"detached-output" + )); + assert!( + sidecar + .vms + .get(&vm_id) + .is_some_and(|vm| !vm.detached_child_processes.contains(&detached_id)), + "detached membership is removed only after its exit is durably queued" + ); - pub(crate) async fn poll_javascript_child_process( - &mut self, - vm_id: &str, - process_id: &str, - child_process_id: &str, - wait_ms: u64, - ) -> Result { - self.poll_descendant_javascript_child_process( - vm_id, - process_id, - &[], - child_process_id, - wait_ms, - false, - ) - .await + let failure_root_id = String::from("root-bridge-failure"); + let failure_child_id = String::from("child-bridge-failure"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + let mut root = binding_process(&mut vm, "failure root", None); + let mut child = + binding_process(&mut vm, "failure child", Some(root.kernel_pid)); + child + .queue_pending_execution_event(ActiveExecutionEvent::Stdout(vec![1])) + .expect("queue failing child bridge event"); + root.child_processes.insert(failure_child_id.clone(), child); + vm.active_processes.insert(failure_root_id, root); + } + let error = sidecar + .pump_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect_err("unsupported parent bridge delivery must be observed"); + assert!( + error.to_string().contains( + "only embedded V8 executions can receive JavaScript stream events" + ), + "unexpected observed bridge error: {error}" + ); + }) + .await; } - pub(crate) fn write_javascript_child_process_stdin( - &mut self, - vm_id: &str, - process_id: &str, - child_process_id: &str, - chunk: &[u8], - ) -> Result<(), SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { - return Err(javascript_child_process_gone_error( - process_id, - &[child_process_id], - )); - }; - let Some(child) = vm - .active_processes - .get_mut(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))? - .child_processes - .get_mut(child_process_id) - else { - return Err(javascript_child_process_gone_error( - process_id, - &[child_process_id], - )); - }; - if let Err(error) = child.execution.write_stdin(chunk) { - if is_broken_pipe_error(&error) { - return Ok(()); - } - return Err(error); - } - write_kernel_process_stdin(&mut vm.kernel, child, chunk) - } + #[tokio::test(flavor = "current_thread")] + async fn detached_descendant_exit_waits_for_public_event_capacity_before_cleanup() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut sidecar, vm_id) = sidecar_with_test_vm(1).await; + let root_id = String::from("root"); + let child_id = String::from("detached-child"); + let detached_id = format!("{root_id}/{child_id}"); + { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("child claim VM"); + let mut root = binding_process(&mut vm, "root", None); + let mut child = + binding_process(&mut vm, "detached child", Some(root.kernel_pid)) + .with_detached(true); + child + .queue_pending_execution_event(ActiveExecutionEvent::Stdout( + b"ordered-before-exit".to_vec(), + )) + .expect("queue detached descendant stdout"); + child + .queue_pending_execution_event(ActiveExecutionEvent::Exited(0)) + .expect("queue detached descendant exit"); + root.child_processes.insert(child_id.clone(), child); + vm.active_processes.insert(root_id.clone(), root); + vm.detached_child_processes.insert(detached_id.clone()); + } - pub(crate) fn close_javascript_child_process_stdin( - &mut self, - vm_id: &str, - process_id: &str, - child_process_id: &str, - ) -> Result<(), SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { - return Err(javascript_child_process_gone_error( - process_id, - &[child_process_id], - )); - }; - let process = vm - .active_processes - .get_mut(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let Some(child) = process.child_processes.get_mut(child_process_id) else { - return missing_javascript_child_cleanup_result( - process.next_child_process_id, - child_process_id, - "stdin close", - ); - }; - child.execution.close_stdin()?; - close_kernel_process_stdin(&mut vm.kernel, child) - } + sidecar + .queue_pending_process_event(ProcessEventEnvelope { + connection_id: String::from("child-claim-connection"), + session_id: String::from("child-claim-session"), + vm_id: vm_id.clone(), + process_id: String::from("capacity-blocker"), + child_path: Vec::new(), + event: ActiveExecutionEvent::Stdout(vec![1]), + }) + .expect("fill public event capacity"); + + let mut javascript = Vec::new(); + let mut python = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge = Vec::new(); + sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("backpressured detached descendant turn"); + { + let vm = sidecar.vms.get(&vm_id).expect("child claim VM"); + assert!(vm.detached_child_processes.contains(&detached_id)); + let child = vm + .active_processes + .get(&root_id) + .and_then(|root| root.child_processes.get(&child_id)) + .expect("exit cleanup must wait for public capacity"); + assert!(matches!( + child.pending_execution_events.front(), + Some(ActiveExecutionEvent::Stdout(bytes)) + if bytes == b"ordered-before-exit" + )); + assert!(matches!( + child.pending_execution_events.back(), + Some(ActiveExecutionEvent::Exited(0)) + )); + } - pub(crate) fn kill_javascript_child_process( - &mut self, - vm_id: &str, - process_id: &str, - child_process_id: &str, - signal: &str, - ) -> Result<(), SidecarError> { - let signal_name = signal.to_owned(); - let signal = parse_signal(signal)?; - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(()); - }; - let registration = vm - .signal_states - .get(child_process_id) - .and_then(|handlers| handlers.get(&(signal as u32))) - .cloned(); - let process = vm - .active_processes - .get_mut(process_id) - .ok_or_else(|| missing_process_error(vm_id, process_id))?; - let source_pid = process.kernel_pid; - let Some(child) = process.child_processes.get_mut(child_process_id) else { - // Child IDs are monotonically allocated per parent. An allocated - // ID that is no longer present was already reaped (or rolled back), - // so cleanup kills are idempotent without hiding never-issued IDs. - return missing_javascript_child_cleanup_result( - process.next_child_process_id, - child_process_id, - "kill", - ); - }; - terminate_tracked_child_process_for_signal( - &mut vm.kernel, - child, - signal, - registration.as_ref(), - )?; - emit_security_audit_event( - &self.bridge, - vm_id, - "security.process.kill", - audit_fields([ - (String::from("source"), String::from("guest_child_process")), - (String::from("source_pid"), source_pid.to_string()), - (String::from("target_pid"), child.kernel_pid.to_string()), - (String::from("process_id"), process_id.to_owned()), - ( - String::from("child_process_id"), - child_process_id.to_owned(), - ), - (String::from("signal"), signal_name), - ]), - ); - self.process_event_notify.notify_one(); - Ok(()) + let blocker = sidecar + .pending_process_events + .pop_front() + .expect("remove capacity blocker"); + assert_eq!(blocker.process_id, "capacity-blocker"); + sidecar.observe_pending_process_event_depth(); + + sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("detached descendant stdout turn after capacity returns"); + assert!(matches!( + sidecar.pending_process_events.front(), + Some(ProcessEventEnvelope { + process_id, + event: ActiveExecutionEvent::Stdout(bytes), + .. + }) if process_id == &detached_id && bytes == b"ordered-before-exit" + )); + { + let vm = sidecar.vms.get(&vm_id).expect("child claim VM"); + assert!(vm.detached_child_processes.contains(&detached_id)); + assert!(vm + .active_processes + .get(&root_id) + .is_some_and(|root| root.child_processes.contains_key(&child_id))); + } + + let stdout = sidecar + .pending_process_events + .pop_front() + .expect("remove ordered detached stdout"); + assert!(matches!(stdout.event, ActiveExecutionEvent::Stdout(_))); + sidecar.observe_pending_process_event_depth(); + + sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("detached descendant exit turn after capacity returns"); + let vm = sidecar.vms.get(&vm_id).expect("child claim VM"); + assert!(!vm.detached_child_processes.contains(&detached_id)); + assert!( + vm.active_processes + .get(&root_id) + .is_some_and(|root| !root.child_processes.contains_key(&child_id)), + "child cleanup should complete after the exit is durably queued" + ); + drop(vm); + assert!(matches!( + sidecar.pending_process_events.front(), + Some(ProcessEventEnvelope { + process_id, + event: ActiveExecutionEvent::Exited(0), + .. + }) if process_id == &detached_id + )); + assert_eq!(sidecar.pending_process_events.len(), 1); + assert!(!sidecar + .pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript, + &mut python, + &mut python_socket_completions, + &mut child_bridge, + 1, + ) + .expect("post-exit detached descendant turn")); + assert_eq!( + sidecar.pending_process_events.len(), + 1, + "durable detached exit must not be queued twice" + ); + }) + .await; } } diff --git a/crates/native-sidecar/src/execution/coordinator.rs b/crates/native-sidecar/src/execution/coordinator.rs index 4ffd887014..75a8269f88 100644 --- a/crates/native-sidecar/src/execution/coordinator.rs +++ b/crates/native-sidecar/src/execution/coordinator.rs @@ -121,10 +121,288 @@ pub(super) fn missing_process_error(vm_id: &str, process_id: &str) -> SidecarErr )) } +pub(crate) type OwnedVmRouteFuture = + Pin> + 'static>>; + +/// Everything an independently supervised VM-owned route needs after the +/// process coordinator has completed ownership validation. The request and VM +/// handle are owned so the returned future never retains `&mut NativeSidecar`. +#[derive(Clone)] +pub(crate) struct OwnedVmRouteInput { + pub(crate) request: RequestFrame, + pub(crate) vm_id: String, + pub(crate) vm: crate::state::VmHandle, +} + +fn drain_root_signal_state_events_owned( + vm: &crate::state::VmHandle, + process_id: &str, +) -> Result<(), SidecarError> { + let mut deferred = VecDeque::new(); + loop { + let event = vm.try_command("drain root signal state", |vm| { + let Some(process) = vm.active_processes.get_mut(process_id) else { + return Ok(None); + }; + if let Some(event) = process.lease_pending_execution_event() { + return Ok(Some(event)); + } + match process.try_poll_execution_event() { + Ok(event) => Ok(event), + Err(SidecarError::Execution(message)) + if (process.runtime == GuestRuntimeKind::JavaScript + && closed_javascript_event_channel(&message)) + || (process.runtime == GuestRuntimeKind::Python + && closed_python_event_channel(&message)) + || (process.runtime == GuestRuntimeKind::WebAssembly + && closed_wasm_event_channel(&message)) => + { + Ok(None) + } + Err(error) => Err(error), + } + })?; + let Some(event) = event else { break }; + match event.event() { + ActiveExecutionEvent::SignalState { + signal, + registration, + } => { + let signal = *signal; + let registration = registration.clone(); + drop(event); + vm.try_command("apply root signal state", |vm| { + apply_process_signal_state_update( + &mut vm.signal_states, + process_id, + signal, + registration, + ); + Ok(()) + })?; + } + _ => deferred.push_back(event), + } + } + + vm.try_command("restore deferred root process events", |vm| { + if let Some(process) = vm.active_processes.get_mut(process_id) { + for event in deferred.into_iter().rev() { + process.requeue_pending_execution_event(event)?; + } + } + Ok(()) + }) +} + +pub(crate) async fn resize_pty_owned( + bridge: SharedBridge, + input: OwnedVmRouteInput, + payload: ResizePtyRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + // Signal registrations are execution events. Consume them before the + // resize so a handler installed immediately before the host request is + // visible when the kernel-generated SIGWINCH is delivered below. + drain_root_signal_state_events_owned(&input.vm, &payload.process_id)?; + let foreground_pgid = input.vm.try_command("resize process PTY", |vm| { + let process = vm + .active_processes + .get(&payload.process_id) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {} has no active process {}", + input.vm_id, payload.process_id + )) + })?; + let Some(writer_fd) = process.kernel_stdin_writer_fd else { + return Err(SidecarError::InvalidState(format!( + "process {} does not have a PTY", + payload.process_id + ))); + }; + let kernel_pid = process.kernel_pid; + let foreground_pgid = vm + .kernel + .tcgetpgrp(EXECUTION_DRIVER_NAME, kernel_pid, writer_fd) + .map_err(kernel_error)?; + vm.kernel + .pty_resize( + EXECUTION_DRIVER_NAME, + kernel_pid, + writer_fd, + payload.cols, + payload.rows, + ) + .map_err(kernel_error)?; + Ok(foreground_pgid) + })?; + deliver_kernel_process_group_signal_to_tracked_runtimes_owned( + &bridge, + &input.vm, + &input.vm_id, + foreground_pgid, + "SIGWINCH", + )?; + + Ok(DispatchResult { + response: agentos_native_sidecar_core::respond( + &input.request, + ResponsePayload::PtyResized(PtyResizedResponse { + process_id: payload.process_id, + cols: payload.cols, + rows: payload.rows, + }), + ), + events: Vec::new(), + }) +} + +pub(crate) async fn write_stdin_owned( + input: OwnedVmRouteInput, + payload: WriteStdinRequest, +) -> Result { + input.vm.try_command("write process stdin", |vm| { + let VmState { + kernel, + active_processes, + .. + } = vm; + let process = active_processes + .get_mut(&payload.process_id) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {} has no active process {}", + input.vm_id, payload.process_id + )) + })?; + let tty_js = + process.runtime == GuestRuntimeKind::JavaScript && process.tty_master_fd.is_some(); + if !tty_js { + process.execution.write_stdin(&payload.chunk)?; + } + write_kernel_process_stdin(kernel, process, &payload.chunk) + })?; + + Ok(DispatchResult { + response: stdin_written_response( + &input.request, + payload.process_id, + payload.chunk.len() as u64, + ), + events: Vec::new(), + }) +} + +pub(crate) async fn close_stdin_owned( + input: OwnedVmRouteInput, + payload: CloseStdinRequest, +) -> Result { + input.vm.try_command("close process stdin", |vm| { + let VmState { + kernel, + active_processes, + .. + } = vm; + let process = active_processes + .get_mut(&payload.process_id) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {} has no active process {}", + input.vm_id, payload.process_id + )) + })?; + process.execution.close_stdin()?; + close_kernel_process_stdin(kernel, process) + })?; + + Ok(DispatchResult { + response: stdin_closed_response(&input.request, payload.process_id), + events: Vec::new(), + }) +} + +pub(crate) async fn find_listener_owned( + bridge: SharedBridge, + input: OwnedVmRouteInput, + payload: FindListenerRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + require_vm_inspection_permission( + &bridge, + &input.vm_id, + "network.inspect", + "network", + &socket_query_resource(SocketQueryKind::TcpListener, &payload), + )?; + let listener = input.vm.try_read("find TCP listener", |vm| { + find_socket_state_entry(Some(vm), SocketQueryKind::TcpListener, &payload) + })??; + Ok(DispatchResult { + response: listener_snapshot_response(&input.request, listener), + events: Vec::new(), + }) +} + +pub(crate) async fn find_bound_udp_owned( + bridge: SharedBridge, + input: OwnedVmRouteInput, + payload: FindBoundUdpRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let lookup_request = FindListenerRequest { + host: payload.host, + port: payload.port, + path: None, + }; + require_vm_inspection_permission( + &bridge, + &input.vm_id, + "network.inspect", + "network", + &socket_query_resource(SocketQueryKind::UdpBound, &lookup_request), + )?; + let socket = input.vm.try_read("find bound UDP socket", |vm| { + find_socket_state_entry(Some(vm), SocketQueryKind::UdpBound, &lookup_request) + })??; + Ok(DispatchResult { + response: bound_udp_snapshot_response(&input.request, socket), + events: Vec::new(), + }) +} + +pub(crate) async fn get_signal_state_owned( + input: OwnedVmRouteInput, + payload: GetSignalStateRequest, +) -> Result { + drain_root_signal_state_events_owned(&input.vm, &payload.process_id)?; + let handlers = input.vm.try_read("read process signal state", |vm| { + vm.signal_states + .get(&payload.process_id) + .cloned() + .unwrap_or_default() + })?; + Ok(DispatchResult { + response: signal_state_response(&input.request, payload.process_id, handlers), + events: Vec::new(), + }) +} + /// Map a shared guest-kernel-call dispatcher error into a sidecar error, /// preserving POSIX errno codes (`ECODE: message`) as kernel errors so guest /// callers observe Linux-faithful failures, mirroring the filesystem path. -fn guest_kernel_core_error(error: agentos_native_sidecar_core::SidecarCoreError) -> SidecarError { +pub(crate) fn guest_kernel_core_error( + error: agentos_native_sidecar_core::SidecarCoreError, +) -> SidecarError { let message = error.to_string(); let is_errno = message.split_once(':').is_some_and(|(code, _)| { code.len() >= 2 @@ -217,267 +495,85 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - pub(crate) async fn resize_pty( - &mut self, + pub(crate) fn prepare_owned_vm_route( + &self, request: &RequestFrame, - payload: ResizePtyRequest, - ) -> Result { + ) -> Result { let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - // Signal registrations are execution events. Consume them before the - // resize so a handler installed immediately before the host request is - // visible when the kernel-generated SIGWINCH is delivered below. - self.drain_root_signal_state_events(&vm_id, &payload.process_id)?; - - let foreground_pgid = { - let vm = self - .vms - .get_mut(&vm_id) - .ok_or_else(|| missing_vm_error(&vm_id))?; - let process = vm - .active_processes - .get_mut(&payload.process_id) - .ok_or_else(|| { - SidecarError::InvalidState(format!( - "VM {vm_id} has no active process {}", - payload.process_id - )) - })?; - let Some(writer_fd) = process.kernel_stdin_writer_fd else { - return Err(SidecarError::InvalidState(format!( - "process {} does not have a PTY", - payload.process_id - ))); - }; - let foreground_pgid = vm - .kernel - .tcgetpgrp(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd) - .map_err(kernel_error)?; - vm.kernel - .pty_resize( - EXECUTION_DRIVER_NAME, - process.kernel_pid, - writer_fd, - payload.cols, - payload.rows, - ) - .map_err(kernel_error)?; - foreground_pgid - }; - - self.deliver_kernel_process_group_signal_to_tracked_runtimes( - &vm_id, - foreground_pgid, - "SIGWINCH", - )?; - - Ok(DispatchResult { - response: self.respond( - request, - ResponsePayload::PtyResized(PtyResizedResponse { - process_id: payload.process_id, - cols: payload.cols, - rows: payload.rows, - }), - ), - events: Vec::new(), + let vm = self + .vms + .handle(&vm_id) + .ok_or_else(|| missing_vm_error(&vm_id))?; + Ok(OwnedVmRouteInput { + request: request.clone(), + vm_id, + vm, }) } - fn drain_root_signal_state_events( + pub(crate) fn resize_pty( &mut self, - vm_id: &str, - process_id: &str, - ) -> Result<(), SidecarError> { - let mut deferred = VecDeque::new(); - loop { - let event = { - let Some(vm) = self.vms.get_mut(vm_id) else { - break; - }; - let Some(process) = vm.active_processes.get_mut(process_id) else { - break; - }; - if let Some(event) = process.lease_pending_execution_event() { - Some(event) - } else { - match process.try_poll_execution_event() { - Ok(event) => event, - Err(SidecarError::Execution(message)) - if (process.runtime == GuestRuntimeKind::JavaScript - && closed_javascript_event_channel(&message)) - || (process.runtime == GuestRuntimeKind::Python - && closed_python_event_channel(&message)) - || (process.runtime == GuestRuntimeKind::WebAssembly - && closed_wasm_event_channel(&message)) => - { - None - } - Err(error) => return Err(error), - } - } - }; - let Some(event) = event else { - break; - }; - match event.event() { - ActiveExecutionEvent::SignalState { - signal, - registration, - } => { - let signal = *signal; - let registration = registration.clone(); - drop(event); - if let Some(vm) = self.vms.get_mut(vm_id) { - apply_process_signal_state_update( - &mut vm.signal_states, - process_id, - signal, - registration, - ); - } - } - _ => deferred.push_back(event), - } - } - - if let Some(process) = self - .vms - .get_mut(vm_id) - .and_then(|vm| vm.active_processes.get_mut(process_id)) - { - for event in deferred.into_iter().rev() { - process.requeue_pending_execution_event(event)?; - } - } - Ok(()) + request: &RequestFrame, + payload: ResizePtyRequest, + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + Box::pin(async move { resize_pty_owned(bridge, input?, payload).await }) } - pub(crate) async fn write_stdin( + pub(crate) fn write_stdin( &mut self, request: &RequestFrame, payload: WriteStdinRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self - .vms - .get_mut(&vm_id) - .ok_or_else(|| missing_vm_error(&vm_id))?; - let process = vm - .active_processes - .get_mut(&payload.process_id) - .ok_or_else(|| { - SidecarError::InvalidState(format!( - "VM {vm_id} has no active process {}", - payload.process_id - )) - })?; - // For a TTY JavaScript process, host stdin must go ONLY to the kernel PTY - // master (so line discipline + echo apply); feeding the in-process local - // stdin bridge as well would double-deliver the input. Non-TTY JS (piped - // stdin) still uses the local bridge; wasm/python always take the - // streaming/no-op `write_stdin` path plus the kernel master write below. - let tty_js = - process.runtime == GuestRuntimeKind::JavaScript && process.tty_master_fd.is_some(); - if !tty_js { - process.execution.write_stdin(&payload.chunk)?; - } - write_kernel_process_stdin(&mut vm.kernel, process, &payload.chunk)?; - - Ok(DispatchResult { - response: stdin_written_response( - request, - payload.process_id, - payload.chunk.len() as u64, - ), - events: Vec::new(), - }) + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + Box::pin(async move { write_stdin_owned(input?, payload).await }) } - pub(crate) async fn close_stdin( + pub(crate) fn close_stdin( &mut self, request: &RequestFrame, payload: CloseStdinRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self - .vms - .get_mut(&vm_id) - .ok_or_else(|| missing_vm_error(&vm_id))?; - let process = vm - .active_processes - .get_mut(&payload.process_id) - .ok_or_else(|| { - SidecarError::InvalidState(format!( - "VM {vm_id} has no active process {}", - payload.process_id - )) - })?; - process.execution.close_stdin()?; - close_kernel_process_stdin(&mut vm.kernel, process)?; - - Ok(DispatchResult { - response: stdin_closed_response(request, payload.process_id), - events: Vec::new(), - }) + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + Box::pin(async move { close_stdin_owned(input?, payload).await }) } - pub(crate) async fn find_listener( + pub(crate) fn find_listener( &mut self, request: &RequestFrame, payload: FindListenerRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - require_vm_inspection_permission( - &self.bridge, - &vm_id, - "network.inspect", - "network", - &socket_query_resource(SocketQueryKind::TcpListener, &payload), - )?; - - let listener = - find_socket_state_entry(self.vms.get(&vm_id), SocketQueryKind::TcpListener, &payload)?; - - Ok(DispatchResult { - response: listener_snapshot_response(request, listener), - events: Vec::new(), - }) + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + Box::pin(async move { find_listener_owned(bridge, input?, payload).await }) } - pub(crate) async fn get_process_snapshot( + pub(crate) fn get_process_snapshot( &mut self, request: &RequestFrame, _payload: GetProcessSnapshotRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - require_vm_inspection_permission( - &self.bridge, - &vm_id, - "process.inspect", - "process", - "process://snapshot", - )?; - - let processes = self - .vms - .get_mut(&vm_id) - .map(|vm| { + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + Box::pin(async move { + let input = input?; + require_vm_inspection_permission( + &bridge, + &input.vm_id, + "process.inspect", + "process", + "process://snapshot", + )?; + let processes = input.vm.try_command("get process snapshot", |vm| { prune_exited_process_snapshots(vm); - snapshot_vm_processes(vm) + Ok(snapshot_vm_processes(vm)) + })?; + Ok(DispatchResult { + response: process_snapshot_response(&input.request, processes), + events: Vec::new(), }) - .unwrap_or_default(); - - Ok(DispatchResult { - response: process_snapshot_response(request, processes), - events: Vec::new(), }) } @@ -489,7 +585,7 @@ where let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - let vm = self.vms.get_mut(&vm_id).ok_or_else(|| { + let mut vm = self.vms.get_mut(&vm_id).ok_or_else(|| { SidecarError::InvalidState(format!("VM {vm_id} no longer exists for guest kernel call")) })?; let kernel_pid = vm @@ -521,96 +617,99 @@ where }) } - pub(crate) async fn get_resource_snapshot( + pub(crate) fn get_resource_snapshot( &mut self, request: &RequestFrame, _payload: GetResourceSnapshotRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - require_vm_inspection_permission( - &self.bridge, - &vm_id, - "process.inspect", - "process", - "process://resources", - )?; - - let snapshot = self - .vms - .get(&vm_id) - .map(|vm| vm.kernel.resource_snapshot()) - .unwrap_or_default(); - let queue_snapshots = queue_tracker::queue_snapshot() - .into_iter() - .map(|queue| QueueSnapshotEntry { - name: queue.name.as_str().to_owned(), - category: queue.category.as_str().to_owned(), - depth: queue.depth as u64, - high_water: queue.high_water as u64, - capacity: queue.capacity as u64, - fill_percent: queue.fill_percent as u64, + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + Box::pin(async move { + let input = input?; + require_vm_inspection_permission( + &bridge, + &input.vm_id, + "process.inspect", + "process", + "process://resources", + )?; + let snapshot = input + .vm + .try_read("get resource snapshot", |vm| vm.kernel.resource_snapshot())?; + let queue_snapshots = queue_tracker::queue_snapshot() + .into_iter() + .map(|queue| QueueSnapshotEntry { + name: queue.name.as_str().to_owned(), + category: queue.category.as_str().to_owned(), + depth: queue.depth as u64, + high_water: queue.high_water as u64, + capacity: queue.capacity as u64, + fill_percent: queue.fill_percent as u64, + }) + .collect(); + + Ok(DispatchResult { + response: agentos_native_sidecar_core::respond( + &input.request, + ResponsePayload::ResourceSnapshot(ResourceSnapshotResponse { + running_processes: snapshot.running_processes as u64, + exited_processes: snapshot.exited_processes as u64, + fd_tables: snapshot.fd_tables as u64, + open_fds: snapshot.open_fds as u64, + pipes: snapshot.pipes as u64, + pipe_buffered_bytes: snapshot.pipe_buffered_bytes as u64, + ptys: snapshot.ptys as u64, + pty_buffered_input_bytes: snapshot.pty_buffered_input_bytes as u64, + pty_buffered_output_bytes: snapshot.pty_buffered_output_bytes as u64, + sockets: snapshot.sockets as u64, + socket_listeners: snapshot.socket_listeners as u64, + socket_connections: snapshot.socket_connections as u64, + socket_buffered_bytes: snapshot.socket_buffered_bytes as u64, + socket_datagram_queue_len: snapshot.socket_datagram_queue_len as u64, + queue_snapshots, + }), + ), + events: Vec::new(), }) - .collect(); - - Ok(DispatchResult { - response: self.respond( - request, - ResponsePayload::ResourceSnapshot(ResourceSnapshotResponse { - running_processes: snapshot.running_processes as u64, - exited_processes: snapshot.exited_processes as u64, - fd_tables: snapshot.fd_tables as u64, - open_fds: snapshot.open_fds as u64, - pipes: snapshot.pipes as u64, - pipe_buffered_bytes: snapshot.pipe_buffered_bytes as u64, - ptys: snapshot.ptys as u64, - pty_buffered_input_bytes: snapshot.pty_buffered_input_bytes as u64, - pty_buffered_output_bytes: snapshot.pty_buffered_output_bytes as u64, - sockets: snapshot.sockets as u64, - socket_listeners: snapshot.socket_listeners as u64, - socket_connections: snapshot.socket_connections as u64, - socket_buffered_bytes: snapshot.socket_buffered_bytes as u64, - socket_datagram_queue_len: snapshot.socket_datagram_queue_len as u64, - queue_snapshots, - }), - ), - events: Vec::new(), }) } - pub(crate) async fn find_bound_udp( + pub(crate) fn find_bound_udp( &mut self, request: &RequestFrame, payload: FindBoundUdpRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let lookup_request = FindListenerRequest { - host: payload.host, - port: payload.port, - path: None, - }; - require_vm_inspection_permission( - &self.bridge, - &vm_id, - "network.inspect", - "network", - &socket_query_resource(SocketQueryKind::UdpBound, &lookup_request), - )?; - let socket = find_socket_state_entry( - self.vms.get(&vm_id), - SocketQueryKind::UdpBound, - &lookup_request, - )?; + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + Box::pin(async move { find_bound_udp_owned(bridge, input?, payload).await }) + } - Ok(DispatchResult { - response: bound_udp_snapshot_response(request, socket), - events: Vec::new(), + pub(crate) fn vm_fetch( + &mut self, + request: &RequestFrame, + payload: VmFetchRequest, + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + let max_frame_bytes = self.config.max_frame_bytes; + Box::pin(async move { + let input = input?; + let response_json = + dispatch_owned_vm_fetch(bridge, &input.vm_id, input.vm.clone(), payload).await?; + let response = agentos_native_sidecar_core::respond( + &input.request, + ResponsePayload::VmFetchResult(VmFetchResponse { response_json }), + ); + ensure_vm_fetch_response_frame_within_limit(&response, max_frame_bytes)?; + Ok(DispatchResult { + response, + events: Vec::new(), + }) }) } - pub(crate) async fn vm_fetch( + #[allow(dead_code)] + async fn vm_fetch_legacy( &mut self, request: &RequestFrame, payload: VmFetchRequest, @@ -625,7 +724,7 @@ where "vm.fetch stream read/cancel requires stream_id", )) })?; - let vm = self + let mut vm = self .vms .get_mut(&vm_id) .ok_or_else(|| SidecarError::InvalidState(String::from("unknown sidecar VM")))?; @@ -633,13 +732,13 @@ where read_kernel_http_fetch_stream( &self.bridge, &vm_id, - vm, + &mut vm, stream_id, payload.max_bytes.unwrap_or(64 * 1024) as usize, ) .await? } else { - cancel_kernel_http_fetch_stream(&self.bridge, &vm_id, vm, stream_id).await? + cancel_kernel_http_fetch_stream(&self.bridge, &vm_id, &mut vm, stream_id).await? }; let response = self.respond( request, @@ -659,7 +758,7 @@ where } } - let vm = self + let mut vm = self .vms .get_mut(&vm_id) .ok_or_else(|| SidecarError::InvalidState(String::from("unknown sidecar VM")))?; @@ -704,14 +803,14 @@ where reject_unauthorized: None, }; let headers = parse_http_header_collection(&options.headers, "vm.fetch headers")?; - let target_process_id = find_kernel_http_listener_process(vm, payload.port); + let target_process_id = find_kernel_http_listener_process(&mut vm, payload.port); if let Some(target_process_id) = target_process_id { let max_fetch_response_bytes = vm.limits.http.max_fetch_response_bytes; let fetch_result = if stream_operation.as_deref() == Some("start") { start_kernel_http_fetch_stream( &self.bridge, &vm_id, - vm, + &mut vm, &target_process_id, payload.port, &target_path, @@ -725,7 +824,7 @@ where dispatch_kernel_http_fetch( &self.bridge, &vm_id, - vm, + &mut vm, &target_process_id, payload.port, &target_path, @@ -740,7 +839,7 @@ where Ok(response_json) => response_json, Err(error) => { if let Some(exit_code) = kernel_http_fetch_target_exit_code(&error) { - let _ = vm; + drop(vm); self.finish_active_process_exit(&vm_id, &target_process_id, exit_code)?; } return Err(error); @@ -784,11 +883,16 @@ where "binary vm.fetch bodies require a kernel-backed HTTP listener", ))); } - let socket_paths = build_javascript_socket_path_context(vm)?; + let socket_paths = build_javascript_socket_path_context(&mut vm)?; let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); let capabilities = vm.capabilities.clone(); - let process = vm - .active_processes + let dns = vm.dns.clone(); + let VmState { + kernel, + active_processes, + .. + } = &mut *vm; + let process = active_processes .get_mut(&target_process_id) .ok_or_else(|| { SidecarError::InvalidState(format!( @@ -799,9 +903,9 @@ where let response_json = dispatch_loopback_http_request(LoopbackHttpDispatchRequest { bridge: &self.bridge, vm_id: &vm_id, - dns: &vm.dns, + dns: &dns, socket_paths: &socket_paths, - kernel: &mut vm.kernel, + kernel, kernel_readiness, process, server_id, @@ -822,46 +926,30 @@ where }) } - pub(crate) async fn get_signal_state( + pub(crate) fn get_signal_state( &mut self, request: &RequestFrame, payload: GetSignalStateRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - self.drain_root_signal_state_events(&vm_id, &payload.process_id)?; - - let handlers = self - .vms - .get(&vm_id) - .and_then(|vm| vm.signal_states.get(&payload.process_id)) - .cloned() - .unwrap_or_default(); - - Ok(DispatchResult { - response: signal_state_response(request, payload.process_id, handlers), - events: Vec::new(), - }) + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + Box::pin(async move { get_signal_state_owned(input?, payload).await }) } - pub(crate) async fn get_zombie_timer_count( + pub(crate) fn get_zombie_timer_count( &mut self, request: &RequestFrame, _payload: GetZombieTimerCountRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let count = self - .vms - .get(&vm_id) - .map(|vm| vm.kernel.zombie_timer_count() as u64) - .unwrap_or_default(); - - Ok(DispatchResult { - response: zombie_timer_count_response(request, count), - events: Vec::new(), + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + Box::pin(async move { + let input = input?; + let count = input.vm.try_read("get zombie timer count", |vm| { + vm.kernel.zombie_timer_count() as u64 + })?; + Ok(DispatchResult { + response: zombie_timer_count_response(&input.request, count), + events: Vec::new(), + }) }) } } diff --git a/crates/native-sidecar/src/execution/javascript/http.rs b/crates/native-sidecar/src/execution/javascript/http.rs index 291303eca5..2c916ac50e 100644 --- a/crates/native-sidecar/src/execution/javascript/http.rs +++ b/crates/native-sidecar/src/execution/javascript/http.rs @@ -1129,6 +1129,1377 @@ where Ok(String::from("{\"cancelled\":true}")) } +/// Cancellation-safe ownership of a kernel socket used by a detached +/// `vm.fetch` operation. Socket I/O happens in short [`VmHandle`] commands; +/// readiness is awaited through this operation's dedicated notification. +struct OwnedKernelFetchSocket { + vm: crate::state::VmHandle, + kernel_readiness: KernelSocketReadinessRegistry, + readiness_notify: Arc, + kernel_pid: u32, + socket_id: SocketId, + capability: Option, + armed: bool, +} + +impl OwnedKernelFetchSocket { + fn identity(&self) -> Option<(u64, u64)> { + self.capability + .as_ref() + .map(|capability| (capability.id(), capability.generation())) + } + + fn unregister_readiness(&self) { + if let Some(identity) = self.identity() { + self.kernel_readiness.unregister(self.socket_id, identity); + } + } + + fn close(&mut self) -> Result<(), SidecarError> { + if !self.armed { + return Ok(()); + } + self.unregister_readiness(); + self.vm.try_command("close owned VM fetch socket", |vm| { + close_kernel_socket_idempotent(&mut vm.kernel, self.kernel_pid, self.socket_id) + })?; + self.capability.take(); + self.armed = false; + Ok(()) + } + + fn into_stream_state( + mut self, + target_process_id: String, + raw_buffer: Vec, + body_mode: VmFetchBodyMode, + peer_closed: bool, + max_response_bytes: usize, + ) -> VmFetchStreamState { + self.unregister_readiness(); + self.armed = false; + VmFetchStreamState { + target_process_id, + kernel_pid: self.kernel_pid, + socket_id: self.socket_id, + _capability: self + .capability + .take() + .expect("armed VM fetch socket must retain its capability"), + raw_buffer, + decoded_buffer: VecDeque::new(), + body_mode, + peer_closed, + response_bytes: 0, + max_response_bytes, + last_progress_at: Instant::now(), + } + } +} + +impl Drop for OwnedKernelFetchSocket { + fn drop(&mut self) { + if self.armed { + if let Err(error) = self.close() { + eprintln!( + "ERR_AGENTOS_VM_FETCH_SOCKET_CLEANUP: failed to close cancelled VM fetch socket: {error}" + ); + } + } + } +} + +fn open_owned_kernel_fetch_socket( + vm: &crate::state::VmHandle, + target_process_id: &str, + port: u16, + path: &str, + options: &JavascriptHttpRequestOptions, + headers: &HttpHeaderCollection, + body_bytes: Option<&[u8]>, + enforce_stream_limit: bool, +) -> Result { + let handle = vm.clone(); + let readiness_notify = Arc::new(tokio::sync::Notify::new()); + let notify = Arc::clone(&readiness_notify); + vm.try_command("open owned VM fetch socket", move |vm| { + if enforce_stream_limit && vm.vm_fetch_streams.len() >= VM_FETCH_STREAM_COUNT_LIMIT { + return Err(SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_STREAM_LIMIT: VM has {} open fetch streams; close or cancel a stream before opening another (limit {})", + vm.vm_fetch_streams.len(), + VM_FETCH_STREAM_COUNT_LIMIT + ))); + } + let pending_capability = + reserve_capability(&vm.capabilities, CapabilityKind::TcpSocket)?; + let kernel_pid = vm + .active_processes + .get(target_process_id) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "vm.fetch target process disappeared: {target_process_id}" + )) + })? + .kernel_pid; + let socket_id = vm + .kernel + .socket_create(EXECUTION_DRIVER_NAME, kernel_pid, SocketSpec::tcp()) + .map_err(kernel_error)?; + let capability = match pending_capability.commit(CapabilityBackend::Kernel { socket_id }) { + Ok(capability) => capability, + Err(error) => { + let _ = close_kernel_socket_idempotent(&mut vm.kernel, kernel_pid, socket_id); + return Err(SidecarError::Execution(error.to_string())); + } + }; + let setup = (|| { + vm.kernel + .socket_bind_inet( + EXECUTION_DRIVER_NAME, + kernel_pid, + socket_id, + InetSocketAddress::new("127.0.0.1", 0), + ) + .map_err(kernel_error)?; + vm.kernel + .socket_connect_inet_loopback( + EXECUTION_DRIVER_NAME, + kernel_pid, + socket_id, + InetSocketAddress::new("127.0.0.1", port), + ) + .map_err(kernel_error)?; + let request_bytes = + serialize_kernel_http_fetch_request(port, path, options, headers, body_bytes); + vm.kernel + .socket_write(EXECUTION_DRIVER_NAME, kernel_pid, socket_id, &request_bytes) + .map_err(kernel_error) + })(); + if let Err(error) = setup { + let close_result = + close_kernel_socket_idempotent(&mut vm.kernel, kernel_pid, socket_id); + if let Err(close_error) = close_result { + eprintln!( + "ERR_AGENTOS_VM_FETCH_SOCKET_CLEANUP: setup rollback failed: {close_error}" + ); + } + return Err(error); + } + + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let target = KernelSocketReadinessTarget { + session: None, + notify: Some(Arc::clone(¬ify)), + capability_id: capability.id(), + capability_generation: capability.generation(), + target_id: format!("vm-fetch:{target_process_id}:{socket_id}"), + event: KernelSocketReadinessEvent::Data, + }; + if let Err(error) = kernel_readiness.register(socket_id, target) { + let close_result = + close_kernel_socket_idempotent(&mut vm.kernel, kernel_pid, socket_id); + if let Err(close_error) = close_result { + eprintln!( + "ERR_AGENTOS_VM_FETCH_SOCKET_CLEANUP: readiness rollback failed: {close_error}" + ); + } + return Err(error); + } + // Registration is level-triggered. Force an initial zero-time probe so + // readiness that preceded registration cannot be lost. + notify.notify_one(); + Ok(OwnedKernelFetchSocket { + vm: handle, + kernel_readiness, + readiness_notify: notify, + kernel_pid, + socket_id, + capability: Some(capability), + armed: true, + }) + }) +} + +fn poll_owned_kernel_fetch_socket( + socket: &OwnedKernelFetchSocket, + response_buffer: &mut Vec, + peer_closed: &mut bool, + label: &str, +) -> Result { + socket.vm.try_command("poll owned VM fetch socket", |vm| { + let poll = vm + .kernel + .poll_targets( + EXECUTION_DRIVER_NAME, + socket.kernel_pid, + vec![PollTargetEntry::socket( + socket.socket_id, + POLLIN | POLLHUP | POLLERR, + )], + 0, + ) + .map_err(kernel_error)?; + let revents = poll + .targets + .first() + .map(|entry| entry.revents) + .unwrap_or_else(PollEvents::empty); + if revents.intersects(POLLERR) { + return Err(SidecarError::Execution(String::from( + "ERR_AGENTOS_VM_FETCH_SOCKET: kernel TCP socket reported POLLERR", + ))); + } + let before = response_buffer.len(); + if revents.intersects(POLLIN) { + loop { + match vm.kernel.socket_read( + EXECUTION_DRIVER_NAME, + socket.kernel_pid, + socket.socket_id, + VM_FETCH_STREAM_CHUNK_MAX_BYTES, + ) { + Ok(Some(bytes)) if !bytes.is_empty() => { + response_buffer.extend(bytes); + ensure_vm_fetch_raw_response_buffer_within_limit( + response_buffer.len(), + label, + ) + .map_err(sidecar_core_execution_error)?; + } + Ok(Some(_)) => break, + Ok(None) => { + *peer_closed = true; + break; + } + Err(error) if error.code() == "EAGAIN" => break, + Err(error) => return Err(kernel_error(error)), + } + } + } + if revents.intersects(POLLHUP) { + *peer_closed = true; + } + Ok(response_buffer.len() != before || *peer_closed) + }) +} + +pub(crate) async fn settle_owned_fetch_sync_rpc( + vm: &crate::state::VmHandle, + process_id: &str, + child_path: &[String], + request: JavascriptSyncRpcRequest, + response: Result, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let response = match response { + Ok(JavascriptSyncRpcServiceResponse::Deferred { + receiver, timeout, .. + }) => { + let receive = async { + receiver.await.unwrap_or_else(|_| { + Err(DeferredRpcError { + code: String::from("ERR_AGENTOS_DEFERRED_RPC_RESPONSE_CHANNEL_CLOSED"), + message: format!( + "deferred sync RPC response channel closed for {}", + request.method + ), + }) + }) + }; + let completion = match timeout { + Some(timeout) => match tokio::time::timeout(timeout, receive).await { + Ok(result) => result, + Err(_) => Err(DeferredRpcError { + code: String::from("ERR_AGENTOS_DEFERRED_RPC_TIMEOUT"), + message: format!( + "{} deferred response timed out after {} ms", + request.method, + timeout.as_millis() + ), + }), + }, + None => receive.await, + }; + return vm.try_command("settle deferred owned JavaScript RPC", |vm| { + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(()); + }; + let path = child_path.iter().map(String::as_str).collect::>(); + let Some(process) = NativeSidecar::::active_process_by_path_mut(root, &path) + else { + return Ok(()); + }; + settle_javascript_sync_rpc_completion( + process, + &kernel_readiness, + request.id, + completion, + ) + }); + } + other => other, + }; + vm.try_command("settle owned VM fetch RPC", |vm| { + let Some(root) = vm.active_processes.get_mut(process_id) else { + return Ok(()); + }; + let path = child_path.iter().map(String::as_str).collect::>(); + let Some(process) = NativeSidecar::::active_process_by_path_mut(root, &path) else { + return Ok(()); + }; + match response { + Ok(result) => process + .execution + .respond_javascript_sync_rpc_response(request.id, result) + .or_else(ignore_stale_javascript_sync_rpc_response), + Err(error) => process + .execution + .respond_javascript_sync_rpc_error( + request.id, + javascript_sync_rpc_error_code(&error), + javascript_sync_rpc_error_message(&error), + ) + .or_else(ignore_stale_javascript_sync_rpc_response), + } + }) +} + +/// Temporarily owns one UDP socket while `dgram.poll` waits outside VM state. +/// Cancellation restores the exact socket to its process. If the process was +/// reaped while the wait was in flight, the guard closes the detached handle +/// instead of leaking its kernel/readiness registration. +struct DetachedJavascriptUdpSocket { + vm: VmHandle, + process_id: String, + child_path: Vec, + socket_id: String, + kernel_pid: u32, + readiness_identity: Option<( + agentos_runtime::capability::CapabilityId, + agentos_runtime::capability::CapabilityGeneration, + )>, + socket: Option, +} + +impl DetachedJavascriptUdpSocket { + fn socket(&self) -> &ActiveUdpSocket { + self.socket + .as_ref() + .expect("detached JavaScript UDP socket remains owned until restoration") + } + + fn restore(&mut self) -> Result<(), SidecarError> { + if self.socket.is_none() { + return Ok(()); + } + let mut vm = self + .vm + .try_borrow_mut("restore detached JavaScript UDP socket")?; + let VmState { + kernel, + active_processes, + kernel_socket_readiness, + .. + } = &mut *vm; + let process = active_processes.get_mut(&self.process_id).and_then(|root| { + let mut process = root; + for child_id in &self.child_path { + process = process.child_processes.get_mut(child_id)?; + } + Some(process) + }); + if let Some(process) = process { + if !process.udp_sockets.contains_key(&self.socket_id) { + process.udp_sockets.insert( + self.socket_id.clone(), + self.socket + .take() + .expect("checked detached JavaScript UDP socket"), + ); + return Ok(()); + } + } + + let mut socket = self + .socket + .take() + .expect("checked detached JavaScript UDP socket"); + unregister_kernel_readiness_target( + kernel_socket_readiness, + socket.kernel_socket_id, + self.readiness_identity, + ); + socket.set_event_pusher(None, None); + if socket.is_final_description_handle() { + socket.close(kernel, self.kernel_pid); + } + Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_UDP_POLL_RESTORE: UDP socket {} could not be restored because its process was reaped or the socket id was replaced", + self.socket_id + ))) + } +} + +impl Drop for DetachedJavascriptUdpSocket { + fn drop(&mut self) { + if let Err(error) = self.restore() { + tracing::error!( + %error, + process_id = %self.process_id, + socket_id = %self.socket_id, + "failed to restore cancelled owned JavaScript UDP poll" + ); + } + } +} + +async fn poll_detached_javascript_udp_socket_once( + vm: &VmHandle, + socket: &DetachedJavascriptUdpSocket, +) -> Result, SidecarError> { + let kernel_ready = vm.try_command("probe owned JavaScript UDP socket", |vm| { + socket + .socket() + .poll_kernel_ready(&mut vm.kernel, socket.kernel_pid, Duration::ZERO) + })?; + if kernel_ready { + let turn = socket.socket().acquire_poll_fair_turn().await?; + let event = vm.try_command("consume owned JavaScript UDP datagram", |vm| { + socket + .socket() + .consume_ready_kernel_datagram(&mut vm.kernel, socket.kernel_pid, turn) + })?; + if event.is_some() { + return Ok(event); + } + } + socket.socket().poll_native(Duration::ZERO).await +} + +pub(in crate::execution) async fn service_owned_javascript_dgram_poll( + vm: &VmHandle, + process_id: &str, + child_path: &[String], + request: &JavascriptSyncRpcRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let socket_id = + javascript_sync_rpc_arg_str(&request.args, 0, "dgram.poll socket id")?.to_owned(); + let wait = Duration::from_millis( + javascript_sync_rpc_arg_u64_optional(&request.args, 1, "dgram.poll wait ms")? + .unwrap_or_default(), + ); + let (socket_paths, kernel_pid, readiness_identity, active_socket) = + vm.try_command("detach owned JavaScript UDP socket", |vm| { + let socket_paths = build_javascript_socket_path_context(vm)?; + let root = vm.active_processes.get_mut(process_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "JavaScript UDP poll target process disappeared: {process_id}" + )) + })?; + let path = child_path.iter().map(String::as_str).collect::>(); + let process = + NativeSidecar::::active_process_by_path_mut(root, &path).ok_or_else(|| { + SidecarError::InvalidState(format!( + "JavaScript UDP poll target descendant disappeared: {process_id}/{}", + child_path.join("/") + )) + })?; + let readiness_identity = process + .capability_readiness_identity(&NativeCapabilityKey::UdpSocket(socket_id.clone())); + let active_socket = process.udp_sockets.remove(&socket_id).ok_or_else(|| { + SidecarError::InvalidState(format!("unknown UDP socket {socket_id}")) + })?; + Ok(( + socket_paths, + process.kernel_pid, + readiness_identity, + active_socket, + )) + })?; + let mut socket = DetachedJavascriptUdpSocket { + vm: vm.clone(), + process_id: process_id.to_owned(), + child_path: child_path.to_vec(), + socket_id, + kernel_pid, + readiness_identity, + socket: Some(active_socket), + }; + let wait = wait.min(socket.socket().reactor_limits.operation_deadline); + let notified = Arc::clone(&socket.socket().read_event_notify).notified_owned(); + tokio::pin!(notified); + notified.as_mut().enable(); + let mut event = poll_detached_javascript_udp_socket_once(vm, &socket).await?; + if event.is_none() && !wait.is_zero() { + let _ = tokio::time::timeout(wait, notified.as_mut()).await; + // Probe after both wake and timeout so a datagram arriving exactly at + // the deadline is not deferred to an unrelated later poll. + event = poll_detached_javascript_udp_socket_once(vm, &socket).await?; + } + socket.restore()?; + javascript_dgram_poll_event_response(&socket_paths, event) +} + +/// Service one target-runtime event without retaining VM state over a wait. +/// The only intrinsically asynchronous generic method (`dgram.poll`) is +/// detached into an owned operation first; every remaining generic dispatch +/// must complete during its short VM command. +pub(crate) async fn service_owned_javascript_sync_rpc_request( + bridge: &SharedBridge, + vm_id: &str, + vm: &crate::state::VmHandle, + process_id: &str, + child_path: &[String], + request: JavascriptSyncRpcRequest, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + if request.method == "dgram.poll" { + let response = + service_owned_javascript_dgram_poll::(vm, process_id, child_path, &request).await; + return settle_owned_fetch_sync_rpc::(vm, process_id, child_path, request, response) + .await; + } + + let response = vm.try_command("service owned JavaScript process event", |vm| { + let socket_paths = build_javascript_socket_path_context(vm)?; + let dns = vm.dns.clone(); + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let capabilities = vm.capabilities.clone(); + let VmState { + kernel, + active_processes, + .. + } = vm; + let root = active_processes.get_mut(process_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "JavaScript event target process disappeared: {process_id}" + )) + })?; + let path = child_path.iter().map(String::as_str).collect::>(); + let process = + NativeSidecar::::active_process_by_path_mut(root, &path).ok_or_else(|| { + SidecarError::InvalidState(format!( + "JavaScript event target descendant disappeared: {process_id}/{}", + child_path.join("/") + )) + })?; + let mut future = Box::pin(service_javascript_sync_rpc( + JavascriptSyncRpcServiceRequest { + bridge, + vm_id, + dns: &dns, + socket_paths: &socket_paths, + kernel, + kernel_readiness, + process, + sync_request: &request, + capabilities, + }, + )); + let mut context = Context::from_waker(Waker::noop()); + let poll = future.as_mut().poll(&mut context); + drop(future); + match poll { + Poll::Ready(response) => Ok(response), + Poll::Pending => Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_JAVASCRIPT_RPC_OWNERSHIP: {} suspended inside the generic VM-state dispatcher; route it through an owned service", + request.method + ))), + } + })?; + settle_owned_fetch_sync_rpc::(vm, process_id, child_path, request, response).await +} + +/// Poll and service one event for the private VM-fetch loop. Protocol-router +/// process events use `service_owned_javascript_sync_rpc_request` after claiming +/// the concrete event, preserving the broker's one-consumer rule. +pub(crate) async fn service_owned_root_javascript_event( + bridge: &SharedBridge, + vm_id: &str, + vm: &crate::state::VmHandle, + process_id: &str, + preserve_http_wait: bool, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + enum Turn { + Idle, + Serviced, + Response { + request: JavascriptSyncRpcRequest, + response: Result, + }, + } + + let turn = vm.try_command("service owned VM fetch target", |vm| { + let socket_paths = build_javascript_socket_path_context(vm)?; + let dns = vm.dns.clone(); + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let capabilities = vm.capabilities.clone(); + let VmState { + kernel, + active_processes, + .. + } = vm; + let process = active_processes.get_mut(process_id).ok_or_else(|| { + SidecarError::InvalidState(format!("vm.fetch target process disappeared: {process_id}")) + })?; + let event = process + .execution + .try_poll_event() + .map_err(|error| SidecarError::Execution(error.to_string()))?; + let Some(event) = event else { + return Ok(Turn::Idle); + }; + match event { + ActiveExecutionEvent::JavascriptSyncRpcRequest(request) + if preserve_http_wait && request.method == "net.http_wait" => + { + process.queue_pending_execution_event( + ActiveExecutionEvent::JavascriptSyncRpcRequest(request), + )?; + Ok(Turn::Serviced) + } + ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => { + let mut future = Box::pin(service_javascript_sync_rpc( + JavascriptSyncRpcServiceRequest { + bridge, + vm_id, + dns: &dns, + socket_paths: &socket_paths, + kernel, + kernel_readiness, + process, + sync_request: &request, + capabilities, + }, + )); + let mut context = Context::from_waker(Waker::noop()); + let poll = future.as_mut().poll(&mut context); + drop(future); + match poll { + Poll::Ready(response) => Ok(Turn::Response { request, response }), + Poll::Pending => { + process.queue_pending_execution_event( + ActiveExecutionEvent::JavascriptSyncRpcRequest(request), + )?; + Ok(Turn::Serviced) + } + } + } + ActiveExecutionEvent::Exited(code) => Err(SidecarError::Execution(format!( + "vm.fetch target exited before responding (exit code {code})" + ))), + other => { + process.queue_pending_execution_event(other)?; + Ok(Turn::Serviced) + } + } + })?; + match turn { + Turn::Idle => Ok(false), + Turn::Serviced => Ok(true), + Turn::Response { request, response } => { + settle_owned_fetch_sync_rpc::(vm, process_id, &[], request, response).await?; + Ok(true) + } + } +} + +fn owned_fetch_process_notify( + vm: &crate::state::VmHandle, + process_id: &str, +) -> Result, SidecarError> { + vm.try_read("clone VM fetch process notification", |vm| { + vm.active_processes + .get(process_id) + .map(|process| Arc::clone(&process.process_event_notify)) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "vm.fetch target process disappeared: {process_id}" + )) + }) + })? +} + +async fn wait_for_owned_fetch_progress( + socket: &OwnedKernelFetchSocket, + process_notify: &Arc, + deadline: Instant, +) -> Result<(), SidecarError> { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(SidecarError::Execution(String::from( + "ERR_AGENTOS_VM_FETCH_TIMEOUT: VM fetch progress deadline elapsed", + ))); + } + tokio::time::timeout(remaining, async { + tokio::select! { + _ = socket.readiness_notify.notified() => {}, + _ = process_notify.notified() => {}, + } + }) + .await + .map_err(|_| { + SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_TIMEOUT: timed out after {} ms waiting for VM fetch progress", + http_loopback_request_timeout().as_millis() + )) + })?; + Ok(()) +} + +async fn dispatch_owned_kernel_http_fetch( + bridge: &SharedBridge, + vm_id: &str, + vm: &crate::state::VmHandle, + target_process_id: &str, + port: u16, + path: &str, + options: &JavascriptHttpRequestOptions, + headers: &HttpHeaderCollection, + body_bytes: Option<&[u8]>, + max_fetch_response_bytes: usize, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let mut socket = open_owned_kernel_fetch_socket( + vm, + target_process_id, + port, + path, + options, + headers, + body_bytes, + false, + )?; + let mut response_buffer = Vec::new(); + let mut peer_closed = false; + let url = format!("http://127.0.0.1:{port}{path}"); + let deadline = Instant::now() + http_loopback_request_timeout(); + let process_notify = owned_fetch_process_notify(vm, target_process_id)?; + loop { + if let Some(response) = + parse_kernel_http_fetch_response(&response_buffer, peer_closed, &url) + .map_err(sidecar_core_execution_error)? + { + ensure_vm_fetch_response_within_limit(&response, "vm.fetch", max_fetch_response_bytes) + .map_err(sidecar_core_execution_error)?; + socket.close()?; + return Ok(response); + } + if Instant::now() >= deadline { + let preview = String::from_utf8_lossy(&response_buffer); + return Err(SidecarError::Execution(format!( + "vm.fetch timed out waiting for kernel TCP HTTP response ({} buffered bytes: {:?})", + response_buffer.len(), + preview.chars().take(200).collect::() + ))); + } + let serviced = + service_owned_root_javascript_event(bridge, vm_id, vm, target_process_id, true).await?; + let progressed = poll_owned_kernel_fetch_socket( + &socket, + &mut response_buffer, + &mut peer_closed, + "vm.fetch", + )?; + if !progressed && !serviced { + wait_for_owned_fetch_progress(&socket, &process_notify, deadline).await?; + } + } +} + +async fn start_owned_kernel_http_fetch_stream( + bridge: &SharedBridge, + vm_id: &str, + vm: &crate::state::VmHandle, + target_process_id: &str, + port: u16, + path: &str, + options: &JavascriptHttpRequestOptions, + headers: &HttpHeaderCollection, + body_bytes: Option<&[u8]>, + max_response_bytes: usize, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let socket = open_owned_kernel_fetch_socket( + vm, + target_process_id, + port, + path, + options, + headers, + body_bytes, + true, + )?; + let mut response_buffer = Vec::new(); + let mut peer_closed = false; + let deadline = Instant::now() + http_loopback_request_timeout(); + let request_method = options.method.as_deref().unwrap_or("GET"); + let process_notify = owned_fetch_process_notify(vm, target_process_id)?; + let (status, status_text, response_headers, body_mode) = loop { + if let Some(header_end) = find_http_header_end(&response_buffer) { + let parsed = parse_stream_response_head( + &response_buffer[..header_end], + request_method, + max_response_bytes, + )?; + if (100..200).contains(&parsed.0) && parsed.0 != 101 { + response_buffer.drain(..header_end + 4); + continue; + } + response_buffer.drain(..header_end + 4); + break parsed; + } + if peer_closed { + return Err(SidecarError::Execution(String::from( + "ERR_AGENTOS_VM_FETCH_TRUNCATED: peer closed before response headers completed", + ))); + } + if Instant::now() >= deadline { + return Err(SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_TIMEOUT: timed out waiting for response headers after {} ms; raise AGENTOS_HTTP_LOOPBACK_REQUEST_TIMEOUT_MS", + http_loopback_request_timeout().as_millis() + ))); + } + let serviced = + service_owned_root_javascript_event(bridge, vm_id, vm, target_process_id, true).await?; + let progressed = poll_owned_kernel_fetch_socket( + &socket, + &mut response_buffer, + &mut peer_closed, + "vm.fetchStream", + )?; + if !progressed && !serviced { + wait_for_owned_fetch_progress(&socket, &process_notify, deadline).await?; + } + }; + + let mut state = socket.into_stream_state( + target_process_id.to_owned(), + response_buffer, + body_mode, + peer_closed, + max_response_bytes, + ); + decode_stream_body(&mut state)?; + let stream_id = vm.try_command("register owned VM fetch stream", |vm| { + vm.next_vm_fetch_stream_id = vm.next_vm_fetch_stream_id.wrapping_add(1); + let stream_id = format!("{}:{}", vm.generation, vm.next_vm_fetch_stream_id); + vm.vm_fetch_streams.insert(stream_id.clone(), state); + Ok(stream_id) + })?; + serde_json::to_string(&json!({ + "streamId": stream_id, + "status": status, + "statusText": status_text, + "headers": response_headers, + })) + .map_err(|error| { + SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_SERIALIZE: failed to serialize response head: {error}" + )) + }) +} + +struct OwnedFetchStreamLease { + vm: crate::state::VmHandle, + stream_id: String, + kernel_readiness: KernelSocketReadinessRegistry, + readiness_notify: Arc, + state: Option, +} + +impl OwnedFetchStreamLease { + fn state(&self) -> &VmFetchStreamState { + self.state + .as_ref() + .expect("owned VM fetch stream state must be present") + } + + fn state_mut(&mut self) -> &mut VmFetchStreamState { + self.state + .as_mut() + .expect("owned VM fetch stream state must be present") + } + + fn identity(&self) -> (u64, u64) { + let capability = &self.state()._capability; + (capability.id(), capability.generation()) + } + + fn unregister_readiness(&self) { + self.kernel_readiness + .unregister(self.state().socket_id, self.identity()); + } + + fn reinsert(mut self) -> Result<(), SidecarError> { + self.unregister_readiness(); + let state = self + .state + .take() + .expect("owned VM fetch stream state must be present"); + self.vm.try_command("restore owned VM fetch stream", |vm| { + vm.vm_fetch_streams.insert(self.stream_id.clone(), state); + Ok(()) + }) + } + + fn close(mut self) -> Result<(), SidecarError> { + self.unregister_readiness(); + let state = self + .state + .take() + .expect("owned VM fetch stream state must be present"); + self.vm.try_command("close owned VM fetch stream", |vm| { + close_kernel_socket_idempotent(&mut vm.kernel, state.kernel_pid, state.socket_id) + })?; + drop(state); + Ok(()) + } +} + +impl Drop for OwnedFetchStreamLease { + fn drop(&mut self) { + let Some(state) = self.state.take() else { + return; + }; + self.kernel_readiness.unregister( + state.socket_id, + (state._capability.id(), state._capability.generation()), + ); + let result = self.vm.try_command("cancel owned VM fetch stream", |vm| { + close_kernel_socket_idempotent(&mut vm.kernel, state.kernel_pid, state.socket_id) + }); + if let Err(error) = result { + eprintln!( + "ERR_AGENTOS_VM_FETCH_STREAM_CLEANUP: failed to close cancelled stream {}: {error}", + self.stream_id + ); + } + drop(state); + } +} + +fn lease_owned_fetch_stream( + vm: &crate::state::VmHandle, + stream_id: &str, +) -> Result { + let readiness_notify = Arc::new(tokio::sync::Notify::new()); + let notify = Arc::clone(&readiness_notify); + let handle = vm.clone(); + vm.try_command("lease owned VM fetch stream", |vm| { + let state = vm.vm_fetch_streams.remove(stream_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_VM_FETCH_STREAM_NOT_FOUND: stream {stream_id:?} is closed or unknown" + )) + })?; + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let target = KernelSocketReadinessTarget { + session: None, + notify: Some(Arc::clone(¬ify)), + capability_id: state._capability.id(), + capability_generation: state._capability.generation(), + target_id: format!("vm-fetch-stream:{stream_id}"), + event: KernelSocketReadinessEvent::Data, + }; + if let Err(error) = kernel_readiness.register(state.socket_id, target) { + vm.vm_fetch_streams.insert(stream_id.to_owned(), state); + return Err(error); + } + notify.notify_one(); + Ok(OwnedFetchStreamLease { + vm: handle, + stream_id: stream_id.to_owned(), + kernel_readiness, + readiness_notify: notify, + state: Some(state), + }) + }) +} + +async fn read_owned_kernel_http_fetch_stream( + bridge: &SharedBridge, + vm_id: &str, + vm: &crate::state::VmHandle, + stream_id: &str, + requested_max_bytes: usize, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let max_bytes = requested_max_bytes.clamp(1, VM_FETCH_STREAM_CHUNK_MAX_BYTES); + let mut lease = lease_owned_fetch_stream(vm, stream_id)?; + let target_process_id = lease.state().target_process_id.clone(); + let process_notify = owned_fetch_process_notify(vm, &target_process_id)?; + loop { + decode_stream_body(lease.state_mut())?; + if !lease.state().decoded_buffer.is_empty() + || matches!(lease.state().body_mode, VmFetchBodyMode::Empty) + { + break; + } + let deadline = lease.state().last_progress_at + http_loopback_request_timeout(); + if Instant::now() >= deadline { + return Err(SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_TIMEOUT: stream produced no data for {} ms; raise AGENTOS_HTTP_LOOPBACK_REQUEST_TIMEOUT_MS", + http_loopback_request_timeout().as_millis() + ))); + } + let serviced = + service_owned_root_javascript_event(bridge, vm_id, vm, &target_process_id, true) + .await?; + let (kernel_pid, socket_id) = (lease.state().kernel_pid, lease.state().socket_id); + let mut raw_buffer = std::mem::take(&mut lease.state_mut().raw_buffer); + let mut peer_closed = lease.state().peer_closed; + let progressed = lease.vm.try_command("poll owned VM fetch stream", |vm| { + let poll = vm + .kernel + .poll_targets( + EXECUTION_DRIVER_NAME, + kernel_pid, + vec![PollTargetEntry::socket( + socket_id, + POLLIN | POLLHUP | POLLERR, + )], + 0, + ) + .map_err(kernel_error)?; + let revents = poll + .targets + .first() + .map(|entry| entry.revents) + .unwrap_or_else(PollEvents::empty); + if revents.intersects(POLLERR) { + return Err(SidecarError::Execution(String::from( + "ERR_AGENTOS_VM_FETCH_SOCKET: kernel TCP stream reported POLLERR", + ))); + } + let before = raw_buffer.len(); + if revents.intersects(POLLIN) { + loop { + match vm.kernel.socket_read( + EXECUTION_DRIVER_NAME, + kernel_pid, + socket_id, + VM_FETCH_STREAM_CHUNK_MAX_BYTES, + ) { + Ok(Some(bytes)) if !bytes.is_empty() => { + raw_buffer.extend(bytes); + ensure_vm_fetch_raw_response_buffer_within_limit( + raw_buffer.len(), + "vm.fetchStream", + ) + .map_err(sidecar_core_execution_error)?; + } + Ok(Some(_)) => break, + Ok(None) => { + peer_closed = true; + break; + } + Err(error) if error.code() == "EAGAIN" => break, + Err(error) => return Err(kernel_error(error)), + } + } + } + if revents.intersects(POLLHUP) { + peer_closed = true; + } + Ok(raw_buffer.len() != before || peer_closed) + })?; + lease.state_mut().raw_buffer = raw_buffer; + lease.state_mut().peer_closed = peer_closed; + if progressed { + lease.state_mut().last_progress_at = Instant::now(); + } else if !serviced { + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::time::timeout(remaining, async { + tokio::select! { + _ = lease.readiness_notify.notified() => {}, + _ = process_notify.notified() => {}, + } + }) + .await + .map_err(|_| { + SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_TIMEOUT: stream produced no data for {} ms; raise AGENTOS_HTTP_LOOPBACK_REQUEST_TIMEOUT_MS", + http_loopback_request_timeout().as_millis() + )) + })?; + } + } + + let state = lease.state_mut(); + let take = max_bytes.min(state.decoded_buffer.len()); + let body: Vec = state.decoded_buffer.drain(..take).collect(); + let done = state.decoded_buffer.is_empty() && matches!(state.body_mode, VmFetchBodyMode::Empty); + let response = serde_json::to_string(&json!({ + "body": base64::engine::general_purpose::STANDARD.encode(body), + "done": done, + })) + .map_err(|error| { + SidecarError::Execution(format!( + "ERR_AGENTOS_VM_FETCH_SERIALIZE: failed to serialize stream chunk: {error}" + )) + })?; + if done { + lease.close()?; + } else { + lease.reinsert()?; + } + Ok(response) +} + +fn cancel_owned_kernel_http_fetch_stream( + vm: &crate::state::VmHandle, + stream_id: &str, +) -> Result { + let lease = lease_owned_fetch_stream(vm, stream_id)?; + lease.close()?; + Ok(String::from("{\"cancelled\":true}")) +} + +struct OwnedLoopbackHttpRequest { + vm: crate::state::VmHandle, + process_id: String, + request_key: (u64, u64), + armed: bool, +} + +impl OwnedLoopbackHttpRequest { + fn complete(&mut self) { + self.armed = false; + } +} + +impl Drop for OwnedLoopbackHttpRequest { + fn drop(&mut self) { + if !self.armed { + return; + } + let result = self + .vm + .try_command("cancel owned loopback HTTP request", |vm| { + if let Some(process) = vm.active_processes.get_mut(&self.process_id) { + process.pending_http_requests.remove(&self.request_key); + } + Ok(()) + }); + if let Err(error) = result { + eprintln!( + "ERR_AGENTOS_VM_FETCH_LOOPBACK_CLEANUP: failed to cancel pending request: {error}" + ); + } + } +} + +async fn dispatch_owned_loopback_http_request( + vm: &crate::state::VmHandle, + process_id: &str, + server_id: u64, + request_json: &str, +) -> Result { + let (request_key, receiver) = vm.try_command("start owned loopback HTTP request", |vm| { + let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { + SidecarError::InvalidState(format!("vm.fetch target process disappeared: {process_id}")) + })?; + let (respond_to, receiver) = tokio::sync::oneshot::channel(); + let request_key = begin_loopback_http_request(process, server_id, request_json, || { + PendingHttpRequest::Deferred(respond_to) + })?; + Ok((request_key, receiver)) + })?; + let mut guard = OwnedLoopbackHttpRequest { + vm: vm.clone(), + process_id: process_id.to_owned(), + request_key, + armed: true, + }; + let response = tokio::time::timeout(http_loopback_request_timeout(), receiver) + .await + .map_err(|_| { + SidecarError::Execution(String::from( + "HTTP loopback request timed out waiting for net.http_respond", + )) + })? + .map_err(|_| { + SidecarError::InvalidState(String::from( + "HTTP loopback response waiter closed before net.http_respond", + )) + })? + .map_err(|error| SidecarError::Execution(format!("{}: {}", error.code, error.message)))?; + guard.complete(); + response.as_str().map(str::to_owned).ok_or_else(|| { + SidecarError::InvalidState(String::from( + "HTTP loopback response completed with a non-string value", + )) + }) +} + +/// Run `vm.fetch` without holding either the process coordinator or a VM state +/// borrow over readiness and adapter-response waits. +pub(in crate::execution) async fn dispatch_owned_vm_fetch( + bridge: SharedBridge, + vm_id: &str, + vm: crate::state::VmHandle, + payload: VmFetchRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let stream_operation = payload.stream_operation.as_deref(); + if matches!(stream_operation, Some("read" | "cancel")) { + let stream_id = payload.stream_id.as_deref().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "vm.fetch stream read/cancel requires stream_id", + )) + })?; + return if stream_operation == Some("read") { + read_owned_kernel_http_fetch_stream( + &bridge, + vm_id, + &vm, + stream_id, + payload.max_bytes.unwrap_or(64 * 1024) as usize, + ) + .await + } else { + cancel_owned_kernel_http_fetch_stream(&vm, stream_id) + }; + } + if let Some(operation) = stream_operation { + if operation != "start" { + return Err(SidecarError::InvalidState(format!( + "unknown vm.fetch stream operation {operation:?}; expected start, read, or cancel" + ))); + } + } + + let target_path = format!("/{}", payload.path.trim_start_matches('/')); + let request_url = Url::parse(&format!("http://127.0.0.1:{}{target_path}", payload.port)) + .map_err(|error| { + SidecarError::InvalidState(format!("invalid vm.fetch target {target_path:?}: {error}")) + })?; + let header_values: BTreeMap = serde_json::from_str(&payload.headers_json) + .map_err(|error| { + SidecarError::InvalidState(format!("vm.fetch headers_json must be valid JSON: {error}")) + })?; + if payload.body.is_some() && payload.body_base64.is_some() { + return Err(SidecarError::InvalidState(String::from( + "vm.fetch accepts either body or body_base64, not both", + ))); + } + let body_bytes = payload + .body_base64 + .as_deref() + .map(|body| { + base64::engine::general_purpose::STANDARD + .decode(body) + .map_err(|error| { + SidecarError::InvalidState(format!( + "vm.fetch body_base64 must be valid base64: {error}" + )) + }) + }) + .transpose()?; + let options = JavascriptHttpRequestOptions { + method: Some(payload.method), + headers: header_values, + body: payload.body, + reject_unauthorized: None, + }; + let headers = parse_http_header_collection(&options.headers, "vm.fetch headers")?; + let (kernel_target, max_fetch_response_bytes) = + vm.try_read("resolve VM fetch target", |vm| { + ( + find_kernel_http_listener_process(vm, payload.port), + vm.limits.http.max_fetch_response_bytes, + ) + })?; + if let Some(target_process_id) = kernel_target { + return if stream_operation == Some("start") { + start_owned_kernel_http_fetch_stream( + &bridge, + vm_id, + &vm, + &target_process_id, + payload.port, + &target_path, + &options, + &headers, + body_bytes.as_deref(), + max_fetch_response_bytes, + ) + .await + } else { + dispatch_owned_kernel_http_fetch( + &bridge, + vm_id, + &vm, + &target_process_id, + payload.port, + &target_path, + &options, + &headers, + body_bytes.as_deref(), + max_fetch_response_bytes, + ) + .await + }; + } + + let target = vm.try_read("resolve loopback VM fetch target", |vm| { + vm.active_processes + .iter() + .find_map(|(process_id, process)| { + process + .http_servers + .iter() + .find(|(_, server)| server.guest_local_addr.port() == payload.port) + .map(|(server_id, _)| (process_id.clone(), *server_id)) + }) + })?; + let Some((target_process_id, server_id)) = target else { + return Err(SidecarError::Execution(format!( + "vm.fetch could not find a guest HTTP listener on port {} in VM {vm_id}", + payload.port + ))); + }; + if stream_operation == Some("start") { + return Err(SidecarError::InvalidState(String::from( + "vm.fetch streaming requires a kernel-backed HTTP listener", + ))); + } + if body_bytes.is_some() { + return Err(SidecarError::InvalidState(String::from( + "binary vm.fetch bodies require a kernel-backed HTTP listener", + ))); + } + let request_json = serialize_http_loopback_request(&request_url, &options, &headers)?; + dispatch_owned_loopback_http_request(&vm, &target_process_id, server_id, &request_json).await +} + #[allow(clippy::too_many_arguments)] async fn dispatch_kernel_http_fetch_with_socket( bridge: &SharedBridge, @@ -1701,6 +3072,7 @@ mod dns_rebinding_pin_tests { /// DNS would resolve elsewhere; pinning forces the connect onto the vetted /// IP only. If the resolver were unpinned, the request would fail to reach /// this server (and on a real host could land on a private/metadata IP). + #[cfg(test)] #[test] fn outbound_http_connect_is_pinned_to_vetted_ip() { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback server"); diff --git a/crates/native-sidecar/src/execution/javascript/mod.rs b/crates/native-sidecar/src/execution/javascript/mod.rs index c85911688b..8b34bed62a 100644 --- a/crates/native-sidecar/src/execution/javascript/mod.rs +++ b/crates/native-sidecar/src/execution/javascript/mod.rs @@ -13,7 +13,8 @@ pub(crate) use self::rpc::{ javascript_sync_rpc_arg_u64_optional, javascript_sync_rpc_bytes_arg, javascript_sync_rpc_bytes_value, javascript_sync_rpc_encoding, javascript_sync_rpc_error_code, javascript_sync_rpc_option_bool, javascript_sync_rpc_option_u32, service_javascript_sync_rpc, - JavascriptSyncRpcServiceRequest, JavascriptSyncRpcServiceResponse, KernelPollFdRequest, + settle_javascript_sync_rpc_completion, JavascriptSyncRpcServiceRequest, + JavascriptSyncRpcServiceResponse, KernelPollFdRequest, }; mod crypto; pub(crate) use self::crypto::service_javascript_crypto_sync_rpc; @@ -23,5 +24,6 @@ mod http; pub(in crate::execution) use self::http::*; pub(crate) use self::http::{ dispatch_loopback_http_request, dispatch_loopback_http_request_deferred, - ensure_vm_fetch_response_frame_within_limit, LoopbackHttpDispatchRequest, + ensure_vm_fetch_response_frame_within_limit, service_owned_javascript_sync_rpc_request, + settle_owned_fetch_sync_rpc, LoopbackHttpDispatchRequest, }; diff --git a/crates/native-sidecar/src/execution/javascript/rpc.rs b/crates/native-sidecar/src/execution/javascript/rpc.rs index bbe575c27e..e0aaac0ff3 100644 --- a/crates/native-sidecar/src/execution/javascript/rpc.rs +++ b/crates/native-sidecar/src/execution/javascript/rpc.rs @@ -2247,6 +2247,48 @@ where Ok(response.into()) } +/// Complete a deferred JavaScript RPC after selecting its exact process. +/// +/// `net.connect` is a two-phase operation: the socket task leaves its owned +/// socket in `pending_javascript_net_connects` and resolves the deferred value +/// to `null`. Every completion path must commit (or roll back) that pending +/// state before replying to the guest; otherwise a successful connect is +/// exposed as an empty socket handle. +pub(crate) fn settle_javascript_sync_rpc_completion( + process: &mut ActiveProcess, + kernel_readiness: &KernelSocketReadinessRegistry, + request_id: u64, + completion: Result, +) -> Result<(), SidecarError> { + let connected = process.pending_javascript_net_connects.remove(&request_id); + let completion = match (completion, connected) { + (Ok(_), Some(connected)) => { + finalize_javascript_net_connect(process, kernel_readiness, connected).map_err(|error| { + crate::state::DeferredRpcError { + code: javascript_sync_rpc_error_code(&error), + message: javascript_sync_rpc_error_message(&error), + } + }) + } + (result @ Err(_), Some(connected)) => { + restore_pending_bound_unix_connect(process, &connected)?; + result + } + (result, None) => result, + }; + match completion { + Ok(value) => process + .execution + .respond_javascript_sync_rpc_success(request_id, value), + Err(error) => process.execution.respond_javascript_sync_rpc_error( + request_id, + error.code, + error.message, + ), + } + .or_else(ignore_stale_javascript_sync_rpc_response) +} + fn service_javascript_internal_bridge_sync_rpc( process: &ActiveProcess, request: &JavascriptSyncRpcRequest, @@ -2881,6 +2923,13 @@ async fn service_javascript_dgram_poll_response( .poll(kernel, process.kernel_pid, Duration::from_millis(wait_ms)) .await?; + javascript_dgram_poll_event_response(socket_paths, event) +} + +pub(in crate::execution) fn javascript_dgram_poll_event_response( + socket_paths: &JavascriptSocketPathContext, + event: Option, +) -> Result { match event { Some(JavascriptUdpSocketEvent::Message { data, diff --git a/crates/native-sidecar/src/execution/launch.rs b/crates/native-sidecar/src/execution/launch.rs index 26b5390bdc..a24a96bfa2 100644 --- a/crates/native-sidecar/src/execution/launch.rs +++ b/crates/native-sidecar/src/execution/launch.rs @@ -4701,416 +4701,471 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - pub(crate) async fn execute( + pub(crate) fn execute( &mut self, request: &RequestFrame, payload: ExecuteRequest, - ) -> Result { - let execute_total_start = Instant::now(); + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + let sidecar_requests = self.sidecar_requests.clone(); + let process_event_notify = Arc::clone(&self.process_event_notify); + let cache_root = self.cache_root.clone(); let process_event_capacity = self.config.runtime.protocol.max_process_events; - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self - .vms - .get_mut(&vm_id) - .ok_or_else(|| missing_vm_error(&vm_id))?; - if vm.active_processes.contains_key(&payload.process_id) { - return Err(SidecarError::InvalidState(format!( - "VM {vm_id} already has an active process with id {}", - payload.process_id - ))); + Box::pin(async move { + execute_owned( + input?, + payload, + bridge, + sidecar_requests, + process_event_notify, + cache_root, + process_event_capacity, + ) + .await + }) + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn execute_owned( + input: OwnedVmRouteInput, + payload: ExecuteRequest, + bridge: SharedBridge, + sidecar_requests: SharedSidecarRequestClient, + process_event_notify: Arc, + cache_root: PathBuf, + process_event_capacity: usize, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let execute_total_start = Instant::now(); + let request = &input.request; + let (connection_id, session_id, vm_id) = match &request.ownership { + OwnershipScope::VmOwnership(ownership) => ( + ownership.connection_id.clone(), + ownership.session_id.clone(), + input.vm_id.clone(), + ), + _ => { + return Err(SidecarError::InvalidState(String::from( + "execute requires VM ownership", + ))) } - let vm_pending_stdin_bytes_budget = Arc::clone(&vm.pending_stdin_bytes_budget); - let vm_pending_event_bytes_budget = Arc::clone(&vm.pending_event_bytes_budget); + }; + let execution_engines = input.vm.try_read("clone VM execution services", |vm| { + vm.execution_engines.clone() + })?; + let mut vm = input.vm.try_borrow_mut("prepare and start execution")?; + if vm.active_processes.contains_key(&payload.process_id) { + return Err(SidecarError::InvalidState(format!( + "VM {vm_id} already has an active process with id {}", + payload.process_id + ))); + } + let vm_pending_stdin_bytes_budget = Arc::clone(&vm.pending_stdin_bytes_budget); + let vm_pending_event_bytes_budget = Arc::clone(&vm.pending_event_bytes_budget); - if let Some(command) = payload.command.as_deref() { - if let Some(binding_resolution) = - resolve_binding_command(vm, command, &payload.args, payload.cwd.as_deref())? - { - let guest_cwd = payload - .cwd - .as_deref() - .map(normalize_path) - .unwrap_or_else(|| vm.guest_cwd.clone()); - let kernel_handle = vm - .kernel - .create_virtual_process( - EXECUTION_DRIVER_NAME, - BINDING_DRIVER_NAME, - command, - std::iter::once(command.to_owned()) - .chain(payload.args.iter().cloned()) - .collect(), - VirtualProcessOptions { - env: vm.guest_env.clone(), - cwd: Some(guest_cwd.clone()), - ..VirtualProcessOptions::default() - }, - ) - .map_err(kernel_error)?; - let kernel_pid = kernel_handle.pid(); - let binding_execution = BindingExecution::with_event_notify( - Arc::clone(&self.process_event_notify), + if let Some(command) = payload.command.as_deref() { + if let Some(binding_resolution) = + resolve_binding_command(&mut vm, command, &payload.args, payload.cwd.as_deref())? + { + let guest_cwd = payload + .cwd + .as_deref() + .map(normalize_path) + .unwrap_or_else(|| vm.guest_cwd.clone()); + let guest_env = vm.guest_env.clone(); + let kernel_handle = vm + .kernel + .create_virtual_process( + EXECUTION_DRIVER_NAME, + BINDING_DRIVER_NAME, + command, + std::iter::once(command.to_owned()) + .chain(payload.args.iter().cloned()) + .collect(), + VirtualProcessOptions { + env: guest_env, + cwd: Some(guest_cwd.clone()), + ..VirtualProcessOptions::default() + }, + ) + .map_err(kernel_error)?; + let kernel_pid = kernel_handle.pid(); + let binding_execution = BindingExecution::with_event_notify( + Arc::clone(&process_event_notify), + process_event_capacity, + ) + .with_vm_pending_event_bytes_budget(Arc::clone(&vm_pending_event_bytes_budget)); + let cancelled = binding_execution.cancelled.clone(); + let pending_events = binding_execution.pending_events.clone(); + let event_overflow_reason = binding_execution.event_overflow_reason.clone(); + let pending_event_bytes = binding_execution.pending_event_bytes.clone(); + let pending_event_count_limit = binding_execution.pending_event_count_limit.clone(); + let pending_event_bytes_limit = binding_execution.pending_event_bytes_limit.clone(); + let binding_vm_pending_event_bytes_budget = + binding_execution.vm_pending_event_bytes_budget.clone(); + let event_notify = binding_execution.event_notify.clone(); + let runtime_context = vm.runtime_context.clone(); + let limits = vm.limits.clone(); + let shadow_root = normalize_host_path(&vm.cwd); + let host_cwd = resolve_vm_guest_path_to_host(&mut vm, &guest_cwd); + vm.active_processes.insert( + payload.process_id.clone(), + ActiveProcess::new( + kernel_pid, + kernel_handle, + runtime_context.clone(), + limits, process_event_capacity, + GuestRuntimeKind::JavaScript, + ActiveExecution::Binding(binding_execution), ) - .with_vm_pending_event_bytes_budget(Arc::clone(&vm_pending_event_bytes_budget)); - let cancelled = binding_execution.cancelled.clone(); - let pending_events = binding_execution.pending_events.clone(); - let event_overflow_reason = binding_execution.event_overflow_reason.clone(); - let pending_event_bytes = binding_execution.pending_event_bytes.clone(); - let pending_event_count_limit = binding_execution.pending_event_count_limit.clone(); - let pending_event_bytes_limit = binding_execution.pending_event_bytes_limit.clone(); - let binding_vm_pending_event_bytes_budget = - binding_execution.vm_pending_event_bytes_budget.clone(); - let event_notify = binding_execution.event_notify.clone(); - vm.active_processes.insert( - payload.process_id.clone(), - ActiveProcess::new( - kernel_pid, - kernel_handle, - vm.runtime_context.clone(), - vm.limits.clone(), - process_event_capacity, - GuestRuntimeKind::JavaScript, - ActiveExecution::Binding(binding_execution), - ) - .with_event_notify(Arc::clone(&self.process_event_notify)) - .with_vm_pending_byte_budgets( - Arc::clone(&vm_pending_stdin_bytes_budget), - Arc::clone(&vm_pending_event_bytes_budget), - ) - .with_guest_cwd(guest_cwd.clone()) - .with_shadow_root(normalize_host_path(&vm.cwd)) - .with_host_cwd(resolve_vm_guest_path_to_host(vm, &guest_cwd)), - ); - self.bridge.emit_lifecycle(&vm_id, LifecycleState::Busy)?; - spawn_binding_process_events(BindingProcessEventRequest { - runtime_context: vm.runtime_context.clone(), - sidecar_requests: self.sidecar_requests.clone(), - connection_id: connection_id.clone(), - session_id: session_id.clone(), - vm_id: vm_id.clone(), - binding_resolution, - cancelled, - pending_events, - event_overflow_reason, - pending_event_bytes, - pending_event_count_limit, - pending_event_bytes_limit, - vm_pending_event_bytes_budget: binding_vm_pending_event_bytes_budget, - event_notify, - }); - return Ok(DispatchResult { - response: process_started_response( - request, - payload.process_id, - Some(kernel_pid), - ), - events: Vec::new(), - }); - } + .with_event_notify(Arc::clone(&process_event_notify)) + .with_vm_pending_byte_budgets( + Arc::clone(&vm_pending_stdin_bytes_budget), + Arc::clone(&vm_pending_event_bytes_budget), + ) + .with_guest_cwd(guest_cwd.clone()) + .with_shadow_root(shadow_root) + .with_host_cwd(host_cwd), + ); + bridge.emit_lifecycle(&vm_id, LifecycleState::Busy)?; + spawn_binding_process_events(BindingProcessEventRequest { + runtime_context, + sidecar_requests: sidecar_requests.clone(), + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + binding_resolution, + cancelled, + pending_events, + event_overflow_reason, + pending_event_bytes, + pending_event_count_limit, + pending_event_bytes_limit, + vm_pending_event_bytes_budget: binding_vm_pending_event_bytes_budget, + event_notify, + }); + return Ok(DispatchResult { + response: process_started_response(request, payload.process_id, Some(kernel_pid)), + events: Vec::new(), + }); } + } - let requested_tty = payload - .env - .get(EXECUTION_REQUEST_TTY_ENV) - .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); - let phase_start = Instant::now(); - let mut resolved = resolve_execute_request(vm, &payload)?; - stage_agentos_package_command(vm, &mut resolved)?; - let resolved = resolved; - record_execute_phase("resolve_execute_request", phase_start.elapsed()); - let phase_start = Instant::now(); - let mut env = resolved.env.clone(); - env.remove(EXECUTION_REQUEST_TTY_ENV); - let sandbox_root = normalize_host_path(&vm.cwd); - env.insert( - String::from(EXECUTION_SANDBOX_ROOT_ENV), - sandbox_root.to_string_lossy().into_owned(), - ); - if resolved.runtime == GuestRuntimeKind::JavaScript { - env.insert(String::from("AGENTOS_KEEP_STDIN_OPEN"), String::from("1")); - // A TTY guest-node process reads stdin through the kernel PTY: host - // input is written to the PTY master (write_kernel_process_stdin), - // line discipline runs (echo / VERASE / ICRNL / VEOF), and the - // sidecar drains the cooked bytes from the slave and forwards them - // to the isolate's stream-stdin dispatch - // (forward_tty_slave_input_to_javascript). The in-isolate - // `_kernelStdinRead` bridge stays local; no RPC forwarding is - // needed because the isolate never reads kernel fd 0 itself. - } else if resolved.runtime == GuestRuntimeKind::WebAssembly { - env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1")); - } - let launch_entrypoint = if resolved.runtime == GuestRuntimeKind::JavaScript { - resolve_agentos_package_javascript_launch_entrypoint(vm, &mut env) - .unwrap_or_else(|| resolved.entrypoint.clone()) - } else { - resolved.entrypoint.clone() - }; - let argv = std::iter::once(launch_entrypoint.clone()) - .chain(resolved.execution_args.iter().cloned()) - .collect::>(); - record_execute_phase("env_argv_setup", phase_start.elapsed()); - let phase_start = Instant::now(); - let kernel_handle = vm + let requested_tty = payload + .env + .get(EXECUTION_REQUEST_TTY_ENV) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + let phase_start = Instant::now(); + let mut resolved = resolve_execute_request(&mut vm, &payload)?; + stage_agentos_package_command(&mut vm, &mut resolved)?; + let resolved = resolved; + record_execute_phase("resolve_execute_request", phase_start.elapsed()); + let phase_start = Instant::now(); + let mut env = resolved.env.clone(); + env.remove(EXECUTION_REQUEST_TTY_ENV); + let sandbox_root = normalize_host_path(&vm.cwd); + env.insert( + String::from(EXECUTION_SANDBOX_ROOT_ENV), + sandbox_root.to_string_lossy().into_owned(), + ); + if resolved.runtime == GuestRuntimeKind::JavaScript { + env.insert(String::from("AGENTOS_KEEP_STDIN_OPEN"), String::from("1")); + // A TTY guest-node process reads stdin through the kernel PTY: host + // input is written to the PTY master (write_kernel_process_stdin), + // line discipline runs (echo / VERASE / ICRNL / VEOF), and the + // sidecar drains the cooked bytes from the slave and forwards them + // to the isolate's stream-stdin dispatch + // (forward_tty_slave_input_to_javascript). The in-isolate + // `_kernelStdinRead` bridge stays local; no RPC forwarding is + // needed because the isolate never reads kernel fd 0 itself. + } else if resolved.runtime == GuestRuntimeKind::WebAssembly { + env.insert(String::from(WASM_STDIO_SYNC_RPC_ENV), String::from("1")); + } + let launch_entrypoint = if resolved.runtime == GuestRuntimeKind::JavaScript { + resolve_agentos_package_javascript_launch_entrypoint(&mut vm, &mut env) + .unwrap_or_else(|| resolved.entrypoint.clone()) + } else { + resolved.entrypoint.clone() + }; + let argv = std::iter::once(launch_entrypoint.clone()) + .chain(resolved.execution_args.iter().cloned()) + .collect::>(); + record_execute_phase("env_argv_setup", phase_start.elapsed()); + let phase_start = Instant::now(); + let kernel_handle = vm + .kernel + .spawn_process( + &resolved.command, + argv, + SpawnOptions { + requester_driver: Some(String::from(EXECUTION_DRIVER_NAME)), + cwd: Some(resolved.guest_cwd.clone()), + ..SpawnOptions::default() + }, + ) + .map_err(kernel_error)?; + let kernel_pid = kernel_handle.pid(); + if let Err(error) = enforce_resolved_wasm_execute_dac(&mut vm, kernel_pid, &resolved) { + kernel_handle.finish(126); + return Err(error); + } + record_execute_phase("kernel_spawn_process", phase_start.elapsed()); + let tty_master_fd = if requested_tty { + let (master_fd, slave_fd, _) = vm .kernel - .spawn_process( - &resolved.command, - argv, - SpawnOptions { - requester_driver: Some(String::from(EXECUTION_DRIVER_NAME)), - cwd: Some(resolved.guest_cwd.clone()), - ..SpawnOptions::default() - }, - ) + .open_pty(EXECUTION_DRIVER_NAME, kernel_pid) .map_err(kernel_error)?; - let kernel_pid = kernel_handle.pid(); - if let Err(error) = enforce_resolved_wasm_execute_dac(vm, kernel_pid, &resolved) { - kernel_handle.finish(126); - return Err(error); - } - record_execute_phase("kernel_spawn_process", phase_start.elapsed()); - let tty_master_fd = if requested_tty { - let (master_fd, slave_fd, _) = vm - .kernel - .open_pty(EXECUTION_DRIVER_NAME, kernel_pid) - .map_err(kernel_error)?; - vm.kernel - .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 0) - .map_err(kernel_error)?; - vm.kernel - .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 1) - .map_err(kernel_error)?; - vm.kernel - .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 2) - .map_err(kernel_error)?; + vm.kernel + .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 0) + .map_err(kernel_error)?; + vm.kernel + .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 1) + .map_err(kernel_error)?; + vm.kernel + .fd_dup2(EXECUTION_DRIVER_NAME, kernel_pid, slave_fd, 2) + .map_err(kernel_error)?; + vm.kernel + .pty_set_foreground_pgid(EXECUTION_DRIVER_NAME, kernel_pid, master_fd, kernel_pid) + .map_err(kernel_error)?; + if let Some((cols, rows)) = requested_pty_window_size(&env) { vm.kernel - .pty_set_foreground_pgid(EXECUTION_DRIVER_NAME, kernel_pid, master_fd, kernel_pid) + .pty_resize(EXECUTION_DRIVER_NAME, kernel_pid, master_fd, cols, rows) .map_err(kernel_error)?; - if let Some((cols, rows)) = requested_pty_window_size(&env) { - vm.kernel - .pty_resize(EXECUTION_DRIVER_NAME, kernel_pid, master_fd, cols, rows) - .map_err(kernel_error)?; - } - Some(master_fd) - } else { - None - }; + } + Some(master_fd) + } else { + None + }; - let (execution, process_env) = match resolved.runtime { - GuestRuntimeKind::JavaScript => { - let phase_start = Instant::now(); - let inline_code = load_javascript_entrypoint_source( - vm, - &resolved.host_cwd, - &launch_entrypoint, - &env, - ); - record_execute_phase("js_load_entrypoint_source", phase_start.elapsed()); - let phase_start = Instant::now(); - prepare_javascript_shadow(vm, &resolved, &env)?; - record_execute_phase("js_prepare_shadow", phase_start.elapsed()); - - let phase_start = Instant::now(); - let context = - self.javascript_engine - .create_context(CreateJavascriptContextRequest { - vm_id: vm_id.clone(), - bootstrap_module: None, - compile_cache_root: Some(self.cache_root.join("node-compile-cache")), - }); - record_execute_phase("js_create_context", phase_start.elapsed()); - let phase_start = Instant::now(); - let built_reader = build_module_reader(vm, &resolved); - let guest_reader = built_reader.clone().map(|reader| { - Box::new(crate::plugins::host_dir::SessionModuleReader::new(reader)) - as Box - }); - let module_reader = - built_reader.map(|reader| Box::new(reader) as Box); - record_execute_phase("js_build_module_reader", phase_start.elapsed()); - let phase_start = Instant::now(); - let execution = self - .javascript_engine - .start_execution_with_module_reader_and_runtime( - StartJavascriptExecutionRequest { - guest_runtime: guest_runtime_identity(vm, None, None), - vm_id: vm_id.clone(), - context_id: context.context_id, - argv: std::iter::once(launch_entrypoint.clone()) - .chain(resolved.execution_args.iter().cloned()) - .collect(), - argv0: None, - env: env.clone(), - cwd: resolved.host_cwd.clone(), - limits: javascript_execution_limits(vm), - inline_code, - wasm_module_bytes: None, - }, - module_reader, - guest_reader, - vm.runtime_context.clone(), - ) - .map_err(javascript_error)?; - record_execute_phase("js_start_execution", phase_start.elapsed()); - (ActiveExecution::Javascript(execution), env.clone()) - } - GuestRuntimeKind::Python => { - // The `python` command path (marked by AGENTOS_PYTHON_ARGV) is - // explicit about file mode via AGENTOS_PYTHON_FILE, so a `-c` code - // string that happens to end in `.py` is never mistaken for a path. - // The low-level execute API keeps the `.py`-suffix heuristic. - let python_file_path = if resolved.env.contains_key("AGENTOS_PYTHON_ARGV") { - resolved.env.get("AGENTOS_PYTHON_FILE").map(PathBuf::from) - } else { - python_file_entrypoint(&resolved.entrypoint) - }; - let pyodide_dist_path = self - .python_engine - .bundled_pyodide_dist_path_for_vm_async(&vm_id, &vm.runtime_context) - .await - .map_err(python_error)?; - let pyodide_cache_path = pyodide_dist_path - .parent() - .and_then(Path::parent) - .unwrap_or(pyodide_dist_path.as_path()) - .join("pyodide-package-cache"); - add_runtime_guest_path_mapping( - &mut env, - PYTHON_PYODIDE_GUEST_ROOT, - &pyodide_dist_path, - ); - add_runtime_guest_path_mapping( - &mut env, - PYTHON_PYODIDE_CACHE_GUEST_ROOT, - &pyodide_cache_path, - ); - add_runtime_host_access_path( - &mut env, - "AGENTOS_EXTRA_FS_READ_PATHS", - &pyodide_dist_path, - true, - ); - add_runtime_host_access_path( - &mut env, - "AGENTOS_EXTRA_FS_READ_PATHS", - &pyodide_cache_path, - true, - ); - add_runtime_host_access_path( - &mut env, - "AGENTOS_EXTRA_FS_WRITE_PATHS", - &pyodide_cache_path, - false, - ); - let context = self - .python_engine - .create_context(CreatePythonContextRequest { + let (execution, process_env) = match resolved.runtime { + GuestRuntimeKind::JavaScript => { + let phase_start = Instant::now(); + let inline_code = load_javascript_entrypoint_source( + &mut vm, + &resolved.host_cwd, + &launch_entrypoint, + &env, + ); + record_execute_phase("js_load_entrypoint_source", phase_start.elapsed()); + let phase_start = Instant::now(); + prepare_javascript_shadow(&mut vm, &resolved, &env)?; + record_execute_phase("js_prepare_shadow", phase_start.elapsed()); + + let phase_start = Instant::now(); + let mut javascript_engine = + execution_engines.javascript("start JavaScript execution")?; + let context = javascript_engine.create_context(CreateJavascriptContextRequest { + vm_id: vm_id.clone(), + bootstrap_module: None, + compile_cache_root: Some(cache_root.join("node-compile-cache")), + }); + record_execute_phase("js_create_context", phase_start.elapsed()); + let phase_start = Instant::now(); + let built_reader = build_module_reader(&mut vm, &resolved); + let guest_reader = built_reader.clone().map(|reader| { + Box::new(crate::plugins::host_dir::SessionModuleReader::new(reader)) + as Box + }); + let module_reader = + built_reader.map(|reader| Box::new(reader) as Box); + record_execute_phase("js_build_module_reader", phase_start.elapsed()); + let phase_start = Instant::now(); + let execution = javascript_engine + .start_execution_with_module_reader_and_runtime( + StartJavascriptExecutionRequest { + guest_runtime: guest_runtime_identity(&mut vm, None, None), vm_id: vm_id.clone(), - pyodide_dist_path, - }); - let execution = self - .python_engine - .start_execution_with_runtime_async( - StartPythonExecutionRequest { - vm_id: vm_id.clone(), - context_id: context.context_id, - code: resolved.entrypoint.clone(), - file_path: python_file_path, - env: env.clone(), - cwd: resolved.host_cwd.clone(), - limits: python_execution_limits_with_env(vm, &env), - guest_runtime: guest_runtime_identity(vm, None, None), - }, - vm.runtime_context.clone(), - ) - .await - .map_err(python_error)?; - (ActiveExecution::Python(execution), env.clone()) - } - GuestRuntimeKind::WebAssembly => { - let wasm_limits = wasm_execution_limits(vm); - let wasm_guest_runtime = - guest_runtime_identity(vm, Some(u64::from(kernel_pid)), Some(0)); - let wasm_permission_tier = resolved.wasm_permission_tier.unwrap_or_else(|| { - resolve_wasm_permission_tier( - vm, - Some(&resolved.command), - None, - &resolved.entrypoint, - ) - }); - let context = self.wasm_engine.create_context(CreateWasmContextRequest { - vm_id: vm_id.clone(), - module_path: Some(resolved.entrypoint.clone()), - }); - let execution = self - .wasm_engine - .start_execution_with_runtime_async( - StartWasmExecutionRequest { - vm_id: vm_id.clone(), - context_id: context.context_id, - argv: resolved.process_args.clone(), - env: env.clone(), - cwd: resolved.host_cwd.clone(), - permission_tier: execution_wasm_permission_tier(wasm_permission_tier), - limits: wasm_limits, - guest_runtime: wasm_guest_runtime, - }, - vm.runtime_context.clone(), - ) - .await - .map_err(wasm_error)?; - (ActiveExecution::Wasm(Box::new(execution)), env) - } - }; - let child_pid = execution.child_pid(); - let phase_start = Instant::now(); - let kernel_stdin_writer_fd = if let Some(master_fd) = tty_master_fd { - master_fd - } else { - install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)? - }; - vm.active_processes.insert( - payload.process_id.clone(), - ActiveProcess::new( - kernel_pid, - kernel_handle, - vm.runtime_context.clone(), - vm.limits.clone(), - process_event_capacity, - resolved.runtime, - execution, - ) - .with_event_notify(Arc::clone(&self.process_event_notify)) - .with_vm_pending_byte_budgets( - vm_pending_stdin_bytes_budget, - vm_pending_event_bytes_budget, - ) - .with_kernel_stdin_writer_fd(kernel_stdin_writer_fd) - .with_tty_master_fd(tty_master_fd) - .with_guest_cwd(resolved.guest_cwd.clone()) - .with_env(process_env) - .with_shadow_root(sandbox_root) - .with_host_cwd(resolved.host_cwd.clone()), - ); - self.bridge.emit_lifecycle(&vm_id, LifecycleState::Busy)?; - mark_execute_response_ready(&vm_id, &payload.process_id); - record_execute_phase("process_register_and_lifecycle", phase_start.elapsed()); - record_execute_phase("execute_total", execute_total_start.elapsed()); - - Ok(DispatchResult { - response: process_started_response( - request, - payload.process_id, - Some(if child_pid == 0 { - kernel_pid - } else { - child_pid - }), - ), - events: Vec::new(), - }) - } + context_id: context.context_id, + argv: std::iter::once(launch_entrypoint.clone()) + .chain(resolved.execution_args.iter().cloned()) + .collect(), + argv0: None, + env: env.clone(), + cwd: resolved.host_cwd.clone(), + limits: javascript_execution_limits(&mut vm), + inline_code, + wasm_module_bytes: None, + }, + module_reader, + guest_reader, + vm.runtime_context.clone(), + ) + .map_err(javascript_error)?; + record_execute_phase("js_start_execution", phase_start.elapsed()); + (ActiveExecution::Javascript(execution), env.clone()) + } + GuestRuntimeKind::Python => { + // The `python` command path (marked by AGENTOS_PYTHON_ARGV) is + // explicit about file mode via AGENTOS_PYTHON_FILE, so a `-c` code + // string that happens to end in `.py` is never mistaken for a path. + // The low-level execute API keeps the `.py`-suffix heuristic. + let python_file_path = if resolved.env.contains_key("AGENTOS_PYTHON_ARGV") { + resolved.env.get("AGENTOS_PYTHON_FILE").map(PathBuf::from) + } else { + python_file_entrypoint(&resolved.entrypoint) + }; + let runtime_context = vm.runtime_context.clone(); + let python_limits = python_execution_limits_with_env(&mut vm, &env); + let python_guest_runtime = guest_runtime_identity(&mut vm, None, None); + // Pyodide discovery and runtime warmup can await bounded workers. + // They own only the VM-local Python service, not the mutable VM + // coordinator, so filesystem/kernel commands can still enter this + // VM and operations in other VMs remain independent. + drop(vm); + let mut python_engine = execution_engines.python("start Python execution")?; + let pyodide_dist_path = python_engine + .bundled_pyodide_dist_path_for_vm_async(&vm_id, &runtime_context) + .await + .map_err(python_error)?; + let pyodide_cache_path = pyodide_dist_path + .parent() + .and_then(Path::parent) + .unwrap_or(pyodide_dist_path.as_path()) + .join("pyodide-package-cache"); + add_runtime_guest_path_mapping(&mut env, PYTHON_PYODIDE_GUEST_ROOT, &pyodide_dist_path); + add_runtime_guest_path_mapping( + &mut env, + PYTHON_PYODIDE_CACHE_GUEST_ROOT, + &pyodide_cache_path, + ); + add_runtime_host_access_path( + &mut env, + "AGENTOS_EXTRA_FS_READ_PATHS", + &pyodide_dist_path, + true, + ); + add_runtime_host_access_path( + &mut env, + "AGENTOS_EXTRA_FS_READ_PATHS", + &pyodide_cache_path, + true, + ); + add_runtime_host_access_path( + &mut env, + "AGENTOS_EXTRA_FS_WRITE_PATHS", + &pyodide_cache_path, + false, + ); + let context = python_engine.create_context(CreatePythonContextRequest { + vm_id: vm_id.clone(), + pyodide_dist_path, + }); + let execution = python_engine + .start_execution_with_runtime_async( + StartPythonExecutionRequest { + vm_id: vm_id.clone(), + context_id: context.context_id, + code: resolved.entrypoint.clone(), + file_path: python_file_path, + env: env.clone(), + cwd: resolved.host_cwd.clone(), + limits: python_limits, + guest_runtime: python_guest_runtime, + }, + runtime_context, + ) + .await + .map_err(python_error)?; + drop(python_engine); + vm = input + .vm + .try_borrow_mut("register started Python execution")?; + (ActiveExecution::Python(execution), env.clone()) + } + GuestRuntimeKind::WebAssembly => { + let wasm_limits = wasm_execution_limits(&mut vm); + let wasm_guest_runtime = + guest_runtime_identity(&mut vm, Some(u64::from(kernel_pid)), Some(0)); + let wasm_permission_tier = resolved.wasm_permission_tier.unwrap_or_else(|| { + resolve_wasm_permission_tier( + &mut vm, + Some(&resolved.command), + None, + &resolved.entrypoint, + ) + }); + let runtime_context = vm.runtime_context.clone(); + // WASM import-cache materialization and prewarm are external + // waits. Release mutable VM state before entering them. + drop(vm); + let mut wasm_engine = execution_engines.wasm("start WebAssembly execution")?; + let context = wasm_engine.create_context(CreateWasmContextRequest { + vm_id: vm_id.clone(), + module_path: Some(resolved.entrypoint.clone()), + }); + let execution = wasm_engine + .start_execution_with_runtime_async( + StartWasmExecutionRequest { + vm_id: vm_id.clone(), + context_id: context.context_id, + argv: resolved.process_args.clone(), + env: env.clone(), + cwd: resolved.host_cwd.clone(), + permission_tier: execution_wasm_permission_tier(wasm_permission_tier), + limits: wasm_limits, + guest_runtime: wasm_guest_runtime, + }, + runtime_context, + ) + .await + .map_err(wasm_error)?; + drop(wasm_engine); + vm = input + .vm + .try_borrow_mut("register started WebAssembly execution")?; + (ActiveExecution::Wasm(Box::new(execution)), env) + } + }; + let child_pid = execution.child_pid(); + let phase_start = Instant::now(); + let kernel_stdin_writer_fd = if let Some(master_fd) = tty_master_fd { + master_fd + } else { + install_kernel_stdin_pipe(&mut vm.kernel, kernel_pid)? + }; + let runtime_context = vm.runtime_context.clone(); + let limits = vm.limits.clone(); + vm.active_processes.insert( + payload.process_id.clone(), + ActiveProcess::new( + kernel_pid, + kernel_handle, + runtime_context, + limits, + process_event_capacity, + resolved.runtime, + execution, + ) + .with_event_notify(Arc::clone(&process_event_notify)) + .with_vm_pending_byte_budgets(vm_pending_stdin_bytes_budget, vm_pending_event_bytes_budget) + .with_kernel_stdin_writer_fd(kernel_stdin_writer_fd) + .with_tty_master_fd(tty_master_fd) + .with_guest_cwd(resolved.guest_cwd.clone()) + .with_env(process_env) + .with_shadow_root(sandbox_root) + .with_host_cwd(resolved.host_cwd.clone()), + ); + bridge.emit_lifecycle(&vm_id, LifecycleState::Busy)?; + mark_execute_response_ready(&vm_id, &payload.process_id); + record_execute_phase("process_register_and_lifecycle", phase_start.elapsed()); + record_execute_phase("execute_total", execute_total_start.elapsed()); + + Ok(DispatchResult { + response: process_started_response( + request, + payload.process_id, + Some(if child_pid == 0 { + kernel_pid + } else { + child_pid + }), + ), + events: Vec::new(), + }) } diff --git a/crates/native-sidecar/src/execution/mod.rs b/crates/native-sidecar/src/execution/mod.rs index 572b0974c0..d9234ebb4d 100644 --- a/crates/native-sidecar/src/execution/mod.rs +++ b/crates/native-sidecar/src/execution/mod.rs @@ -2,12 +2,19 @@ mod child_process; use self::child_process::*; +pub(crate) use self::child_process::{ + service_owned_child_bridge_event, OwnedChildBridgeEventService, +}; mod coordinator; use self::coordinator::*; +pub(crate) use self::coordinator::{ + close_stdin_owned, guest_kernel_core_error, resize_pty_owned, write_stdin_owned, + OwnedVmRouteFuture, OwnedVmRouteInput, +}; mod launch; use self::launch::*; pub(crate) use self::launch::{ - host_path_from_runtime_guest_mappings, initial_shadow_sync_inventory, + execute_owned, host_path_from_runtime_guest_mappings, initial_shadow_sync_inventory, is_protected_agentos_shadow_sync_path, sanitize_javascript_child_process_internal_bootstrap_env, sync_active_process_host_writes_to_kernel, sync_process_host_writes_to_kernel, @@ -22,8 +29,10 @@ pub(crate) use self::process_events::send_binding_process_event; use self::process_events::*; pub(crate) use self::process_events::{ mark_execute_exit_event_queued, record_execute_exit_event_queue_wait, record_execute_phase, - record_execute_response_to_exit_milestone, + record_execute_response_to_exit_milestone, OwnedJavascriptEventService, + OwnedPythonEventService, OwnedPythonSocketCompletionService, }; +pub(crate) use self::signals::kill_process_owned; mod signals; #[cfg(test)] #[allow(unused_imports)] @@ -72,10 +81,15 @@ pub(crate) use self::javascript::{ javascript_sync_rpc_may_make_fd_readable, javascript_sync_rpc_may_make_fd_writable, javascript_sync_rpc_option_bool, javascript_sync_rpc_option_u32, service_javascript_crypto_sync_rpc, service_javascript_sync_rpc, + service_owned_javascript_sync_rpc_request, settle_javascript_sync_rpc_completion, JavascriptSyncRpcServiceRequest, JavascriptSyncRpcServiceResponse, KernelPollFdRequest, LoopbackHttpDispatchRequest, }; mod python; +pub(crate) use self::python::{ + prepare_owned_python_process_event_service, respond_owned_python_rpc, + service_owned_python_socket_connect_completion, service_owned_python_vfs_rpc_request, +}; use agentos_vm_config as vm_config; @@ -87,6 +101,7 @@ use crate::filesystem::{ handle_python_vfs_rpc_request as filesystem_handle_python_vfs_rpc_request, service_javascript_fs_read_sync_rpc, service_javascript_fs_readdir_raw_sync_rpc, service_javascript_fs_sync_rpc, service_javascript_module_sync_rpc, + service_owned_python_filesystem_rpc_request, }; use crate::protocol::{ CloseStdinRequest, EventFrame, EventPayload, ExecuteRequest, FindBoundUdpRequest, @@ -137,7 +152,7 @@ use crate::state::{ ShadowSyncInventoryEntry, SharedBridge, SharedSidecarRequestClient, SidecarKernel, SocketDescriptionLease, SocketQueryKind, SocketReadinessRegistration, SocketReadinessSubscribers, TlsWritePayload, VmDnsConfig, VmFetchBodyMode, VmFetchStreamState, - VmListenPolicy, VmPendingByteBudget, VmState, BINDING_DRIVER_NAME, + VmHandle, VmListenPolicy, VmPendingByteBudget, VmState, BINDING_DRIVER_NAME, DEFAULT_JAVASCRIPT_NET_BACKLOG, EXECUTION_DRIVER_NAME, EXECUTION_SANDBOX_ROOT_ENV, JAVASCRIPT_COMMAND, LOOPBACK_EXEMPT_PORTS_ENV, MAPPED_HOST_FD_START, PYTHON_COMMAND, VM_LISTEN_ALLOW_PRIVILEGED_METADATA_KEY, WASM_COMMAND, WASM_EXEC_COMMIT_RPC_ENV, diff --git a/crates/native-sidecar/src/execution/network/udp.rs b/crates/native-sidecar/src/execution/network/udp.rs index 3411b29060..ea572f14b7 100644 --- a/crates/native-sidecar/src/execution/network/udp.rs +++ b/crates/native-sidecar/src/execution/network/udp.rs @@ -1418,8 +1418,7 @@ impl ActiveUdpSocket { wait: Duration, ) -> Result, SidecarError> { let wait = wait.min(self.reactor_limits.operation_deadline); - let receive_capacity = udp_receive_capacity(&self.resources, self.reactor_limits); - if let Some(socket_id) = self.kernel_socket_id { + if self.kernel_socket_id.is_some() { // A hybrid socket may be readable through either the VM-local // kernel socket or its native external socket. Never block on one // source while the other can already make progress. @@ -1428,88 +1427,127 @@ impl ActiveUdpSocket { } else { wait }; - let result = kernel - .poll_targets( - EXECUTION_DRIVER_NAME, - kernel_pid, - vec![PollTargetEntry::socket(socket_id, POLLIN)], - i32::try_from(kernel_wait.as_millis()).unwrap_or(i32::MAX), - ) - .map_err(kernel_error)?; - let revents = result - .targets - .first() - .map(|entry| entry.revents) - .unwrap_or_else(PollEvents::empty); - if revents.is_empty() && self.native_commands.is_none() { + let ready = self.poll_kernel_ready(kernel, kernel_pid, kernel_wait)?; + if !ready && self.native_commands.is_none() { return Ok(None); } - if !revents.is_empty() { - let turn = self.acquire_fair_turn().await?; - let receive_capacity = receive_capacity.min(turn.allowance().bytes).max(1); - let (event, used_bytes) = match kernel.socket_recv_datagram_charged( - EXECUTION_DRIVER_NAME, - kernel_pid, - socket_id, - receive_capacity, - ) { - Ok(Some(datagram)) => { - let (source_address, payload, reservations) = datagram.into_parts(); - let used_bytes = payload.len(); - let ( - byte_reservation, - datagram_reservation, - udp_byte_reservation, - udp_datagram_reservation, - ) = reservations.ok_or_else(|| { - SidecarError::Execution(String::from( - "ERR_AGENTOS_RESOURCE_ACCOUNTING_INVARIANT: kernel UDP handoff did not transfer its queue reservations", - )) - })?; - let remote_addr = source_address - .map(|source| { - resolve_udp_bind_addr(source.host(), source.port(), self.family) - }) - .transpose()? - .unwrap_or_else(|| match self.family { - JavascriptUdpFamily::Ipv4 => { - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0) - } - JavascriptUdpFamily::Ipv6 => { - SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0) - } - }); - ( - Some(JavascriptUdpSocketEvent::Message { - data: payload, - remote_addr, - _byte_reservation: SharedReservation::new(byte_reservation), - _datagram_reservation: SharedReservation::new(datagram_reservation), - _udp_byte_reservation: SharedReservation::new(udp_byte_reservation), - _udp_datagram_reservation: SharedReservation::new( - udp_datagram_reservation, - ), - }), - used_bytes, - ) - } - Ok(None) => (None, 0), - Err(error) if error.code() == "EAGAIN" => (None, 0), - Err(error) => ( - Some(JavascriptUdpSocketEvent::Error { - code: Some(error.code().to_string()), - message: error.to_string(), - }), - 0, - ), - }; - turn.complete(FairBudget::new(1, used_bytes), false) - .map_err(|error| SidecarError::Execution(error.to_string()))?; + if ready { + let turn = self.acquire_poll_fair_turn().await?; + let event = self.consume_ready_kernel_datagram(kernel, kernel_pid, turn)?; if event.is_some() { return Ok(event); } } } + self.poll_native(wait).await + } + + pub(in crate::execution) fn poll_kernel_ready( + &self, + kernel: &mut SidecarKernel, + kernel_pid: u32, + wait: Duration, + ) -> Result { + let Some(socket_id) = self.kernel_socket_id else { + return Ok(false); + }; + let result = kernel + .poll_targets( + EXECUTION_DRIVER_NAME, + kernel_pid, + vec![PollTargetEntry::socket(socket_id, POLLIN)], + i32::try_from(wait.as_millis()).unwrap_or(i32::MAX), + ) + .map_err(kernel_error)?; + Ok(!result + .targets + .first() + .map(|entry| entry.revents) + .unwrap_or_else(PollEvents::empty) + .is_empty()) + } + + pub(in crate::execution) async fn acquire_poll_fair_turn( + &self, + ) -> Result { + self.acquire_fair_turn().await + } + + pub(in crate::execution) fn consume_ready_kernel_datagram( + &self, + kernel: &mut SidecarKernel, + kernel_pid: u32, + turn: FairWorkTurn, + ) -> Result, SidecarError> { + let socket_id = self.kernel_socket_id.ok_or_else(|| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_UDP_KERNEL_SOCKET_MISSING: ready kernel UDP poll lost its socket", + )) + })?; + let receive_capacity = udp_receive_capacity(&self.resources, self.reactor_limits) + .min(turn.allowance().bytes) + .max(1); + let (event, used_bytes) = match kernel.socket_recv_datagram_charged( + EXECUTION_DRIVER_NAME, + kernel_pid, + socket_id, + receive_capacity, + ) { + Ok(Some(datagram)) => { + let (source_address, payload, reservations) = datagram.into_parts(); + let used_bytes = payload.len(); + let ( + byte_reservation, + datagram_reservation, + udp_byte_reservation, + udp_datagram_reservation, + ) = reservations.ok_or_else(|| { + SidecarError::Execution(String::from( + "ERR_AGENTOS_RESOURCE_ACCOUNTING_INVARIANT: kernel UDP handoff did not transfer its queue reservations", + )) + })?; + let remote_addr = source_address + .map(|source| resolve_udp_bind_addr(source.host(), source.port(), self.family)) + .transpose()? + .unwrap_or_else(|| match self.family { + JavascriptUdpFamily::Ipv4 => { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0) + } + JavascriptUdpFamily::Ipv6 => { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0) + } + }); + ( + Some(JavascriptUdpSocketEvent::Message { + data: payload, + remote_addr, + _byte_reservation: SharedReservation::new(byte_reservation), + _datagram_reservation: SharedReservation::new(datagram_reservation), + _udp_byte_reservation: SharedReservation::new(udp_byte_reservation), + _udp_datagram_reservation: SharedReservation::new(udp_datagram_reservation), + }), + used_bytes, + ) + } + Ok(None) => (None, 0), + Err(error) if error.code() == "EAGAIN" => (None, 0), + Err(error) => ( + Some(JavascriptUdpSocketEvent::Error { + code: Some(error.code().to_string()), + message: error.to_string(), + }), + 0, + ), + }; + turn.complete(FairBudget::new(1, used_bytes), false) + .map_err(|error| SidecarError::Execution(error.to_string()))?; + Ok(event) + } + + pub(in crate::execution) async fn poll_native( + &self, + wait: Duration, + ) -> Result, SidecarError> { let Some(commands) = self.native_commands.as_ref() else { return Ok(None); }; diff --git a/crates/native-sidecar/src/execution/process.rs b/crates/native-sidecar/src/execution/process.rs index ec24f235f9..fc554f0f05 100644 --- a/crates/native-sidecar/src/execution/process.rs +++ b/crates/native-sidecar/src/execution/process.rs @@ -7,7 +7,7 @@ static NEXT_SQLITE_HOST_NAMESPACE: AtomicU64 = AtomicU64::new(1); /// capacity check prevents a concurrent producer from consuming the bytes an /// already-accepted event needs if that event must be put back. #[derive(Debug)] -pub(super) struct PendingExecutionEventReservation { +pub(crate) struct PendingExecutionEventReservation { budget: Arc, bytes: usize, } @@ -124,6 +124,7 @@ impl ActiveProcess { pending_event_bytes_limit, ), vm_pending_event_bytes_budget, + child_bridge_relay_in_flight: Arc::new(AtomicBool::new(false)), pending_javascript_net_connects: BTreeMap::new(), pending_self_signal_exit: None, exit_signal: None, @@ -253,6 +254,7 @@ impl ActiveProcess { connection_id, session_id, vm_id, + child_path, process_id, event, } = envelope; @@ -264,6 +266,7 @@ impl ActiveProcess { connection_id, session_id, vm_id, + child_path, process_id, event, }, @@ -1357,6 +1360,7 @@ impl ProcessEventEnvelope { .len() .saturating_add(self.session_id.len()) .saturating_add(self.vm_id.len()) + .saturating_add(self.child_path.iter().map(String::len).sum::()) .saturating_add(self.process_id.len()) .saturating_add(self.event.retained_bytes()) } @@ -1545,6 +1549,26 @@ impl ActiveExecution { matches!(self, Self::Javascript(execution) if execution.has_exited()) } + pub(crate) fn has_pending_events(&self) -> bool { + match self { + Self::Javascript(execution) => execution.has_pending_events(), + Self::Python(execution) => execution.has_pending_events(), + Self::Wasm(execution) => execution.has_pending_events(), + Self::Binding(execution) => { + !execution + .pending_events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + || execution + .event_overflow_reason + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some() + } + } + } + pub(crate) fn execute_retained_language( &mut self, language: RetainedExecutionLanguage, diff --git a/crates/native-sidecar/src/execution/process_events.rs b/crates/native-sidecar/src/execution/process_events.rs index ae7037fc22..2c8e6ff62c 100644 --- a/crates/native-sidecar/src/execution/process_events.rs +++ b/crates/native-sidecar/src/execution/process_events.rs @@ -1,6 +1,144 @@ use super::*; use crate::protocol::ExecutionStreamChannel; +pub(crate) struct ProcessEventPumpTurn { + pub(crate) emitted_any: bool, + pub(crate) javascript_services: Vec, + pub(crate) python_services: Vec, + pub(crate) python_socket_completions: Vec, + pub(crate) child_bridge_services: Vec, +} + +pub(crate) struct OwnedJavascriptEventService { + pub(crate) ownership: OwnershipScope, + pub(crate) vm_id: String, + pub(crate) process_id: String, + /// Descendant path below `process_id`; empty for a root process. + pub(crate) child_path: Vec, + pub(crate) vm: crate::state::VmHandle, + pub(crate) request: JavascriptSyncRpcRequest, + _reservation: Option, +} + +pub(crate) struct OwnedPythonEventService { + pub(crate) ownership: OwnershipScope, + pub(crate) vm_id: String, + pub(crate) process_id: String, + /// Descendant path below `process_id`; empty for a root process. + pub(crate) child_path: Vec, + pub(crate) vm: crate::state::VmHandle, + pub(crate) responder: PythonVfsRpcResponder, + pub(crate) request: PythonVfsRpcRequest, + _reservation: Option, +} + +pub(crate) struct OwnedPythonSocketCompletionService { + pub(crate) ownership: OwnershipScope, + pub(crate) vm_id: String, + pub(crate) process_id: String, + pub(crate) child_path: Vec, + pub(crate) vm: crate::state::VmHandle, + pub(crate) responder: PythonVfsRpcResponder, + pub(crate) completion: PythonSocketConnectCompletion, + _reservation: Option, +} + +impl OwnedJavascriptEventService { + pub(super) fn new( + ownership: OwnershipScope, + vm_id: String, + process_id: String, + child_path: Vec, + vm: crate::state::VmHandle, + request: JavascriptSyncRpcRequest, + reservation: Option, + ) -> Self { + Self { + ownership, + vm_id, + process_id, + child_path, + vm, + request, + _reservation: reservation, + } + } +} + +impl OwnedPythonEventService { + pub(super) fn new( + ownership: OwnershipScope, + vm_id: String, + process_id: String, + child_path: Vec, + vm: crate::state::VmHandle, + responder: PythonVfsRpcResponder, + request: PythonVfsRpcRequest, + reservation: Option, + ) -> Self { + Self { + ownership, + vm_id, + process_id, + child_path, + vm, + responder, + request, + _reservation: reservation, + } + } +} + +impl OwnedPythonSocketCompletionService { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + ownership: OwnershipScope, + vm_id: String, + process_id: String, + child_path: Vec, + vm: crate::state::VmHandle, + responder: PythonVfsRpcResponder, + completion: PythonSocketConnectCompletion, + reservation: Option, + ) -> Self { + Self { + ownership, + vm_id, + process_id, + child_path, + vm, + responder, + completion, + _reservation: reservation, + } + } + + #[allow(clippy::type_complexity)] + pub(crate) fn into_parts( + self, + ) -> ( + OwnershipScope, + String, + String, + Vec, + crate::state::VmHandle, + PythonVfsRpcResponder, + PythonSocketConnectCompletion, + Option, + ) { + ( + self.ownership, + self.vm_id, + self.process_id, + self.child_path, + self.vm, + self.responder, + self.completion, + self._reservation, + ) + } +} + pub(super) struct BindingProcessEventRequest { pub(super) runtime_context: agentos_runtime::RuntimeContext, pub(super) sidecar_requests: SharedSidecarRequestClient, @@ -402,40 +540,540 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - pub async fn pump_process_events( + /// Move a thread-safe runtime completion back into the exact LocalSet-owned + /// process queue. Public stdout/stderr/exit envelopes continue to the + /// broker unchanged. + fn route_received_internal_process_event( + &mut self, + envelope: ProcessEventEnvelope, + ) -> Result, SidecarError> { + if !Self::internal_execution_event(&envelope.event) { + return Ok(Some(envelope)); + } + self.validate_process_event_envelope_locator(&envelope)?; + let target_label = if envelope.child_path.is_empty() { + envelope.process_id.clone() + } else { + format!("{}/{}", envelope.process_id, envelope.child_path.join("/")) + }; + let Some(mut vm) = self.vms.get_mut(&envelope.vm_id) else { + tracing::debug!( + vm_id = envelope.vm_id, + process_id = target_label, + "ERR_AGENTOS_STALE_PROCESS_EVENT: runtime completion targeted a disposed VM" + ); + return Ok(None); + }; + if vm.connection_id != envelope.connection_id || vm.session_id != envelope.session_id { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_SCOPE_MISMATCH: runtime completion for VM {} carried connection/session {}/{}, expected {}/{}", + envelope.vm_id, + envelope.connection_id, + envelope.session_id, + vm.connection_id, + vm.session_id + ))); + } + let Some(root) = vm.active_processes.get_mut(&envelope.process_id) else { + tracing::debug!( + vm_id = envelope.vm_id, + process_id = target_label, + "ERR_AGENTOS_STALE_PROCESS_EVENT: runtime completion targeted a reaped root process" + ); + return Ok(None); + }; + let Some(process) = Self::active_process_by_owned_path_mut(root, &envelope.child_path) + else { + tracing::debug!( + vm_id = envelope.vm_id, + process_id = target_label, + "ERR_AGENTOS_STALE_PROCESS_EVENT: runtime completion targeted a reaped descendant" + ); + return Ok(None); + }; + let python_failure = match &envelope.event { + ActiveExecutionEvent::PythonVfsRpcRequest(request) => { + Some((request.id, process.execution.python_vfs_rpc_responder()?)) + } + ActiveExecutionEvent::PythonSocketConnectCompletion(completion) => Some(( + completion.request_id, + process.execution.python_vfs_rpc_responder()?, + )), + _ => None, + }; + match process.try_queue_pending_execution_envelope(envelope) { + Ok(()) => Ok(None), + Err((error, _envelope)) => { + if let Some((request_id, responder)) = python_failure { + if let Err(reply_error) = respond_owned_python_rpc( + &responder, + request_id, + Err(SidecarError::InvalidState(error.to_string())), + ) { + tracing::debug!( + request_id, + %reply_error, + "Python runtime completion failure reply was no longer pending" + ); + } + } + Err(error) + } + } + } + + /// Transfer channel events one at a time so an admission error cannot drop + /// later envelopes that were already removed from the bounded channel. + fn drain_runtime_process_event_channel_nowait(&mut self) -> Result { + let transfer_limit = self.config.runtime.protocol.max_process_events.max(1); + let mut transferred = 0usize; + while transferred < transfer_limit { + let envelope = if let Some(envelope) = self.deferred_process_event_envelope.take() { + self.observe_pending_process_event_depth(); + envelope + } else { + if self.pending_process_event_capacity() == 0 { + break; + } + let next = { + let receiver = self.process_event_receiver.as_mut().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "process event receiver unavailable", + )) + })?; + receiver.try_recv() + }; + match next { + Ok(envelope) => envelope, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + }; + transferred = transferred.saturating_add(1); + if let Some(envelope) = self.route_received_internal_process_event(envelope)? { + self.validate_process_event_envelope_locator(&envelope)?; + let event_byte_limit = self + .vms + .get(&envelope.vm_id) + .map(|vm| vm.limits.process.pending_event_bytes) + .unwrap_or( + agentos_native_sidecar_core::limits::DEFAULT_PROCESS_PENDING_EVENT_BYTES, + ); + if envelope.retained_bytes() > event_byte_limit { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_BYTES_LIMIT: process event for VM {} retains {} bytes, exceeding limits.process.pendingEventBytes ({event_byte_limit}); raise limits.process.pendingEventBytes", + envelope.vm_id, + envelope.retained_bytes() + ))); + } + if let Err((error, envelope)) = self.try_queue_pending_process_event(envelope) { + debug_assert!(self.deferred_process_event_envelope.is_none()); + self.deferred_process_event_envelope = Some(envelope); + self.observe_pending_process_event_depth(); + tracing::debug!( + %error, + "process-event receiver paused at temporary public-queue capacity" + ); + break; + } + } + } + let has_more = self + .process_event_receiver + .as_ref() + .is_some_and(|receiver| !receiver.is_empty()); + if has_more && self.deferred_process_event_envelope.is_none() { + self.process_event_notify.notify_one(); + } + Ok(transferred > 0) + } + + /// Perform one bounded, non-suspending process-event turn. Runtime-owned + /// queues remain durable; this command only transfers events that are + /// already ready and never waits for a producer. + pub(crate) fn pump_process_events_nowait( &mut self, ownership: &OwnershipScope, - ) -> Result { + max_service_claims: usize, + ) -> Result { let mut emitted_any = false; + let mut javascript_services = Vec::new(); + let mut python_services = Vec::new(); + let mut python_socket_completions = Vec::new(); + let mut child_bridge_services = Vec::new(); + let mut root_source_remains = false; self.expire_public_execution_deadlines()?; - let mut queued_envelopes = Vec::new(); - { - let pending_capacity = self.pending_process_event_capacity(); - let receiver = self.process_event_receiver.as_mut().ok_or_else(|| { - SidecarError::InvalidState(String::from("process event receiver unavailable")) - })?; - loop { - if queued_envelopes.len() >= pending_capacity { - if receiver.is_empty() { - break; + if self.drain_runtime_process_event_channel_nowait()? { + emitted_any = true; + } + + for vm_id in self.vm_ids_for_scope(ownership)? { + let work_limit = self.config.runtime.fairness.vm_quantum_operations; + let Some((connection_id, session_id, process_ids)) = self.vms.get(&vm_id).map(|vm| { + vm.kernel.reap_due_zombies(); + ( + vm.connection_id.clone(), + vm.session_id.clone(), + vm.active_processes.keys().cloned().collect::>(), + ) + }) else { + continue; + }; + let mut work = 0usize; + for process_id in process_ids { + if javascript_services + .len() + .saturating_add(python_services.len()) + .saturating_add(python_socket_completions.len()) + .saturating_add(child_bridge_services.len()) + >= max_service_claims + { + self.process_event_notify.notify_one(); + break; + } + if work >= work_limit { + self.process_event_notify.notify_one(); + break; + } + if self + .vms + .get(&vm_id) + .is_some_and(|vm| vm.detached_child_processes.contains(&process_id)) + { + continue; + } + enum PollResult { + Event(Option), + RecoverClosed, + } + let polled = { + let Some(mut vm) = self.vms.get_mut(&vm_id) else { + continue; + }; + let Some(process) = vm.active_processes.get_mut(&process_id) else { + continue; + }; + if let Some(event) = process.lease_pending_execution_event() { + PollResult::Event(Some(event)) + } else { + match process.try_poll_execution_event() { + Ok(event) => PollResult::Event(event), + Err(SidecarError::Execution(message)) + if (process.runtime == GuestRuntimeKind::JavaScript + && closed_javascript_event_channel(&message)) + || (process.runtime == GuestRuntimeKind::Python + && closed_python_event_channel(&message)) + || (process.runtime == GuestRuntimeKind::WebAssembly + && closed_wasm_event_channel(&message)) => + { + PollResult::RecoverClosed + } + Err(error) => return Err(error), + } } - return Err(process_event_queue_overflow_error( - self.config.runtime.protocol.max_process_events, - )); + }; + let event = match polled { + PollResult::Event(event) => event, + PollResult::RecoverClosed => self + .recover_closed_root_runtime_process_event(&vm_id, &process_id)? + .map(PolledExecutionEvent::unreserved), + }; + let Some(event) = event else { continue }; + root_source_remains |= self.vms.get(&vm_id).is_some_and(|vm| { + vm.active_processes.get(&process_id).is_some_and(|process| { + !process.pending_execution_events.is_empty() + || process.execution.has_pending_events() + }) + }); + if Self::internal_execution_event(event.event()) { + let PolledExecutionEvent { event, reservation } = event; + match event { + ActiveExecutionEvent::JavascriptSyncRpcRequest(request) => { + if let Some(vm) = self.vms.handle(&vm_id) { + javascript_services.push(OwnedJavascriptEventService::new( + OwnershipScope::vm(&connection_id, &session_id, &vm_id), + vm_id.clone(), + process_id, + Vec::new(), + vm, + request, + reservation, + )); + } + } + ActiveExecutionEvent::JavascriptSyncRpcCompletion(completion) => { + self.handle_javascript_sync_rpc_completion( + &vm_id, + &process_id, + completion, + )?; + drop(reservation); + } + ActiveExecutionEvent::PythonSocketConnectCompletion(completion) => { + let Some(vm) = self.vms.handle(&vm_id) else { + continue; + }; + let responder = + vm.try_read("claim Python socket completion service", |state| { + state + .active_processes + .get(&process_id) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { + continue; + }; + python_socket_completions.push( + OwnedPythonSocketCompletionService::new( + OwnershipScope::vm(&connection_id, &session_id, &vm_id), + vm_id.clone(), + process_id, + Vec::new(), + vm, + responder?, + *completion, + reservation, + ), + ); + } + ActiveExecutionEvent::SignalState { + signal, + registration, + } => { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + vm.signal_states + .entry(process_id) + .or_default() + .insert(signal, registration); + } + drop(reservation); + } + ActiveExecutionEvent::PythonVfsRpcRequest(request) => { + let Some(vm) = self.vms.handle(&vm_id) else { + continue; + }; + let responder = + vm.try_read("claim Python process-event service", |state| { + state + .active_processes + .get(&process_id) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { + continue; + }; + python_services.push(OwnedPythonEventService::new( + OwnershipScope::vm(&connection_id, &session_id, &vm_id), + vm_id.clone(), + process_id, + Vec::new(), + vm, + responder?, + *request, + reservation, + )); + } + _ => unreachable!("internal event classification changed"), + } + emitted_any = true; + work = work.saturating_add(1); + continue; } - match receiver.try_recv() { - Ok(envelope) => { - queued_envelopes.push(envelope); - emitted_any = true; + let PolledExecutionEvent { event, reservation } = event; + let envelope = ProcessEventEnvelope { + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + child_path: Vec::new(), + process_id, + event, + }; + if let Err(error) = self.check_pending_process_event_capacity(&envelope) { + let Some(mut vm) = self.vms.get_mut(&vm_id) else { + return Err(error); + }; + if let Some(process) = vm.active_processes.get_mut(&envelope.process_id) { + process.requeue_pending_execution_event(PolledExecutionEvent { + event: envelope.event, + reservation, + })?; } - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + return Err(error); } + self.queue_pending_process_event(envelope)?; + drop(reservation); + emitted_any = true; + work = work.saturating_add(1); + } + if self.pump_child_process_events_nowait( + &vm_id, + &mut javascript_services, + &mut python_services, + &mut python_socket_completions, + &mut child_bridge_services, + max_service_claims, + )? { + emitted_any = true; + } + if self.pump_detached_child_process_events_nowait( + &vm_id, + &mut javascript_services, + &mut python_services, + &mut python_socket_completions, + &mut child_bridge_services, + max_service_claims, + )? { + emitted_any = true; } } - for envelope in queued_envelopes { - self.queue_pending_process_event(envelope)?; + if self.route_claimed_pending_process_events()? > 0 { + emitted_any = true; + } + let service_claims = javascript_services + .len() + .saturating_add(python_services.len()) + .saturating_add(python_socket_completions.len()) + .saturating_add(child_bridge_services.len()); + if max_service_claims > 0 && service_claims >= max_service_claims { + // `Notify` coalesces producer edges. If this turn consumes the one + // stored permit while filling the owned-service staging capacity, + // source queues may still contain work but no producer will emit a + // second edge. Preserve one continuation permit; an empty follow-up + // turn does not rearm and therefore cannot hot-spin. + self.process_event_notify.notify_one(); + } + if root_source_remains { + // A root process is probed once per bounded coordinator turn. Its + // runtime producer uses a coalesced Notify, so one producer edge + // can represent an arbitrary number of already-durable stdout, + // stderr, exit, or internal events. Preserve one continuation + // edge whenever the non-consuming post-claim probe still observes + // source work. An exactly drained turn does not rearm and therefore + // cannot hot-spin. + self.process_event_notify.notify_one(); + } + self.rearm_kernel_reaper_task()?; + Ok(ProcessEventPumpTurn { + emitted_any, + javascript_services, + python_services, + python_socket_completions, + child_bridge_services, + }) + } + + /// Apply one already-polled public process event without suspending the + /// protocol coordinator. Internal RPC events are never valid broker + /// payloads; they stay on the owned VM event-service path. + pub(crate) fn handle_public_execution_event_nowait( + &mut self, + vm_id: &str, + process_id: &str, + event: ActiveExecutionEvent, + ) -> Result, SidecarError> { + let Some((connection_id, session_id, active)) = self.vms.get(vm_id).map(|vm| { + ( + vm.connection_id.clone(), + vm.session_id.clone(), + vm.active_processes.contains_key(process_id), + ) + }) else { + log_stale_process_event(&self.bridge, vm_id, process_id, "public event dispatch"); + return Ok(None); + }; + if !active { + log_stale_process_event(&self.bridge, vm_id, process_id, "public event dispatch"); + return Ok(None); + } + let ownership = OwnershipScope::vm(&connection_id, &session_id, vm_id); + let public_execution = self.is_public_execution_process(vm_id, process_id); + + if self.capture_extension_process_output_event(vm_id, process_id, &event) { + return Ok(None); + } + + match event { + ActiveExecutionEvent::Stdout(chunk) if public_execution => Ok(self + .record_public_execution_output( + vm_id, + process_id, + ExecutionStreamChannel::Stdout, + chunk, + ) + .map(|payload| EventFrame::new(ownership, payload))), + ActiveExecutionEvent::Stderr(chunk) if public_execution => Ok(self + .record_public_execution_output( + vm_id, + process_id, + ExecutionStreamChannel::Stderr, + chunk, + ) + .map(|payload| EventFrame::new(ownership, payload))), + ActiveExecutionEvent::Stdout(chunk) => Ok(Some(EventFrame::new( + ownership, + EventPayload::ProcessOutput(ProcessOutputEvent { + process_id: process_id.to_owned(), + channel: StreamChannel::Stdout, + chunk, + }), + ))), + ActiveExecutionEvent::Stderr(chunk) => Ok(Some(EventFrame::new( + ownership, + EventPayload::ProcessOutput(ProcessOutputEvent { + process_id: process_id.to_owned(), + channel: StreamChannel::Stderr, + chunk, + }), + ))), + ActiveExecutionEvent::Exited(exit_code) => { + record_execute_response_to_exit_milestone( + "execute_response_to_exit_event_handle", + vm_id, + process_id, + ); + record_execute_response_to_exit(vm_id, process_id); + let park_resident = public_execution + && self.should_park_public_execution_process(vm_id, process_id); + let became_idle = if park_resident { + false + } else { + self.finish_active_process_exit(vm_id, process_id, exit_code)? + .unwrap_or(false) + }; + if became_idle || (park_resident && !self.has_running_nonresident_processes(vm_id)) + { + self.bridge.emit_lifecycle(vm_id, LifecycleState::Ready)?; + } + if public_execution { + Ok(self + .complete_public_execution(vm_id, process_id, exit_code) + .map(|payload| EventFrame::new(ownership, payload))) + } else { + Ok(Some(EventFrame::new( + ownership, + EventPayload::ProcessExited(ProcessExitedEvent { + process_id: process_id.to_owned(), + exit_code, + }), + ))) + } + } + other => Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_INTERNAL_EVENT_ON_PUBLIC_BROKER: process {process_id} produced internal event {other:?}" + ))), + } + } + + pub async fn pump_process_events( + &mut self, + ownership: &OwnershipScope, + ) -> Result { + let mut emitted_any = false; + self.expire_public_execution_deadlines()?; + + if self.drain_runtime_process_event_channel_nowait()? { + emitted_any = true; } let vm_ids = self.vm_ids_for_scope(ownership)?; @@ -445,14 +1083,18 @@ where if let Some(vm) = self.vms.get(&vm_id) { vm.kernel.reap_due_zombies(); } - 'vm_event_turn: while let Some(vm) = self.vms.get(&vm_id) { - let connection_id = vm.connection_id.clone(); - let session_id = vm.session_id.clone(); - let process_ids = self - .vms - .get(&vm_id) - .map(|vm| vm.active_processes.keys().cloned().collect::>()) - .unwrap_or_default(); + 'vm_event_turn: while self.vms.contains_key(&vm_id) { + let Some((connection_id, session_id, process_ids)) = + self.vms.get(&vm_id).map(|vm| { + ( + vm.connection_id.clone(), + vm.session_id.clone(), + vm.active_processes.keys().cloned().collect::>(), + ) + }) + else { + break; + }; let mut emitted_this_pass = false; for process_id in process_ids { @@ -472,7 +1114,7 @@ where RecoverClosedChannel, } let poll_result = { - let Some(vm) = self.vms.get_mut(&vm_id) else { + let Some(mut vm) = self.vms.get_mut(&vm_id) else { continue; }; let Some(process) = vm.active_processes.get_mut(&process_id) else { @@ -528,19 +1170,20 @@ where connection_id: connection_id.clone(), session_id: session_id.clone(), vm_id: vm_id.clone(), + child_path: Vec::new(), process_id: process_id.clone(), event, }; if let Err(error) = self.check_pending_process_event_capacity(&envelope) { - if let Some(process) = self - .vms - .get_mut(&vm_id) - .and_then(|vm| vm.active_processes.get_mut(&process_id)) - { - process.requeue_pending_execution_event(PolledExecutionEvent { - event: envelope.event, - reservation, - })?; + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + if let Some(process) = vm.active_processes.get_mut(&process_id) { + process.requeue_pending_execution_event( + PolledExecutionEvent { + event: envelope.event, + reservation, + }, + )?; + } } return Err(error); } @@ -565,6 +1208,9 @@ where } } + if self.route_claimed_pending_process_events()? > 0 { + emitted_any = true; + } self.rearm_kernel_reaper_task()?; Ok(emitted_any) } @@ -626,6 +1272,7 @@ where matches!( event, ActiveExecutionEvent::JavascriptSyncRpcRequest(_) + | ActiveExecutionEvent::JavascriptSyncRpcCompletion(_) | ActiveExecutionEvent::PythonVfsRpcRequest(_) | ActiveExecutionEvent::PythonSocketConnectCompletion(_) | ActiveExecutionEvent::SignalState { .. } @@ -637,7 +1284,7 @@ where vm_id: &str, process_id: &str, ) -> Result, SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(None); }; let Some(process) = vm.active_processes.get_mut(process_id) else { @@ -669,7 +1316,7 @@ where } } - pub(super) fn active_process_by_path<'a>( + pub(crate) fn active_process_by_path<'a>( process: &'a ActiveProcess, child_path: &[&str], ) -> Option<&'a ActiveProcess> { @@ -680,7 +1327,7 @@ where Some(current) } - pub(super) fn active_process_by_path_mut<'a>( + pub(crate) fn active_process_by_path_mut<'a>( process: &'a mut ActiveProcess, child_path: &[&str], ) -> Option<&'a mut ActiveProcess> { @@ -838,15 +1485,20 @@ where process_id: &str, event: ActiveExecutionEvent, ) -> Result, SidecarError> { - let Some(vm) = self.vms.get(vm_id) else { + let Some((connection_id, session_id, active)) = self.vms.get(vm_id).map(|vm| { + ( + vm.connection_id.clone(), + vm.session_id.clone(), + vm.active_processes.contains_key(process_id), + ) + }) else { log_stale_process_event(&self.bridge, vm_id, process_id, "execution event dispatch"); return Ok(None); }; - if !vm.active_processes.contains_key(process_id) { + if !active { log_stale_process_event(&self.bridge, vm_id, process_id, "execution event dispatch"); return Ok(None); } - let (connection_id, session_id) = { (vm.connection_id.clone(), vm.session_id.clone()) }; let ownership = OwnershipScope::vm(&connection_id, &session_id, vm_id); let public_execution = self.is_public_execution_process(vm_id, process_id); @@ -909,7 +1561,7 @@ where signal, registration, } => { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(None); }; if !vm.active_processes.contains_key(process_id) { @@ -969,42 +1621,19 @@ where process_id: &str, completion: crate::state::JavascriptSyncRpcCompletion, ) -> Result<(), SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); let Some(process) = vm.active_processes.get_mut(process_id) else { return Ok(()); }; - let connected = process - .pending_javascript_net_connects - .remove(&completion.request_id); - let completion_result = match (completion.result, connected) { - (Ok(_), Some(connected)) => { - finalize_javascript_net_connect(process, &kernel_readiness, connected).map_err( - |error| crate::state::DeferredRpcError { - code: javascript_sync_rpc_error_code(&error), - message: javascript_sync_rpc_error_message(&error), - }, - ) - } - (result @ Err(_), Some(connected)) => { - restore_pending_bound_unix_connect(process, &connected)?; - result - } - (result, None) => result, - }; - let result = match completion_result { - Ok(value) => process - .execution - .respond_javascript_sync_rpc_success(completion.request_id, value), - Err(error) => process.execution.respond_javascript_sync_rpc_error( - completion.request_id, - error.code, - error.message, - ), - }; - result.or_else(ignore_stale_javascript_sync_rpc_response) + settle_javascript_sync_rpc_completion( + process, + &kernel_readiness, + completion.request_id, + completion.result, + ) } pub(super) fn handle_python_socket_connect_completion( @@ -1028,99 +1657,96 @@ where ); } }; - let Some(vm) = self.vms.get_mut(vm_id) else { - return Ok(()); - }; - let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); - let Some(process) = vm.active_processes.get_mut(process_id) else { - return Ok(()); - }; - let PendingPythonTcpConnect { - native_socket_id, - python_socket_id, - socket, - pending_capability, - } = connected; - let capability_key = NativeCapabilityKey::TcpSocket(native_socket_id.clone()); - if let Err(error) = commit_process_capability( - process, - pending_capability, - capability_key.clone(), - native_socket_id.clone(), - socket.kernel_socket_id, - ) { - if let Err(close_error) = socket.close(&mut vm.kernel, process.kernel_pid) { - eprintln!( - "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: deferred TCP connect rollback failed: {close_error}" + let result = { + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return Ok(()); + }; + let vm = &mut *vm; + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let Some(process) = vm.active_processes.get_mut(process_id) else { + return Ok(()); + }; + let PendingPythonTcpConnect { + native_socket_id, + python_socket_id, + socket, + pending_capability, + } = connected; + let capability_key = NativeCapabilityKey::TcpSocket(native_socket_id.clone()); + if let Err(error) = commit_process_capability( + process, + pending_capability, + capability_key.clone(), + native_socket_id.clone(), + socket.kernel_socket_id, + ) { + if let Err(close_error) = socket.close(&mut vm.kernel, process.kernel_pid) { + eprintln!( + "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: deferred TCP connect rollback failed: {close_error}" + ); + } + Err(error) + } else if let Err(error) = + socket.set_fairness_identity(process.capability_fairness_identity(&capability_key)) + { + if let Err(release_error) = process.release_capability(&capability_key) { + eprintln!( + "ERR_AGENTOS_CAPABILITY_RELEASE: deferred Python TCP rollback failed: {release_error}" + ); + } + if let Err(close_error) = socket.close(&mut vm.kernel, process.kernel_pid) { + eprintln!( + "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: deferred TCP fairness rollback failed: {close_error}" + ); + } + Err(error) + } else { + socket.retain_description_lease( + process + .shared_capability_lease(&capability_key) + .expect("committed deferred Python TCP capability lease"), ); - } - return self.respond_python_rpc(vm_id, process_id, request_id, Err(error)); - } - if let Err(error) = - socket.set_fairness_identity(process.capability_fairness_identity(&capability_key)) - { - if let Err(release_error) = process.release_capability(&capability_key) { - eprintln!( - "ERR_AGENTOS_CAPABILITY_RELEASE: deferred Python TCP rollback failed: {release_error}" + register_kernel_readiness_target( + &kernel_readiness, + socket.kernel_socket_id, + None, + Some(Arc::clone(&socket.read_event_notify)), + process.capability_readiness_identity(&capability_key), + native_socket_id.clone(), + KernelSocketReadinessEvent::Data, ); - } - if let Err(close_error) = socket.close(&mut vm.kernel, process.kernel_pid) { - eprintln!( - "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: deferred TCP fairness rollback failed: {close_error}" + process.tcp_sockets.insert(native_socket_id.clone(), socket); + process.python_sockets.insert( + python_socket_id, + PythonHostSocket::Tcp { + socket_id: native_socket_id, + pending_read: None, + }, ); + debug_assert!(process.capability_leases.contains_key(&capability_key)); + Ok(PythonVfsRpcResponsePayload::SocketCreated { + socket_id: python_socket_id, + }) } - return self.respond_python_rpc(vm_id, process_id, request_id, Err(error)); - } - socket.retain_description_lease( - process - .shared_capability_lease(&capability_key) - .expect("committed deferred Python TCP capability lease"), - ); - register_kernel_readiness_target( - &kernel_readiness, - socket.kernel_socket_id, - None, - Some(Arc::clone(&socket.read_event_notify)), - process.capability_readiness_identity(&capability_key), - native_socket_id.clone(), - KernelSocketReadinessEvent::Data, - ); - process.tcp_sockets.insert(native_socket_id.clone(), socket); - process.python_sockets.insert( - python_socket_id, - PythonHostSocket::Tcp { - socket_id: native_socket_id, - pending_read: None, - }, - ); - debug_assert!(process.capability_leases.contains_key(&capability_key)); - self.respond_python_rpc( - vm_id, - process_id, - request_id, - Ok(PythonVfsRpcResponsePayload::SocketCreated { - socket_id: python_socket_id, - }), - ) + }; + self.respond_python_rpc(vm_id, process_id, request_id, result) } - pub(crate) fn finish_active_process_exit( - &mut self, + pub(crate) fn finish_active_process_exit_owned( + bridge: &SharedBridge, + vm_handle: &crate::state::VmHandle, vm_id: &str, process_id: &str, exit_code: i32, - ) -> Result, SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { - log_stale_process_event(&self.bridge, vm_id, process_id, "process exit cleanup"); - return Ok(None); - }; + ) -> Result, SidecarError> { + let mut vm = vm_handle.try_borrow_mut("finish active process exit")?; if !vm.active_processes.contains_key(process_id) { - log_stale_process_event(&self.bridge, vm_id, process_id, "process exit cleanup"); + log_stale_process_event(bridge, vm_id, process_id, "process exit cleanup"); return Ok(None); } let phase_start = Instant::now(); - prune_exited_process_snapshots(vm); + prune_exited_process_snapshots(&mut vm); record_execute_phase( "process_exit_cleanup_prune_snapshots", phase_start.elapsed(), @@ -1154,7 +1780,7 @@ where let should_sync_host_writes = process.host_write_dirty_recursive() || !process.clean_host_writes_are_observable_recursive(); let host_sync_result = if should_sync_host_writes { - sync_process_host_writes_to_kernel(vm, &process) + sync_process_host_writes_to_kernel(&mut vm, &process) } else { record_execute_phase( "process_exit_cleanup_sync_host_writes_clean_skip", @@ -1206,9 +1832,7 @@ where let phase_start = Instant::now(); let became_idle = vm.active_processes.is_empty(); record_execute_phase("process_exit_cleanup_became_idle", phase_start.elapsed()); - let phase_start = Instant::now(); - self.prune_extension_process_resource(process_id); - record_execute_phase("process_exit_cleanup_prune_resource", phase_start.elapsed()); + drop(vm); // The process was removed from active_processes before the fallible // host/raw-mode cleanup. Surface those errors only after all process- @@ -1216,6 +1840,227 @@ where // been copied back and finalized. host_sync_result?; raw_mode_result?; - Ok(Some(became_idle)) + Ok(Some(FinishedActiveProcessExit { + became_idle, + process_id: process_id.to_owned(), + })) + } + + pub(crate) fn finish_active_process_exit( + &mut self, + vm_id: &str, + process_id: &str, + exit_code: i32, + ) -> Result, SidecarError> { + let Some(vm_handle) = self.vms.handle(vm_id) else { + log_stale_process_event(&self.bridge, vm_id, process_id, "process exit cleanup"); + return Ok(None); + }; + let finished = Self::finish_active_process_exit_owned( + &self.bridge, + &vm_handle, + vm_id, + process_id, + exit_code, + )?; + if let Some(finished) = finished { + let phase_start = Instant::now(); + self.prune_extension_process_resource(&finished.process_id); + record_execute_phase("process_exit_cleanup_prune_resource", phase_start.elapsed()); + return Ok(Some(finished.became_idle)); + } + Ok(None) + } +} + +pub(crate) struct FinishedActiveProcessExit { + pub(crate) became_idle: bool, + pub(crate) process_id: String, +} + +#[cfg(test)] +mod process_event_channel_tests { + use super::*; + use crate::stdio::LocalBridge; + use crate::NativeSidecarConfig; + use std::future::Future as _; + use std::task::{Context, Poll, Waker}; + + #[test] + fn receiver_admission_failure_does_not_drop_later_envelopes() { + let config = NativeSidecarConfig::default(); + let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime) + .expect("process-event channel test runtime"); + let mut sidecar = NativeSidecar::with_config_extensions_and_runtime( + LocalBridge::default(), + config, + Vec::new(), + runtime.context(), + ) + .expect("process-event channel test sidecar"); + sidecar.config.runtime.protocol.max_process_events = 2; + let envelope = |child_path, byte| ProcessEventEnvelope { + connection_id: String::from("connection"), + session_id: String::from("session"), + vm_id: String::from("vm"), + child_path, + process_id: String::from("process"), + event: ActiveExecutionEvent::Stdout(vec![byte]), + }; + sidecar + .process_event_sender + .try_send(envelope( + vec![String::from("a"), String::from("b"), String::from("c")], + 1, + )) + .expect("queue invalid first envelope"); + sidecar + .process_event_sender + .try_send(envelope(Vec::new(), 2)) + .expect("queue valid later envelope"); + + let error = sidecar + .drain_runtime_process_event_channel_nowait() + .expect_err("invalid locator must fail admission"); + assert!(error + .to_string() + .contains("ERR_AGENTOS_PROCESS_EVENT_PATH_LIMIT")); + assert_eq!( + sidecar + .process_event_receiver + .as_ref() + .expect("process event receiver") + .len(), + 1, + "the later envelope must remain in the bounded channel" + ); + } + + #[test] + fn deferred_current_envelope_retries_before_later_channel_envelopes() { + let config = NativeSidecarConfig::default(); + let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime) + .expect("process-event retry-order test runtime"); + let mut sidecar = NativeSidecar::with_config_extensions_and_runtime( + LocalBridge::default(), + config, + Vec::new(), + runtime.context(), + ) + .expect("process-event retry-order test sidecar"); + let envelope = |byte| ProcessEventEnvelope { + connection_id: String::from("connection"), + session_id: String::from("session"), + vm_id: String::from("vm"), + child_path: Vec::new(), + process_id: String::from("process"), + event: ActiveExecutionEvent::Stdout(vec![byte]), + }; + sidecar.deferred_process_event_envelope = Some(envelope(1)); + sidecar + .process_event_sender + .try_send(envelope(2)) + .expect("queue later envelope"); + + assert!(sidecar + .drain_runtime_process_event_channel_nowait() + .expect("retry staged and later envelopes")); + let bytes = sidecar + .pending_process_events + .drain(..) + .map(|envelope| match envelope.event { + ActiveExecutionEvent::Stdout(bytes) => bytes[0], + other => panic!("expected stdout, received {other:?}"), + }) + .collect::>(); + assert_eq!(bytes, vec![1, 2]); + assert!(sidecar.deferred_process_event_envelope.is_none()); + assert!(sidecar + .process_event_receiver + .as_ref() + .expect("process event receiver") + .is_empty()); + } + + #[test] + fn temporary_rejection_stays_open_and_rearms_only_after_capacity_release() { + let config = NativeSidecarConfig::default(); + let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime) + .expect("process-event no-spin test runtime"); + let mut sidecar = NativeSidecar::with_config_extensions_and_runtime( + LocalBridge::default(), + config, + Vec::new(), + runtime.context(), + ) + .expect("process-event no-spin test sidecar"); + sidecar.config.runtime.protocol.max_process_events = 2; + let envelope = |byte| ProcessEventEnvelope { + connection_id: String::from("connection"), + session_id: String::from("session"), + vm_id: String::from("vm"), + child_path: Vec::new(), + process_id: String::from("process"), + event: ActiveExecutionEvent::Stdout(vec![byte]), + }; + sidecar.pending_process_events.push_back(envelope(8)); + sidecar.pending_process_events.push_back(envelope(9)); + sidecar.deferred_process_event_envelope = Some(envelope(1)); + sidecar + .process_event_sender + .try_send(envelope(2)) + .expect("queue later envelope"); + + assert!(sidecar + .drain_runtime_process_event_channel_nowait() + .expect("temporary saturation must not close the protocol")); + assert!(sidecar.deferred_process_event_envelope.is_some()); + assert_eq!(sidecar.pending_process_events.len(), 2); + assert_eq!( + sidecar + .process_event_receiver + .as_ref() + .expect("process event receiver") + .len(), + 1 + ); + + let mut notified = Box::pin(sidecar.process_event_notify.notified()); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + notified.as_mut().poll(&mut context), + Poll::Pending + )); + + sidecar.pending_process_events.pop_front(); + sidecar.observe_pending_process_event_depth(); + sidecar.rearm_deferred_process_event_after_capacity_release(); + assert!(matches!( + notified.as_mut().poll(&mut context), + Poll::Ready(()) + )); + drop(notified); + + sidecar + .drain_runtime_process_event_channel_nowait() + .expect("retry staged current envelope"); + let queued = sidecar + .pending_process_events + .iter() + .map(|envelope| match &envelope.event { + ActiveExecutionEvent::Stdout(bytes) => bytes[0], + other => panic!("expected stdout, received {other:?}"), + }) + .collect::>(); + assert_eq!(queued, vec![9, 1]); + assert_eq!( + sidecar + .process_event_receiver + .as_ref() + .expect("process event receiver") + .len(), + 1, + "later envelope remains behind the retried current envelope" + ); } } diff --git a/crates/native-sidecar/src/execution/python/mod.rs b/crates/native-sidecar/src/execution/python/mod.rs index cc8600a35c..4e7fcf9cc8 100644 --- a/crates/native-sidecar/src/execution/python/mod.rs +++ b/crates/native-sidecar/src/execution/python/mod.rs @@ -1,3 +1,7 @@ mod rpc; mod sockets; mod subprocess; + +pub(crate) use rpc::{respond_owned_python_rpc, service_owned_python_vfs_rpc_request}; +pub(crate) use sockets::service_owned_python_socket_connect_completion; +pub(crate) use subprocess::prepare_owned_python_process_event_service; diff --git a/crates/native-sidecar/src/execution/python/rpc.rs b/crates/native-sidecar/src/execution/python/rpc.rs index 2fcec12d8c..250dfdb51c 100644 --- a/crates/native-sidecar/src/execution/python/rpc.rs +++ b/crates/native-sidecar/src/execution/python/rpc.rs @@ -1,5 +1,358 @@ use super::super::*; +const PYTHON_HTTP_BLOCKING_JOB_BYTES: usize = 64 * 1024; + +pub(crate) fn respond_owned_python_rpc( + responder: &PythonVfsRpcResponder, + request_id: u64, + response: Result, +) -> Result<(), SidecarError> { + let result = match response { + Ok(payload) => responder.respond_success(request_id, payload), + Err(error) => { + responder.respond_error(request_id, "ERR_AGENTOS_PYTHON_VFS_RPC", error.to_string()) + } + }; + match result { + Ok(()) => Ok(()), + Err(error) => Err(python_error(error)), + } +} + +/// Service an already-claimed root Python runtime request without borrowing +/// the protocol router's `NativeSidecar`. +pub(crate) async fn service_owned_python_vfs_rpc_request( + bridge: SharedBridge, + vm: VmHandle, + vm_id: String, + process_id: String, + child_path: Vec, + responder: PythonVfsRpcResponder, + request: PythonVfsRpcRequest, + process_event_sender: tokio::sync::mpsc::Sender, + process_event_notify: Arc, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + match request.method { + PythonVfsRpcMethod::Read + | PythonVfsRpcMethod::Write + | PythonVfsRpcMethod::Stat + | PythonVfsRpcMethod::Lstat + | PythonVfsRpcMethod::ReadDir + | PythonVfsRpcMethod::Mkdir + | PythonVfsRpcMethod::Unlink + | PythonVfsRpcMethod::Rmdir + | PythonVfsRpcMethod::Rename + | PythonVfsRpcMethod::Symlink + | PythonVfsRpcMethod::ReadLink + | PythonVfsRpcMethod::Setattr => service_owned_python_filesystem_rpc_request( + &bridge, + &vm, + &vm_id, + &process_id, + &child_path, + &responder, + request, + ), + PythonVfsRpcMethod::HttpRequest => { + service_owned_python_http_rpc_request( + bridge, + vm, + vm_id, + process_id, + child_path, + responder, + request, + ) + .await + } + PythonVfsRpcMethod::DnsLookup => service_owned_python_dns_rpc_request( + &bridge, + &vm, + &vm_id, + &process_id, + &child_path, + &responder, + request, + ), + PythonVfsRpcMethod::SubprocessRun => Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_PYTHON_SUBPROCESS_ROUTE: subprocessRun must be prepared through the owned child-process service", + ))), + PythonVfsRpcMethod::SocketConnect + | PythonVfsRpcMethod::SocketSend + | PythonVfsRpcMethod::SocketRecv + | PythonVfsRpcMethod::SocketClose + | PythonVfsRpcMethod::UdpCreate + | PythonVfsRpcMethod::UdpSendto + | PythonVfsRpcMethod::UdpRecvfrom => { + super::sockets::service_owned_python_socket_rpc_request( + bridge, + vm, + vm_id, + process_id, + child_path, + responder, + request, + process_event_sender, + process_event_notify, + ) + .await + } + } +} + +async fn service_owned_python_http_rpc_request( + bridge: SharedBridge, + vm: VmHandle, + vm_id: String, + process_id: String, + child_path: Vec, + responder: PythonVfsRpcResponder, + request: PythonVfsRpcRequest, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + struct PreparedHttp { + url: Url, + url_text: String, + options: JavascriptHttpRequestOptions, + headers: HttpHeaderCollection, + pinned_addresses: Vec, + default_ca_bundle: Vec, + runtime: agentos_runtime::RuntimeContext, + } + + let prepared = (|| { + let mut vm_state = vm.try_borrow_mut("prepare Python HTTP RPC")?; + let path = child_path.iter().map(String::as_str).collect::>(); + let process_active = vm_state + .active_processes + .get(&process_id) + .and_then(|root| NativeSidecar::::active_process_by_path(root, &path)) + .is_some(); + if !process_active { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_STALE_PROCESS_EVENT: Python HTTP RPC targeted reaped process {process_id} in VM {vm_id}" + ))); + } + let url_text = request.url.clone().ok_or_else(|| { + SidecarError::InvalidState(String::from("python httpRequest requires a url")) + })?; + let url = Url::parse(&url_text) + .map_err(|error| SidecarError::Execution(format!("ERR_INVALID_URL: {error}")))?; + let host = url.host_str().ok_or_else(|| { + SidecarError::Execution(String::from("ERR_INVALID_URL: missing host")) + })?; + let port = url.port_or_known_default().ok_or_else(|| { + SidecarError::Execution(String::from("ERR_INVALID_URL: missing port")) + })?; + bridge.require_network_access( + &vm_id, + NetworkOperation::Http, + format_tcp_resource(host, port), + )?; + let pinned_addresses = if let Ok(literal_ip) = host.parse::() { + filter_dns_safe_ip_addrs(vec![literal_ip], host)? + } else { + filter_dns_safe_ip_addrs( + resolve_dns_ip_addrs( + &bridge, + &vm_state.kernel, + &vm_id, + &vm_state.dns, + host, + DnsLookupPolicy::SkipPermissions, + )?, + host, + )? + }; + bridge.require_resolved_network_access( + &vm_id, + NetworkOperation::Http, + &format_tcp_resource(host, port), + &pinned_addresses + .iter() + .map(|ip| format_tcp_resource(&ip.to_string(), port)) + .collect::>(), + )?; + let mut request_headers = BTreeMap::new(); + for (name, value) in &request.headers { + request_headers.insert(name.clone(), Value::String(value.clone())); + } + let options = JavascriptHttpRequestOptions { + method: Some( + request + .http_method + .clone() + .unwrap_or_else(|| String::from("GET")), + ), + headers: request_headers, + body: request.body_base64.as_deref().map(|body| { + String::from_utf8( + base64::engine::general_purpose::STANDARD + .decode(body) + .unwrap_or_default(), + ) + .unwrap_or_default() + }), + reject_unauthorized: None, + }; + let headers = parse_http_header_collection(&options.headers, "python httpRequest headers")?; + let default_ca_bundle = if url.scheme() == "https" { + read_vm_default_ca_bundle(&mut vm_state.kernel)? + } else { + Vec::new() + }; + Ok(PreparedHttp { + url, + url_text, + options, + headers, + pinned_addresses, + default_ca_bundle, + runtime: vm_state.runtime_context.clone(), + }) + })(); + + let response = match prepared { + Ok(prepared) => prepared + .runtime + .blocking() + .run(PYTHON_HTTP_BLOCKING_JOB_BYTES, move || { + let response = issue_outbound_http_request( + &prepared.url, + &prepared.options, + &prepared.headers, + &prepared.pinned_addresses, + &prepared.default_ca_bundle, + )?; + let payload_json = response.as_str().ok_or_else(|| { + SidecarError::Execution(String::from( + "python httpRequest returned a non-string response payload", + )) + })?; + let payload: Value = serde_json::from_str(payload_json).map_err(|error| { + SidecarError::Execution(format!( + "python httpRequest response must be valid JSON: {error}" + )) + })?; + let header_map = payload + .get("headers") + .and_then(Value::as_array) + .map(|entries| { + let mut normalized = BTreeMap::>::new(); + for entry in entries { + let Some(pair) = entry.as_array() else { + continue; + }; + let Some(name) = pair.first().and_then(Value::as_str) else { + continue; + }; + let Some(value) = pair.get(1).and_then(Value::as_str) else { + continue; + }; + normalized + .entry(name.to_owned()) + .or_default() + .push(value.to_owned()); + } + normalized + }) + .unwrap_or_default(); + Ok(PythonVfsRpcResponsePayload::Http { + status: payload + .get("status") + .and_then(Value::as_u64) + .map(|value| value as u16) + .unwrap_or_default(), + reason: payload + .get("statusText") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + url: payload + .get("url") + .and_then(Value::as_str) + .unwrap_or(&prepared.url_text) + .to_owned(), + headers: header_map, + body_base64: payload + .get("body") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + }) + }) + .await + .map_err(|error| SidecarError::Execution(error.to_string())) + .and_then(|response| response), + Err(error) => Err(error), + }; + respond_owned_python_rpc(&responder, request.id, response) +} + +fn service_owned_python_dns_rpc_request( + bridge: &SharedBridge, + vm: &VmHandle, + vm_id: &str, + process_id: &str, + child_path: &[String], + responder: &PythonVfsRpcResponder, + request: PythonVfsRpcRequest, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let response = (|| { + let vm = vm.try_read("service Python DNS RPC", |state| { + let path = child_path.iter().map(String::as_str).collect::>(); + let process_active = state + .active_processes + .get(process_id) + .and_then(|root| NativeSidecar::::active_process_by_path(root, &path)) + .is_some(); + if !process_active { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_STALE_PROCESS_EVENT: Python DNS RPC targeted reaped process {process_id} in VM {vm_id}" + ))); + } + let hostname = request.hostname.as_deref().ok_or_else(|| { + SidecarError::InvalidState(String::from("python dnsLookup requires a hostname")) + })?; + let mut addresses = filter_dns_safe_ip_addrs( + resolve_dns_ip_addrs( + bridge, + &state.kernel, + vm_id, + &state.dns, + hostname, + DnsLookupPolicy::CheckPermissions, + )?, + hostname, + )?; + if let Some(family) = request.family { + addresses.retain(|address| { + matches!((family, address), (4, IpAddr::V4(_)) | (6, IpAddr::V6(_))) + }); + } + Ok(PythonVfsRpcResponsePayload::DnsLookup { + addresses: addresses + .into_iter() + .map(|address| address.to_string()) + .collect(), + }) + })?; + vm + })(); + respond_owned_python_rpc(responder, request.id, response) +} + impl NativeSidecar where B: NativeSidecarBridge + Send + 'static, @@ -55,7 +408,7 @@ where process_id: &str, request: PythonVfsRpcRequest, ) -> Result<(), SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; if !vm.active_processes.contains_key(process_id) { @@ -201,6 +554,7 @@ where .to_owned(), }) })(); + drop(vm); self.respond_python_rpc(vm_id, process_id, request.id, response) } @@ -244,6 +598,7 @@ where .collect(), }) })(); + drop(vm); self.respond_python_rpc(vm_id, process_id, request.id, response) } diff --git a/crates/native-sidecar/src/execution/python/sockets.rs b/crates/native-sidecar/src/execution/python/sockets.rs index 63c6997367..8eda7eef52 100644 --- a/crates/native-sidecar/src/execution/python/sockets.rs +++ b/crates/native-sidecar/src/execution/python/sockets.rs @@ -288,13 +288,157 @@ fn respond_python_socket_async( } } +trait PythonSocketConnectResponder { + fn respond( + &self, + request_id: u64, + response: Result, + ) -> Result<(), SidecarError>; +} + +impl PythonSocketConnectResponder for PythonVfsRpcResponder { + fn respond( + &self, + request_id: u64, + response: Result, + ) -> Result<(), SidecarError> { + respond_owned_python_rpc(self, request_id, response) + } +} + +fn respond_python_socket_connect_result( + responder: &R, + request_id: u64, + response: Result, +) -> Result<(), SidecarError> { + responder.respond(request_id, response) +} + fn python_socket_kind_error(op: &str, expected: &str) -> SidecarError { SidecarError::Execution(format!( "EOPNOTSUPP: python socket {op} requires a {expected} socket" )) } -impl NativeSidecar +struct OwnedPythonVmRegistry { + vm_id: String, + vm: VmHandle, +} + +impl OwnedPythonVmRegistry { + fn contains_key(&self, vm_id: &str) -> bool { + self.vm_id == vm_id + } + + fn get(&self, vm_id: &str) -> Option> { + self.contains_key(vm_id).then(|| self.vm.borrow()) + } + + fn get_mut(&self, vm_id: &str) -> Option> { + self.contains_key(vm_id).then(|| self.vm.borrow_mut()) + } + + fn handle(&self, vm_id: &str) -> Option { + self.contains_key(vm_id).then(|| self.vm.clone()) + } +} + +struct OwnedPythonSocketService { + bridge: SharedBridge, + vms: OwnedPythonVmRegistry, + process_event_sender: tokio::sync::mpsc::Sender, + process_event_notify: Arc, + target: PythonProcessTarget, + responder: PythonVfsRpcResponder, +} + +#[derive(Clone, Debug)] +struct PythonProcessTarget { + root_process_id: String, + child_path: Vec, +} + +impl PythonProcessTarget { + fn process<'a>(&self, vm: &'a VmState) -> Option<&'a ActiveProcess> { + self.process_in_roots(&vm.active_processes) + } + + fn process_in_roots<'a>( + &self, + roots: &'a BTreeMap, + ) -> Option<&'a ActiveProcess> { + let mut process = roots.get(&self.root_process_id)?; + for child_id in &self.child_path { + process = process.child_processes.get(child_id)?; + } + Some(process) + } + + fn process_mut<'a>(&self, vm: &'a mut VmState) -> Option<&'a mut ActiveProcess> { + self.process_in_roots_mut(&mut vm.active_processes) + } + + fn process_in_roots_mut<'a>( + &self, + roots: &'a mut BTreeMap, + ) -> Option<&'a mut ActiveProcess> { + let mut process = roots.get_mut(&self.root_process_id)?; + for child_id in &self.child_path { + process = process.child_processes.get_mut(child_id)?; + } + Some(process) + } + + fn label(&self) -> String { + if self.child_path.is_empty() { + return self.root_process_id.clone(); + } + format!("{}:{}", self.root_process_id, self.child_path.join(":")) + } +} + +struct DetachedPythonUdpSocket { + vm: VmHandle, + target: PythonProcessTarget, + native_socket_id: String, + socket: Option, +} + +impl DetachedPythonUdpSocket { + fn socket(&self) -> &ActiveUdpSocket { + self.socket + .as_ref() + .expect("detached Python UDP socket remains owned until guard drop") + } +} + +impl Drop for DetachedPythonUdpSocket { + fn drop(&mut self) { + let Some(socket) = self.socket.take() else { + return; + }; + let native_socket_id = self.native_socket_id.clone(); + let target = self.target.clone(); + if let Err(error) = self.vm.try_command("restore detached Python UDP socket", |vm| { + let Some(process) = target.process_mut(vm) else { + return Ok(()); + }; + if process.udp_sockets.contains_key(&native_socket_id) { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PYTHON_SOCKET_RESTORE_CONFLICT: UDP socket {native_socket_id} was replaced while an owned receive was pending" + ))); + } + process.udp_sockets.insert(native_socket_id, socket); + Ok(()) + }) { + eprintln!( + "ERR_AGENTOS_PYTHON_SOCKET_RESTORE: failed to restore detached UDP socket: {error}" + ); + } + } +} + +impl OwnedPythonSocketService where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, @@ -302,31 +446,30 @@ where pub(in crate::execution) async fn handle_python_socket_rpc_request( &mut self, vm_id: &str, - process_id: &str, request: PythonVfsRpcRequest, ) -> Result<(), SidecarError> { if !self.vms.contains_key(vm_id) { return Ok(()); } - match self.python_socket_op(vm_id, process_id, &request).await { + match self.python_socket_op(vm_id, &request).await { Ok(PythonSocketOp::Immediate(response)) => { - self.respond_python_rpc(vm_id, process_id, request.id, Ok(response)) + self.respond_python_rpc(request.id, Ok(response)) } Ok(PythonSocketOp::Charged(response)) => { - self.respond_python_rpc(vm_id, process_id, request.id, Ok(response.payload)) + self.respond_python_rpc(request.id, Ok(response.payload)) } Ok(PythonSocketOp::Deferred) => Ok(()), Ok(PythonSocketOp::Wait(wait)) => { - self.schedule_python_socket_wait(vm_id, process_id, request, wait) + self.schedule_python_socket_wait(vm_id, request, wait) } - Err(error) => self.respond_python_rpc(vm_id, process_id, request.id, Err(error)), + Err(error) => self.respond_python_rpc(request.id, Err(error)), } } + #[deny(clippy::await_holding_refcell_ref)] async fn python_socket_op( &mut self, vm_id: &str, - process_id: &str, request: &PythonVfsRpcRequest, ) -> Result { match request.method { @@ -338,9 +481,10 @@ where NetworkOperation::Http, format_tcp_resource(&host, port), )?; - let socket_paths = build_javascript_socket_path_context( - self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?, - )?; + let socket_paths = { + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + build_javascript_socket_path_context(&vm)? + }; let resolved = { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; resolve_tcp_connect_addr( @@ -355,28 +499,35 @@ where )? }; if !resolved.use_kernel_loopback { - return self - .defer_python_native_tcp_connect(vm_id, process_id, request.id, resolved); + return self.defer_python_native_tcp_connect(vm_id, request.id, resolved); } - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; let pending = reserve_capability(&vm.capabilities, CapabilityKind::TcpSocket)?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket op for reaped vm/process", - )) - })?; + let resources = vm.capabilities.resources(); + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let vm = &mut *vm; + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let process = self + .target + .process_in_roots_mut(active_processes) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() + )) + })?; let socket = ActiveTcpSocket::connect_kernel_loopback( - &mut vm.kernel, + kernel, process.kernel_pid, resolved, None, None, None, &socket_paths, - vm.capabilities.resources(), + resources, process.runtime_context.clone(), reactor_io_limits(&process.limits), )?; @@ -391,7 +542,7 @@ where ) { Ok(identity) => identity, Err(error) => { - if let Err(close_error) = socket.close(&mut vm.kernel, process.kernel_pid) { + if let Err(close_error) = socket.close(kernel, process.kernel_pid) { eprintln!( "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: TCP connect rollback failed: {close_error}" ); @@ -407,7 +558,7 @@ where .expect("committed Python TCP capability lease"), ); register_kernel_readiness_target( - &vm.kernel_socket_readiness, + &kernel_readiness, socket.kernel_socket_id, None, Some(Arc::clone(&socket.read_event_notify)), @@ -435,15 +586,21 @@ where } PythonVfsRpcMethod::SocketSend => { let python_socket_id = python_socket_id(request)?; - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket op for reaped vm/process", - )) - })?; + let vm = &mut *vm; + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let process = self + .target + .process_in_roots_mut(active_processes) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() + )) + })?; let data = python_socket_payload(request, process.runtime_context.resources())?; let native_socket_id = match process.python_sockets.get(&python_socket_id) { Some(PythonHostSocket::Tcp { socket_id, .. }) => socket_id.clone(), @@ -461,14 +618,13 @@ where .get(&native_socket_id) .ok_or_else(|| python_socket_backend_missing_error(python_socket_id))?; if socket.kernel_socket_id.is_some() { - let bytes_sent = - socket.write_all(&mut vm.kernel, process.kernel_pid, &data.bytes)?; + let bytes_sent = socket.write_all(kernel, process.kernel_pid, &data.bytes)?; return Ok(PythonSocketOp::Immediate( PythonVfsRpcResponsePayload::SocketSent { bytes_sent }, )); } let response = socket.begin_plain_write(&data.bytes)?; - let (runtime, responder) = self.python_socket_async_context(vm_id, process_id)?; + let (runtime, responder) = self.python_socket_async_context(vm_id)?; let request_id = request.id; runtime .spawn(agentos_runtime::TaskClass::Socket, async move { @@ -498,15 +654,21 @@ where PythonVfsRpcMethod::SocketRecv => { let max = python_socket_recv_len(request); let python_socket_id = python_socket_id(request)?; - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket op for reaped vm/process", - )) - })?; + let vm = &mut *vm; + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let process = self + .target + .process_in_roots_mut(active_processes) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() + )) + })?; let resources = Arc::clone(process.runtime_context.resources()); let mut handle = process .python_sockets @@ -534,8 +696,7 @@ where .get_mut(socket_id) .ok_or_else(|| python_socket_backend_missing_error(python_socket_id))?; socket.set_application_read_interest(true)?; - let event = - socket.poll(&mut vm.kernel, process.kernel_pid, Duration::ZERO, false)?; + let event = socket.poll(kernel, process.kernel_pid, Duration::ZERO, false)?; let wait_timeout = python_socket_wait_timeout(request, socket.reactor_limits); if event.is_none() && !wait_timeout.is_zero() { return Ok(PythonSocketOp::Wait(PythonSocketWait { @@ -561,25 +722,27 @@ where result } PythonVfsRpcMethod::SocketClose => { - self.remove_python_socket(vm_id, process_id, request)?; + self.remove_python_socket(vm_id, request)?; Ok(PythonSocketOp::Immediate( PythonVfsRpcResponsePayload::Empty, )) } PythonVfsRpcMethod::UdpCreate => { - let vm = self + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; let pending = reserve_capability(&vm.capabilities, CapabilityKind::UdpSocket)?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket op for reaped vm/process", + let resources = vm.capabilities.resources(); + let process = self.target.process_mut(&mut vm).ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() )) })?; let mut socket = ActiveUdpSocket::new_native( JavascriptUdpFamily::Ipv4, - vm.capabilities.resources(), + resources, process.runtime_context.clone(), reactor_io_limits(&process.limits), )?; @@ -621,46 +784,56 @@ where NetworkOperation::Http, format_tcp_resource(&host, port), )?; - let socket_paths = build_javascript_socket_path_context( - self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?, - )?; + let socket_paths = { + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + build_javascript_socket_path_context(&vm)? + }; let python_socket_id = python_socket_id(request)?; - let vm = self - .vms - .get_mut(vm_id) - .ok_or_else(|| missing_vm_error(vm_id))?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket op for reaped vm/process", - )) - })?; - let data = python_socket_payload(request, process.runtime_context.resources())?; - let native_socket_id = match process.python_sockets.get(&python_socket_id) { - Some(PythonHostSocket::Udp { socket_id }) => socket_id.clone(), - Some(PythonHostSocket::Tcp { .. }) => { - return Err(python_socket_kind_error("sendto", "UDP")); - } - None => return Err(python_socket_missing_error(python_socket_id)), + let send = { + let mut vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + let vm = &mut *vm; + let (kernel, dns, active_processes) = + (&mut vm.kernel, &vm.dns, &mut vm.active_processes); + let process = self + .target + .process_in_roots_mut(active_processes) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() + )) + })?; + let data = python_socket_payload(request, process.runtime_context.resources())?; + let native_socket_id = match process.python_sockets.get(&python_socket_id) { + Some(PythonHostSocket::Udp { socket_id }) => socket_id.clone(), + Some(PythonHostSocket::Tcp { .. }) => { + return Err(python_socket_kind_error("sendto", "UDP")); + } + None => return Err(python_socket_missing_error(python_socket_id)), + }; + process.validate_capability_alias( + &NativeCapabilityKey::UdpSocket(native_socket_id.clone()), + CapabilityKind::UdpSocket, + )?; + let socket = process + .udp_sockets + .get_mut(&native_socket_id) + .ok_or_else(|| python_socket_backend_missing_error(python_socket_id))?; + socket.send_to(ActiveUdpSendToRequest { + bridge: &self.bridge, + kernel, + kernel_pid: process.kernel_pid, + vm_id, + dns, + host: &host, + port, + context: &socket_paths, + contents: &data.bytes, + })? }; - process.validate_capability_alias( - &NativeCapabilityKey::UdpSocket(native_socket_id.clone()), - CapabilityKind::UdpSocket, - )?; - let socket = process - .udp_sockets - .get_mut(&native_socket_id) - .ok_or_else(|| python_socket_backend_missing_error(python_socket_id))?; - let send = socket.send_to(ActiveUdpSendToRequest { - bridge: &self.bridge, - kernel: &mut vm.kernel, - kernel_pid: process.kernel_pid, - vm_id, - dns: &vm.dns, - host: &host, - port, - context: &socket_paths, - contents: &data.bytes, - })?; let bytes_sent = await_udp_send_result(send).await?; Ok(PythonSocketOp::Immediate( PythonVfsRpcResponsePayload::SocketSent { bytes_sent }, @@ -669,39 +842,79 @@ where PythonVfsRpcMethod::UdpRecvfrom => { let max = python_socket_recv_len(request); let python_socket_id = python_socket_id(request)?; - let vm = self + let vm_handle = self .vms - .get_mut(vm_id) + .handle(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket op for reaped vm/process", - )) - })?; - let resources = Arc::clone(process.runtime_context.resources()); - let native_socket_id = match process.python_sockets.get(&python_socket_id) { - Some(PythonHostSocket::Udp { socket_id }) => socket_id.clone(), - Some(PythonHostSocket::Tcp { .. }) => { - return Err(python_socket_kind_error("recvfrom", "UDP")); - } - None => return Err(python_socket_missing_error(python_socket_id)), + let (resources, kernel_pid, native_socket_id, active_socket) = { + let mut vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + let process = self.target.process_mut(&mut vm).ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() + )) + })?; + let resources = Arc::clone(process.runtime_context.resources()); + let native_socket_id = match process.python_sockets.get(&python_socket_id) { + Some(PythonHostSocket::Udp { socket_id }) => socket_id.clone(), + Some(PythonHostSocket::Tcp { .. }) => { + return Err(python_socket_kind_error("recvfrom", "UDP")); + } + None => return Err(python_socket_missing_error(python_socket_id)), + }; + process.validate_capability_alias( + &NativeCapabilityKey::UdpSocket(native_socket_id.clone()), + CapabilityKind::UdpSocket, + )?; + let active_socket = process + .udp_sockets + .remove(&native_socket_id) + .ok_or_else(|| python_socket_backend_missing_error(python_socket_id))?; + ( + resources, + process.kernel_pid, + native_socket_id, + active_socket, + ) }; - process.validate_capability_alias( - &NativeCapabilityKey::UdpSocket(native_socket_id.clone()), - CapabilityKind::UdpSocket, - )?; - let socket = process - .udp_sockets - .get(&native_socket_id) - .ok_or_else(|| python_socket_backend_missing_error(python_socket_id))?; - let event = socket - .poll(&mut vm.kernel, process.kernel_pid, Duration::ZERO) - .await?; - let wait_timeout = python_socket_wait_timeout(request, socket.reactor_limits); + let socket = DetachedPythonUdpSocket { + vm: vm_handle, + target: self.target.clone(), + native_socket_id, + socket: Some(active_socket), + }; + let kernel_ready = { + let mut vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + socket + .socket() + .poll_kernel_ready(&mut vm.kernel, kernel_pid, Duration::ZERO)? + }; + let event = if kernel_ready { + let turn = socket.socket().acquire_poll_fair_turn().await?; + let mut vm = self + .vms + .get_mut(vm_id) + .ok_or_else(|| missing_vm_error(vm_id))?; + socket.socket().consume_ready_kernel_datagram( + &mut vm.kernel, + kernel_pid, + turn, + )? + } else { + socket.socket().poll_native(Duration::ZERO).await? + }; + let wait_timeout = + python_socket_wait_timeout(request, socket.socket().reactor_limits); if event.is_none() && !wait_timeout.is_zero() { return Ok(PythonSocketOp::Wait(PythonSocketWait { source: PythonSocketWaitSource::Notify(Arc::clone( - &socket.read_event_notify, + &socket.socket().read_event_notify, )), timeout: wait_timeout, task_class: agentos_runtime::TaskClass::Udp, @@ -723,40 +936,30 @@ where fn defer_python_native_tcp_connect( &mut self, vm_id: &str, - process_id: &str, request_id: u64, resolved: ResolvedTcpConnectAddr, ) -> Result { debug_assert!(!resolved.use_kernel_loopback); - let ( - connection_id, - session_id, - runtime, - resources, - limits, - pending_capability, - native_socket_id, - python_socket_id, - ) = { - let vm = self + let (runtime, resources, limits, pending_capability, native_socket_id, python_socket_id) = { + let mut vm = self .vms .get_mut(vm_id) .ok_or_else(|| missing_vm_error(vm_id))?; let pending_capability = reserve_capability(&vm.capabilities, CapabilityKind::TcpSocket)?; - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from( - "python socket connect for reaped vm/process", + let resources = vm.capabilities.resources(); + let process = self.target.process_mut(&mut vm).ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket connect for reaped process {}", + self.target.label() )) })?; let native_socket_id = process.allocate_tcp_socket_id(); let python_socket_id = process.next_python_socket_id; process.next_python_socket_id = process.next_python_socket_id.wrapping_add(1); ( - vm.connection_id.clone(), - vm.session_id.clone(), process.runtime_context.clone(), - vm.capabilities.resources(), + resources, reactor_io_limits(&process.limits), pending_capability, native_socket_id, @@ -764,10 +967,15 @@ where ) }; let task_runtime = runtime.clone(); + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; + let connection_id = vm.connection_id.clone(); + let session_id = vm.session_id.clone(); + let vm_id = vm_id.to_owned(); + let process_id = self.target.root_process_id.clone(); + let child_path = self.target.child_path.clone(); let sender = self.process_event_sender.clone(); + let responder = self.responder.clone(); let event_notify = Arc::clone(&self.process_event_notify); - let vm_id = vm_id.to_owned(); - let process_id = process_id.to_owned(); runtime .spawn(agentos_runtime::TaskClass::Socket, async move { let result = match tokio::time::timeout( @@ -815,21 +1023,23 @@ where ), }), }; - if sender - .send(ProcessEventEnvelope { - connection_id, - session_id, - vm_id, - process_id, - event: ActiveExecutionEvent::PythonSocketConnectCompletion( - Box::new(PythonSocketConnectCompletion { request_id, result }), - ), - }) - .await - .is_err() - { - eprintln!( - "ERR_AGENTOS_PROCESS_EVENT_CHANNEL_CLOSED: Python TCP connect completion could not be delivered" + let envelope = ProcessEventEnvelope { + connection_id, + session_id, + vm_id, + child_path, + process_id, + event: ActiveExecutionEvent::PythonSocketConnectCompletion(Box::new( + PythonSocketConnectCompletion { request_id, result }, + )), + }; + if sender.send(envelope).await.is_err() { + respond_python_socket_async( + &responder, + request_id, + Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_PROCESS_EVENT_CHANNEL_CLOSED: Python TCP connect completion could not be delivered", + ))), ); } else { event_notify.notify_one(); @@ -842,11 +1052,13 @@ where fn python_socket_async_context( &self, vm_id: &str, - process_id: &str, ) -> Result<(agentos_runtime::RuntimeContext, PythonVfsRpcResponder), SidecarError> { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - let process = vm.active_processes.get(process_id).ok_or_else(|| { - SidecarError::InvalidState(String::from("python socket op for reaped vm/process")) + let process = self.target.process(&vm).ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket op for reaped process {}", + self.target.label() + )) })?; Ok(( vm.runtime_context.clone(), @@ -857,19 +1069,29 @@ where fn schedule_python_socket_wait( &self, vm_id: &str, - process_id: &str, mut request: PythonVfsRpcRequest, wait: PythonSocketWait, ) -> Result<(), SidecarError> { let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; - let runtime = vm.runtime_context.clone(); + let process = self.target.process(&vm).ok_or_else(|| { + SidecarError::InvalidState(format!( + "python socket wait for reaped process {}", + self.target.label() + )) + })?; + let runtime = process.runtime_context.clone(); + drop(vm); + let vm = self.vms.get(vm_id).ok_or_else(|| missing_vm_error(vm_id))?; let connection_id = vm.connection_id.clone(); let session_id = vm.session_id.clone(); let vm_id = vm_id.to_owned(); - let process_id = process_id.to_owned(); + let process_id = self.target.root_process_id.clone(); + let child_path = self.target.child_path.clone(); let sender = self.process_event_sender.clone(); + let responder = self.responder.clone(); let event_notify = Arc::clone(&self.process_event_notify); request.timeout_ms = Some(0); + let request_id = request.id; let cancellation = runtime.clone(); runtime .spawn(wait.task_class, async move { @@ -887,19 +1109,21 @@ where if !cancellation.admission_is_open() { return; } - if sender - .send(ProcessEventEnvelope { - connection_id, - session_id, - vm_id, - process_id, - event: ActiveExecutionEvent::PythonVfsRpcRequest(Box::new(request)), - }) - .await - .is_err() - { - eprintln!( - "ERR_AGENTOS_PROCESS_EVENT_CHANNEL_CLOSED: Python socket readiness completion could not be delivered" + let envelope = ProcessEventEnvelope { + connection_id, + session_id, + vm_id, + child_path, + process_id, + event: ActiveExecutionEvent::PythonVfsRpcRequest(Box::new(request)), + }; + if sender.send(envelope).await.is_err() { + respond_python_socket_async( + &responder, + request_id, + Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_PROCESS_EVENT_CHANNEL_CLOSED: Python socket readiness retry could not be delivered", + ))), ); } else { event_notify.notify_one(); @@ -912,17 +1136,18 @@ where fn remove_python_socket( &mut self, vm_id: &str, - process_id: &str, request: &PythonVfsRpcRequest, ) -> Result<(), SidecarError> { let Some(socket_id) = request.socket_id else { return Ok(()); }; - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); - let Some(process) = vm.active_processes.get_mut(process_id) else { + let vm = &mut *vm; + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let Some(process) = self.target.process_in_roots_mut(active_processes) else { return Ok(()); }; let Some(socket) = process.python_sockets.get(&socket_id) else { @@ -952,7 +1177,7 @@ where process, &native_socket_id, socket, - &mut vm.kernel, + kernel, &kernel_readiness, ); } @@ -965,7 +1190,7 @@ where process, &native_socket_id, socket, - &mut vm.kernel, + kernel, &kernel_readiness, )?; } @@ -974,6 +1199,190 @@ where Ok(()) } + pub(in crate::execution) fn respond_python_rpc( + &mut self, + request_id: u64, + response: Result, + ) -> Result<(), SidecarError> { + respond_owned_python_rpc(&self.responder, request_id, response) + } +} + +pub(in crate::execution) async fn service_owned_python_socket_rpc_request( + bridge: SharedBridge, + vm: VmHandle, + vm_id: String, + process_id: String, + child_path: Vec, + responder: PythonVfsRpcResponder, + request: PythonVfsRpcRequest, + process_event_sender: tokio::sync::mpsc::Sender, + process_event_notify: Arc, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let mut service = OwnedPythonSocketService { + bridge, + vms: OwnedPythonVmRegistry { + vm_id: vm_id.clone(), + vm, + }, + process_event_sender, + process_event_notify, + target: PythonProcessTarget { + root_process_id: process_id, + child_path, + }, + responder, + }; + service + .handle_python_socket_rpc_request(&vm_id, request) + .await +} + +pub(crate) async fn service_owned_python_socket_connect_completion( + vm: VmHandle, + vm_id: String, + process_id: String, + child_path: Vec, + responder: PythonVfsRpcResponder, + completion: PythonSocketConnectCompletion, +) -> Result<(), SidecarError> { + let request_id = completion.request_id; + let connected = match completion.result { + Ok(connected) => connected, + Err(error) => { + return respond_python_socket_connect_result( + &responder, + request_id, + Err(SidecarError::Execution(format!( + "{}: {}", + error.code, error.message + ))), + ); + } + }; + let target = PythonProcessTarget { + root_process_id: process_id, + child_path, + }; + let result = vm.try_command("complete owned Python TCP connect", move |state| { + let kernel_readiness = Arc::clone(&state.kernel_socket_readiness); + let state = &mut *state; + let (kernel, active_processes) = (&mut state.kernel, &mut state.active_processes); + let process = target + .process_in_roots_mut(active_processes) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_STALE_PROCESS_EVENT: Python TCP completion targeted reaped process {} in VM {vm_id}", + target.label() + )) + })?; + let PendingPythonTcpConnect { + native_socket_id, + python_socket_id, + socket, + pending_capability, + } = connected; + let capability_key = NativeCapabilityKey::TcpSocket(native_socket_id.clone()); + if let Err(error) = commit_process_capability( + process, + pending_capability, + capability_key.clone(), + native_socket_id.clone(), + socket.kernel_socket_id, + ) { + if let Err(close_error) = socket.close(kernel, process.kernel_pid) { + eprintln!( + "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: deferred TCP connect rollback failed: {close_error}" + ); + } + return Err(error); + } + if let Err(error) = + socket.set_fairness_identity(process.capability_fairness_identity(&capability_key)) + { + if let Err(release_error) = process.release_capability(&capability_key) { + eprintln!( + "ERR_AGENTOS_CAPABILITY_RELEASE: deferred Python TCP rollback failed: {release_error}" + ); + } + if let Err(close_error) = socket.close(kernel, process.kernel_pid) { + eprintln!( + "ERR_AGENTOS_PYTHON_SOCKET_CLOSE: deferred TCP fairness rollback failed: {close_error}" + ); + } + return Err(error); + } + socket.retain_description_lease( + process + .shared_capability_lease(&capability_key) + .expect("committed deferred Python TCP capability lease"), + ); + register_kernel_readiness_target( + &kernel_readiness, + socket.kernel_socket_id, + None, + Some(Arc::clone(&socket.read_event_notify)), + process.capability_readiness_identity(&capability_key), + native_socket_id.clone(), + KernelSocketReadinessEvent::Data, + ); + process.tcp_sockets.insert(native_socket_id.clone(), socket); + process.python_sockets.insert( + python_socket_id, + PythonHostSocket::Tcp { + socket_id: native_socket_id, + pending_read: None, + }, + ); + debug_assert!(process.capability_leases.contains_key(&capability_key)); + Ok(PythonVfsRpcResponsePayload::SocketCreated { + socket_id: python_socket_id, + }) + }); + respond_python_socket_connect_result(&responder, request_id, result) +} + +impl NativeSidecar +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + pub(in crate::execution) async fn handle_python_socket_rpc_request( + &mut self, + vm_id: &str, + process_id: &str, + request: PythonVfsRpcRequest, + ) -> Result<(), SidecarError> { + let Some(vm) = self.vms.handle(vm_id) else { + return Ok(()); + }; + let responder = vm.try_read("prepare Python socket RPC", |state| { + state + .active_processes + .get(process_id) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { + return Ok(()); + }; + service_owned_python_socket_rpc_request( + self.bridge.clone(), + vm, + vm_id.to_owned(), + process_id.to_owned(), + Vec::new(), + responder?, + request, + self.process_event_sender.clone(), + Arc::clone(&self.process_event_notify), + ) + .await + } + pub(in crate::execution) fn respond_python_rpc( &mut self, vm_id: &str, @@ -981,7 +1390,7 @@ where request_id: u64, response: Result, ) -> Result<(), SidecarError> { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; let Some(process) = vm.active_processes.get_mut(process_id) else { @@ -1009,11 +1418,99 @@ where mod python_socket_accounting_tests { use super::{ decode_python_socket_payload, encode_python_socket_bytes, - reserve_plain_socket_write_payload, + reserve_plain_socket_write_payload, respond_python_socket_connect_result, + PythonSocketConnectResponder, PythonVfsRpcResponsePayload, SidecarError, }; use agentos_runtime::accounting::{ResourceClass, ResourceLedger, ResourceLimit}; + use std::cell::RefCell; use std::sync::Arc; + #[derive(Default)] + struct CapturingConnectResponder { + response: RefCell)>>, + } + + impl PythonSocketConnectResponder for CapturingConnectResponder { + fn respond( + &self, + request_id: u64, + response: Result, + ) -> Result<(), SidecarError> { + *self.response.borrow_mut() = Some((request_id, response)); + Ok(()) + } + } + + #[test] + fn root_socket_connect_success_replies_with_the_created_socket_id() { + let responder = CapturingConnectResponder::default(); + respond_python_socket_connect_result( + &responder, + 41, + Ok(PythonVfsRpcResponsePayload::SocketCreated { socket_id: 73 }), + ) + .expect("deliver root Python socket success"); + let (request_id, response) = responder + .response + .borrow_mut() + .take() + .expect("captured root Python socket response"); + assert_eq!(request_id, 41); + match response.expect("successful root Python socket response") { + PythonVfsRpcResponsePayload::SocketCreated { socket_id } => { + assert_eq!(socket_id, 73); + } + other => panic!("expected SocketCreated response, received {other:?}"), + } + } + + #[test] + fn deferred_socket_events_preserve_explicit_process_paths() { + let source = include_str!("sockets.rs"); + let connect = source + .split("fn defer_python_native_tcp_connect") + .nth(1) + .and_then(|source| source.split("fn python_socket_async_context").next()) + .expect("deferred TCP connect source"); + let readiness = source + .split("fn schedule_python_socket_wait") + .nth(1) + .and_then(|source| source.split("fn remove_python_socket").next()) + .expect("socket readiness source"); + for path in [connect, readiness] { + assert!(path.contains("process_id = self.target.root_process_id.clone()")); + assert!(path.contains("child_path = self.target.child_path.clone()")); + assert!(path.contains("ProcessEventEnvelope")); + } + } + + #[test] + fn root_socket_completion_is_owned_supervised_work() { + let process_events = include_str!("../process_events.rs"); + let root_turn = process_events + .split("pub(crate) fn pump_process_events_nowait") + .nth(1) + .and_then(|source| { + source + .split("pub(crate) fn handle_public_execution_event_nowait") + .next() + }) + .expect("root process-event turn source"); + assert!(root_turn.contains("OwnedPythonSocketCompletionService::new")); + assert!(root_turn.contains("python_socket_completions.push")); + assert!(!root_turn.contains("handle_python_socket_connect_completion(")); + + let extension_services = include_str!("../../extension_services.rs"); + let supervised = extension_services + .split("fn prepare_owned_python_socket_completion_service") + .nth(1) + .and_then(|source| source.split("pub(crate) fn prepare_owned_child").next()) + .expect("owned Python completion supervisor source"); + assert!(supervised.contains("with_internal_vm_event_admission")); + assert!(supervised.contains("panic_responder.respond_error")); + assert!(supervised.contains("completion_responder.respond_error")); + } + #[test] fn adapter_copies_are_charged_before_decode_encode_and_plain_write() { let resources = Arc::new(ResourceLedger::root( diff --git a/crates/native-sidecar/src/execution/python/subprocess.rs b/crates/native-sidecar/src/execution/python/subprocess.rs index f5f6ec1010..9f13dd2395 100644 --- a/crates/native-sidecar/src/execution/python/subprocess.rs +++ b/crates/native-sidecar/src/execution/python/subprocess.rs @@ -1,5 +1,232 @@ use super::super::*; +/// Build the complete owned service for one claimed Python process event. +/// Keeping the claimed target alive here also retains its queue reservation +/// until the request has either been deferred or replied to. +pub(crate) fn prepare_owned_python_process_event_service( + sidecar: &NativeSidecar, + target: OwnedPythonEventService, +) -> Pin> + 'static>> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + if target.request.method == PythonVfsRpcMethod::SubprocessRun { + let vm = target.vm.clone(); + let vm_id = target.vm_id.clone(); + let process_id = target.process_id.clone(); + let child_path = target.child_path.clone(); + let responder = target.responder.clone(); + let request = target.request.clone(); + let process_event_capacity = sidecar.config.runtime.protocol.max_process_events; + let sidecar_requests = SharedSidecarRequestClient::clone(&sidecar.sidecar_requests); + let process_event_notify = Arc::clone(&sidecar.process_event_notify); + let cache_root = sidecar.cache_root.clone(); + return Box::pin(async move { + let service = prepare_owned_python_subprocess_run::( + vm, + vm_id, + process_id, + child_path, + responder, + request, + process_event_capacity, + sidecar_requests, + process_event_notify, + cache_root, + ); + let result = service.await; + drop(target); + result + }); + } + + let bridge = sidecar.bridge.clone(); + let process_event_sender = sidecar.process_event_sender.clone(); + let process_event_notify = Arc::clone(&sidecar.process_event_notify); + Box::pin(async move { + let result = service_owned_python_vfs_rpc_request( + bridge, + target.vm.clone(), + target.vm_id.clone(), + target.process_id.clone(), + target.child_path.clone(), + target.responder.clone(), + target.request.clone(), + process_event_sender, + process_event_notify, + ) + .await; + drop(target); + result + }) +} + +/// Prepare a Python `subprocess.run` service whose child startup and eventual +/// response are owned by the request rather than the protocol dispatcher. +/// Every VM-state access is a short synchronous command; the returned future +/// never retains `NativeSidecar` or a `VmState` borrow across an await. +pub(crate) fn prepare_owned_python_subprocess_run( + vm: VmHandle, + vm_id: String, + process_id: String, + child_path: Vec, + responder: PythonVfsRpcResponder, + request: PythonVfsRpcRequest, + process_event_capacity: usize, + sidecar_requests: SharedSidecarRequestClient, + process_event_notify: Arc, + cache_root: PathBuf, +) -> Pin> + 'static>> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let request_id = request.id; + let prepared = (|| { + let command = request.command.clone().ok_or_else(|| { + SidecarError::InvalidState(String::from("python subprocessRun requires a command")) + })?; + let (internal_bootstrap_env, cwd, max_buffer) = + vm.try_command("prepare owned Python subprocess", |vm| { + let root = vm + .active_processes + .get(&process_id) + .ok_or_else(|| missing_process_error(&vm_id, &process_id))?; + let path = child_path.iter().map(String::as_str).collect::>(); + let process = NativeSidecar::::active_process_by_path(root, &path) + .ok_or_else(|| javascript_child_process_gone_error(&process_id, &path))?; + let virtual_home = guest_virtual_home(vm); + let cwd = request.cwd.clone().or_else(|| { + guest_runtime_path_for_host_path( + &vm.guest_env, + &virtual_home, + &vm.host_cwd, + &process.host_cwd.to_string_lossy(), + ) + }); + Ok(( + sanitize_javascript_child_process_internal_bootstrap_env(&vm.guest_env), + cwd, + NativeSidecar::::child_process_sync_max_buffer(process, request.max_buffer)?, + )) + })?; + let spawn = NativeSidecar::::build_owned_descendant_javascript_child_process_spawn( + vm.clone(), + vm_id.clone(), + process_id.clone(), + child_path.clone(), + JavascriptChildProcessSpawnRequest { + command, + args: request.args.clone(), + options: JavascriptChildProcessSpawnOptions { + cwd, + env: request.env.clone(), + input: None, + internal_bootstrap_env, + shell: request.shell, + detached: false, + stdio: vec![ + String::from("pipe"), + String::from("pipe"), + String::from("pipe"), + ], + timeout: None, + kill_signal: None, + ..JavascriptChildProcessSpawnOptions::default() + }, + }, + process_event_capacity, + sidecar_requests, + process_event_notify, + cache_root, + ); + Ok::<_, SidecarError>((spawn, max_buffer)) + })(); + + Box::pin(async move { + let (spawn, max_buffer) = prepared?; + let spawned = spawn.await?; + let child_process_id = spawned + .get("childId") + .and_then(Value::as_str) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "python subprocessRun spawn response is missing childId", + )) + })? + .to_owned(); + let pid = spawned + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "python subprocessRun spawn response is missing a valid pid", + )) + })?; + let mut pending_subprocess = PendingOwnedRegisteredChild::new( + vm.clone(), + process_id.clone(), + child_path.clone(), + child_process_id.clone(), + ); + + vm.try_command("close owned Python subprocess stdin", |vm| { + let VmState { + kernel, + active_processes, + .. + } = vm; + let root = active_processes + .get_mut(&process_id) + .ok_or_else(|| missing_process_error(&vm_id, &process_id))?; + let path = child_path.iter().map(String::as_str).collect::>(); + let process = NativeSidecar::::active_process_by_path_mut(root, &path) + .ok_or_else(|| javascript_child_process_gone_error(&process_id, &path))?; + let child = process + .child_processes + .get_mut(&child_process_id) + .ok_or_else(|| { + javascript_child_process_gone_error(&process_id, &[child_process_id.as_str()]) + })?; + child.execution.close_stdin()?; + close_kernel_process_stdin(kernel, child) + })?; + + vm.try_command("register owned Python subprocess completion", |vm| { + let root = vm + .active_processes + .get_mut(&process_id) + .ok_or_else(|| missing_process_error(&vm_id, &process_id))?; + let path = child_path.iter().map(String::as_str).collect::>(); + let process = NativeSidecar::::active_process_by_path_mut(root, &path) + .ok_or_else(|| javascript_child_process_gone_error(&process_id, &path))?; + process.pending_child_process_sync.insert( + child_process_id, + PendingChildProcessSync { + pid, + stdout: Vec::new(), + stderr: Vec::new(), + max_buffer, + deadline: None, + timeout_signal: String::from("SIGTERM"), + kill_sent: false, + timed_out: false, + max_buffer_exceeded: false, + completion: PendingChildProcessSyncCompletion::Python { + request_id, + responder, + }, + }, + ); + Ok(()) + })?; + pending_subprocess.disarm(); + Ok(()) + }) +} + impl NativeSidecar where B: NativeSidecarBridge + Send + 'static, @@ -21,14 +248,14 @@ where ))), ); }; - let (internal_bootstrap_env, cwd) = { + let (internal_bootstrap_env, cwd, responder) = { let Some(vm) = self.vms.get(vm_id) else { return Ok(()); }; let Some(process) = vm.active_processes.get(process_id) else { return Ok(()); }; - let virtual_home = guest_virtual_home(vm); + let virtual_home = guest_virtual_home(&vm); let cwd = request.cwd.clone().or_else(|| { guest_runtime_path_for_host_path( &vm.guest_env, @@ -40,6 +267,7 @@ where ( sanitize_javascript_child_process_internal_bootstrap_env(&vm.guest_env), cwd, + process.execution.python_vfs_rpc_responder()?, ) }; let result = self @@ -69,6 +297,7 @@ where request.max_buffer, PendingChildProcessSyncCompletion::Python { request_id: request.id, + responder, }, ) .await; @@ -78,3 +307,153 @@ where } } } + +#[cfg(test)] +mod owned_subprocess_tests { + use super::*; + use crate::protocol::{RequestFrame, RequestPayload, ResponsePayload}; + use crate::state::{BindingExecution, ConnectionState, SessionState}; + use crate::stdio::LocalBridge; + use crate::NativeSidecarConfig; + + #[tokio::test(flavor = "current_thread")] + async fn cancelled_python_subprocess_registration_removes_spawned_child() { + tokio::task::LocalSet::new() + .run_until(async { + let config = NativeSidecarConfig::default(); + let runtime = agentos_runtime::SidecarRuntime::process(&config.runtime) + .expect("Python subprocess rollback runtime"); + let mut sidecar = NativeSidecar::with_config_extensions_and_runtime( + LocalBridge::default(), + config, + Vec::new(), + runtime.context(), + ) + .expect("Python subprocess rollback sidecar"); + let connection_id = "python-subprocess-rollback-connection"; + let session_id = "python-subprocess-rollback-session"; + sidecar.connections.insert( + connection_id.to_owned(), + ConnectionState { + auth_token: String::new(), + sessions: BTreeSet::from([session_id.to_owned()]), + }, + ); + sidecar.sessions.insert( + session_id.to_owned(), + SessionState { + connection_id: connection_id.to_owned(), + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: BTreeMap::new(), + vm_ids: BTreeSet::new(), + }, + ); + let request = RequestFrame::new( + 1, + OwnershipScope::session(connection_id, session_id), + RequestPayload::CreateVm(crate::protocol::CreateVmRequest::legacy_test_config( + GuestRuntimeKind::JavaScript, + Default::default(), + Default::default(), + Some(crate::wire::PermissionsPolicy::allow_all()), + )), + ); + let RequestPayload::CreateVm(payload) = request.payload.clone() else { + unreachable!("rollback fixture creates a VM"); + }; + let dispatch = sidecar + .create_vm(&request, payload) + .await + .expect("create rollback VM"); + let ResponsePayload::VmCreated(created) = dispatch.response.payload else { + panic!("rollback VM creation returned another response"); + }; + let vm_id = created.vm_id; + let process_id = String::from("python-caller"); + let child_id = String::from("unregistered-subprocess"); + let (vm_handle, child_pid) = { + let mut vm = sidecar.vms.get_mut(&vm_id).expect("rollback VM"); + let guest_env = vm.guest_env.clone(); + let root_handle = vm + .kernel + .create_virtual_process( + EXECUTION_DRIVER_NAME, + EXECUTION_DRIVER_NAME, + JAVASCRIPT_COMMAND, + vec![String::from(JAVASCRIPT_COMMAND)], + VirtualProcessOptions { + env: guest_env.clone(), + ..Default::default() + }, + ) + .expect("create rollback parent"); + let mut root = ActiveProcess::new( + root_handle.pid(), + root_handle, + vm.runtime_context.clone(), + vm.limits.clone(), + agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS, + GuestRuntimeKind::JavaScript, + ActiveExecution::Binding(BindingExecution::default()), + ); + let child_handle = vm + .kernel + .create_virtual_process( + EXECUTION_DRIVER_NAME, + EXECUTION_DRIVER_NAME, + JAVASCRIPT_COMMAND, + vec![String::from(JAVASCRIPT_COMMAND)], + VirtualProcessOptions { + parent_pid: Some(root.kernel_pid), + env: guest_env, + ..Default::default() + }, + ) + .expect("create rollback child"); + let child_pid = child_handle.pid(); + root.child_processes.insert( + child_id.clone(), + ActiveProcess::new( + child_pid, + child_handle, + vm.runtime_context.clone(), + vm.limits.clone(), + agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS, + GuestRuntimeKind::JavaScript, + ActiveExecution::Binding(BindingExecution::default()), + ), + ); + vm.active_processes.insert(process_id.clone(), root); + ( + sidecar.vms.handle(&vm_id).expect("rollback VM handle"), + child_pid, + ) + }; + + let pending = PendingOwnedRegisteredChild::new( + vm_handle.clone(), + process_id.clone(), + Vec::new(), + child_id.clone(), + ); + drop(pending); + + vm_handle + .try_read("verify Python subprocess rollback", |vm| { + assert!(vm + .active_processes + .get(&process_id) + .is_some_and(|root| !root.child_processes.contains_key(&child_id))); + assert!(vm + .kernel + .list_processes() + .get(&child_pid) + .is_none_or(|process| process.status == ProcessStatus::Exited)); + }) + .expect("verify Python subprocess rollback state"); + }) + .await; + } +} diff --git a/crates/native-sidecar/src/execution/signals.rs b/crates/native-sidecar/src/execution/signals.rs index 8f45603e4d..496755e838 100644 --- a/crates/native-sidecar/src/execution/signals.rs +++ b/crates/native-sidecar/src/execution/signals.rs @@ -416,24 +416,185 @@ pub(crate) fn signal_runtime_process(child_pid: u32, signal: i32) -> Result<(), } } +pub(crate) async fn kill_process_owned( + bridge: SharedBridge, + input: OwnedVmRouteInput, + payload: KillProcessRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + input.vm.try_command("kill process", |vm| { + NativeSidecar::::kill_process_in_vm( + &bridge, + vm, + &input.vm_id, + &payload.process_id, + &payload.signal, + ) + })?; + Ok(DispatchResult { + response: process_killed_response(&input.request, payload.process_id), + events: Vec::new(), + }) +} + +pub(crate) fn signal_vm_kernel_pid_owned( + bridge: &SharedBridge, + vm: &crate::state::VmHandle, + vm_id: &str, + target_kernel_pid: u32, + signal_name: &str, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let signal = parse_signal(signal_name)?; + let located = vm.try_read("locate signal target", |vm| { + let alive = vm + .kernel + .list_processes() + .get(&target_kernel_pid) + .is_some_and(|info| info.status != ProcessStatus::Exited); + if !alive { + return Err(SidecarError::InvalidState(format!( + "ESRCH: no such process {target_kernel_pid}" + ))); + } + Ok(vm.active_processes.iter().find_map(|(process_id, root)| { + NativeSidecar::::active_process_path_by_kernel_pid(root, target_kernel_pid) + .map(|path| (process_id.clone(), path)) + })) + })??; + + match located { + Some((process_id, path)) if path.is_empty() => { + vm.try_command("signal root process", |vm| { + NativeSidecar::::kill_process_in_vm(bridge, vm, vm_id, &process_id, signal_name) + }) + } + Some((process_id, path)) => { + vm.try_command("signal tracked child process", |vm| { + let signal_key = path.last().map(String::as_str).unwrap_or(&process_id); + let registration = vm + .signal_states + .get(signal_key) + .and_then(|handlers| handlers.get(&(signal as u32))) + .cloned(); + let VmState { + kernel, + active_processes, + .. + } = vm; + let Some(root) = active_processes.get_mut(&process_id) else { + return Ok(()); + }; + let Some(target) = + NativeSidecar::::active_process_by_owned_path_mut(root, &path) + else { + return Err(SidecarError::InvalidState(format!( + "ESRCH: no such process {target_kernel_pid}" + ))); + }; + terminate_tracked_child_process_for_signal( + kernel, + target, + signal, + registration.as_ref(), + ) + })?; + emit_security_audit_event( + bridge, + vm_id, + "security.process.kill", + audit_fields([ + (String::from("source"), String::from("guest_process")), + (String::from("target_pid"), target_kernel_pid.to_string()), + (String::from("process_id"), process_id), + (String::from("signal"), signal_name.to_owned()), + ]), + ); + Ok(()) + } + None => { + let target_pid = i32::try_from(target_kernel_pid).map_err(|_| { + SidecarError::InvalidState(format!( + "EINVAL: invalid process pid {target_kernel_pid}" + )) + })?; + vm.try_command("signal untracked kernel process", |vm| { + vm.kernel + .signal_process(EXECUTION_DRIVER_NAME, target_pid, signal) + .map_err(kernel_error) + })?; + emit_security_audit_event( + bridge, + vm_id, + "security.process.kill", + audit_fields([ + (String::from("source"), String::from("guest_process")), + (String::from("target_pid"), target_kernel_pid.to_string()), + (String::from("signal"), signal_name.to_owned()), + ]), + ); + Ok(()) + } + } +} + +pub(crate) fn deliver_kernel_process_group_signal_to_tracked_runtimes_owned( + bridge: &SharedBridge, + vm: &crate::state::VmHandle, + vm_id: &str, + pgid: u32, + signal_name: &str, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + parse_signal(signal_name)?; + let tracked_members = vm.try_read("list tracked process-group members", |vm| { + vm.kernel + .list_processes() + .into_iter() + .filter(|(_, info)| info.pgid == pgid && info.status != ProcessStatus::Exited) + .filter_map(|(kernel_pid, _)| { + vm.active_processes + .values() + .any(|root| { + NativeSidecar::::active_process_path_by_kernel_pid(root, kernel_pid) + .is_some() + }) + .then_some(kernel_pid) + }) + .collect::>() + })?; + for kernel_pid in tracked_members { + match signal_vm_kernel_pid_owned(bridge, vm, vm_id, kernel_pid, signal_name) { + Ok(()) => {} + Err(error) if sidecar_error_is_esrch(&error) => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + impl NativeSidecar where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - pub(crate) async fn kill_process( + pub(crate) fn kill_process( &mut self, request: &RequestFrame, payload: KillProcessRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - self.kill_process_internal(&vm_id, &payload.process_id, &payload.signal)?; - - Ok(DispatchResult { - response: process_killed_response(request, payload.process_id), - events: Vec::new(), - }) + ) -> OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + Box::pin(async move { kill_process_owned(bridge, input?, payload).await }) } pub(crate) fn kill_process_internal( @@ -442,19 +603,34 @@ where process_id: &str, signal: &str, ) -> Result<(), SidecarError> { - let signal_name = signal.to_owned(); - let signal = parse_signal(signal)?; let vm = self .vms - .get_mut(vm_id) + .handle(vm_id) .ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?; + let bridge = self.bridge.clone(); + vm.try_command("kill process", |vm| { + Self::kill_process_in_vm(&bridge, vm, vm_id, process_id, signal) + }) + } + + pub(crate) fn kill_process_in_vm( + bridge: &SharedBridge, + vm: &mut VmState, + vm_id: &str, + process_id: &str, + signal: &str, + ) -> Result<(), SidecarError> { + let signal_name = signal.to_owned(); + let signal = parse_signal(signal)?; let signal_action = vm .signal_states .get(process_id) .and_then(|handlers| handlers.get(&(signal as u32))) .map(|registration| registration.action.clone()) .unwrap_or(SignalDispositionAction::Default); - let process = vm.active_processes.get_mut(process_id).ok_or_else(|| { + let vm = &mut *vm; + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let process = active_processes.get_mut(process_id).ok_or_else(|| { SidecarError::InvalidState(format!("VM {vm_id} has no active process {process_id}")) })?; let kernel_pid = process.kernel_pid; @@ -546,19 +722,19 @@ where } } KillBehavior::SharedV8StateOnly => { - vm.kernel + kernel .kill_process(EXECUTION_DRIVER_NAME, kernel_pid, signal) .map_err(kernel_error)?; } KillBehavior::SharedV8Pause => { process.execution.pause()?; - vm.kernel + kernel .kill_process(EXECUTION_DRIVER_NAME, kernel_pid, signal) .map_err(kernel_error)?; } KillBehavior::SharedV8Continue => { process.execution.resume()?; - vm.kernel + kernel .kill_process(EXECUTION_DRIVER_NAME, kernel_pid, signal) .map_err(kernel_error)?; if matches!(&process.execution, ActiveExecution::Javascript(_)) { @@ -580,7 +756,7 @@ where } KillBehavior::SharedV8Terminate => { if signal != 0 && matches!(process.execution, ActiveExecution::Python(_)) { - close_kernel_process_stdin(&mut vm.kernel, process)?; + close_kernel_process_stdin(kernel, process)?; } process.exit_signal = (signal != 0).then_some(signal); process.exit_core_dumped = false; @@ -644,13 +820,13 @@ where KillBehavior::Noop => {} KillBehavior::HostPid(pid) => { if signal != 0 && matches!(process.execution, ActiveExecution::Python(_)) { - close_kernel_process_stdin(&mut vm.kernel, process)?; + close_kernel_process_stdin(kernel, process)?; } signal_runtime_process(pid, signal)?; } } emit_security_audit_event( - &self.bridge, + bridge, vm_id, "security.process.kill", audit_fields([ @@ -707,7 +883,7 @@ where self.kill_process_internal(vm_id, &process_id, signal_name) } Some((process_id, path)) => { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(()); }; let signal_key = path.last().map(String::as_str).unwrap_or(&process_id); @@ -716,7 +892,9 @@ where .get(signal_key) .and_then(|handlers| handlers.get(&(signal as u32))) .cloned(); - let Some(root) = vm.active_processes.get_mut(&process_id) else { + let vm = &mut *vm; + let (kernel, active_processes) = (&mut vm.kernel, &mut vm.active_processes); + let Some(root) = active_processes.get_mut(&process_id) else { return Ok(()); }; let Some(target) = Self::active_process_by_owned_path_mut(root, &path) else { @@ -725,7 +903,7 @@ where ))); }; terminate_tracked_child_process_for_signal( - &mut vm.kernel, + kernel, target, signal, registration.as_ref(), diff --git a/crates/native-sidecar/src/extension.rs b/crates/native-sidecar/src/extension.rs index 4355071942..4f28a8181c 100644 --- a/crates/native-sidecar/src/extension.rs +++ b/crates/native-sidecar/src/extension.rs @@ -1,5 +1,6 @@ use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::time::Duration; use crate::protocol::{ @@ -24,6 +25,12 @@ pub struct ProjectedAgentLaunchEntry { } pub trait ExtensionHost { + /// Grace allowed for an active ACP prompt to commit its terminal durable + /// state after teardown signals cancellation, before adapter escalation. + fn acp_termination_grace<'a>(&'a mut self) -> ExtensionFuture<'a, Duration> { + Box::pin(async { Ok(Duration::from_secs(3)) }) + } + /// Return the VM-scoped ACP/session limits. Test hosts that do not own a VM /// use the same generous defaults as a normal sidecar. fn vm_acp_limits<'a>( @@ -75,6 +82,13 @@ pub trait ExtensionHost { timeout: Duration, ) -> ExtensionFuture<'a, Option>; + fn poll_process_event<'a>( + &'a mut self, + ownership: OwnershipScope, + process_id: String, + timeout: Duration, + ) -> ExtensionFuture<'a, Option>; + fn guest_filesystem_call<'a>( &'a mut self, ownership: OwnershipScope, @@ -119,6 +133,112 @@ pub trait ExtensionHost { ) -> ExtensionFuture<'a, ExtensionBufferedProcessOutput>; } +/// Cloneable host services used by independently executing extension requests. +/// +/// The mutable [`ExtensionHost`] backend exists for the direct, in-process +/// `NativeSidecar::dispatch` API. Transports that supervise more than one +/// request at a time provide this owned backend instead, so an extension never +/// retains the sidecar coordinator's mutable borrow while it waits on an agent, +/// a permission decision, process output, or cancellation. +pub trait ExtensionServices: Send + Sync { + fn acp_termination_grace(&self) -> ExtensionFuture<'static, Duration>; + + fn vm_acp_limits( + &self, + ownership: OwnershipScope, + ) -> ExtensionFuture<'static, agentos_native_sidecar_core::limits::AcpLimits>; + + fn vm_database( + &self, + ownership: OwnershipScope, + ) -> ExtensionFuture<'static, Option>; + + fn projected_agents( + &self, + ownership: OwnershipScope, + ) -> ExtensionFuture<'static, Vec>; + + fn spawn_process( + &self, + ownership: OwnershipScope, + request: ExecuteRequest, + ) -> ExtensionFuture<'static, ProcessStartedResponse>; + + fn write_stdin( + &self, + ownership: OwnershipScope, + request: WriteStdinRequest, + ) -> ExtensionFuture<'static, StdinWrittenResponse>; + + fn close_stdin( + &self, + ownership: OwnershipScope, + request: CloseStdinRequest, + ) -> ExtensionFuture<'static, StdinClosedResponse>; + + fn kill_process( + &self, + ownership: OwnershipScope, + request: KillProcessRequest, + ) -> ExtensionFuture<'static, ProcessKilledResponse>; + + fn poll_event( + &self, + ownership: OwnershipScope, + timeout: Duration, + ) -> ExtensionFuture<'static, Option>; + + fn poll_process_event( + &self, + ownership: OwnershipScope, + process_id: String, + timeout: Duration, + ) -> ExtensionFuture<'static, Option>; + + fn guest_filesystem_call( + &self, + ownership: OwnershipScope, + request: GuestFilesystemCallRequest, + ) -> ExtensionFuture<'static, GuestFilesystemResultResponse>; + + fn bind_process_to_session( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + ) -> ExtensionFuture<'static, ()>; + + fn bind_vm_to_session( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + ) -> ExtensionFuture<'static, ()>; + + fn dispose_session_resources( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + ) -> ExtensionFuture<'static, Vec>; + + fn start_buffering_process_output( + &self, + ownership: OwnershipScope, + process_id: String, + ) -> ExtensionFuture<'static, ()>; + + fn handoff_buffered_process_output( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + timeout: Duration, + ) -> ExtensionFuture<'static, ExtensionBufferedProcessOutput>; +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ExtensionBufferedProcessOutput { pub stdout: Vec, @@ -186,9 +306,9 @@ pub struct ExtensionSnapshot { event_sink: SharedEventSink, } -pub struct ExtensionContext<'a> { +pub struct ExtensionContext { snapshot: ExtensionSnapshot, - host: &'a mut dyn ExtensionHost, + services: Arc, } impl ExtensionSnapshot { @@ -268,11 +388,33 @@ impl ExtensionSnapshot { )?; extension_callback_response_payload(&self.namespace, response) } + + pub async fn invoke_callback_async( + &self, + payload: Vec, + timeout: Duration, + ) -> Result, SidecarError> { + let response = self + .sidecar_requests + .invoke_async( + self.ownership.clone(), + SidecarRequestPayload::Ext(ExtEnvelope { + namespace: self.namespace.clone(), + payload, + }), + timeout, + ) + .await?; + extension_callback_response_payload(&self.namespace, response) + } } -impl<'a> ExtensionContext<'a> { - pub(crate) fn new(snapshot: ExtensionSnapshot, host: &'a mut dyn ExtensionHost) -> Self { - Self { snapshot, host } +impl ExtensionContext { + pub(crate) fn with_services( + snapshot: ExtensionSnapshot, + services: Arc, + ) -> Self { + ExtensionContext { snapshot, services } } pub fn snapshot(&self) -> ExtensionSnapshot { @@ -324,16 +466,30 @@ impl<'a> ExtensionContext<'a> { self.snapshot.invoke_callback(payload, timeout) } + pub async fn invoke_callback_async( + &self, + payload: Vec, + timeout: Duration, + ) -> Result, SidecarError> { + self.snapshot.invoke_callback_async(payload, timeout).await + } + pub async fn vm_database( &mut self, ) -> Result, SidecarError> { - self.host.vm_database(self.snapshot.ownership.clone()).await + self.services + .vm_database(self.snapshot.ownership.clone()) + .await + } + + pub async fn acp_termination_grace(&mut self) -> Result { + self.services.acp_termination_grace().await } pub async fn vm_acp_limits( &mut self, ) -> Result { - self.host + self.services .vm_acp_limits(self.snapshot.ownership.clone()) .await } @@ -342,7 +498,7 @@ impl<'a> ExtensionContext<'a> { &mut self, request: ExecuteRequest, ) -> Result { - self.host + self.services .spawn_process(self.snapshot.ownership.clone(), request) .await } @@ -375,7 +531,7 @@ impl<'a> ExtensionContext<'a> { &mut self, request: WriteStdinRequest, ) -> Result { - self.host + self.services .write_stdin(self.snapshot.ownership.clone(), request) .await } @@ -408,7 +564,7 @@ impl<'a> ExtensionContext<'a> { &mut self, request: CloseStdinRequest, ) -> Result { - self.host + self.services .close_stdin(self.snapshot.ownership.clone(), request) .await } @@ -441,7 +597,7 @@ impl<'a> ExtensionContext<'a> { &mut self, request: KillProcessRequest, ) -> Result { - self.host + self.services .kill_process(self.snapshot.ownership.clone(), request) .await } @@ -474,7 +630,7 @@ impl<'a> ExtensionContext<'a> { &mut self, timeout: Duration, ) -> Result, SidecarError> { - self.host + self.services .poll_event(self.snapshot.ownership.clone(), timeout) .await } @@ -490,11 +646,32 @@ impl<'a> ExtensionContext<'a> { .map_err(wire_protocol_error) } + /// Await one event from exactly `process_id` through the ownership-aware + /// event broker without retaining VM or whole-sidecar state. + pub async fn poll_process_event_wire( + &mut self, + process_id: &str, + timeout: Duration, + ) -> Result, SidecarError> { + let event = self + .services + .poll_process_event( + self.snapshot.ownership.clone(), + process_id.to_owned(), + timeout, + ) + .await?; + event + .map(crate::wire::event_frame_from_compat) + .transpose() + .map_err(wire_protocol_error) + } + pub async fn guest_filesystem_call( &mut self, request: GuestFilesystemCallRequest, ) -> Result { - self.host + self.services .guest_filesystem_call(self.snapshot.ownership.clone(), request) .await } @@ -506,7 +683,7 @@ impl<'a> ExtensionContext<'a> { &mut self, ) -> Result, SidecarError> { let ownership = self.snapshot.ownership().clone(); - self.host.projected_agents(ownership).await + self.services.projected_agents(ownership).await } pub async fn guest_filesystem_call_wire( @@ -538,13 +715,12 @@ impl<'a> ExtensionContext<'a> { ext_session_id: impl Into, process_id: impl Into, ) -> Result<(), SidecarError> { - self.host - .bind_process_to_session( - self.snapshot.ownership.clone(), - self.snapshot.namespace.clone(), - ext_session_id.into(), - process_id.into(), - ) + let ownership = self.snapshot.ownership.clone(); + let namespace = self.snapshot.namespace.clone(); + let ext_session_id = ext_session_id.into(); + let process_id = process_id.into(); + self.services + .bind_process_to_session(ownership, namespace, ext_session_id, process_id) .await } @@ -552,12 +728,11 @@ impl<'a> ExtensionContext<'a> { &mut self, ext_session_id: impl Into, ) -> Result<(), SidecarError> { - self.host - .bind_vm_to_session( - self.snapshot.ownership.clone(), - self.snapshot.namespace.clone(), - ext_session_id.into(), - ) + let ownership = self.snapshot.ownership.clone(); + let namespace = self.snapshot.namespace.clone(); + let ext_session_id = ext_session_id.into(); + self.services + .bind_vm_to_session(ownership, namespace, ext_session_id) .await } @@ -565,12 +740,11 @@ impl<'a> ExtensionContext<'a> { &mut self, ext_session_id: impl Into, ) -> Result, SidecarError> { - self.host - .dispose_session_resources( - self.snapshot.ownership.clone(), - self.snapshot.namespace.clone(), - ext_session_id.into(), - ) + let ownership = self.snapshot.ownership.clone(); + let namespace = self.snapshot.namespace.clone(); + let ext_session_id = ext_session_id.into(); + self.services + .dispose_session_resources(ownership, namespace, ext_session_id) .await } @@ -590,8 +764,10 @@ impl<'a> ExtensionContext<'a> { &mut self, process_id: impl Into, ) -> Result<(), SidecarError> { - self.host - .start_buffering_process_output(self.snapshot.ownership.clone(), process_id.into()) + let ownership = self.snapshot.ownership.clone(); + let process_id = process_id.into(); + self.services + .start_buffering_process_output(ownership, process_id) .await } @@ -601,12 +777,16 @@ impl<'a> ExtensionContext<'a> { process_id: impl Into, timeout: Duration, ) -> Result { - self.host + let ownership = self.snapshot.ownership.clone(); + let namespace = self.snapshot.namespace.clone(); + let ext_session_id = ext_session_id.into(); + let process_id = process_id.into(); + self.services .handoff_buffered_process_output( - self.snapshot.ownership.clone(), - self.snapshot.namespace.clone(), - ext_session_id.into(), - process_id.into(), + ownership, + namespace, + ext_session_id, + process_id, timeout, ) .await @@ -648,6 +828,31 @@ fn extension_callback_response_payload( } } +/// Admission class chosen by the extension after decoding its own opaque +/// payload. Progress requests receive reserved routing admission because they +/// unblock an already-active operation; core never interprets their payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionRequestClass { + Ordinary, + Progress, +} + +/// Who enforces exclusivity for an extension-provided opaque ordering key. +/// +/// Core-exclusive keys receive a bounded, typed conflict rejection before the +/// second request starts. An extension may retain enforcement when it needs to +/// preserve a richer protocol-specific rejection (ACP's `session_busy` is the +/// motivating case); core still scopes and validates the opaque key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionOrderingPolicy { + CoreExclusive, + ExtensionManaged, +} + +/// Compatibility input for the legacy blocking-extension stdio path. The +/// routed protocol engine no longer uses this hook; it remains present only in +/// the lower ownership-partition revision until that caller is removed later +/// in the stack. pub enum ExtensionInterruptRequest<'a> { ExtensionPayload { payload: &'a [u8], @@ -658,9 +863,6 @@ pub enum ExtensionInterruptRequest<'a> { #[derive(Debug, Clone)] pub struct ExtensionInterruptResponse { - /// Whether the active request future should be dropped and replaced by the - /// synthetic response. Cooperative interrupts can signal the active future - /// and leave this false so it commits its own terminal state. pub interrupt_active: bool, pub interrupted_response_payload: Vec, pub interrupting_response_payload: Option>, @@ -669,9 +871,37 @@ pub struct ExtensionInterruptResponse { pub trait Extension: Send + Sync { fn namespace(&self) -> &str; + /// Return the extension-owned conflict key for an ordinary request. + /// + /// Core treats this value as opaque and combines it with the extension + /// namespace and connection ownership when it builds request-operation + /// metadata. Extensions whose identities are scoped more narrowly than a + /// connection (for example, to one VM) must include that ownership in the + /// returned key. `None` means the request has no extension-specific + /// ordering boundary. + fn request_ordering_key( + &self, + _ownership: &OwnershipScope, + _payload: &[u8], + ) -> Option> { + None + } + + fn request_ordering_policy( + &self, + _ownership: &OwnershipScope, + _payload: &[u8], + ) -> ExtensionOrderingPolicy { + ExtensionOrderingPolicy::CoreExclusive + } + + fn request_class(&self, _payload: &[u8]) -> ExtensionRequestClass { + ExtensionRequestClass::Ordinary + } + fn handle_request<'a>( &'a self, - ctx: ExtensionContext<'a>, + ctx: ExtensionContext, payload: Vec, ) -> ExtensionFuture<'a, ExtensionResponse>; diff --git a/crates/native-sidecar/src/extension_services.rs b/crates/native-sidecar/src/extension_services.rs new file mode 100644 index 0000000000..d9742b02df --- /dev/null +++ b/crates/native-sidecar/src/extension_services.rs @@ -0,0 +1,1914 @@ +use std::collections::{BTreeMap, VecDeque}; +use std::future::Future; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::pin::Pin; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; + +use tokio::sync::{mpsc, oneshot, Notify, OwnedSemaphorePermit, Semaphore}; + +use crate::execution::{ + prepare_owned_python_process_event_service, service_owned_child_bridge_event, + service_owned_python_socket_connect_completion, OwnedChildBridgeEventService, + OwnedJavascriptEventService, OwnedPythonEventService, OwnedPythonSocketCompletionService, +}; +use crate::extension::{ + ExtensionBufferedProcessOutput, ExtensionFuture, ExtensionServices, ProjectedAgentLaunchEntry, +}; +use crate::filesystem::guest_filesystem_call_vm; +use crate::ownership_coordinator::{ + CoordinatorOperationPermit, InternalVmEventAdmission, OwnershipCoordinator, + OwnershipCoordinatorError, VmDisposal, +}; +use crate::process_event_broker::{ProcessEventBroker, ProcessEventLease, ProcessEventTarget}; +use crate::protocol::{ + CloseStdinRequest, DisposeReason, EventFrame, ExecuteRequest, GuestFilesystemCallRequest, + GuestFilesystemResultResponse, KillProcessRequest, OwnershipScope, ProcessKilledResponse, + ProcessStartedResponse, RequestFrame, RequestPayload, ResponsePayload, StdinClosedResponse, + StdinWrittenResponse, WriteStdinRequest, +}; +use crate::request_operations::{ + OperationCancellation, OperationCancellationReason, RequestOperationMetadata, + RequestOrderingKey, +}; +use crate::service::NativeSidecar; +use crate::state::SidecarError; +use crate::stdio::LocalBridge; +use crate::vm_sqlite::SharedVmSqliteDatabase; + +type Reply = oneshot::Sender>; + +type ExtensionServiceCompletionMutation = Box< + dyn FnOnce(&mut NativeSidecar) -> Option + + 'static, +>; +type ExtensionServiceCommandFuture = + Pin + 'static>>; + +/// Command work that no longer borrows the protocol router's `NativeSidecar`. +/// +/// The owned future runs under the bounded stdio supervisor. Its terminal +/// mutation is deliberately synchronous: it may publish a result or perform a +/// short coordinator update, but it may never wait on external work. +pub(crate) struct PreparedExtensionServiceCommand { + operation: &'static str, + future: ExtensionServiceCommandFuture, + panic_reply: Box, + admission: Option, +} + +pub(crate) struct CompletedExtensionServiceCommand { + operation: &'static str, + mutation: ExtensionServiceCompletionMutation, + failure_reply: Box, + _permit: Option, +} + +struct ExtensionServiceAdmission { + coordinator: OwnershipCoordinator, + metadata: RequestOperationMetadata, + cancellation: OperationCancellation, + pre_admitted: Option, + deferred_tracked: Option, + class: ExtensionServiceAdmissionClass, +} + +#[derive(Clone, Copy)] +enum ExtensionServiceAdmissionClass { + Ordinary, + InternalVmEvent, +} + +pub(crate) enum VmEventAdmissionResult { + Admitted(PreparedExtensionServiceCommand), + Deferred(PreparedExtensionServiceCommand), +} + +impl PreparedExtensionServiceCommand { + pub(crate) fn operation(&self) -> &'static str { + self.operation + } + + /// Register a VM-bound claimed event before it enters the local pending + /// queue. VM-operation admission is intentionally non-suspending; holding + /// its permit makes disposal cancel and drain work that has been claimed + /// but has not yet reached a task slot. + pub(crate) fn admit_vm_event_nowait(mut self) -> Result { + let Some(admission) = self.admission.as_mut() else { + return Ok(VmEventAdmissionResult::Admitted(self)); + }; + if admission.pre_admitted.is_some() { + return Ok(VmEventAdmissionResult::Admitted(self)); + } + if !matches!( + admission.class, + ExtensionServiceAdmissionClass::InternalVmEvent + ) { + let message = String::from( + "ERR_AGENTOS_PROCESS_EVENT_ADMISSION_CLASS: claimed VM event did not use internal-event admission", + ); + (self.panic_reply)(SidecarError::InvalidState(message.clone())); + return Err(SidecarError::InvalidState(message)); + } + if let Some(mut permit) = admission.deferred_tracked.take() { + match permit.try_activate_deferred_internal_event() { + Ok(true) => { + admission.pre_admitted = Some(permit); + return Ok(VmEventAdmissionResult::Admitted(self)); + } + Ok(false) => { + admission.deferred_tracked = Some(permit); + return Ok(VmEventAdmissionResult::Deferred(self)); + } + Err(error) => { + let message = error.to_string(); + (self.panic_reply)(SidecarError::InvalidState(message.clone())); + return Err(SidecarError::InvalidState(message)); + } + } + } + match admission + .coordinator + .admit_internal_vm_event(&admission.metadata, admission.cancellation.clone()) + { + Ok(InternalVmEventAdmission::Admitted(permit)) => { + admission.pre_admitted = Some(permit); + Ok(VmEventAdmissionResult::Admitted(self)) + } + Ok(InternalVmEventAdmission::Deferred(permit)) => { + tracing::debug!( + operation = self.operation, + "claimed internal process event remains durably pending at its admission bound" + ); + admission.deferred_tracked = Some(permit); + Ok(VmEventAdmissionResult::Deferred(self)) + } + Err(error) => { + let message = error.to_string(); + (self.panic_reply)(SidecarError::InvalidState(message.clone())); + Err(SidecarError::InvalidState(message)) + } + } + } + + pub(crate) fn cancel_before_schedule(self, reason: OperationCancellationReason) { + let PreparedExtensionServiceCommand { + operation, + panic_reply, + admission, + .. + } = self; + panic_reply(SidecarError::InvalidState(format!( + "ERR_AGENTOS_EXTENSION_SERVICE_CANCELLED: extension service {operation} cancelled before task admission: {reason:?}" + ))); + drop(admission); + } + + pub(crate) async fn execute_supervised(self) -> Option { + let PreparedExtensionServiceCommand { + operation, + future, + panic_reply, + admission, + } = self; + let (permit, cancellation) = match admission { + Some(mut admission) => { + let cancellation = admission.cancellation; + match admission.pre_admitted.take() { + Some(permit) => (Some(permit), Some(cancellation)), + None => { + let admitted = match admission.class { + ExtensionServiceAdmissionClass::Ordinary => { + admission + .coordinator + .admit(&admission.metadata, cancellation.clone()) + .await + } + ExtensionServiceAdmissionClass::InternalVmEvent => { + match admission.coordinator.admit_internal_vm_event( + &admission.metadata, + cancellation.clone(), + ) { + Ok(InternalVmEventAdmission::Admitted(permit)) => Ok(permit), + Ok(InternalVmEventAdmission::Deferred(permit)) => { + drop(permit); + Err(OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from( + "claimed internal event pre-admitted before scheduling", + ), + actual: String::from( + "untracked direct internal-event scheduling", + ), + }) + } + Err(error) => Err(error), + } + } + }; + match admitted { + Ok(permit) => (Some(permit), Some(cancellation)), + Err(error) => { + panic_reply(SidecarError::InvalidState(error.to_string())); + return None; + } + } + } + } + } + None => (None, None), + }; + let mut task = tokio::task::spawn_local(future); + let joined = match cancellation { + Some(cancellation) => tokio::select! { + result = &mut task => result, + reason = cancellation.cancelled() => { + task.abort(); + if let Err(error) = task.await { + if !error.is_cancelled() { + tracing::error!( + operation, + %error, + "ERR_AGENTOS_EXTENSION_SERVICE_CANCEL_JOIN: cancelled extension service task failed while being observed" + ); + } + } + panic_reply(SidecarError::InvalidState(format!( + "ERR_AGENTOS_EXTENSION_SERVICE_CANCELLED: extension service {operation} cancelled: {reason:?}" + ))); + return None; + } + }, + None => task.await, + }; + match joined { + Ok(mutation) => Some(CompletedExtensionServiceCommand { + operation, + mutation, + failure_reply: panic_reply, + _permit: permit, + }), + Err(error) => { + let failure = SidecarError::Execution(format!( + "ERR_AGENTOS_EXTENSION_SERVICE_TASK_PANIC: extension service {operation} failed: {error}" + )); + tracing::error!( + operation, + %error, + "ERR_AGENTOS_EXTENSION_SERVICE_TASK_PANIC: extension service task failed" + ); + panic_reply(failure); + None + } + } + } +} + +impl CompletedExtensionServiceCommand { + pub(crate) fn complete( + self, + sidecar: &mut NativeSidecar, + ) -> Option { + let CompletedExtensionServiceCommand { + operation, + mutation, + failure_reply, + _permit, + } = self; + let result = catch_unwind(AssertUnwindSafe(|| mutation(sidecar))); + drop(_permit); + match result { + Ok(next) => next, + Err(_) => { + let error = SidecarError::Execution(format!( + "ERR_AGENTOS_EXTENSION_SERVICE_COMPLETION_PANIC: extension service {operation} completion mutation panicked" + )); + tracing::error!( + operation, + "ERR_AGENTOS_EXTENSION_SERVICE_COMPLETION_PANIC: extension service completion mutation panicked" + ); + failure_reply(error); + None + } + } + } +} + +fn with_vm_admission( + prepared: PreparedExtensionServiceCommand, + coordinator: &OwnershipCoordinator, + ownership: &OwnershipScope, +) -> PreparedExtensionServiceCommand { + with_vm_admission_class( + prepared, + coordinator, + ownership, + ExtensionServiceAdmissionClass::Ordinary, + ) +} + +fn with_internal_vm_event_admission( + prepared: PreparedExtensionServiceCommand, + coordinator: &OwnershipCoordinator, + ownership: &OwnershipScope, +) -> PreparedExtensionServiceCommand { + with_vm_admission_class( + prepared, + coordinator, + ownership, + ExtensionServiceAdmissionClass::InternalVmEvent, + ) +} + +fn with_vm_admission_class( + mut prepared: PreparedExtensionServiceCommand, + coordinator: &OwnershipCoordinator, + ownership: &OwnershipScope, + class: ExtensionServiceAdmissionClass, +) -> PreparedExtensionServiceCommand { + let OwnershipScope::VmOwnership(scope) = ownership else { + return prepared; + }; + prepared.admission = Some(ExtensionServiceAdmission { + coordinator: coordinator.clone(), + metadata: RequestOperationMetadata::new( + ownership.clone(), + prepared.operation, + RequestOrderingKey::VmOperation { + connection_id: scope.connection_id.clone(), + session_id: scope.session_id.clone(), + vm_id: scope.vm_id.clone(), + }, + ), + cancellation: OperationCancellation::new(), + pre_admitted: None, + deferred_tracked: None, + class, + }); + prepared +} + +struct SharedReply { + inner: Arc>>>, +} + +impl Clone for SharedReply { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl SharedReply { + fn new(reply: Reply) -> Self { + Self { + inner: Arc::new(StdMutex::new(Some(reply))), + } + } + + fn send(&self, result: Result, operation: &str) { + if let Err(result) = self.try_send(result, operation) { + tracing::debug!( + operation, + result = ?result.as_ref().err(), + "extension service caller ended before its reply" + ); + } + } + + fn try_send( + &self, + result: Result, + operation: &str, + ) -> Result<(), Result> { + let reply = match self.inner.lock() { + Ok(mut reply) => reply.take(), + Err(_) => { + eprintln!("ERR_AGENTOS_EXTENSION_SERVICE_REPLY_POISONED: operation={operation}"); + return Err(result); + } + }; + let Some(reply) = reply else { + tracing::error!( + operation, + "ERR_AGENTOS_EXTENSION_SERVICE_DOUBLE_REPLY: extension service command completed more than once" + ); + return Err(result); + }; + reply.send(result) + } +} + +fn prepared_command( + operation: &'static str, + reply: Reply, + work: F, +) -> PreparedExtensionServiceCommand +where + T: 'static, + F: FnOnce() -> Fut + 'static, + Fut: Future> + 'static, +{ + let reply = SharedReply::new(reply); + let completion_reply = reply.clone(); + let panic_reply = reply.clone(); + PreparedExtensionServiceCommand { + operation, + future: Box::pin(async move { + let result = work().await; + Box::new(move |_sidecar: &mut NativeSidecar| { + completion_reply.send(result, operation); + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| panic_reply.send(Err(error), operation)), + admission: None, + } +} + +fn prepared_mutation( + operation: &'static str, + reply: Reply, + mutation: F, +) -> PreparedExtensionServiceCommand +where + T: 'static, + F: FnOnce(&mut NativeSidecar) -> Result + 'static, +{ + let reply = SharedReply::new(reply); + let completion_reply = reply.clone(); + let panic_reply = reply.clone(); + PreparedExtensionServiceCommand { + operation, + future: Box::pin(async move { + Box::new(move |sidecar: &mut NativeSidecar| { + completion_reply.send(mutation(sidecar), operation); + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| panic_reply.send(Err(error), operation)), + admission: None, + } +} + +#[cfg(test)] +pub(crate) fn prepared_test_service_command( + operation: &'static str, + future: Fut, +) -> ( + PreparedExtensionServiceCommand, + oneshot::Receiver>, +) +where + Fut: Future + 'static, +{ + let (reply, response) = oneshot::channel(); + ( + prepared_command(operation, reply, move || async move { + future.await; + Ok(()) + }), + response, + ) +} + +#[cfg(test)] +pub(crate) fn prepared_test_vm_service_command( + operation: &'static str, + coordinator: &OwnershipCoordinator, + ownership: &OwnershipScope, + future: Fut, +) -> ( + PreparedExtensionServiceCommand, + oneshot::Receiver>, +) +where + Fut: Future + 'static, +{ + let (prepared, response) = prepared_test_service_command(operation, future); + ( + with_vm_admission(prepared, coordinator, ownership), + response, + ) +} + +pub(crate) fn prepare_owned_javascript_event_service( + sidecar: &mut NativeSidecar, + coordinator: &OwnershipCoordinator, + target: OwnedJavascriptEventService, +) -> PreparedExtensionServiceCommand { + let ownership = target.ownership.clone(); + let panic_vm = target.vm.clone(); + let panic_process_id = target.process_id.clone(); + let panic_child_path = target.child_path.clone(); + let panic_request_id = target.request.id; + let service = sidecar.prepare_owned_javascript_process_event_service(target); + let operation = "service_javascript_process_event"; + let prepared = PreparedExtensionServiceCommand { + operation, + future: Box::pin(async move { + let result = service.await; + Box::new(move |_sidecar: &mut NativeSidecar| { + if let Err(error) = result { + tracing::error!( + operation, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE: owned JavaScript event service failed" + ); + } + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + tracing::error!( + operation, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE_PANIC: owned JavaScript event service failed" + ); + let message = error.to_string(); + if let Err(reply_error) = + panic_vm.try_command("reject owned JavaScript process event", |vm| { + let Some(mut process) = vm.active_processes.get_mut(&panic_process_id) else { + return Ok(()); + }; + for child_id in &panic_child_path { + let Some(child) = process.child_processes.get_mut(child_id) else { + return Ok(()); + }; + process = child; + } + process.execution.respond_javascript_sync_rpc_error( + panic_request_id, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE", + message, + ) + }) + { + tracing::debug!( + operation, + request_id = panic_request_id, + %reply_error, + "owned JavaScript event service failure reply was no longer pending" + ); + } + }), + admission: None, + }; + with_internal_vm_event_admission(prepared, coordinator, &ownership) +} + +pub(crate) fn prepare_owned_python_event_service( + sidecar: &NativeSidecar, + coordinator: &OwnershipCoordinator, + target: OwnedPythonEventService, +) -> PreparedExtensionServiceCommand { + let ownership = target.ownership.clone(); + let request_id = target.request.id; + let completion_responder = target.responder.clone(); + let panic_responder = target.responder.clone(); + let service = prepare_owned_python_process_event_service(sidecar, target); + let operation = "service_python_process_event"; + let prepared = PreparedExtensionServiceCommand { + operation, + future: Box::pin(async move { + let result = service.await; + Box::new(move |_sidecar: &mut NativeSidecar| { + if let Err(error) = result { + tracing::error!( + operation, + request_id, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE: owned Python event service failed" + ); + if let Err(reply_error) = completion_responder.respond_error( + request_id, + "ERR_AGENTOS_PYTHON_VFS_RPC", + error.to_string(), + ) { + tracing::debug!( + operation, + request_id, + %reply_error, + "owned Python event service failure reply was no longer pending" + ); + } + } + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + tracing::error!( + operation, + request_id, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE_PANIC: owned Python event service failed" + ); + if let Err(reply_error) = panic_responder.respond_error( + request_id, + "ERR_AGENTOS_PYTHON_VFS_RPC", + error.to_string(), + ) { + tracing::debug!( + operation, + request_id, + %reply_error, + "owned Python event service panic/cancellation reply was no longer pending" + ); + } + }), + admission: None, + }; + with_internal_vm_event_admission(prepared, coordinator, &ownership) +} + +pub(crate) fn prepare_owned_python_socket_completion_service( + coordinator: &OwnershipCoordinator, + target: OwnedPythonSocketCompletionService, +) -> PreparedExtensionServiceCommand { + let (ownership, vm_id, process_id, child_path, vm, responder, completion, reservation) = + target.into_parts(); + let request_id = completion.request_id; + let completion_responder = responder.clone(); + let panic_responder = responder.clone(); + let operation = "complete_python_socket_connect"; + let prepared = PreparedExtensionServiceCommand { + operation, + future: Box::pin(async move { + let _reservation = reservation; + let result = service_owned_python_socket_connect_completion( + vm, vm_id, process_id, child_path, responder, completion, + ) + .await; + Box::new(move |_sidecar: &mut NativeSidecar| { + if let Err(error) = result { + tracing::error!( + operation, + request_id, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE: owned Python socket completion failed" + ); + if let Err(reply_error) = completion_responder.respond_error( + request_id, + "ERR_AGENTOS_PYTHON_VFS_RPC", + error.to_string(), + ) { + tracing::debug!( + operation, + request_id, + %reply_error, + "owned Python socket completion failure reply was no longer pending" + ); + } + } + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + tracing::error!( + operation, + request_id, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE_PANIC: owned Python socket completion failed" + ); + if let Err(reply_error) = panic_responder.respond_error( + request_id, + "ERR_AGENTOS_PYTHON_VFS_RPC", + error.to_string(), + ) { + tracing::debug!( + operation, + request_id, + %reply_error, + "owned Python socket completion panic/cancellation reply was no longer pending" + ); + } + }), + admission: None, + }; + with_internal_vm_event_admission(prepared, coordinator, &ownership) +} + +pub(crate) fn prepare_owned_child_bridge_event_service( + coordinator: &OwnershipCoordinator, + target: OwnedChildBridgeEventService, +) -> PreparedExtensionServiceCommand { + let ownership = target.ownership().clone(); + let operation = "service_child_bridge_process_event"; + let prepared = PreparedExtensionServiceCommand { + operation, + future: Box::pin(async move { + let result = service_owned_child_bridge_event(target).await; + Box::new(move |_sidecar: &mut NativeSidecar| { + if let Err(error) = result { + tracing::error!( + operation, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE: owned child bridge event service failed" + ); + } + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + tracing::error!( + operation, + %error, + "ERR_AGENTOS_PROCESS_EVENT_SERVICE_PANIC: owned child bridge event service failed" + ); + }), + admission: None, + }; + with_internal_vm_event_admission(prepared, coordinator, &ownership) +} + +pub(crate) enum OwnedProcessEventService { + Javascript(OwnedJavascriptEventService), + Python(OwnedPythonEventService), + PythonSocketCompletion(OwnedPythonSocketCompletionService), + ChildBridge(OwnedChildBridgeEventService), +} + +pub(crate) fn prepare_owned_process_event_service( + sidecar: &mut NativeSidecar, + coordinator: &OwnershipCoordinator, + target: OwnedProcessEventService, +) -> PreparedExtensionServiceCommand { + match target { + OwnedProcessEventService::Javascript(target) => { + prepare_owned_javascript_event_service(sidecar, coordinator, target) + } + OwnedProcessEventService::Python(target) => { + prepare_owned_python_event_service(sidecar, coordinator, target) + } + OwnedProcessEventService::PythonSocketCompletion(target) => { + prepare_owned_python_socket_completion_service(coordinator, target) + } + OwnedProcessEventService::ChildBridge(target) => { + prepare_owned_child_bridge_event_service(coordinator, target) + } + } +} + +/// Short coordinator operations requested by an independently running +/// extension. This channel is bounded by the request supervisor's configured +/// in-flight limit; long extension waits remain in the caller, never here. +pub(crate) enum ExtensionServiceCommand { + AcpTerminationGrace { + reply: Reply, + }, + VmAcpLimits { + ownership: OwnershipScope, + reply: Reply, + }, + VmDatabase { + ownership: OwnershipScope, + reply: Reply>, + }, + ProjectedAgents { + ownership: OwnershipScope, + reply: Reply>, + }, + SpawnProcess { + ownership: OwnershipScope, + request: ExecuteRequest, + reply: Reply, + }, + WriteStdin { + ownership: OwnershipScope, + request: WriteStdinRequest, + reply: Reply, + }, + CloseStdin { + ownership: OwnershipScope, + request: CloseStdinRequest, + reply: Reply, + }, + KillProcess { + ownership: OwnershipScope, + request: KillProcessRequest, + reply: Reply, + }, + PollEvent { + ownership: OwnershipScope, + reply: Reply>, + }, + RouteProcessEvent { + ownership: OwnershipScope, + target: ProcessEventTarget, + reply: Reply, + }, + HandleProcessEvent { + target: ProcessEventTarget, + lease: ProcessEventLease, + completion_store: CompletedProcessEventStore, + completion_reservation: OwnedSemaphorePermit, + reply: Reply>, + }, + GuestFilesystemCall { + ownership: OwnershipScope, + request: GuestFilesystemCallRequest, + reply: Reply, + }, + BindProcessToSession { + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + reply: Reply<()>, + }, + BindVmToSession { + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + reply: Reply<()>, + }, + DisposeSessionResources { + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + reply: Reply>, + }, + StartBufferingProcessOutput { + ownership: OwnershipScope, + process_id: String, + reply: Reply<()>, + }, + ProbeBufferedProcessOutputHandoff { + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + finalize_if_empty: bool, + reply: Reply>, + }, +} + +#[derive(Clone)] +pub(crate) struct RoutedExtensionServices { + commands: mpsc::Sender, + /// Wakes extension-side probes only after the protocol coordinator has + /// drained the runtime producer queues. Runtime producers use a separate + /// notification owned exclusively by the central process-event pump, so + /// an ACP/public-event waiter cannot consume the pump's only wake. + routed_process_event_notify: Arc, + process_event_broker: Option, + completed_process_events: CompletedProcessEventStore, +} + +#[derive(Clone)] +pub(crate) struct CompletedProcessEventStore { + inner: Arc>>>, + budget: Arc, + notify: Arc, +} + +struct CompletedProcessEvent { + event: EventFrame, + _reservation: OwnedSemaphorePermit, +} + +impl CompletedProcessEventStore { + fn new(capacity: usize) -> Self { + Self { + inner: Arc::new(StdMutex::new(BTreeMap::new())), + budget: Arc::new(Semaphore::new(capacity.max(1))), + notify: Arc::new(Notify::new()), + } + } + + fn take(&self, target: &ProcessEventTarget) -> Result, SidecarError> { + let mut state = self.inner.lock().map_err(|_| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_COMPLETED_PROCESS_EVENT_POISONED: completed process event store lock poisoned", + )) + })?; + let event = state + .get_mut(target) + .and_then(VecDeque::pop_front) + .map(|completed| completed.event); + if state.get(target).is_some_and(VecDeque::is_empty) { + state.remove(target); + } + Ok(event) + } + + fn retain( + &self, + target: ProcessEventTarget, + event: EventFrame, + reservation: OwnedSemaphorePermit, + ) -> Result<(), SidecarError> { + self.inner + .lock() + .map_err(|_| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_COMPLETED_PROCESS_EVENT_POISONED: completed process event store lock poisoned", + )) + })? + .entry(target) + .or_default() + .push_back(CompletedProcessEvent { + event, + _reservation: reservation, + }); + self.notify.notify_waiters(); + Ok(()) + } + + async fn reserve(&self) -> Result { + Arc::clone(&self.budget).acquire_owned().await.map_err(|_| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_COMPLETED_PROCESS_EVENT_CLOSED: completed process event store closed", + )) + }) + } +} + +impl RoutedExtensionServices { + pub(crate) fn new( + commands: mpsc::Sender, + routed_process_event_notify: Arc, + ) -> Self { + Self { + commands, + routed_process_event_notify, + process_event_broker: None, + completed_process_events: CompletedProcessEventStore::new(1), + } + } + + pub(crate) fn new_with_process_event_broker( + commands: mpsc::Sender, + routed_process_event_notify: Arc, + process_event_broker: ProcessEventBroker, + ) -> Self { + let completed_process_events = + CompletedProcessEventStore::new(process_event_broker.max_events()); + Self { + commands, + routed_process_event_notify, + process_event_broker: Some(process_event_broker), + completed_process_events, + } + } + + fn call(&self, build: F) -> ExtensionFuture<'static, T> + where + T: Send + 'static, + F: FnOnce(Reply) -> ExtensionServiceCommand + Send + 'static, + { + let commands = self.commands.clone(); + Box::pin(async move { + let (reply, response) = oneshot::channel(); + commands.send(build(reply)).await.map_err(|_| { + SidecarError::Io(String::from( + "ERR_AGENTOS_EXTENSION_SERVICE_CLOSED: sidecar coordinator stopped; active extension operation was cancelled", + )) + })?; + response.await.map_err(|_| { + SidecarError::Io(String::from( + "ERR_AGENTOS_EXTENSION_SERVICE_REPLY_CLOSED: sidecar coordinator dropped an extension operation without a result", + )) + })? + }) + } +} + +impl ExtensionServices for RoutedExtensionServices { + fn acp_termination_grace(&self) -> ExtensionFuture<'static, Duration> { + self.call(move |reply| ExtensionServiceCommand::AcpTerminationGrace { reply }) + } + + fn vm_acp_limits( + &self, + ownership: OwnershipScope, + ) -> ExtensionFuture<'static, agentos_native_sidecar_core::limits::AcpLimits> { + self.call(move |reply| ExtensionServiceCommand::VmAcpLimits { ownership, reply }) + } + + fn vm_database( + &self, + ownership: OwnershipScope, + ) -> ExtensionFuture<'static, Option> { + self.call(move |reply| ExtensionServiceCommand::VmDatabase { ownership, reply }) + } + + fn projected_agents( + &self, + ownership: OwnershipScope, + ) -> ExtensionFuture<'static, Vec> { + self.call(move |reply| ExtensionServiceCommand::ProjectedAgents { ownership, reply }) + } + + fn spawn_process( + &self, + ownership: OwnershipScope, + request: ExecuteRequest, + ) -> ExtensionFuture<'static, ProcessStartedResponse> { + self.call(move |reply| ExtensionServiceCommand::SpawnProcess { + ownership, + request, + reply, + }) + } + + fn write_stdin( + &self, + ownership: OwnershipScope, + request: WriteStdinRequest, + ) -> ExtensionFuture<'static, StdinWrittenResponse> { + self.call(move |reply| ExtensionServiceCommand::WriteStdin { + ownership, + request, + reply, + }) + } + + fn close_stdin( + &self, + ownership: OwnershipScope, + request: CloseStdinRequest, + ) -> ExtensionFuture<'static, StdinClosedResponse> { + self.call(move |reply| ExtensionServiceCommand::CloseStdin { + ownership, + request, + reply, + }) + } + + fn kill_process( + &self, + ownership: OwnershipScope, + request: KillProcessRequest, + ) -> ExtensionFuture<'static, ProcessKilledResponse> { + self.call(move |reply| ExtensionServiceCommand::KillProcess { + ownership, + request, + reply, + }) + } + + fn poll_event( + &self, + ownership: OwnershipScope, + timeout: Duration, + ) -> ExtensionFuture<'static, Option> { + let services = self.clone(); + Box::pin(async move { + let deadline = Instant::now() + timeout; + loop { + // Register before the durable zero-time probe so an event edge + // racing the probe cannot be lost between probe and wait. + let notified = services.routed_process_event_notify.notified(); + let polled = services + .call({ + let ownership = ownership.clone(); + move |reply| ExtensionServiceCommand::PollEvent { ownership, reply } + }) + .await?; + if polled.is_some() || timeout.is_zero() { + return Ok(polled); + } + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + tokio::select! { + _ = notified => {} + _ = tokio::time::sleep(deadline.saturating_duration_since(now)) => { + return Ok(None); + } + } + } + }) + } + + fn poll_process_event( + &self, + ownership: OwnershipScope, + process_id: String, + timeout: Duration, + ) -> ExtensionFuture<'static, Option> { + let services = self.clone(); + Box::pin(async move { + let broker = services.process_event_broker.clone().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_PROCESS_EVENT_BROKER_UNAVAILABLE: owned extension services were not wired to the process event broker", + )) + })?; + let target = ProcessEventTarget::for_owned_process(&ownership, process_id) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + // Claim synchronously before the first coordinator command. The + // stdio event pump consults this broker-owned claim, so it cannot + // win a select race and consume adapter output already queued + // between spawn and the first targeted RouteProcessEvent command. + broker + .claim_target(&ownership, target.clone()) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + let waiter = broker + .register_waiter(&ownership, target.clone(), OperationCancellation::new()) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + let deadline = Instant::now() + timeout; + loop { + let completed = services.completed_process_events.notify.notified(); + if let Some(event) = services.completed_process_events.take(&target)? { + return Ok(Some(event)); + } + let routed = services + .call({ + let ownership = ownership.clone(); + let target = target.clone(); + move |reply| ExtensionServiceCommand::RouteProcessEvent { + ownership, + target, + reply, + } + }) + .await?; + + let lease = + if routed { + if timeout.is_zero() { + match tokio::time::timeout(Duration::ZERO, waiter.next_lease()).await { + Ok(result) => Some(result.map_err(|error| { + SidecarError::InvalidState(error.to_string()) + })?), + Err(_) => None, + } + } else { + let remaining = deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(remaining, waiter.next_lease()).await { + Ok(result) => Some(result.map_err(|error| { + SidecarError::InvalidState(error.to_string()) + })?), + Err(_) => None, + } + } + } else if timeout.is_zero() || Instant::now() >= deadline { + None + } else { + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::select! { + result = waiter.next_lease() => Some(result.map_err(|error| { + SidecarError::InvalidState(error.to_string()) + })?), + _ = completed => continue, + _ = tokio::time::sleep(remaining) => None, + } + }; + + let Some(lease) = lease else { + return Ok(None); + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let completion_reservation = if timeout.is_zero() { + match Arc::clone(&services.completed_process_events.budget).try_acquire_owned() + { + Ok(reservation) => reservation, + Err(_) => return Ok(None), + } + } else { + match tokio::time::timeout( + remaining, + services.completed_process_events.reserve(), + ) + .await + { + Ok(result) => result?, + Err(_) => return Ok(None), + } + }; + let command_target = target.clone(); + let completion_store = services.completed_process_events.clone(); + let event = services + .call(move |reply| ExtensionServiceCommand::HandleProcessEvent { + target: command_target, + lease, + completion_store, + completion_reservation, + reply, + }) + .await?; + if event.is_some() { + return Ok(event); + } + if timeout.is_zero() || Instant::now() >= deadline { + return Ok(None); + } + } + }) + } + + fn guest_filesystem_call( + &self, + ownership: OwnershipScope, + request: GuestFilesystemCallRequest, + ) -> ExtensionFuture<'static, GuestFilesystemResultResponse> { + self.call(move |reply| ExtensionServiceCommand::GuestFilesystemCall { + ownership, + request, + reply, + }) + } + + fn bind_process_to_session( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + ) -> ExtensionFuture<'static, ()> { + self.call(move |reply| ExtensionServiceCommand::BindProcessToSession { + ownership, + namespace, + ext_session_id, + process_id, + reply, + }) + } + + fn bind_vm_to_session( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + ) -> ExtensionFuture<'static, ()> { + self.call(move |reply| ExtensionServiceCommand::BindVmToSession { + ownership, + namespace, + ext_session_id, + reply, + }) + } + + fn dispose_session_resources( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + ) -> ExtensionFuture<'static, Vec> { + self.call( + move |reply| ExtensionServiceCommand::DisposeSessionResources { + ownership, + namespace, + ext_session_id, + reply, + }, + ) + } + + fn start_buffering_process_output( + &self, + ownership: OwnershipScope, + process_id: String, + ) -> ExtensionFuture<'static, ()> { + self.call( + move |reply| ExtensionServiceCommand::StartBufferingProcessOutput { + ownership, + process_id, + reply, + }, + ) + } + + fn handoff_buffered_process_output( + &self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + timeout: Duration, + ) -> ExtensionFuture<'static, ExtensionBufferedProcessOutput> { + let services = self.clone(); + Box::pin(async move { + let deadline = Instant::now() + timeout; + loop { + let notified = services.routed_process_event_notify.notified(); + let finalize_if_empty = timeout.is_zero() || Instant::now() >= deadline; + let output = services + .call({ + let ownership = ownership.clone(); + let namespace = namespace.clone(); + let ext_session_id = ext_session_id.clone(); + let process_id = process_id.clone(); + move |reply| ExtensionServiceCommand::ProbeBufferedProcessOutputHandoff { + ownership, + namespace, + ext_session_id, + process_id, + finalize_if_empty, + reply, + } + }) + .await?; + if let Some(output) = output { + return Ok(output); + } + let now = Instant::now(); + tokio::select! { + _ = notified => {} + _ = tokio::time::sleep(deadline.saturating_duration_since(now)) => {} + } + } + }) + } +} + +#[cfg(test)] +mod internal_event_lifecycle_tests { + use super::*; + use crate::ownership_coordinator::OwnershipCoordinatorLimits; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + + struct DropCounter(Arc); + + impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::AcqRel); + } + } + + fn prepared_internal_event( + coordinator: &OwnershipCoordinator, + ownership: &OwnershipScope, + panic_count: Arc, + reservation_drop_count: Arc, + ) -> PreparedExtensionServiceCommand { + let reservation = DropCounter(reservation_drop_count); + let prepared = PreparedExtensionServiceCommand { + operation: "test_claimed_internal_event", + future: Box::pin(async move { + drop(reservation); + Box::new(|_sidecar: &mut NativeSidecar| None) + as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |_error| { + panic_count.fetch_add(1, Ordering::AcqRel); + }), + admission: None, + }; + with_internal_vm_event_admission(prepared, coordinator, ownership) + } + + #[tokio::test(flavor = "current_thread")] + async fn deferred_claimed_event_stays_owned_until_disposal_cancels_it_once() { + let coordinator = OwnershipCoordinator::new(OwnershipCoordinatorLimits { + max_connections: 1, + max_sessions_per_connection: 1, + max_vms_per_session: 1, + max_operations_per_entity: 1, + max_internal_event_operations_per_entity: 1, + }); + let connection = coordinator + .register_connection("connection-a") + .expect("register connection"); + let session = connection.open_session("session-a").expect("open session"); + session.open_vm("vm-a").expect("open VM"); + let ownership = OwnershipScope::vm("connection-a", "session-a", "vm-a"); + + let active = prepared_internal_event( + &coordinator, + &ownership, + Arc::new(AtomicUsize::new(0)), + Arc::new(AtomicUsize::new(0)), + ) + .admit_vm_event_nowait() + .expect("admit first claimed event"); + let VmEventAdmissionResult::Admitted(active) = active else { + panic!("first claimed event must fill active capacity"); + }; + + let panic_count = Arc::new(AtomicUsize::new(0)); + let reservation_drop_count = Arc::new(AtomicUsize::new(0)); + let deferred = prepared_internal_event( + &coordinator, + &ownership, + Arc::clone(&panic_count), + Arc::clone(&reservation_drop_count), + ) + .admit_vm_event_nowait() + .expect("track second claimed event"); + let VmEventAdmissionResult::Deferred(deferred) = deferred else { + panic!("second claimed event must defer at the independent bound"); + }; + let deferred_cancellation = deferred + .admission + .as_ref() + .expect("deferred admission metadata") + .cancellation + .clone(); + assert_eq!(reservation_drop_count.load(Ordering::Acquire), 0); + + let disposal = coordinator + .begin_vm_disposal(&ownership, OperationCancellationReason::Explicit) + .expect("begin VM disposal"); + assert_eq!( + deferred_cancellation.reason(), + Some(OperationCancellationReason::Explicit) + ); + let mut drained = Box::pin(disposal.wait_drained()); + let waker = std::task::Waker::noop(); + let mut context = Context::from_waker(waker); + assert!(matches!(drained.as_mut().poll(&mut context), Poll::Pending)); + assert_eq!(reservation_drop_count.load(Ordering::Acquire), 0); + + assert!( + deferred.admit_vm_event_nowait().is_err(), + "disposal cancellation must reject the exact deferred target" + ); + assert_eq!(panic_count.load(Ordering::Acquire), 1); + assert_eq!( + reservation_drop_count.load(Ordering::Acquire), + 1, + "the retained source reservation must release exactly once" + ); + assert!( + matches!(drained.as_mut().poll(&mut context), Poll::Pending), + "the first active event still owns disposal" + ); + + drop(active); + drained.as_mut().await; + drop(drained); + disposal.complete().expect("complete drained disposal"); + assert_eq!(panic_count.load(Ordering::Acquire), 1); + assert_eq!(reservation_drop_count.load(Ordering::Acquire), 1); + } +} + +fn unexpected_service_response(operation: &str, payload: ResponsePayload) -> SidecarError { + match payload { + ResponsePayload::Rejected(response) => SidecarError::InvalidState(format!( + "extension {operation} rejected with {}: {}", + response.code, response.message + )), + other => SidecarError::InvalidState(format!( + "extension {operation} returned unexpected response: {other:?}" + )), + } +} + +struct ExtensionVmDisposalState { + connection_id: String, + session_id: String, + remaining_vm_ids: VecDeque, + events: Vec, + reply: SharedReply>, + coordinator: OwnershipCoordinator, +} + +fn prepare_next_extension_vm_disposal( + sidecar: &mut NativeSidecar, + mut state: ExtensionVmDisposalState, +) -> Option { + let Some(vm_id) = state.remaining_vm_ids.pop_front() else { + state + .reply + .send(Ok(state.events), "dispose_session_resources"); + return None; + }; + let ownership = OwnershipScope::vm( + state.connection_id.clone(), + state.session_id.clone(), + vm_id.clone(), + ); + let plan = match sidecar.prepare_internal_vm_disposal( + state.connection_id.clone(), + state.session_id.clone(), + vm_id, + DisposeReason::Requested, + ) { + Ok(plan) => plan, + Err(error) => { + state.reply.send(Err(error), "dispose_session_resources"); + return None; + } + }; + let disposal = match state + .coordinator + .begin_vm_disposal(&ownership, OperationCancellationReason::Explicit) + { + Ok(disposal) => disposal, + Err(error) => { + state.reply.send( + Err(SidecarError::InvalidState(error.to_string())), + "dispose_session_resources", + ); + return None; + } + }; + let panic_reply = state.reply.clone(); + Some(PreparedExtensionServiceCommand { + operation: "dispose_session_resources.wait_vm_drain", + future: Box::pin(async move { + disposal.wait_drained().await; + Box::new(move |sidecar: &mut NativeSidecar| { + let prepared = match sidecar.detach_vm_for_disposal(plan) { + Ok(prepared) => prepared, + Err(error) => { + state.reply.send(Err(error), "dispose_session_resources"); + return None; + } + }; + Some(prepare_extension_vm_teardown(prepared, disposal, state)) + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + panic_reply.send(Err(error), "dispose_session_resources") + }), + admission: None, + }) +} + +fn prepare_extension_vm_teardown( + prepared: crate::vm::PreparedDisposeVm, + disposal: VmDisposal, + mut state: ExtensionVmDisposalState, +) -> PreparedExtensionServiceCommand { + let panic_reply = state.reply.clone(); + PreparedExtensionServiceCommand { + operation: "dispose_session_resources.teardown_vm", + future: Box::pin(async move { + let completed = prepared.execute().await; + Box::new(move |sidecar: &mut NativeSidecar| { + let teardown_result = sidecar.complete_owned_vm_disposal(completed); + let coordinator_result = disposal.complete().map_err(|error| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_EXTENSION_VM_COORDINATOR_DISPOSAL: {error}" + )) + }); + match (teardown_result, coordinator_result) { + (Ok(events), Ok(())) => state.events.extend(events), + (Err(error), Ok(())) | (Ok(_), Err(error)) => { + state.reply.send(Err(error), "dispose_session_resources"); + return None; + } + (Err(teardown), Err(coordinator)) => { + state.reply.send( + Err(SidecarError::Execution(format!( + "{teardown}; coordinator completion also failed: {coordinator}" + ))), + "dispose_session_resources", + ); + return None; + } + } + prepare_next_extension_vm_disposal(sidecar, state) + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + panic_reply.send(Err(error), "dispose_session_resources") + }), + admission: None, + } +} + +fn prepare_extension_session_disposal( + coordinator: OwnershipCoordinator, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + reply: Reply>, +) -> PreparedExtensionServiceCommand { + let reply = SharedReply::new(reply); + let completion_reply = reply.clone(); + let panic_reply = reply.clone(); + PreparedExtensionServiceCommand { + operation: "dispose_session_resources", + future: Box::pin(async move { + Box::new(move |sidecar: &mut NativeSidecar| { + let (connection_id, session_id, _) = match sidecar.vm_scope_for(&ownership) { + Ok(scope) => scope, + Err(error) => { + completion_reply.send(Err(error), "dispose_session_resources"); + return None; + } + }; + let remaining_vm_ids = match sidecar + .detach_extension_session_resources_for_owned_disposal( + ownership, + namespace, + ext_session_id, + ) { + Ok(vm_ids) => vm_ids.into(), + Err(error) => { + completion_reply.send(Err(error), "dispose_session_resources"); + return None; + } + }; + prepare_next_extension_vm_disposal( + sidecar, + ExtensionVmDisposalState { + connection_id, + session_id, + remaining_vm_ids, + events: Vec::new(), + reply: completion_reply, + coordinator, + }, + ) + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + panic_reply.send(Err(error), "dispose_session_resources") + }), + admission: None, + } +} + +/// Validate and detach one extension-service command from the protocol router. +/// No future returned by this function borrows `sidecar`. +pub(crate) fn prepare_extension_service_command( + sidecar: &mut NativeSidecar, + coordinator: &OwnershipCoordinator, + command: ExtensionServiceCommand, +) -> PreparedExtensionServiceCommand { + let admission_ownership = match &command { + ExtensionServiceCommand::VmAcpLimits { ownership, .. } + | ExtensionServiceCommand::VmDatabase { ownership, .. } + | ExtensionServiceCommand::ProjectedAgents { ownership, .. } + | ExtensionServiceCommand::SpawnProcess { ownership, .. } + | ExtensionServiceCommand::WriteStdin { ownership, .. } + | ExtensionServiceCommand::CloseStdin { ownership, .. } + | ExtensionServiceCommand::KillProcess { ownership, .. } + | ExtensionServiceCommand::PollEvent { ownership, .. } + | ExtensionServiceCommand::RouteProcessEvent { ownership, .. } + | ExtensionServiceCommand::GuestFilesystemCall { ownership, .. } + | ExtensionServiceCommand::BindProcessToSession { ownership, .. } + | ExtensionServiceCommand::BindVmToSession { ownership, .. } + | ExtensionServiceCommand::StartBufferingProcessOutput { ownership, .. } + | ExtensionServiceCommand::ProbeBufferedProcessOutputHandoff { ownership, .. } => { + Some(ownership.clone()) + } + ExtensionServiceCommand::HandleProcessEvent { target, .. } => Some(OwnershipScope::vm( + target.connection_id.clone(), + target.session_id.clone(), + target.vm_id.clone(), + )), + ExtensionServiceCommand::AcpTerminationGrace { .. } + | ExtensionServiceCommand::DisposeSessionResources { .. } => None, + }; + let prepared = match command { + ExtensionServiceCommand::AcpTerminationGrace { reply } => { + let grace = sidecar.config.acp_termination_grace; + prepared_command( + "acp_termination_grace", + reply, + move || async move { Ok(grace) }, + ) + } + ExtensionServiceCommand::VmAcpLimits { ownership, reply } => { + let result = (|| { + let (connection_id, session_id, vm_id) = sidecar.vm_scope_for(&ownership)?; + sidecar.require_owned_vm(&connection_id, &session_id, &vm_id)?; + sidecar + .vms + .get(&vm_id) + .map(|vm| vm.limits.acp.clone()) + .ok_or_else(|| SidecarError::InvalidState(format!("VM not found: {vm_id}"))) + })(); + prepared_command("vm_acp_limits", reply, move || async move { result }) + } + ExtensionServiceCommand::VmDatabase { ownership, reply } => { + let result = (|| { + let (connection_id, session_id, vm_id) = sidecar.vm_scope_for(&ownership)?; + sidecar.require_owned_vm(&connection_id, &session_id, &vm_id)?; + Ok(sidecar.vms.get(&vm_id).and_then(|vm| vm.database.clone())) + })(); + prepared_command("vm_database", reply, move || async move { result }) + } + ExtensionServiceCommand::ProjectedAgents { ownership, reply } => { + let result = (|| { + let (connection_id, session_id, vm_id) = sidecar.vm_scope_for(&ownership)?; + sidecar.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let vm = sidecar + .vms + .get(&vm_id) + .ok_or_else(|| SidecarError::InvalidState(format!("unknown VM {vm_id}")))?; + Ok(vm + .projected_agent_launch + .iter() + .map(|(id, launch)| ProjectedAgentLaunchEntry { + id: id.clone(), + acp_entrypoint: launch.acp_entrypoint.clone(), + env: launch.env.clone(), + launch_args: launch.launch_args.clone(), + }) + .collect()) + })(); + prepared_command("projected_agents", reply, move || async move { result }) + } + ExtensionServiceCommand::SpawnProcess { + ownership, + request: payload, + reply, + } => { + let request = RequestFrame::new(0, ownership, RequestPayload::Execute(payload.clone())); + let future = sidecar.execute(&request, payload); + prepared_command("spawn_process", reply, move || async move { + let dispatch = future.await?; + match dispatch.response.payload { + ResponsePayload::ProcessStarted(response) => Ok(response), + other => Err(unexpected_service_response("execute", other)), + } + }) + } + ExtensionServiceCommand::WriteStdin { + ownership, + request: payload, + reply, + } => { + let request = + RequestFrame::new(0, ownership, RequestPayload::WriteStdin(payload.clone())); + let future = sidecar.write_stdin(&request, payload); + prepared_command("write_stdin", reply, move || async move { + let dispatch = future.await?; + match dispatch.response.payload { + ResponsePayload::StdinWritten(response) => Ok(response), + other => Err(unexpected_service_response("write_stdin", other)), + } + }) + } + ExtensionServiceCommand::CloseStdin { + ownership, + request: payload, + reply, + } => { + let request = + RequestFrame::new(0, ownership, RequestPayload::CloseStdin(payload.clone())); + let future = sidecar.close_stdin(&request, payload); + prepared_command("close_stdin", reply, move || async move { + let dispatch = future.await?; + match dispatch.response.payload { + ResponsePayload::StdinClosed(response) => Ok(response), + other => Err(unexpected_service_response("close_stdin", other)), + } + }) + } + ExtensionServiceCommand::KillProcess { + ownership, + request: payload, + reply, + } => { + let request = + RequestFrame::new(0, ownership, RequestPayload::KillProcess(payload.clone())); + let future = sidecar.kill_process(&request, payload); + prepared_command("kill_process", reply, move || async move { + let dispatch = future.await?; + match dispatch.response.payload { + ResponsePayload::ProcessKilled(response) => Ok(response), + other => Err(unexpected_service_response("kill_process", other)), + } + }) + } + ExtensionServiceCommand::PollEvent { ownership, reply } => { + prepared_mutation("poll_event", reply, move |sidecar| { + sidecar.poll_event_nowait(&ownership) + }) + } + ExtensionServiceCommand::RouteProcessEvent { + ownership, + target, + reply, + } => prepared_mutation("route_process_event", reply, move |sidecar| { + sidecar.route_owned_process_event_to_broker_nowait(&ownership, &target) + }), + ExtensionServiceCommand::HandleProcessEvent { + target, + lease, + completion_store, + completion_reservation, + reply, + } => { + let reply = SharedReply::new(reply); + let completion_reply = reply.clone(); + let panic_reply = reply.clone(); + PreparedExtensionServiceCommand { + operation: "handle_process_event", + future: Box::pin(async move { + let envelope = lease + .commit() + .map_err(|error| SidecarError::InvalidState(error.to_string())); + Box::new(move |sidecar: &mut NativeSidecar| { + let result = envelope.and_then(|envelope| { + sidecar.handle_public_process_event_envelope_nowait(envelope) + }); + match completion_reply.try_send(result, "handle_process_event") { + Ok(()) => {} + Err(Ok(Some(event))) => { + if let Err(error) = + completion_store.retain(target, event, completion_reservation) + { + eprintln!( + "ERR_AGENTOS_COMPLETED_PROCESS_EVENT_RETAIN: {error}" + ); + } + } + Err(other) => { + drop(completion_reservation); + tracing::debug!( + operation = "handle_process_event", + result = ?other, + "extension service caller ended before its process event reply" + ); + } + } + None + }) as ExtensionServiceCompletionMutation + }), + panic_reply: Box::new(move |error| { + panic_reply.send(Err(error), "handle_process_event") + }), + admission: None, + } + } + ExtensionServiceCommand::GuestFilesystemCall { + ownership, + request: payload, + reply, + } => { + let prepared: Result<_, SidecarError> = (|| { + let (connection_id, session_id, vm_id) = sidecar.vm_scope_for(&ownership)?; + sidecar.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let handle = sidecar.vms.handle(&vm_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {vm_id} no longer exists for guest filesystem call" + )) + })?; + Ok((handle, payload)) + })(); + prepared_command("guest_filesystem_call", reply, move || async move { + let (handle, payload) = prepared?; + handle.try_command("extension guest filesystem call", |vm| { + guest_filesystem_call_vm(vm, &payload) + }) + }) + } + ExtensionServiceCommand::BindProcessToSession { + ownership, + namespace, + ext_session_id, + process_id, + reply, + } => prepared_mutation("bind_process_to_session", reply, move |sidecar| { + sidecar.bind_extension_process_resource( + ownership, + namespace, + ext_session_id, + process_id, + ) + }), + ExtensionServiceCommand::BindVmToSession { + ownership, + namespace, + ext_session_id, + reply, + } => prepared_mutation("bind_vm_to_session", reply, move |sidecar| { + sidecar.bind_extension_vm_resource(ownership, namespace, ext_session_id) + }), + ExtensionServiceCommand::DisposeSessionResources { + ownership, + namespace, + ext_session_id, + reply, + } => prepare_extension_session_disposal( + coordinator.clone(), + ownership, + namespace, + ext_session_id, + reply, + ), + ExtensionServiceCommand::StartBufferingProcessOutput { + ownership, + process_id, + reply, + } => prepared_mutation("start_buffering_process_output", reply, move |sidecar| { + sidecar.start_buffering_process_output_nowait(ownership, process_id) + }), + ExtensionServiceCommand::ProbeBufferedProcessOutputHandoff { + ownership, + namespace, + ext_session_id, + process_id, + finalize_if_empty, + reply, + } => prepared_mutation( + "probe_buffered_process_output_handoff", + reply, + move |sidecar| { + sidecar.probe_extension_process_output_handoff_nowait( + ownership, + namespace, + ext_session_id, + process_id, + finalize_if_empty, + ) + }, + ), + }; + match admission_ownership { + Some(ownership) => with_vm_admission(prepared, coordinator, &ownership), + None => prepared, + } +} diff --git a/crates/native-sidecar/src/filesystem.rs b/crates/native-sidecar/src/filesystem.rs index 1254e2b319..66489e071f 100644 --- a/crates/native-sidecar/src/filesystem.rs +++ b/crates/native-sidecar/src/filesystem.rs @@ -5,8 +5,8 @@ use crate::execution::{ sync_active_process_host_writes_to_kernel, }; use crate::protocol::{ - GuestFilesystemCallRequest, GuestFilesystemOperation, GuestRuntimeKind, RequestFrame, - ResponsePayload, + GuestFilesystemCallRequest, GuestFilesystemOperation, GuestFilesystemResultResponse, + GuestRuntimeKind, RequestFrame, ResponsePayload, }; use crate::service::{ javascript_sync_rpc_arg_str, javascript_sync_rpc_arg_u32, javascript_sync_rpc_arg_u32_optional, @@ -14,10 +14,12 @@ use crate::service::{ javascript_sync_rpc_bytes_arg, javascript_sync_rpc_bytes_value, javascript_sync_rpc_encoding, javascript_sync_rpc_option_bool, javascript_sync_rpc_option_u32, kernel_error, log_stale_process_event, normalize_host_path, normalize_path, path_is_within_root, + python_error, }; use crate::state::{ ActiveExecutionEvent, ActiveProcess, BridgeError, ShadowNodeType, ShadowSyncInventoryEntry, - SidecarKernel, VmState, EXECUTION_DRIVER_NAME, PYTHON_VFS_RPC_GUEST_ROOT, + SharedBridge, SidecarKernel, VmHandle, VmState, EXECUTION_DRIVER_NAME, + PYTHON_VFS_RPC_GUEST_ROOT, }; use crate::{DispatchResult, NativeSidecar, NativeSidecarBridge, SidecarError}; @@ -39,8 +41,8 @@ const CHMOD_PATH_ANCHOR: OFlag = OFlag::O_RDONLY; const O_TMPFILE_FLAG: OFlag = OFlag::empty(); use agentos_execution::{ JavascriptSyncRpcRequest, LocalResolvedModuleFormat, ModuleFsReader, ModuleResolveMode, - ModuleResolver, PythonVfsRpcMethod, PythonVfsRpcRequest, PythonVfsRpcResponsePayload, - PythonVfsRpcStat, + ModuleResolver, PythonVfsRpcMethod, PythonVfsRpcRequest, PythonVfsRpcResponder, + PythonVfsRpcResponsePayload, PythonVfsRpcStat, }; use agentos_kernel::kernel::is_internal_unnamed_file_name; use agentos_kernel::vfs::{ @@ -482,23 +484,16 @@ where let (connection_id, session_id, vm_id) = sidecar.vm_scope_for(&request.ownership)?; sidecar.require_owned_vm(&connection_id, &session_id, &vm_id)?; - let response = { - let vm = match sidecar.vms.get_mut(&vm_id) { - Some(vm) => vm, - None => { - return Err(stale_filesystem_request_error( - sidecar, - &vm_id, - None, - "guest filesystem dispatch", - )); - } - }; - sync_guest_filesystem_shadow_before_call(vm, &payload)?; - let response = core_guest_filesystem_call(&mut vm.kernel, payload.clone()) - .map_err(native_guest_filesystem_core_error)?; - mirror_guest_filesystem_shadow_after_call(vm, &payload)?; - response + let response = match sidecar.vms.get_mut(&vm_id) { + Some(mut vm) => guest_filesystem_call_vm(&mut vm, &payload)?, + None => { + return Err(stale_filesystem_request_error( + sidecar, + &vm_id, + None, + "guest filesystem dispatch", + )); + } }; Ok(DispatchResult { @@ -507,6 +502,21 @@ where }) } +/// Execute one filesystem command against an already ownership-validated VM. +/// +/// This contains no await point and is the short per-VM critical section used +/// by both coordinated in-process dispatch and detached protocol requests. +pub(crate) fn guest_filesystem_call_vm( + vm: &mut VmState, + payload: &GuestFilesystemCallRequest, +) -> Result { + sync_guest_filesystem_shadow_before_call(vm, payload)?; + let response = core_guest_filesystem_call(&mut vm.kernel, payload.clone()) + .map_err(native_guest_filesystem_core_error)?; + mirror_guest_filesystem_shadow_after_call(vm, payload)?; + Ok(response) +} + fn native_guest_filesystem_core_error( error: agentos_native_sidecar_core::SidecarCoreError, ) -> SidecarError { @@ -831,21 +841,65 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - let Some(vm) = sidecar.vms.get(vm_id) else { + let Some(vm) = sidecar.vms.handle(vm_id) else { log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); return Ok(()); }; - if !vm.active_processes.contains_key(process_id) { + let responder = vm.try_read("prepare Python VFS RPC", |state| { + state + .active_processes + .get(process_id) + .map(|process| process.execution.python_vfs_rpc_responder()) + })?; + let Some(responder) = responder else { log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); return Ok(()); + }; + service_owned_python_filesystem_rpc_request( + &sidecar.bridge, + &vm, + vm_id, + process_id, + &[], + &responder?, + request, + ) +} + +/// Service one already-claimed Python filesystem RPC against its VM handle. +/// +/// The caller owns the concrete request and responder, so the runtime event is +/// consumed exactly once and no whole-sidecar borrow is needed while the work +/// runs under the bounded process-event supervisor. +pub(crate) fn service_owned_python_filesystem_rpc_request( + bridge: &SharedBridge, + vm: &VmHandle, + vm_id: &str, + process_id: &str, + child_path: &[String], + responder: &PythonVfsRpcResponder, + request: PythonVfsRpcRequest, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let process_active = vm.try_read("service Python VFS RPC", |state| { + let path = child_path.iter().map(String::as_str).collect::>(); + state + .active_processes + .get(process_id) + .and_then(|root| NativeSidecar::::active_process_by_path(root, &path)) + .is_some() + })?; + if !process_active { + log_stale_process_event(bridge, vm_id, process_id, "python VFS RPC"); + return Ok(()); } let response = match normalize_python_vfs_rpc_path(&request.path) { Ok(path) => { - let Some(vm) = sidecar.vms.get_mut(vm_id) else { - log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); - return Ok(()); - }; + let mut vm = vm.try_borrow_mut("service Python VFS RPC")?; match request.method { PythonVfsRpcMethod::Read => vm .kernel @@ -916,8 +970,8 @@ where // entry the guest just removed. PythonVfsRpcMethod::Unlink => { match vm.kernel.remove_file(&path).map_err(kernel_error) { - Ok(()) => remove_guest_shadow_path(vm, &path).map(|()| { - forget_shadow_inventory_path(vm, &path); + Ok(()) => remove_guest_shadow_path(&mut vm, &path).map(|()| { + forget_shadow_inventory_path(&mut vm, &path); PythonVfsRpcResponsePayload::Empty }), Err(error) => Err(error), @@ -925,8 +979,8 @@ where } PythonVfsRpcMethod::Rmdir => { match vm.kernel.remove_dir(&path).map_err(kernel_error) { - Ok(()) => remove_guest_shadow_path(vm, &path).map(|()| { - forget_shadow_inventory_path(vm, &path); + Ok(()) => remove_guest_shadow_path(&mut vm, &path).map(|()| { + forget_shadow_inventory_path(&mut vm, &path); PythonVfsRpcResponsePayload::Empty }), Err(error) => Err(error), @@ -942,9 +996,9 @@ where let destination = normalize_python_vfs_rpc_path(destination)?; match vm.kernel.rename(&path, &destination).map_err(kernel_error) { Ok(()) => { - rename_guest_shadow_path(vm, &path, &destination).and_then(|()| { - forget_shadow_inventory_path(vm, &path); - refresh_shadow_inventory_path(vm, &destination)?; + rename_guest_shadow_path(&mut vm, &path, &destination).and_then(|()| { + forget_shadow_inventory_path(&mut vm, &path); + refresh_shadow_inventory_path(&mut vm, &destination)?; Ok(PythonVfsRpcResponsePayload::Empty) }) } @@ -986,7 +1040,7 @@ where if let Some(mode) = request.mode { vm.kernel.chmod(&path, mode).map_err(kernel_error)?; if mirror { - mirror_guest_chmod_to_shadow(vm, &path, mode)?; + mirror_guest_chmod_to_shadow(&mut vm, &path, mode)?; } } // uid/gid apply independently (`os.chown(p, uid, -1)` keeps @@ -1006,7 +1060,7 @@ where .map_err(kernel_error)?; if mirror { mirror_guest_utimes_to_shadow( - vm, + &mut vm, &path, VirtualUtimeSpec::Set(VirtualTimeSpec::from_millis(atime_ms)), VirtualUtimeSpec::Set(VirtualTimeSpec::from_millis(mtime_ms)), @@ -1034,23 +1088,13 @@ where Err(error) => Err(error), }; - let Some(vm) = sidecar.vms.get_mut(vm_id) else { - log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); - return Ok(()); - }; - let Some(process) = vm.active_processes.get_mut(process_id) else { - log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); - return Ok(()); - }; match response { - Ok(payload) => process - .execution - .respond_python_vfs_rpc_success(request.id, payload), - Err(error) => process.execution.respond_python_vfs_rpc_error( - request.id, - "ERR_AGENTOS_PYTHON_VFS_RPC", - error.to_string(), - ), + Ok(payload) => responder + .respond_success(request.id, payload) + .map_err(python_error), + Err(error) => responder + .respond_error(request.id, "ERR_AGENTOS_PYTHON_VFS_RPC", error.to_string()) + .map_err(python_error), } } diff --git a/crates/native-sidecar/src/language_execution.rs b/crates/native-sidecar/src/language_execution.rs index 677e1bcabb..46810a4362 100644 --- a/crates/native-sidecar/src/language_execution.rs +++ b/crates/native-sidecar/src/language_execution.rs @@ -5,8 +5,13 @@ //! engines; clients never construct runtime or package-manager commands. use crate::protocol::*; -use crate::service::{normalize_path, DispatchResult, NativeSidecar, SidecarError}; -use crate::state::{BridgeError, ExecutionValueKind, ManagedLanguageExecution}; +use crate::service::{ + normalize_path, DispatchResult, NativeSidecar, RequestCompletionEffects, SidecarError, +}; +use crate::state::{ + BridgeError, ExecutionValueKind, ManagedLanguageExecution, SharedBridge, + SharedSidecarRequestClient, VmHandle, VmState, +}; use crate::NativeSidecarBridge; use oxc_allocator::Allocator; use oxc_ast::ast::{ImportDeclarationSpecifier, Statement}; @@ -827,7 +832,393 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { - pub(crate) async fn execute_language_operation( + pub(crate) fn execute_language_operation( + &mut self, + request: &RequestFrame, + payload: RequestPayload, + completion_effects: RequestCompletionEffects, + ) -> crate::execution::OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + let sidecar_requests = self.sidecar_requests.clone(); + let process_event_notify = Arc::clone(&self.process_event_notify); + let cache_root = self.cache_root.clone(); + let max_process_events = self.config.runtime.protocol.max_process_events; + Box::pin(async move { + let input = input?; + let request = input.request.clone(); + let mut service = OwnedLanguageSidecar { + request: request.clone(), + vms: OwnedLanguageVmRegistry { + vm_id: input.vm_id.clone(), + vm: input.vm, + }, + bridge, + sidecar_requests, + process_event_notify, + cache_root, + max_process_events, + completion_effects, + }; + service + .execute_language_operation_owned(&request, payload) + .await + }) + } +} + +struct OwnedLanguageVmRegistry { + vm_id: String, + vm: VmHandle, +} + +impl OwnedLanguageVmRegistry { + fn get(&self, vm_id: &str) -> Option> { + (vm_id == self.vm_id).then(|| self.vm.borrow()) + } + + fn get_mut(&self, vm_id: &str) -> Option> { + (vm_id == self.vm_id).then(|| self.vm.borrow_mut()) + } +} + +struct OwnedLanguageSidecar { + request: RequestFrame, + vms: OwnedLanguageVmRegistry, + bridge: SharedBridge, + sidecar_requests: SharedSidecarRequestClient, + process_event_notify: Arc, + cache_root: std::path::PathBuf, + max_process_events: usize, + completion_effects: RequestCompletionEffects, +} + +impl OwnedLanguageSidecar +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + fn vm_input(&self, request: &RequestFrame) -> crate::execution::OwnedVmRouteInput { + crate::execution::OwnedVmRouteInput { + request: request.clone(), + vm_id: self.vms.vm_id.clone(), + vm: self.vms.vm.clone(), + } + } + + async fn execute( + &self, + request: &RequestFrame, + payload: ExecuteRequest, + ) -> Result { + crate::execution::execute_owned( + self.vm_input(request), + payload, + self.bridge.clone(), + self.sidecar_requests.clone(), + Arc::clone(&self.process_event_notify), + self.cache_root.clone(), + self.max_process_events, + ) + .await + } + + async fn write_stdin( + &self, + request: &RequestFrame, + payload: WriteStdinRequest, + ) -> Result { + crate::execution::write_stdin_owned(self.vm_input(request), payload).await + } + + async fn close_stdin( + &self, + request: &RequestFrame, + payload: CloseStdinRequest, + ) -> Result { + crate::execution::close_stdin_owned(self.vm_input(request), payload).await + } + + async fn resize_pty( + &self, + request: &RequestFrame, + payload: ResizePtyRequest, + ) -> Result { + crate::execution::resize_pty_owned(self.bridge.clone(), self.vm_input(request), payload) + .await + } + + fn active_process_id( + &self, + vm_id: &str, + execution_id: &str, + ) -> Result { + let Some((state, process_id)) = self.vms.get(vm_id).and_then(|vm| { + vm.executions.get(execution_id).map(|execution| { + ( + execution.descriptor.state.clone(), + execution.descriptor.process_id.clone(), + ) + }) + }) else { + return Err(( + "execution_not_found", + format!("execution {execution_id} does not exist"), + )); + }; + if state != ExecutionState::Running { + return Err(( + "execution_not_running", + format!("execution {execution_id} is not running"), + )); + } + process_id.ok_or_else(|| { + ( + "execution_not_running", + format!("execution {execution_id} has no active process"), + ) + }) + } + + async fn kill_process_internal( + &self, + process_id: &str, + signal: &str, + ) -> Result<(), SidecarError> { + crate::execution::kill_process_owned( + self.bridge.clone(), + self.vm_input(&self.request), + KillProcessRequest { + process_id: process_id.to_owned(), + signal: signal.to_owned(), + }, + ) + .await?; + Ok(()) + } + + fn finish_active_process_exit( + &self, + vm_id: &str, + process_id: &str, + exit_code: i32, + ) -> Result, SidecarError> { + let finished = NativeSidecar::::finish_active_process_exit_owned( + &self.bridge, + &self.vms.vm, + vm_id, + process_id, + exit_code, + )?; + finished + .map(|finished| { + self.completion_effects + .record_exited_process(&finished.process_id)?; + Ok(finished.became_idle) + }) + .transpose() + } + + fn schedule_execution_retention_wake(&self, vm_id: &str) { + let now = now_ms(); + let next_deadline = self.vms.get(vm_id).and_then(|vm| { + let completed_count = vm + .executions + .values() + .filter(|execution| { + execution.public + && !execution.context + && execution.descriptor.state != ExecutionState::Running + && execution.result.is_some() + }) + .count(); + if completed_count > vm.limits.execution.max_completed_executions { + return Some(now); + } + vm.executions + .values() + .filter_map(|execution| execution.expires_at_ms) + .min() + }); + let Some(mut vm) = self.vms.get_mut(vm_id) else { + return; + }; + if vm.execution_retention_wake_deadline_ms == next_deadline + && vm + .execution_retention_wake_task + .as_ref() + .is_some_and(|task| !task.is_finished()) + { + return; + } + if let Some(task) = vm.execution_retention_wake_task.take() { + task.abort(); + } + vm.execution_retention_wake_deadline_ms = next_deadline; + let Some(deadline) = next_deadline else { + return; + }; + let notify = Arc::clone(&self.process_event_notify); + let delay_ms = deadline.saturating_sub(now); + match vm + .runtime_context + .spawn(agentos_runtime::TaskClass::Timer, async move { + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + notify.notify_one(); + }) { + Ok(task) => vm.execution_retention_wake_task = Some(task), + Err(error) => { + eprintln!("agentos VM {vm_id} failed to schedule execution retention wake: {error}") + } + } + } + + async fn expire_public_execution_deadlines( + &self, + request_effect_reserve: usize, + ) -> Result<(), SidecarError> { + let vm_id = self.vms.vm_id.clone(); + let now = now_ms(); + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + if vm + .execution_retention_wake_task + .as_ref() + .is_some_and(tokio::task::JoinHandle::is_finished) + { + vm.execution_retention_wake_task = None; + vm.execution_retention_wake_deadline_ms = None; + } + } + + let expired_budget = self + .completion_effects + .remaining_exited_process_capacity(request_effect_reserve) + .min(64); + let expired = self + .vms + .get(&vm_id) + .map(|vm| { + vm.executions + .iter() + .filter_map(|(execution_id, execution)| { + (execution.public + && !execution.context + && execution.descriptor.state != ExecutionState::Running + && execution + .expires_at_ms + .is_some_and(|expires_at| now >= expires_at)) + .then(|| (execution_id.clone(), execution.resident_process_id.clone())) + }) + .take(expired_budget) + .collect::>() + }) + .unwrap_or_default(); + for (execution_id, resident_process_id) in expired { + if let Some(process_id) = resident_process_id { + self.finish_active_process_exit(&vm_id, &process_id, 0)?; + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + vm.execution_processes.remove(&process_id); + } + } + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + vm.executions.remove(&execution_id); + if vm.package_mutation_execution_id.as_deref() == Some(&execution_id) { + vm.package_mutation_execution_id = None; + } + } + } + + let over_limit_budget = self + .completion_effects + .remaining_exited_process_capacity(request_effect_reserve) + .min(64); + let over_limit = self + .vms + .get(&vm_id) + .map(|vm| { + let mut completed = vm + .executions + .iter() + .filter(|(_, execution)| { + execution.public + && !execution.context + && execution.descriptor.state != ExecutionState::Running + && execution.result.is_some() + }) + .map(|(execution_id, execution)| { + ( + execution_id.clone(), + execution.descriptor.last_completed_at_ms.unwrap_or(0), + execution.resident_process_id.clone(), + ) + }) + .collect::>(); + completed.sort_by_key(|(_, completed_at, _)| *completed_at); + let excess = completed + .len() + .saturating_sub(vm.limits.execution.max_completed_executions); + completed.into_iter().take(excess).collect::>() + }) + .unwrap_or_default(); + for (execution_id, _, resident_process_id) in over_limit.into_iter().take(over_limit_budget) + { + eprintln!( + "agentos VM {vm_id} evicted completed execution {execution_id} to enforce limits.execution.maxCompletedExecutions" + ); + if let Some(process_id) = resident_process_id { + self.finish_active_process_exit(&vm_id, &process_id, 0)?; + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + vm.execution_processes.remove(&process_id); + } + } + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + vm.executions.remove(&execution_id); + } + } + + let due = self + .vms + .get(&vm_id) + .map(|vm| { + vm.executions + .values() + .filter_map(|execution| { + (execution.descriptor.state == ExecutionState::Running + && execution + .deadline_ms + .is_some_and(|deadline| now >= deadline)) + .then(|| execution.descriptor.process_id.clone()) + .flatten() + }) + .collect::>() + }) + .unwrap_or_default(); + for process_id in due { + let execution_id = self + .vms + .get(&vm_id) + .and_then(|vm| vm.execution_processes.get(&process_id).cloned()); + if let Some(execution_id) = execution_id { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + if let Some(execution) = vm.executions.get_mut(&execution_id) { + if execution.pending_outcome != Some(ExecutionOutcome::Cancelled) { + execution.pending_outcome = Some(ExecutionOutcome::TimedOut); + } + execution.deadline_ms = None; + } + } + self.kill_process_internal(&process_id, "SIGKILL").await?; + } + } + self.schedule_execution_retention_wake(&vm_id); + Ok(()) + } + + fn respond(&self, request: &RequestFrame, payload: ResponsePayload) -> ResponseFrame { + agentos_native_sidecar_core::respond(request, payload) + } + + async fn execute_language_operation_owned( &mut self, request: &RequestFrame, payload: RequestPayload, @@ -846,8 +1237,7 @@ where )); } }; - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let vm_id = self.vms.vm_id.clone(); let context_execution = operation.identity.context_id.is_some(); if operation.background && context_execution { return Ok(typed_rejection( @@ -895,7 +1285,7 @@ where "executeTypeScriptFile requires a file path", )) })?; - let vm = self + let mut vm = self .vms .get_mut(&vm_id) .ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?; @@ -936,7 +1326,7 @@ where { const COMPILER_ROOT: &str = "/.agentos/runtime/typescript"; const COMPILER_PATH: &str = "/.agentos/runtime/typescript/typescript.js"; - let vm = self + let mut vm = self .vms .get_mut(&vm_id) .ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?; @@ -995,7 +1385,7 @@ where } }; let execution_id = { - let vm = self + let mut vm = self .vms .get_mut(&vm_id) .ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?; @@ -1103,7 +1493,8 @@ where } let (process_id, generation, descriptor, reused_resident) = { - let vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); + let mut vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); + let vm = &mut *vm; let resident_process_id = operation .retained_source .as_ref() @@ -1151,7 +1542,7 @@ where .clone() .unwrap_or(ExecutionOutputCapture::None), retain_events: operation.output.retain_events.unwrap_or(false), - event_limit: self.config.runtime.protocol.max_process_events.max(1), + event_limit: self.max_process_events.max(1), event_bytes_limit: EXECUTION_EVENT_BYTES_LIMIT, uses_pty: false, value_kind: ExecutionValueKind::None, @@ -1236,9 +1627,9 @@ where notify.notify_one(); }) .map_err(|error| SidecarError::Execution(error.to_string()))?; - self.vms - .get_mut(&vm_id) - .and_then(|vm| vm.executions.get_mut(&execution_id)) + let mut vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); + vm.executions + .get_mut(&execution_id) .expect("admitted execution exists") .deadline_task = Some(task); } @@ -1302,7 +1693,7 @@ where .retained_file_path .clone() .unwrap_or_else(|| String::from("/[agentos-retained]")); - let vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); + let mut vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); let process = vm.active_processes.get_mut(&process_id).ok_or_else(|| { SidecarError::InvalidState(format!( "resident process {process_id} disappeared before execution" @@ -1319,9 +1710,18 @@ where Ok(result) => result, Err(error) => { if reused_resident { - let _ = self.finish_active_process_exit(&vm_id, &process_id, 1); + if let Err(cleanup_error) = + self.finish_active_process_exit(&vm_id, &process_id, 1) + { + tracing::error!( + vm_id, + process_id, + %cleanup_error, + "failed to finish reused resident process after launch failure" + ); + } } - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { let completed_ttl_ms = vm.limits.execution.completed_ttl_ms; vm.execution_processes.remove(&process_id); if vm.package_mutation_execution_id.as_deref() == Some(&execution_id) { @@ -1350,8 +1750,11 @@ where let result = self .vms .get(&vm_id) - .and_then(|vm| vm.executions.get(&execution_id)) - .and_then(|execution| execution.result.clone()) + .and_then(|vm| { + vm.executions + .get(&execution_id) + .and_then(|execution| execution.result.clone()) + }) .expect("admitted start failure stores a result"); self.schedule_execution_retention_wake(&vm_id); return Ok(DispatchResult { @@ -1378,12 +1781,10 @@ where if let Some(launch) = &launch { if let ResponsePayload::ProcessStarted(started) = &launch.response.payload { - if let Some(execution) = self - .vms - .get_mut(&vm_id) - .and_then(|vm| vm.executions.get_mut(&execution_id)) - { - execution.descriptor.pid = started.pid; + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + if let Some(execution) = vm.executions.get_mut(&execution_id) { + execution.descriptor.pid = started.pid; + } } } } @@ -1402,8 +1803,11 @@ where let descriptor = self .vms .get(&vm_id) - .and_then(|vm| vm.executions.get(&execution_id)) - .map(|execution| execution.descriptor.clone()) + .and_then(|vm| { + vm.executions + .get(&execution_id) + .map(|execution| execution.descriptor.clone()) + }) .unwrap_or(descriptor); debug_assert_eq!(descriptor.generation, generation); Ok(DispatchResult { @@ -1417,15 +1821,65 @@ where events: launch.map_or_else(Vec::new, |launch| launch.events), }) } +} + +impl NativeSidecar +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + pub(crate) fn handle_execution_lifecycle( + &mut self, + request: &RequestFrame, + payload: RequestPayload, + completion_effects: RequestCompletionEffects, + ) -> crate::execution::OwnedVmRouteFuture { + let input = self.prepare_owned_vm_route(request); + let bridge = self.bridge.clone(); + let sidecar_requests = self.sidecar_requests.clone(); + let process_event_notify = Arc::clone(&self.process_event_notify); + let cache_root = self.cache_root.clone(); + let max_process_events = self.config.runtime.protocol.max_process_events; + Box::pin(async move { + let input = input?; + let request = input.request.clone(); + let mut service = OwnedLanguageSidecar { + request: request.clone(), + vms: OwnedLanguageVmRegistry { + vm_id: input.vm_id.clone(), + vm: input.vm, + }, + bridge, + sidecar_requests, + process_event_notify, + cache_root, + max_process_events, + completion_effects, + }; + service + .handle_execution_lifecycle_owned(&request, payload) + .await + }) + } +} - pub(crate) async fn handle_execution_lifecycle( +impl OwnedLanguageSidecar +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + async fn handle_execution_lifecycle_owned( &mut self, request: &RequestFrame, payload: RequestPayload, ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - self.expire_public_execution_deadlines()?; + let vm_id = self.vms.vm_id.clone(); + let request_effect_reserve = usize::from(matches!( + &payload, + RequestPayload::ResetExecution(_) | RequestPayload::DeleteExecution(_) + )); + self.expire_public_execution_deadlines(request_effect_reserve) + .await?; let response = match payload { RequestPayload::CreateContext(payload) => { @@ -1436,21 +1890,30 @@ where "contextId must not be empty", )); } - let vm = self.vms.get(&vm_id).expect("owned VM checked above"); - if vm.executions.contains_key(&payload.context_id) { + let (exists, context_count, max_contexts, warning_threshold) = self + .vms + .get(&vm_id) + .map(|vm| { + ( + vm.executions.contains_key(&payload.context_id), + vm.executions + .values() + .filter(|execution| execution.context) + .count(), + vm.limits.execution.max_completed_executions, + vm.limits.execution.live_execution_warning_threshold, + ) + }) + .expect("owned VM checked above"); + if exists { return Ok(typed_rejection( request, "context_conflict", format!("context {} already exists", payload.context_id), )); } - let context_count = vm - .executions - .values() - .filter(|execution| execution.context) - .count(); - if context_count >= vm.limits.execution.max_completed_executions { - let configured_limit = vm.limits.execution.max_completed_executions; + if context_count >= max_contexts { + let configured_limit = max_contexts; return Ok(DispatchResult { response: self.respond( request, @@ -1485,16 +1948,14 @@ where events: Vec::new(), }); } - if context_count.saturating_add(1) - == vm.limits.execution.live_execution_warning_threshold - { + if context_count.saturating_add(1) == warning_threshold { eprintln!( "agentos VM {vm_id} reached limits.execution.liveExecutionWarningThreshold ({}) with {} contexts", - vm.limits.execution.live_execution_warning_threshold, + warning_threshold, context_count.saturating_add(1) ); } - let vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); + let mut vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); let now = now_ms(); let context_id = payload.context_id; let descriptor = ExecutionDescriptor { @@ -1529,7 +1990,7 @@ where output_limit_setting: "limits.execution.maxCompletedExecutions", capture: ExecutionOutputCapture::None, retain_events: false, - event_limit: self.config.runtime.protocol.max_process_events.max(1), + event_limit: self.max_process_events.max(1), event_bytes_limit: EXECUTION_EVENT_BYTES_LIMIT, uses_pty: false, value_kind: ExecutionValueKind::None, @@ -1546,21 +2007,19 @@ where }) } RequestPayload::GetExecution(payload) => { - let Some(execution) = self - .vms - .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) - .filter(|execution| execution.context) - else { + let Some(execution) = self.vms.get(&vm_id).and_then(|vm| { + vm.executions + .get(&payload.execution_id) + .filter(|execution| execution.context) + .map(|execution| execution.descriptor.clone()) + }) else { return Ok(typed_rejection( request, "context_not_found", format!("context {} does not exist", payload.execution_id), )); }; - ResponsePayload::ExecutionDescriptor(ExecutionDescriptorResponse { - execution: execution.descriptor.clone(), - }) + ResponsePayload::ExecutionDescriptor(ExecutionDescriptorResponse { execution }) } RequestPayload::ListExecutions(_) => { let executions = self @@ -1577,29 +2036,27 @@ where ResponsePayload::ExecutionList(ExecutionListResponse { executions }) } RequestPayload::WaitExecution(payload) => { - let Some((result, ephemeral)) = self - .vms - .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) - .and_then(|execution| { - (execution.descriptor.state != ExecutionState::Running) - .then(|| { - execution - .result - .clone() - .map(|result| (result, !execution.public)) - }) - .flatten() - }) - else { - if self - .vms - .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) - .is_some_and(|execution| { - execution.descriptor.state == ExecutionState::Running + let Some((result, ephemeral)) = self.vms.get(&vm_id).and_then(|vm| { + vm.executions + .get(&payload.execution_id) + .and_then(|execution| { + (execution.descriptor.state != ExecutionState::Running) + .then(|| { + execution + .result + .clone() + .map(|result| (result, !execution.public)) + }) + .flatten() }) - { + }) else { + if self.vms.get(&vm_id).is_some_and(|vm| { + vm.executions + .get(&payload.execution_id) + .is_some_and(|execution| { + execution.descriptor.state == ExecutionState::Running + }) + }) { return Ok(typed_rejection( request, "execution_busy", @@ -1625,23 +2082,21 @@ where ResponsePayload::ExecutionCompleted(result) } RequestPayload::CancelExecution(payload) => { - let process_id = match active_process_id(self, &vm_id, &payload.execution_id) { + let process_id = match self.active_process_id(&vm_id, &payload.execution_id) { Ok(process_id) => process_id, Err((code, message)) => return Ok(typed_rejection(request, code, message)), }; - if let Some(execution) = self - .vms - .get_mut(&vm_id) - .and_then(|vm| vm.executions.get_mut(&payload.execution_id)) - { - execution.pending_outcome = Some(ExecutionOutcome::Cancelled); - execution.deadline_ms = - Some(now_ms().saturating_add(EXECUTION_CANCEL_GRACE_MS)); - if let Some(task) = execution.deadline_task.take() { - task.abort(); + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + if let Some(execution) = vm.executions.get_mut(&payload.execution_id) { + execution.pending_outcome = Some(ExecutionOutcome::Cancelled); + execution.deadline_ms = + Some(now_ms().saturating_add(EXECUTION_CANCEL_GRACE_MS)); + if let Some(task) = execution.deadline_task.take() { + task.abort(); + } } } - self.kill_process_internal(&vm_id, &process_id, "SIGTERM")?; + self.kill_process_internal(&process_id, "SIGTERM").await?; let notify = Arc::clone(&self.process_event_notify); let runtime = self .vms @@ -1658,44 +2113,54 @@ where notify.notify_one(); }) .map_err(|error| SidecarError::Execution(error.to_string()))?; - self.vms - .get_mut(&vm_id) - .and_then(|vm| vm.executions.get_mut(&payload.execution_id)) + let mut vm = self.vms.get_mut(&vm_id).expect("execution VM exists"); + vm.executions + .get_mut(&payload.execution_id) .expect("execution checked above") .deadline_task = Some(task); let descriptor = self .vms .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) - .expect("execution checked above") - .descriptor - .clone(); + .and_then(|vm| { + vm.executions + .get(&payload.execution_id) + .map(|execution| execution.descriptor.clone()) + }) + .expect("execution checked above"); ResponsePayload::ExecutionDescriptor(ExecutionDescriptorResponse { execution: descriptor, }) } RequestPayload::SignalExecution(payload) => { - let process_id = match active_process_id(self, &vm_id, &payload.execution_id) { + let process_id = match self.active_process_id(&vm_id, &payload.execution_id) { Ok(process_id) => process_id, Err((code, message)) => return Ok(typed_rejection(request, code, message)), }; - self.kill_process_internal(&vm_id, &process_id, &payload.signal)?; + self.kill_process_internal(&process_id, &payload.signal) + .await?; let descriptor = self .vms .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) - .expect("execution checked above") - .descriptor - .clone(); + .and_then(|vm| { + vm.executions + .get(&payload.execution_id) + .map(|execution| execution.descriptor.clone()) + }) + .expect("execution checked above"); ResponsePayload::ExecutionDescriptor(ExecutionDescriptorResponse { execution: descriptor, }) } RequestPayload::ResetExecution(payload) => { - let Some(existing) = self - .vms - .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) + let Some((existing_state, resident_process_id)) = + self.vms.get(&vm_id).and_then(|vm| { + vm.executions.get(&payload.execution_id).map(|execution| { + ( + execution.descriptor.state.clone(), + execution.resident_process_id.clone(), + ) + }) + }) else { return Ok(typed_rejection( request, @@ -1703,17 +2168,16 @@ where format!("context {} does not exist", payload.execution_id), )); }; - if existing.descriptor.state == ExecutionState::Running { + if existing_state == ExecutionState::Running { return Ok(typed_rejection( request, "execution_busy", format!("execution {} is running", payload.execution_id), )); } - let resident_process_id = existing.resident_process_id.clone(); if let Some(process_id) = resident_process_id { self.finish_active_process_exit(&vm_id, &process_id, 0)?; - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { vm.execution_processes.remove(&process_id); } } @@ -1724,10 +2188,10 @@ where .limits .execution .completed_ttl_ms; - let execution = self - .vms - .get_mut(&vm_id) - .and_then(|vm| vm.executions.get_mut(&payload.execution_id)) + let mut vm = self.vms.get_mut(&vm_id).expect("owned VM checked above"); + let execution = vm + .executions + .get_mut(&payload.execution_id) .expect("execution checked above"); execution.descriptor.state = ExecutionState::Resetting; execution.descriptor.generation = execution.descriptor.generation.saturating_add(1); @@ -1758,10 +2222,15 @@ where }) } RequestPayload::DeleteExecution(payload) => { - let Some(execution) = self - .vms - .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) + let Some((execution_state, resident_process_id)) = + self.vms.get(&vm_id).and_then(|vm| { + vm.executions.get(&payload.execution_id).map(|execution| { + ( + execution.descriptor.state.clone(), + execution.resident_process_id.clone(), + ) + }) + }) else { return Ok(typed_rejection( request, @@ -1769,17 +2238,16 @@ where format!("context {} does not exist", payload.execution_id), )); }; - if execution.descriptor.state == ExecutionState::Running { + if execution_state == ExecutionState::Running { return Ok(typed_rejection( request, "execution_busy", format!("execution {} is running", payload.execution_id), )); } - let resident_process_id = execution.resident_process_id.clone(); if let Some(process_id) = resident_process_id { self.finish_active_process_exit(&vm_id, &process_id, 0)?; - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { vm.execution_processes.remove(&process_id); } } @@ -1793,7 +2261,7 @@ where }) } RequestPayload::WriteExecutionStdin(payload) => { - let process_id = match active_process_id(self, &vm_id, &payload.execution_id) { + let process_id = match self.active_process_id(&vm_id, &payload.execution_id) { Ok(process_id) => process_id, Err((code, message)) => return Ok(typed_rejection(request, code, message)), }; @@ -1812,7 +2280,7 @@ where }) } RequestPayload::CloseExecutionStdin(payload) => { - let process_id = match active_process_id(self, &vm_id, &payload.execution_id) { + let process_id = match self.active_process_id(&vm_id, &payload.execution_id) { Ok(process_id) => process_id, Err((code, message)) => return Ok(typed_rejection(request, code, message)), }; @@ -1824,7 +2292,7 @@ where }) } RequestPayload::ResizeExecutionPty(payload) => { - let process_id = match active_process_id(self, &vm_id, &payload.execution_id) { + let process_id = match self.active_process_id(&vm_id, &payload.execution_id) { Ok(process_id) => process_id, Err((code, message)) => return Ok(typed_rejection(request, code, message)), }; @@ -1843,10 +2311,17 @@ where }) } RequestPayload::ReadExecutionOutput(payload) => { - let Some(execution) = self - .vms - .get(&vm_id) - .and_then(|vm| vm.executions.get(&payload.execution_id)) + let Some((retain_events, generation, retained_events, output_truncated)) = + self.vms.get(&vm_id).and_then(|vm| { + vm.executions.get(&payload.execution_id).map(|execution| { + ( + execution.retain_events, + execution.descriptor.generation, + execution.events.clone(), + execution.output_truncated, + ) + }) + }) else { return Ok(typed_rejection( request, @@ -1854,7 +2329,7 @@ where format!("execution {} does not exist", payload.execution_id), )); }; - if !execution.retain_events { + if !retain_events { return Ok(typed_rejection( request, "execution_output_not_retained", @@ -1864,8 +2339,7 @@ where let start = match payload.cursor.as_deref() { None => 0, Some(cursor) => { - let Some(start) = parse_cursor(cursor, execution.descriptor.generation) - else { + let Some(start) = parse_cursor(cursor, generation) else { return Ok(typed_rejection( request, "execution_output_cursor_expired", @@ -1880,8 +2354,7 @@ where .unwrap_or(DEFAULT_EXECUTION_OUTPUT_PAGE_EVENTS) .clamp(1, MAX_EXECUTION_OUTPUT_PAGE_EVENTS) as usize; - let events: Vec<_> = execution - .events + let events: Vec<_> = retained_events .iter() .filter(|event| event.sequence >= start) .take(limit) @@ -1890,17 +2363,16 @@ where let next_sequence = events .last() .map_or(start, |event| event.sequence.saturating_add(1)); - let has_more = execution - .events + let has_more = retained_events .iter() .any(|event| event.sequence >= next_sequence); ResponsePayload::ExecutionOutputPage(ExecutionOutputPageResponse { execution_id: payload.execution_id, - generation: execution.descriptor.generation, + generation, events, - next_cursor: format!("{}:{next_sequence}", execution.descriptor.generation), + next_cursor: format!("{generation}:{next_sequence}"), has_more, - truncated: execution.output_truncated, + truncated: output_truncated, }) } _ => { @@ -1915,7 +2387,13 @@ where events: Vec::new(), }) } +} +impl NativeSidecar +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ pub(crate) fn is_public_execution_process(&self, vm_id: &str, process_id: &str) -> bool { self.vms .get(vm_id) @@ -1927,19 +2405,18 @@ where vm_id: &str, process_id: &str, ) -> bool { - self.vms - .get(vm_id) - .and_then(|vm| { - let execution_id = vm.execution_processes.get(process_id)?; - vm.executions.get(execution_id) - }) - .is_some_and(|execution| { + self.vms.get(vm_id).is_some_and(|vm| { + let Some(execution_id) = vm.execution_processes.get(process_id) else { + return false; + }; + vm.executions.get(execution_id).is_some_and(|execution| { execution.resident_process_id.as_deref() == Some(process_id) && execution.pending_outcome.is_none() && !execution .deadline_ms .is_some_and(|deadline| now_ms() >= deadline) }) + }) } pub(crate) fn has_running_nonresident_processes(&self, vm_id: &str) -> bool { @@ -1974,7 +2451,7 @@ where .filter_map(|execution| execution.expires_at_ms) .min() }); - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return; }; if vm.execution_retention_wake_deadline_ms == next_deadline @@ -2009,7 +2486,7 @@ where pub(crate) fn expire_public_execution_deadlines(&mut self) -> Result<(), SidecarError> { let now = now_ms(); - for vm in self.vms.values_mut() { + for mut vm in self.vms.values_mut() { if vm .execution_retention_wake_task .as_ref() @@ -2042,17 +2519,18 @@ where ) }) }) + .collect::>() }) .take(64) .collect::>(); for (vm_id, execution_id, resident_process_id) in expired { if let Some(process_id) = resident_process_id { self.finish_active_process_exit(&vm_id, &process_id, 0)?; - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { vm.execution_processes.remove(&process_id); } } - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { vm.executions.remove(&execution_id); if vm.package_mutation_execution_id.as_deref() == Some(&execution_id) { vm.package_mutation_execution_id = None; @@ -2088,6 +2566,7 @@ where .into_iter() .take(excess) .map(move |(execution_id, _, resident)| (vm_id.clone(), execution_id, resident)) + .collect::>() }) .take(64) .collect::>(); @@ -2097,11 +2576,11 @@ where ); if let Some(process_id) = resident_process_id { self.finish_active_process_exit(&vm_id, &process_id, 0)?; - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { vm.execution_processes.remove(&process_id); } } - if let Some(vm) = self.vms.get_mut(&vm_id) { + if let Some(mut vm) = self.vms.get_mut(&vm_id) { vm.executions.remove(&execution_id); } } @@ -2109,38 +2588,38 @@ where .vms .iter() .flat_map(|(vm_id, vm)| { - vm.executions.iter().filter_map(move |(_, execution)| { - (execution.descriptor.state == ExecutionState::Running - && execution - .deadline_ms - .is_some_and(|deadline| now >= deadline)) - .then(|| { - execution - .descriptor - .process_id - .as_ref() - .map(|process_id| (vm_id.clone(), process_id.clone())) + vm.executions + .iter() + .filter_map(move |(_, execution)| { + (execution.descriptor.state == ExecutionState::Running + && execution + .deadline_ms + .is_some_and(|deadline| now >= deadline)) + .then(|| { + execution + .descriptor + .process_id + .as_ref() + .map(|process_id| (vm_id.clone(), process_id.clone())) + }) + .flatten() }) - .flatten() - }) + .collect::>() }) .collect::>(); for (vm_id, process_id) in due { if let Some(execution_id) = self .vms .get(&vm_id) - .and_then(|vm| vm.execution_processes.get(&process_id)) - .cloned() + .and_then(|vm| vm.execution_processes.get(&process_id).cloned()) { - if let Some(execution) = self - .vms - .get_mut(&vm_id) - .and_then(|vm| vm.executions.get_mut(&execution_id)) - { - if execution.pending_outcome != Some(ExecutionOutcome::Cancelled) { - execution.pending_outcome = Some(ExecutionOutcome::TimedOut); + if let Some(mut vm) = self.vms.get_mut(&vm_id) { + if let Some(execution) = vm.executions.get_mut(&execution_id) { + if execution.pending_outcome != Some(ExecutionOutcome::Cancelled) { + execution.pending_outcome = Some(ExecutionOutcome::TimedOut); + } + execution.deadline_ms = None; } - execution.deadline_ms = None; } // A deadline is already terminal. Force the process tree so a // CPU-bound guest cannot defer timeout handling indefinitely. @@ -2161,7 +2640,8 @@ where channel: ExecutionStreamChannel, chunk: Vec, ) -> Option { - let vm = self.vms.get_mut(vm_id)?; + let mut vm = self.vms.get_mut(vm_id)?; + let vm = &mut *vm; let execution_id = vm.execution_processes.get(process_id)?.clone(); let execution = vm.executions.get_mut(&execution_id)?; if vm.package_mutation_execution_id.as_deref() == Some(&execution_id) { @@ -2253,12 +2733,15 @@ where process_id: &str, exit_code: i32, ) -> Option { - let vm = self.vms.get_mut(vm_id)?; + let mut vm_guard = self.vms.get_mut(vm_id)?; + let vm = &mut *vm_guard; let execution_id = vm.execution_processes.get(process_id)?.clone(); - let semantic_result = vm + let semantic_result_path = vm .executions .get(&execution_id) - .and_then(|execution| execution.semantic_result_path.as_deref()) + .and_then(|execution| execution.semantic_result_path.clone()); + let semantic_result = semantic_result_path + .as_deref() .map(|path| { let result = vm .kernel @@ -2390,6 +2873,7 @@ where exit_code: Some(exit_code), error, }); + drop(vm_guard); self.schedule_execution_retention_wake(vm_id); Some(event) } @@ -2466,35 +2950,6 @@ fn extract_semantic_result( } } -fn active_process_id( - sidecar: &NativeSidecar, - vm_id: &str, - execution_id: &str, -) -> Result { - let Some(execution) = sidecar - .vms - .get(vm_id) - .and_then(|vm| vm.executions.get(execution_id)) - else { - return Err(( - "execution_not_found", - format!("execution {execution_id} does not exist"), - )); - }; - if execution.descriptor.state != ExecutionState::Running { - return Err(( - "execution_not_running", - format!("execution {execution_id} is not running"), - )); - } - execution.descriptor.process_id.clone().ok_or_else(|| { - ( - "execution_not_running", - format!("execution {execution_id} has no active process"), - ) - }) -} - fn parse_cursor(cursor: &str, generation: u64) -> Option { let (cursor_generation, sequence) = cursor.split_once(':')?; (cursor_generation.parse::().ok()? == generation) diff --git a/crates/native-sidecar/src/lib.rs b/crates/native-sidecar/src/lib.rs index a6607fb770..2bc5031f86 100644 --- a/crates/native-sidecar/src/lib.rs +++ b/crates/native-sidecar/src/lib.rs @@ -9,14 +9,18 @@ pub(crate) mod bindings; pub(crate) mod crypto_cipher; pub(crate) mod execution; pub mod extension; +pub(crate) mod extension_services; pub(crate) mod filesystem; #[allow(dead_code)] pub(crate) mod json_rpc; pub(crate) mod language_execution; pub mod limits; pub(crate) mod metadata; +pub(crate) mod ownership_coordinator; pub mod package_projection; pub(crate) mod plugins; +pub(crate) mod process_event_broker; +pub(crate) mod request_operations; pub mod service; pub(crate) mod state; pub mod stdio; @@ -26,7 +30,7 @@ pub use agentos_sidecar_protocol::{generated_protocol, protocol, wire}; pub use extension::{ Extension, ExtensionContext, ExtensionFuture, ExtensionInterruptRequest, - ExtensionInterruptResponse, ExtensionResponse, + ExtensionInterruptResponse, ExtensionOrderingPolicy, ExtensionRequestClass, ExtensionResponse, }; pub use service::{DispatchResult, NativeSidecar, NativeSidecarConfig, SidecarError}; pub use state::EventSinkTransport; diff --git a/crates/native-sidecar/src/ownership_coordinator.rs b/crates/native-sidecar/src/ownership_coordinator.rs new file mode 100644 index 0000000000..55797bcb73 --- /dev/null +++ b/crates/native-sidecar/src/ownership_coordinator.rs @@ -0,0 +1,2335 @@ +//! Cloneable connection/session/VM coordination handles. +//! +//! The coordinator owns only bounded identity indexes, short admission state, +//! and cancellation registrations. It never performs guest work, adapter I/O, +//! filesystem/network I/O, output writes, or an external wait while holding a +//! mutex. A request keeps the returned permit while it runs; every actual +//! access to mutable VM state remains a separate short service command. + +use crate::extension::ExtensionOrderingPolicy; +use crate::request_operations::{ + OperationCancellation, OperationCancellationReason, RequestOperationMetadata, + RequestOrderingKey, IN_FLIGHT_REQUEST_COUNT_PATH, +}; +use crate::wire::OwnershipScope; +use agentos_runtime::RuntimeConfig; +use std::collections::BTreeMap; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use tokio::sync::Notify; + +const CONNECTION_LIMIT_PATH: &str = "runtime.resources.maxConnections"; +const SESSION_LIMIT_PATH: &str = "runtime.protocol.maxInFlightRequests"; +const VM_LIMIT_PATH: &str = "runtime.fairness.maxVms"; +pub(crate) const INTERNAL_EVENT_OPERATION_LIMIT_PATH: &str = "runtime.protocol.maxProcessEvents"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct OwnershipCoordinatorLimits { + pub(crate) max_connections: usize, + pub(crate) max_sessions_per_connection: usize, + pub(crate) max_vms_per_session: usize, + pub(crate) max_operations_per_entity: usize, + /// Internal runtime events have independent admission so ordinary request + /// saturation cannot prevent the response/progress work needed to complete + /// those same requests. The process-event broker provides this bound. + pub(crate) max_internal_event_operations_per_entity: usize, +} + +impl OwnershipCoordinatorLimits { + pub(crate) fn from_runtime_config(config: &RuntimeConfig) -> Self { + Self { + max_connections: config.resources.max_connections, + // There is not a second request queue hidden in this coordinator. + // Retained live session handles reuse the protocol's bounded + // request-count ceiling until a dedicated session-membership limit + // is introduced. + max_sessions_per_connection: config.protocol.max_in_flight_requests, + max_vms_per_session: config.fairness.max_vms, + max_operations_per_entity: config.protocol.max_in_flight_requests, + max_internal_event_operations_per_entity: config.protocol.max_process_events, + } + } + + fn validate(self) { + assert!( + self.max_connections > 0, + "connection limit must be positive" + ); + assert!( + self.max_sessions_per_connection > 0, + "session limit must be positive" + ); + assert!( + self.max_vms_per_session > 0, + "VM membership limit must be positive" + ); + assert!( + self.max_operations_per_entity > 0, + "entity operation limit must be positive" + ); + assert!( + self.max_internal_event_operations_per_entity > 0, + "internal event operation limit must be positive" + ); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CoordinatorPhase { + Open, + Closing, + Closed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum VmLifecyclePhase { + Idle, + Pending, + Active, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EntityCoordinatorSnapshot { + pub(crate) phase: CoordinatorPhase, + pub(crate) active_operations: usize, + pub(crate) child_count: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct VmCoordinatorSnapshot { + pub(crate) phase: CoordinatorPhase, + pub(crate) active_operations: usize, + pub(crate) lifecycle: VmLifecyclePhase, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum OwnershipCoordinatorError { + Limit { + scope: &'static str, + current: usize, + limit: usize, + configuration_path: &'static str, + }, + Duplicate { + scope: &'static str, + id: String, + }, + NotFound { + scope: &'static str, + id: String, + }, + OwnershipMismatch { + expected: String, + actual: String, + }, + Closing { + scope: String, + phase: CoordinatorPhase, + }, + LifecycleConflict { + vm: String, + lifecycle: VmLifecyclePhase, + }, + OrderingConflict { + scope: String, + }, + Cancelled { + reason: OperationCancellationReason, + }, + NotDrained { + scope: String, + active_operations: usize, + }, +} + +impl OwnershipCoordinatorError { + pub(crate) fn code(&self) -> &'static str { + match self { + Self::Limit { .. } => "ERR_AGENTOS_COORDINATOR_LIMIT", + Self::Duplicate { .. } => "ERR_AGENTOS_COORDINATOR_DUPLICATE", + Self::NotFound { .. } => "ERR_AGENTOS_COORDINATOR_NOT_FOUND", + Self::OwnershipMismatch { .. } => "ERR_AGENTOS_COORDINATOR_OWNERSHIP", + Self::Closing { .. } => "ERR_AGENTOS_COORDINATOR_CLOSING", + Self::LifecycleConflict { .. } => "ERR_AGENTOS_VM_LIFECYCLE_CONFLICT", + Self::OrderingConflict { .. } => "ERR_AGENTOS_ORDERING_CONFLICT", + Self::Cancelled { .. } => "ERR_AGENTOS_COORDINATOR_CANCELLED", + Self::NotDrained { .. } => "ERR_AGENTOS_COORDINATOR_NOT_DRAINED", + } + } +} + +impl fmt::Display for OwnershipCoordinatorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Limit { + scope, + current, + limit, + configuration_path, + } => write!( + formatter, + "{}: {scope} used {current}, limit {limit}; raise {configuration_path}", + self.code() + ), + Self::Duplicate { scope, id } => { + write!(formatter, "{}: duplicate {scope} {id}", self.code()) + } + Self::NotFound { scope, id } => { + write!(formatter, "{}: unknown {scope} {id}", self.code()) + } + Self::OwnershipMismatch { expected, actual } => write!( + formatter, + "{}: expected ownership {expected}, received {actual}", + self.code() + ), + Self::Closing { scope, phase } => write!( + formatter, + "{}: {scope} is {phase:?}; new owned operations are rejected", + self.code() + ), + Self::LifecycleConflict { vm, lifecycle } => write!( + formatter, + "{}: VM {vm} lifecycle is {lifecycle:?}", + self.code() + ), + Self::OrderingConflict { scope } => write!( + formatter, + "{}: {scope} already has an active operation", + self.code() + ), + Self::Cancelled { reason } => write!( + formatter, + "{}: coordinator admission was cancelled ({reason:?})", + self.code() + ), + Self::NotDrained { + scope, + active_operations, + } => write!( + formatter, + "{}: {scope} still owns {active_operations} active operations", + self.code() + ), + } + } +} + +impl std::error::Error for OwnershipCoordinatorError {} + +#[derive(Clone, Debug)] +pub(crate) struct OwnershipCoordinator { + inner: Arc, +} + +#[derive(Debug)] +struct OwnershipCoordinatorInner { + limits: OwnershipCoordinatorLimits, + state: Mutex, +} + +#[derive(Debug, Default)] +struct OwnershipCoordinatorState { + connections: BTreeMap, +} + +#[derive(Clone, Debug)] +pub(crate) struct ConnectionCoordinator { + root: Weak, + inner: Arc, +} + +#[derive(Debug)] +struct ConnectionCoordinatorInner { + connection_id: String, + limits: OwnershipCoordinatorLimits, + state: Mutex, + drained: Notify, +} + +#[derive(Debug)] +struct ConnectionCoordinatorState { + phase: CoordinatorPhase, + sessions: BTreeMap, + operations: BTreeMap, + extension_ordering: BTreeMap<(String, Vec), ()>, + next_operation_id: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct SessionCoordinator { + parent: Weak, + inner: Arc, +} + +#[derive(Debug)] +struct SessionCoordinatorInner { + connection_id: String, + session_id: String, + limits: OwnershipCoordinatorLimits, + state: Mutex, + drained: Notify, +} + +#[derive(Debug)] +struct SessionCoordinatorState { + phase: CoordinatorPhase, + vms: BTreeMap, + operations: BTreeMap, + next_operation_id: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct VmCoordinator { + parent: Weak, + inner: Arc, +} + +#[derive(Debug)] +struct VmCoordinatorInner { + connection_id: String, + session_id: String, + vm_id: String, + limits: OwnershipCoordinatorLimits, + state: Mutex, + changed: Notify, +} + +#[derive(Debug)] +struct VmCoordinatorState { + phase: CoordinatorPhase, + operations: BTreeMap, + next_operation_id: u64, + next_lifecycle_id: u64, + lifecycle: VmLifecycleState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OperationAdmissionClass { + Ordinary, + InternalEvent, + DeferredInternalEvent, +} + +#[derive(Debug)] +struct RegisteredOperation { + cancellation: OperationCancellation, + class: OperationAdmissionClass, +} + +#[derive(Debug)] +pub(crate) enum InternalVmEventAdmission { + Admitted(CoordinatorOperationPermit), + Deferred(CoordinatorOperationPermit), +} + +#[derive(Debug)] +enum VmLifecycleState { + Idle, + Pending { + id: u64, + cancellation: OperationCancellation, + }, + Active { + id: u64, + cancellation: OperationCancellation, + }, +} + +impl VmLifecycleState { + fn phase(&self) -> VmLifecyclePhase { + match self { + Self::Idle => VmLifecyclePhase::Idle, + Self::Pending { .. } => VmLifecyclePhase::Pending, + Self::Active { .. } => VmLifecyclePhase::Active, + } + } + + fn signal(&self, reason: OperationCancellationReason) { + match self { + Self::Pending { cancellation, .. } | Self::Active { cancellation, .. } => { + cancellation.signal(reason); + } + Self::Idle => {} + } + } +} + +impl OwnershipCoordinator { + pub(crate) fn new(limits: OwnershipCoordinatorLimits) -> Self { + limits.validate(); + Self { + inner: Arc::new(OwnershipCoordinatorInner { + limits, + state: Mutex::new(OwnershipCoordinatorState::default()), + }), + } + } + + pub(crate) fn from_runtime_config(config: &RuntimeConfig) -> Self { + Self::new(OwnershipCoordinatorLimits::from_runtime_config(config)) + } + + pub(crate) fn register_connection( + &self, + connection_id: impl Into, + ) -> Result { + let connection_id = connection_id.into(); + let mut state = lock(&self.inner.state, "connection registry"); + if state.connections.contains_key(&connection_id) { + return Err(OwnershipCoordinatorError::Duplicate { + scope: "connection", + id: connection_id, + }); + } + check_limit( + "connection coordinators", + state.connections.len(), + self.inner.limits.max_connections, + CONNECTION_LIMIT_PATH, + )?; + let connection = ConnectionCoordinator { + root: Arc::downgrade(&self.inner), + inner: Arc::new(ConnectionCoordinatorInner { + connection_id: connection_id.clone(), + limits: self.inner.limits, + state: Mutex::new(ConnectionCoordinatorState { + phase: CoordinatorPhase::Open, + sessions: BTreeMap::new(), + operations: BTreeMap::new(), + extension_ordering: BTreeMap::new(), + next_operation_id: 1, + }), + drained: Notify::new(), + }), + }; + state.connections.insert(connection_id, connection.clone()); + Ok(connection) + } + + pub(crate) fn connection( + &self, + connection_id: &str, + ) -> Result { + lock(&self.inner.state, "connection registry") + .connections + .get(connection_id) + .cloned() + .ok_or_else(|| OwnershipCoordinatorError::NotFound { + scope: "connection", + id: connection_id.to_owned(), + }) + } + + pub(crate) async fn admit( + &self, + metadata: &RequestOperationMetadata, + cancellation: OperationCancellation, + ) -> Result { + validate_ordering_ownership(&metadata.ownership, &metadata.ordering_key)?; + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + let resolved = self.resolve(&metadata.ownership)?; + let connection = resolved + .connection + .register_operation(cancellation.clone())?; + let session = match resolved.session.as_ref() { + Some(session) => Some(session.register_operation(cancellation.clone())?), + None => None, + }; + + let mut permit = CoordinatorOperationPermit { + _connection: connection, + _session: session, + vm_operation: None, + vm_lifecycle: None, + _extension_ordering: None, + }; + match &metadata.ordering_key { + RequestOrderingKey::VmOperation { .. } => { + permit.vm_operation = Some( + resolved + .vm + .as_ref() + .expect("VM ordering key requires VM ownership") + .register_operation(cancellation.clone())?, + ); + } + RequestOrderingKey::VmLifecycle { .. } => { + let admission = resolved + .vm + .as_ref() + .expect("VM lifecycle key requires VM ownership") + .begin_lifecycle(cancellation.clone())?; + permit.vm_lifecycle = Some(admission.wait().await?); + } + RequestOrderingKey::Extension { + namespace, + key, + policy, + .. + } => { + if *policy == ExtensionOrderingPolicy::CoreExclusive { + permit._extension_ordering = Some( + resolved + .connection + .register_extension_ordering(namespace.clone(), key.clone())?, + ); + } + } + RequestOrderingKey::Connection(_) + | RequestOrderingKey::Session { .. } + | RequestOrderingKey::Unordered => {} + } + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + Ok(permit) + } + + /// Admit progress-critical runtime event work independently from ordinary + /// request capacity. These registrations remain visible to connection, + /// session, and VM disposal so shutdown still cancels and drains them. + /// + /// This path is deliberately synchronous. A process event has already left + /// its durable producer queue when it reaches admission, so it may not wait + /// in an untracked future or lose ownership between coordinator turns. + pub(crate) fn admit_internal_vm_event( + &self, + metadata: &RequestOperationMetadata, + cancellation: OperationCancellation, + ) -> Result { + validate_ordering_ownership(&metadata.ownership, &metadata.ordering_key)?; + if !matches!(&metadata.ownership, OwnershipScope::VmOwnership(_)) + || !matches!( + &metadata.ordering_key, + RequestOrderingKey::VmOperation { .. } + ) + { + return Err(OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from("VM ownership with VM-operation ordering"), + actual: format!( + "{}/{}", + ownership_label(&metadata.ownership), + ordering_label(&metadata.ordering_key) + ), + }); + } + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + let resolved = self.resolve(&metadata.ownership)?; + let vm = resolved + .vm + .as_ref() + .expect("internal VM event requires VM ownership"); + let session = resolved + .session + .as_ref() + .expect("internal VM event requires session ownership"); + + // Lock narrowest-to-broadest and register all three scopes as one + // non-suspending ownership transition. If active service capacity is + // unavailable, the claimed event still receives a bounded deferred + // registration, so disposal can cancel and drain it before it ever + // obtains an execution slot. + let mut vm_state = lock(&vm.inner.state, "VM coordinator"); + ensure_open(vm_state.phase, vm.label())?; + let mut session_state = lock(&session.inner.state, "session coordinator"); + ensure_open(session_state.phase, session.label())?; + let mut connection_state = lock(&resolved.connection.inner.state, "connection coordinator"); + ensure_open( + connection_state.phase, + format!("connection {}", resolved.connection.inner.connection_id), + )?; + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + + let active_capacity = [&vm_state.operations, &session_state.operations] + .into_iter() + .all(|operations| { + operation_count(operations, OperationAdmissionClass::InternalEvent) + < self.inner.limits.max_internal_event_operations_per_entity + }) + && operation_count( + &connection_state.operations, + OperationAdmissionClass::InternalEvent, + ) < self.inner.limits.max_internal_event_operations_per_entity; + let class = if active_capacity { + OperationAdmissionClass::InternalEvent + } else { + OperationAdmissionClass::DeferredInternalEvent + }; + check_operation_limit( + &vm_state.operations, + class, + self.inner.limits, + "VM-owned operations", + "VM internal-event operations", + )?; + check_operation_limit( + &session_state.operations, + class, + self.inner.limits, + "session-owned operations", + "session internal-event operations", + )?; + check_operation_limit( + &connection_state.operations, + class, + self.inner.limits, + "connection-owned operations", + "connection internal-event operations", + )?; + + let vm_operation_id = take_id(&mut vm_state.next_operation_id); + vm_state.operations.insert( + vm_operation_id, + RegisteredOperation { + cancellation: cancellation.clone(), + class, + }, + ); + let session_operation_id = take_id(&mut session_state.next_operation_id); + session_state.operations.insert( + session_operation_id, + RegisteredOperation { + cancellation: cancellation.clone(), + class, + }, + ); + let connection_operation_id = take_id(&mut connection_state.next_operation_id); + connection_state.operations.insert( + connection_operation_id, + RegisteredOperation { + cancellation: cancellation.clone(), + class, + }, + ); + drop(connection_state); + drop(session_state); + drop(vm_state); + + let vm_operation = VmOperationRegistration { + inner: Arc::clone(&vm.inner), + id: vm_operation_id, + }; + let session = SessionOperationRegistration { + inner: Arc::clone(&session.inner), + id: session_operation_id, + }; + let connection = ConnectionOperationRegistration { + inner: Arc::clone(&resolved.connection.inner), + id: connection_operation_id, + }; + let permit = CoordinatorOperationPermit { + _connection: connection, + _session: Some(session), + vm_operation: Some(vm_operation), + vm_lifecycle: None, + _extension_ordering: None, + }; + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + Ok(match class { + OperationAdmissionClass::InternalEvent => InternalVmEventAdmission::Admitted(permit), + OperationAdmissionClass::DeferredInternalEvent => { + InternalVmEventAdmission::Deferred(permit) + } + OperationAdmissionClass::Ordinary => { + unreachable!("internal event registered as ordinary work") + } + }) + } + + pub(crate) fn begin_session_disposal( + &self, + ownership: &OwnershipScope, + reason: OperationCancellationReason, + ) -> Result { + let resolved = self.resolve(ownership)?; + let session = + resolved + .session + .ok_or_else(|| OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from("session or VM ownership"), + actual: ownership_label(ownership), + })?; + session.begin_disposal(reason) + } + + pub(crate) fn begin_vm_disposal( + &self, + ownership: &OwnershipScope, + reason: OperationCancellationReason, + ) -> Result { + let resolved = self.resolve(ownership)?; + let vm = resolved + .vm + .ok_or_else(|| OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from("VM ownership"), + actual: ownership_label(ownership), + })?; + vm.begin_disposal(reason) + } + + pub(crate) fn begin_connection_disposal( + &self, + connection_id: &str, + reason: OperationCancellationReason, + ) -> Result { + self.connection(connection_id)?.begin_disposal(reason) + } + + fn resolve( + &self, + ownership: &OwnershipScope, + ) -> Result { + let (connection_id, session_id, vm_id) = ownership_ids(ownership); + let connection = self.connection(connection_id)?; + let session = match session_id { + Some(session_id) => Some(connection.session(session_id)?), + None => None, + }; + let vm = match (session.as_ref(), vm_id) { + (Some(session), Some(vm_id)) => Some(session.vm(vm_id)?), + _ => None, + }; + Ok(ResolvedCoordinators { + connection, + session, + vm, + }) + } +} + +struct ResolvedCoordinators { + connection: ConnectionCoordinator, + session: Option, + vm: Option, +} + +impl ConnectionCoordinator { + pub(crate) fn connection_id(&self) -> &str { + &self.inner.connection_id + } + + pub(crate) fn open_session( + &self, + session_id: impl Into, + ) -> Result { + let session_id = session_id.into(); + let mut state = lock(&self.inner.state, "connection coordinator"); + ensure_open( + state.phase, + format!("connection {}", self.inner.connection_id), + )?; + if state.sessions.contains_key(&session_id) { + return Err(OwnershipCoordinatorError::Duplicate { + scope: "session", + id: format!("{}:{session_id}", self.inner.connection_id), + }); + } + check_limit( + "session coordinators per connection", + state.sessions.len(), + self.inner.limits.max_sessions_per_connection, + SESSION_LIMIT_PATH, + )?; + let session = SessionCoordinator { + parent: Arc::downgrade(&self.inner), + inner: Arc::new(SessionCoordinatorInner { + connection_id: self.inner.connection_id.clone(), + session_id: session_id.clone(), + limits: self.inner.limits, + state: Mutex::new(SessionCoordinatorState { + phase: CoordinatorPhase::Open, + vms: BTreeMap::new(), + operations: BTreeMap::new(), + next_operation_id: 1, + }), + drained: Notify::new(), + }), + }; + state.sessions.insert(session_id, session.clone()); + Ok(session) + } + + pub(crate) fn session( + &self, + session_id: &str, + ) -> Result { + lock(&self.inner.state, "connection coordinator") + .sessions + .get(session_id) + .cloned() + .ok_or_else(|| OwnershipCoordinatorError::NotFound { + scope: "session", + id: format!("{}:{session_id}", self.inner.connection_id), + }) + } + + pub(crate) fn snapshot(&self) -> EntityCoordinatorSnapshot { + let state = lock(&self.inner.state, "connection coordinator"); + EntityCoordinatorSnapshot { + phase: state.phase, + active_operations: state.operations.len(), + child_count: state.sessions.len(), + } + } + + pub(crate) fn begin_disposal( + &self, + reason: OperationCancellationReason, + ) -> Result { + let sessions = { + let mut state = lock(&self.inner.state, "connection coordinator"); + ensure_open( + state.phase, + format!("connection {}", self.inner.connection_id), + )?; + state.phase = CoordinatorPhase::Closing; + signal_all(&state.operations, reason); + state.sessions.values().cloned().collect::>() + }; + for session in &sessions { + session.force_closing(reason); + } + Ok(ConnectionDisposal { + connection: self.clone(), + sessions, + completed: false, + }) + } + + fn register_operation( + &self, + cancellation: OperationCancellation, + ) -> Result { + self.register_operation_with_class(cancellation, OperationAdmissionClass::Ordinary) + } + + fn register_operation_with_class( + &self, + cancellation: OperationCancellation, + class: OperationAdmissionClass, + ) -> Result { + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + let mut state = lock(&self.inner.state, "connection coordinator"); + ensure_open( + state.phase, + format!("connection {}", self.inner.connection_id), + )?; + check_operation_limit( + &state.operations, + class, + self.inner.limits, + "connection-owned operations", + "connection internal-event operations", + )?; + let id = take_id(&mut state.next_operation_id); + state.operations.insert( + id, + RegisteredOperation { + cancellation, + class, + }, + ); + Ok(ConnectionOperationRegistration { + inner: Arc::clone(&self.inner), + id, + }) + } + + fn register_extension_ordering( + &self, + namespace: String, + key: Vec, + ) -> Result { + let mut state = lock(&self.inner.state, "connection coordinator"); + ensure_open( + state.phase, + format!("connection {}", self.inner.connection_id), + )?; + let ordering_key = (namespace, key); + if state.extension_ordering.contains_key(&ordering_key) { + return Err(OwnershipCoordinatorError::OrderingConflict { + scope: format!( + "connection {}/extension/{}/{}-bytes", + self.inner.connection_id, + ordering_key.0, + ordering_key.1.len() + ), + }); + } + state.extension_ordering.insert(ordering_key.clone(), ()); + Ok(ExtensionOrderingRegistration { + inner: Arc::clone(&self.inner), + key: ordering_key, + }) + } +} + +impl SessionCoordinator { + pub(crate) fn connection_id(&self) -> &str { + &self.inner.connection_id + } + + pub(crate) fn session_id(&self) -> &str { + &self.inner.session_id + } + + pub(crate) fn open_vm( + &self, + vm_id: impl Into, + ) -> Result { + let vm_id = vm_id.into(); + let mut state = lock(&self.inner.state, "session coordinator"); + ensure_open(state.phase, self.label())?; + if state.vms.contains_key(&vm_id) { + return Err(OwnershipCoordinatorError::Duplicate { + scope: "VM", + id: format!("{}:{vm_id}", self.label()), + }); + } + check_limit( + "VM coordinators per session", + state.vms.len(), + self.inner.limits.max_vms_per_session, + VM_LIMIT_PATH, + )?; + let vm = VmCoordinator { + parent: Arc::downgrade(&self.inner), + inner: Arc::new(VmCoordinatorInner { + connection_id: self.inner.connection_id.clone(), + session_id: self.inner.session_id.clone(), + vm_id: vm_id.clone(), + limits: self.inner.limits, + state: Mutex::new(VmCoordinatorState { + phase: CoordinatorPhase::Open, + operations: BTreeMap::new(), + next_operation_id: 1, + next_lifecycle_id: 1, + lifecycle: VmLifecycleState::Idle, + }), + changed: Notify::new(), + }), + }; + state.vms.insert(vm_id, vm.clone()); + Ok(vm) + } + + pub(crate) fn vm(&self, vm_id: &str) -> Result { + lock(&self.inner.state, "session coordinator") + .vms + .get(vm_id) + .cloned() + .ok_or_else(|| OwnershipCoordinatorError::NotFound { + scope: "VM", + id: format!("{}:{vm_id}", self.label()), + }) + } + + pub(crate) fn snapshot(&self) -> EntityCoordinatorSnapshot { + let state = lock(&self.inner.state, "session coordinator"); + EntityCoordinatorSnapshot { + phase: state.phase, + active_operations: state.operations.len(), + child_count: state.vms.len(), + } + } + + pub(crate) fn begin_disposal( + &self, + reason: OperationCancellationReason, + ) -> Result { + let vms = { + let mut state = lock(&self.inner.state, "session coordinator"); + ensure_open(state.phase, self.label())?; + state.phase = CoordinatorPhase::Closing; + signal_all(&state.operations, reason); + state.vms.values().cloned().collect::>() + }; + for vm in &vms { + vm.begin_closing(reason); + } + Ok(SessionDisposal { + session: self.clone(), + vms, + completed: false, + }) + } + + fn force_closing(&self, reason: OperationCancellationReason) { + let vms = { + let mut state = lock(&self.inner.state, "session coordinator"); + if state.phase == CoordinatorPhase::Open { + state.phase = CoordinatorPhase::Closing; + } + signal_all(&state.operations, reason); + state.vms.values().cloned().collect::>() + }; + for vm in &vms { + vm.begin_closing(reason); + } + } + + fn register_operation( + &self, + cancellation: OperationCancellation, + ) -> Result { + self.register_operation_with_class(cancellation, OperationAdmissionClass::Ordinary) + } + + fn register_operation_with_class( + &self, + cancellation: OperationCancellation, + class: OperationAdmissionClass, + ) -> Result { + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + let mut state = lock(&self.inner.state, "session coordinator"); + ensure_open(state.phase, self.label())?; + check_operation_limit( + &state.operations, + class, + self.inner.limits, + "session-owned operations", + "session internal-event operations", + )?; + let id = take_id(&mut state.next_operation_id); + state.operations.insert( + id, + RegisteredOperation { + cancellation, + class, + }, + ); + Ok(SessionOperationRegistration { + inner: Arc::clone(&self.inner), + id, + }) + } + + fn label(&self) -> String { + format!( + "session {}:{}", + self.inner.connection_id, self.inner.session_id + ) + } +} + +impl VmCoordinator { + pub(crate) fn connection_id(&self) -> &str { + &self.inner.connection_id + } + + pub(crate) fn session_id(&self) -> &str { + &self.inner.session_id + } + + pub(crate) fn vm_id(&self) -> &str { + &self.inner.vm_id + } + + pub(crate) fn snapshot(&self) -> VmCoordinatorSnapshot { + let state = lock(&self.inner.state, "VM coordinator"); + VmCoordinatorSnapshot { + phase: state.phase, + active_operations: state.operations.len(), + lifecycle: state.lifecycle.phase(), + } + } + + fn register_operation( + &self, + cancellation: OperationCancellation, + ) -> Result { + self.register_operation_with_class(cancellation, OperationAdmissionClass::Ordinary) + } + + fn register_operation_with_class( + &self, + cancellation: OperationCancellation, + class: OperationAdmissionClass, + ) -> Result { + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + let mut state = lock(&self.inner.state, "VM coordinator"); + ensure_open(state.phase, self.label())?; + if class == OperationAdmissionClass::Ordinary + && !matches!(state.lifecycle, VmLifecycleState::Idle) + { + return Err(OwnershipCoordinatorError::LifecycleConflict { + vm: self.label(), + lifecycle: state.lifecycle.phase(), + }); + } + check_operation_limit( + &state.operations, + class, + self.inner.limits, + "VM-owned operations", + "VM internal-event operations", + )?; + let id = take_id(&mut state.next_operation_id); + state.operations.insert( + id, + RegisteredOperation { + cancellation, + class, + }, + ); + Ok(VmOperationRegistration { + inner: Arc::clone(&self.inner), + id, + }) + } + + fn begin_lifecycle( + &self, + cancellation: OperationCancellation, + ) -> Result { + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + let id = { + let mut state = lock(&self.inner.state, "VM coordinator"); + ensure_open(state.phase, self.label())?; + if !matches!(state.lifecycle, VmLifecycleState::Idle) { + return Err(OwnershipCoordinatorError::LifecycleConflict { + vm: self.label(), + lifecycle: state.lifecycle.phase(), + }); + } + let id = take_id(&mut state.next_lifecycle_id); + state.lifecycle = VmLifecycleState::Pending { + id, + cancellation: cancellation.clone(), + }; + id + }; + Ok(VmLifecycleAdmission { + inner: Arc::clone(&self.inner), + id, + cancellation, + armed: true, + }) + } + + fn begin_closing(&self, reason: OperationCancellationReason) { + let mut state = lock(&self.inner.state, "VM coordinator"); + if state.phase == CoordinatorPhase::Open { + state.phase = CoordinatorPhase::Closing; + } + signal_all(&state.operations, reason); + state.lifecycle.signal(reason); + self.inner.changed.notify_one(); + } + + pub(crate) fn begin_disposal( + &self, + reason: OperationCancellationReason, + ) -> Result { + { + let state = lock(&self.inner.state, "VM coordinator"); + ensure_open(state.phase, self.label())?; + } + self.begin_closing(reason); + Ok(VmDisposal { + vm: self.clone(), + completed: false, + }) + } + + fn label(&self) -> String { + format!( + "VM {}:{}:{}", + self.inner.connection_id, self.inner.session_id, self.inner.vm_id + ) + } +} + +#[derive(Debug)] +pub(crate) struct CoordinatorOperationPermit { + vm_lifecycle: Option, + vm_operation: Option, + _extension_ordering: Option, + _session: Option, + _connection: ConnectionOperationRegistration, +} + +impl CoordinatorOperationPermit { + pub(crate) fn is_vm_lifecycle(&self) -> bool { + self.vm_lifecycle.is_some() + } + + pub(crate) fn is_vm_operation(&self) -> bool { + self.vm_operation.is_some() + } + + /// Promote one already-tracked internal event into active service + /// capacity. A false result keeps the same ownership registrations live; + /// disposal can therefore cancel and drain the event while it waits. + pub(crate) fn try_activate_deferred_internal_event( + &mut self, + ) -> Result { + let vm = self.vm_operation.as_ref().ok_or_else(|| { + OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from("VM-bound internal-event permit"), + actual: String::from("permit without VM operation registration"), + } + })?; + let session = + self._session + .as_ref() + .ok_or_else(|| OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from("session-bound internal-event permit"), + actual: String::from("permit without session registration"), + })?; + + let mut vm_state = lock(&vm.inner.state, "VM coordinator"); + let mut session_state = lock(&session.inner.state, "session coordinator"); + let mut connection_state = lock(&self._connection.inner.state, "connection coordinator"); + let cancellation = vm_state + .operations + .get(&vm.id) + .ok_or_else(|| OwnershipCoordinatorError::NotFound { + scope: "VM operation", + id: vm.id.to_string(), + })? + .cancellation + .clone(); + if let Some(reason) = cancellation.reason() { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + ensure_open(vm_state.phase, format!("VM {}", vm.inner.vm_id))?; + ensure_open( + session_state.phase, + format!( + "session {}:{}", + session.inner.connection_id, session.inner.session_id + ), + )?; + ensure_open( + connection_state.phase, + format!("connection {}", self._connection.inner.connection_id), + )?; + + let classes = [ + vm_state + .operations + .get(&vm.id) + .map(|operation| operation.class), + session_state + .operations + .get(&session.id) + .map(|operation| operation.class), + connection_state + .operations + .get(&self._connection.id) + .map(|operation| operation.class), + ]; + if classes + .iter() + .all(|class| *class == Some(OperationAdmissionClass::InternalEvent)) + { + return Ok(true); + } + if !classes + .iter() + .all(|class| *class == Some(OperationAdmissionClass::DeferredInternalEvent)) + { + return Err(OwnershipCoordinatorError::OwnershipMismatch { + expected: String::from("matching deferred internal-event registrations"), + actual: format!("registration classes {classes:?}"), + }); + } + + let limit = vm.inner.limits.max_internal_event_operations_per_entity; + let has_capacity = + operation_count(&vm_state.operations, OperationAdmissionClass::InternalEvent) < limit + && operation_count( + &session_state.operations, + OperationAdmissionClass::InternalEvent, + ) < limit + && operation_count( + &connection_state.operations, + OperationAdmissionClass::InternalEvent, + ) < limit; + if !has_capacity { + return Ok(false); + } + vm_state + .operations + .get_mut(&vm.id) + .expect("deferred VM registration checked above") + .class = OperationAdmissionClass::InternalEvent; + session_state + .operations + .get_mut(&session.id) + .expect("deferred session registration checked above") + .class = OperationAdmissionClass::InternalEvent; + connection_state + .operations + .get_mut(&self._connection.id) + .expect("deferred connection registration checked above") + .class = OperationAdmissionClass::InternalEvent; + Ok(true) + } +} + +#[derive(Debug)] +struct ConnectionOperationRegistration { + inner: Arc, + id: u64, +} + +impl Drop for ConnectionOperationRegistration { + fn drop(&mut self) { + let mut state = lock(&self.inner.state, "connection coordinator"); + if state.operations.remove(&self.id).is_some() { + self.inner.drained.notify_one(); + } + } +} + +#[derive(Debug)] +struct ExtensionOrderingRegistration { + inner: Arc, + key: (String, Vec), +} + +impl Drop for ExtensionOrderingRegistration { + fn drop(&mut self) { + lock(&self.inner.state, "connection coordinator") + .extension_ordering + .remove(&self.key); + } +} + +#[derive(Debug)] +struct SessionOperationRegistration { + inner: Arc, + id: u64, +} + +impl Drop for SessionOperationRegistration { + fn drop(&mut self) { + let mut state = lock(&self.inner.state, "session coordinator"); + if state.operations.remove(&self.id).is_some() { + self.inner.drained.notify_one(); + } + } +} + +#[derive(Debug)] +struct VmOperationRegistration { + inner: Arc, + id: u64, +} + +impl Drop for VmOperationRegistration { + fn drop(&mut self) { + let mut state = lock(&self.inner.state, "VM coordinator"); + if state.operations.remove(&self.id).is_some() { + self.inner.changed.notify_one(); + } + } +} + +#[derive(Debug)] +struct VmLifecycleAdmission { + inner: Arc, + id: u64, + cancellation: OperationCancellation, + armed: bool, +} + +impl VmLifecycleAdmission { + async fn wait(mut self) -> Result { + loop { + let changed = self.inner.changed.notified(); + { + let mut state = lock(&self.inner.state, "VM coordinator"); + match &state.lifecycle { + VmLifecycleState::Pending { id, .. } if *id == self.id => { + if state.operations.is_empty() { + let cancellation = self.cancellation.clone(); + state.lifecycle = VmLifecycleState::Active { + id: self.id, + cancellation, + }; + self.armed = false; + return Ok(VmLifecycleGuard { + inner: Arc::clone(&self.inner), + id: self.id, + }); + } + } + _ => { + let reason = self + .cancellation + .reason() + .unwrap_or(OperationCancellationReason::ConnectionClosed); + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + } + } + tokio::select! { + _ = changed => {} + reason = self.cancellation.cancelled() => { + return Err(OwnershipCoordinatorError::Cancelled { reason }); + } + } + } + } +} + +impl Drop for VmLifecycleAdmission { + fn drop(&mut self) { + if self.armed { + release_lifecycle(&self.inner, self.id); + } + } +} + +#[derive(Debug)] +struct VmLifecycleGuard { + inner: Arc, + id: u64, +} + +impl Drop for VmLifecycleGuard { + fn drop(&mut self) { + release_lifecycle(&self.inner, self.id); + } +} + +fn release_lifecycle(inner: &Arc, id: u64) { + let mut state = lock(&inner.state, "VM coordinator"); + let matches_id = match &state.lifecycle { + VmLifecycleState::Pending { id: active, .. } + | VmLifecycleState::Active { id: active, .. } => *active == id, + VmLifecycleState::Idle => false, + }; + if matches_id { + state.lifecycle = VmLifecycleState::Idle; + inner.changed.notify_one(); + } +} + +#[derive(Debug)] +pub(crate) struct SessionDisposal { + session: SessionCoordinator, + vms: Vec, + completed: bool, +} + +impl SessionDisposal { + pub(crate) async fn wait_drained(&self) { + loop { + let drained = self.session.inner.drained.notified(); + if lock(&self.session.inner.state, "session coordinator") + .operations + .is_empty() + { + return; + } + drained.await; + } + } + + pub(crate) fn complete(mut self) -> Result<(), OwnershipCoordinatorError> { + let active_operations = lock(&self.session.inner.state, "session coordinator") + .operations + .len(); + if active_operations != 0 { + return Err(OwnershipCoordinatorError::NotDrained { + scope: self.session.label(), + active_operations, + }); + } + for vm in &self.vms { + let mut state = lock(&vm.inner.state, "VM coordinator"); + let vm_active = state.operations.len() + + usize::from(!matches!(state.lifecycle, VmLifecycleState::Idle)); + if vm_active != 0 { + return Err(OwnershipCoordinatorError::NotDrained { + scope: vm.label(), + active_operations: vm_active, + }); + } + state.phase = CoordinatorPhase::Closed; + } + { + let mut state = lock(&self.session.inner.state, "session coordinator"); + state.phase = CoordinatorPhase::Closed; + state.vms.clear(); + } + if let Some(parent) = self.session.parent.upgrade() { + let mut state = lock(&parent.state, "connection coordinator"); + if state + .sessions + .get(&self.session.inner.session_id) + .is_some_and(|current| Arc::ptr_eq(¤t.inner, &self.session.inner)) + { + state.sessions.remove(&self.session.inner.session_id); + } + } + self.completed = true; + Ok(()) + } +} + +impl Drop for SessionDisposal { + fn drop(&mut self) { + if !self.completed { + tracing::warn!( + connection_id = %self.session.inner.connection_id, + session_id = %self.session.inner.session_id, + "ERR_AGENTOS_SESSION_DISPOSAL_INCOMPLETE: session remains Closing for a bounded retry" + ); + } + } +} + +#[derive(Debug)] +pub(crate) struct VmDisposal { + vm: VmCoordinator, + completed: bool, +} + +impl VmDisposal { + pub(crate) async fn wait_drained(&self) { + loop { + let changed = self.vm.inner.changed.notified(); + let drained = { + let state = lock(&self.vm.inner.state, "VM coordinator"); + state.operations.is_empty() && matches!(state.lifecycle, VmLifecycleState::Idle) + }; + if drained { + return; + } + changed.await; + } + } + + pub(crate) fn complete(mut self) -> Result<(), OwnershipCoordinatorError> { + { + let mut state = lock(&self.vm.inner.state, "VM coordinator"); + let active_operations = state.operations.len() + + usize::from(!matches!(state.lifecycle, VmLifecycleState::Idle)); + if active_operations != 0 { + return Err(OwnershipCoordinatorError::NotDrained { + scope: self.vm.label(), + active_operations, + }); + } + state.phase = CoordinatorPhase::Closed; + } + if let Some(parent) = self.vm.parent.upgrade() { + let mut state = lock(&parent.state, "session coordinator"); + if state + .vms + .get(&self.vm.inner.vm_id) + .is_some_and(|current| Arc::ptr_eq(¤t.inner, &self.vm.inner)) + { + state.vms.remove(&self.vm.inner.vm_id); + } + } + self.completed = true; + Ok(()) + } +} + +impl Drop for VmDisposal { + fn drop(&mut self) { + if !self.completed { + tracing::warn!( + connection_id = %self.vm.inner.connection_id, + session_id = %self.vm.inner.session_id, + vm_id = %self.vm.inner.vm_id, + "ERR_AGENTOS_VM_DISPOSAL_INCOMPLETE: VM remains Closing for a bounded retry" + ); + } + } +} + +#[derive(Debug)] +pub(crate) struct ConnectionDisposal { + connection: ConnectionCoordinator, + sessions: Vec, + completed: bool, +} + +impl ConnectionDisposal { + pub(crate) async fn wait_drained(&self) { + loop { + let drained = self.connection.inner.drained.notified(); + if lock(&self.connection.inner.state, "connection coordinator") + .operations + .is_empty() + { + return; + } + drained.await; + } + } + + pub(crate) fn complete(mut self) -> Result<(), OwnershipCoordinatorError> { + let active_operations = lock(&self.connection.inner.state, "connection coordinator") + .operations + .len(); + if active_operations != 0 { + return Err(OwnershipCoordinatorError::NotDrained { + scope: format!("connection {}", self.connection.inner.connection_id), + active_operations, + }); + } + for session in &self.sessions { + let mut session_state = lock(&session.inner.state, "session coordinator"); + if !session_state.operations.is_empty() { + return Err(OwnershipCoordinatorError::NotDrained { + scope: session.label(), + active_operations: session_state.operations.len(), + }); + } + for vm in session_state.vms.values() { + let mut vm_state = lock(&vm.inner.state, "VM coordinator"); + let vm_active = vm_state.operations.len() + + usize::from(!matches!(vm_state.lifecycle, VmLifecycleState::Idle)); + if vm_active != 0 { + return Err(OwnershipCoordinatorError::NotDrained { + scope: vm.label(), + active_operations: vm_active, + }); + } + vm_state.phase = CoordinatorPhase::Closed; + } + session_state.vms.clear(); + session_state.phase = CoordinatorPhase::Closed; + } + { + let mut state = lock(&self.connection.inner.state, "connection coordinator"); + state.sessions.clear(); + state.phase = CoordinatorPhase::Closed; + } + if let Some(root) = self.connection.root.upgrade() { + let mut state = lock(&root.state, "connection registry"); + if state + .connections + .get(&self.connection.inner.connection_id) + .is_some_and(|current| Arc::ptr_eq(¤t.inner, &self.connection.inner)) + { + state + .connections + .remove(&self.connection.inner.connection_id); + } + } + self.completed = true; + Ok(()) + } +} + +impl Drop for ConnectionDisposal { + fn drop(&mut self) { + if !self.completed { + tracing::warn!( + connection_id = %self.connection.inner.connection_id, + "ERR_AGENTOS_CONNECTION_DISPOSAL_INCOMPLETE: connection remains Closing for a bounded retry" + ); + } + } +} + +fn ownership_ids(ownership: &OwnershipScope) -> (&str, Option<&str>, Option<&str>) { + match ownership { + OwnershipScope::ConnectionOwnership(scope) => (&scope.connection_id, None, None), + OwnershipScope::SessionOwnership(scope) => { + (&scope.connection_id, Some(&scope.session_id), None) + } + OwnershipScope::VmOwnership(scope) => ( + &scope.connection_id, + Some(&scope.session_id), + Some(&scope.vm_id), + ), + } +} + +fn validate_ordering_ownership( + ownership: &OwnershipScope, + ordering: &RequestOrderingKey, +) -> Result<(), OwnershipCoordinatorError> { + let (connection_id, session_id, vm_id) = ownership_ids(ownership); + let valid = match ordering { + RequestOrderingKey::Connection(key_connection) => key_connection == connection_id, + RequestOrderingKey::Session { + connection_id: key_connection, + session_id: key_session, + } => key_connection == connection_id && session_id.is_some_and(|id| id == key_session), + RequestOrderingKey::VmLifecycle { + connection_id: key_connection, + session_id: key_session, + vm_id: key_vm, + } + | RequestOrderingKey::VmOperation { + connection_id: key_connection, + session_id: key_session, + vm_id: key_vm, + } => { + key_connection == connection_id + && session_id.is_some_and(|id| id == key_session) + && vm_id.is_some_and(|id| id == key_vm) + } + RequestOrderingKey::Extension { + connection_id: key_connection, + .. + } => key_connection == connection_id, + RequestOrderingKey::Unordered => true, + }; + if valid { + Ok(()) + } else { + Err(OwnershipCoordinatorError::OwnershipMismatch { + expected: ownership_label(ownership), + actual: ordering_label(ordering), + }) + } +} + +fn ownership_label(ownership: &OwnershipScope) -> String { + let (connection_id, session_id, vm_id) = ownership_ids(ownership); + match (session_id, vm_id) { + (Some(session), Some(vm)) => format!("{connection_id}/{session}/{vm}"), + (Some(session), None) => format!("{connection_id}/{session}"), + _ => connection_id.to_owned(), + } +} + +fn ordering_label(ordering: &RequestOrderingKey) -> String { + match ordering { + RequestOrderingKey::Connection(connection) => connection.clone(), + RequestOrderingKey::Session { + connection_id, + session_id, + } => format!("{connection_id}/{session_id}"), + RequestOrderingKey::VmLifecycle { + connection_id, + session_id, + vm_id, + } + | RequestOrderingKey::VmOperation { + connection_id, + session_id, + vm_id, + } => format!("{connection_id}/{session_id}/{vm_id}"), + RequestOrderingKey::Extension { + namespace, + connection_id, + key, + .. + } => format!("{connection_id}/extension/{namespace}/{}-bytes", key.len()), + RequestOrderingKey::Unordered => String::from("unordered"), + } +} + +fn ensure_open(phase: CoordinatorPhase, scope: String) -> Result<(), OwnershipCoordinatorError> { + if phase == CoordinatorPhase::Open { + Ok(()) + } else { + Err(OwnershipCoordinatorError::Closing { scope, phase }) + } +} + +fn check_limit( + scope: &'static str, + current: usize, + limit: usize, + configuration_path: &'static str, +) -> Result<(), OwnershipCoordinatorError> { + if current >= limit { + return Err(OwnershipCoordinatorError::Limit { + scope, + current, + limit, + configuration_path, + }); + } + let after = current + 1; + if after.saturating_mul(10) >= limit.saturating_mul(8) { + tracing::warn!( + scope, + current = after, + limit, + configuration_path, + "agentOS ownership coordinator is near its configured bound" + ); + } + Ok(()) +} + +fn check_operation_limit( + operations: &BTreeMap, + class: OperationAdmissionClass, + limits: OwnershipCoordinatorLimits, + ordinary_scope: &'static str, + internal_event_scope: &'static str, +) -> Result<(), OwnershipCoordinatorError> { + let current = operation_count(operations, class); + match class { + OperationAdmissionClass::Ordinary => check_limit( + ordinary_scope, + current, + limits.max_operations_per_entity, + IN_FLIGHT_REQUEST_COUNT_PATH, + ), + OperationAdmissionClass::InternalEvent => check_limit( + internal_event_scope, + current, + limits.max_internal_event_operations_per_entity, + INTERNAL_EVENT_OPERATION_LIMIT_PATH, + ), + OperationAdmissionClass::DeferredInternalEvent => check_limit( + "deferred claimed internal-event operations", + current, + limits.max_internal_event_operations_per_entity, + INTERNAL_EVENT_OPERATION_LIMIT_PATH, + ), + } +} + +fn operation_count( + operations: &BTreeMap, + class: OperationAdmissionClass, +) -> usize { + operations + .values() + .filter(|operation| operation.class == class) + .count() +} + +fn signal_all( + operations: &BTreeMap, + reason: OperationCancellationReason, +) { + for operation in operations.values() { + operation.cancellation.signal(reason); + } +} + +fn take_id(next: &mut u64) -> u64 { + let id = *next; + *next = next.wrapping_add(1).max(1); + id +} + +fn lock<'a, T>(mutex: &'a Mutex, label: &str) -> MutexGuard<'a, T> { + mutex.lock().unwrap_or_else(|poisoned| { + tracing::error!( + coordinator = label, + "ERR_AGENTOS_OWNERSHIP_COORDINATOR_POISONED: recovering coordinator state" + ); + poisoned.into_inner() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::OwnershipScope; + use std::future::Future as _; + use std::task::{Context, Poll}; + + fn coordinator() -> OwnershipCoordinator { + OwnershipCoordinator::new(OwnershipCoordinatorLimits { + max_connections: 4, + max_sessions_per_connection: 4, + max_vms_per_session: 4, + max_operations_per_entity: 8, + max_internal_event_operations_per_entity: 8, + }) + } + + fn expect_internal_admitted(admission: InternalVmEventAdmission) -> CoordinatorOperationPermit { + match admission { + InternalVmEventAdmission::Admitted(permit) => permit, + InternalVmEventAdmission::Deferred(_) => { + panic!("expected active internal-event admission") + } + } + } + + fn expect_internal_deferred(admission: InternalVmEventAdmission) -> CoordinatorOperationPermit { + match admission { + InternalVmEventAdmission::Deferred(permit) => permit, + InternalVmEventAdmission::Admitted(_) => { + panic!("expected deferred internal-event registration") + } + } + } + + fn configured() -> ( + OwnershipCoordinator, + ConnectionCoordinator, + SessionCoordinator, + VmCoordinator, + VmCoordinator, + ) { + let coordinator = coordinator(); + let connection = coordinator + .register_connection("connection-a") + .expect("register connection"); + let session = connection.open_session("session-a").expect("open session"); + let vm_a = session.open_vm("vm-a").expect("open VM A"); + let vm_b = session.open_vm("vm-b").expect("open VM B"); + (coordinator, connection, session, vm_a, vm_b) + } + + fn vm_metadata(connection: &str, session: &str, vm: &str) -> RequestOperationMetadata { + RequestOperationMetadata::new( + OwnershipScope::vm(connection, session, vm), + "VM operation", + RequestOrderingKey::VmOperation { + connection_id: connection.to_owned(), + session_id: session.to_owned(), + vm_id: vm.to_owned(), + }, + ) + } + + fn lifecycle_metadata(connection: &str, session: &str, vm: &str) -> RequestOperationMetadata { + RequestOperationMetadata::new( + OwnershipScope::vm(connection, session, vm), + "VM lifecycle", + RequestOrderingKey::VmLifecycle { + connection_id: connection.to_owned(), + session_id: session.to_owned(), + vm_id: vm.to_owned(), + }, + ) + } + + fn extension_metadata(connection: &str, key: &[u8]) -> RequestOperationMetadata { + RequestOperationMetadata::new( + OwnershipScope::connection(connection), + "extension operation", + RequestOrderingKey::Extension { + namespace: String::from("test.extension"), + connection_id: connection.to_owned(), + key: key.to_vec(), + policy: ExtensionOrderingPolicy::CoreExclusive, + }, + ) + } + + #[tokio::test(flavor = "current_thread")] + async fn gated_vm_a_does_not_delay_vm_b() { + let (coordinator, _, _, vm_a, vm_b) = configured(); + let gate_a = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-a"), + OperationCancellation::new(), + ) + .await + .expect("VM A operation starts"); + assert_eq!(vm_a.snapshot().active_operations, 1); + + let operation_b = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-b"), + OperationCancellation::new(), + ) + .await + .expect("VM B operation starts independently"); + assert_eq!(vm_a.snapshot().active_operations, 1); + assert_eq!(vm_b.snapshot().active_operations, 1); + drop(operation_b); + drop(gate_a); + } + + #[tokio::test(flavor = "current_thread")] + async fn lifecycle_waits_for_same_vm_operation_and_excludes_new_work() { + let (coordinator, _, _, vm_a, _) = configured(); + let operation = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-a"), + OperationCancellation::new(), + ) + .await + .expect("VM operation starts"); + let lifecycle_cancel = OperationCancellation::new(); + let lifecycle_request = lifecycle_metadata("connection-a", "session-a", "vm-a"); + let mut lifecycle = Box::pin(coordinator.admit(&lifecycle_request, lifecycle_cancel)); + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!(matches!(lifecycle.as_mut().poll(&mut cx), Poll::Pending)); + assert_eq!(vm_a.snapshot().lifecycle, VmLifecyclePhase::Pending); + + let conflict = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-a"), + OperationCancellation::new(), + ) + .await + .expect_err("pending lifecycle excludes new VM operations"); + assert_eq!(conflict.code(), "ERR_AGENTOS_VM_LIFECYCLE_CONFLICT"); + + drop(operation); + let lifecycle = lifecycle + .await + .expect("lifecycle starts after prior operation drains"); + assert!(lifecycle.is_vm_lifecycle()); + assert_eq!(vm_a.snapshot().lifecycle, VmLifecyclePhase::Active); + drop(lifecycle); + + let after = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-a"), + OperationCancellation::new(), + ) + .await + .expect("VM reopens after lifecycle critical section"); + drop(after); + } + + #[tokio::test(flavor = "current_thread")] + async fn internal_vm_events_have_independent_bounds_and_remain_disposal_tracked() { + let coordinator = OwnershipCoordinator::new(OwnershipCoordinatorLimits { + max_connections: 1, + max_sessions_per_connection: 1, + max_vms_per_session: 1, + max_operations_per_entity: 1, + max_internal_event_operations_per_entity: 1, + }); + let connection = coordinator + .register_connection("connection-a") + .expect("register connection"); + let session = connection.open_session("session-a").expect("open session"); + let vm = session.open_vm("vm-a").expect("open VM"); + let metadata = vm_metadata("connection-a", "session-a", "vm-a"); + + let ordinary = coordinator + .admit(&metadata, OperationCancellation::new()) + .await + .expect("ordinary VM operation fills its independent bound"); + let internal = coordinator + .admit_internal_vm_event(&metadata, OperationCancellation::new()) + .map(expect_internal_admitted) + .expect("internal event bypasses saturated ordinary admission"); + assert_eq!(vm.snapshot().active_operations, 2); + + let deferred = coordinator + .admit_internal_vm_event(&metadata, OperationCancellation::new()) + .map(expect_internal_deferred) + .expect("saturated internal event remains disposal-tracked"); + assert_eq!(vm.snapshot().active_operations, 3); + let internal_limit = coordinator + .admit_internal_vm_event(&metadata, OperationCancellation::new()) + .expect_err("deferred internal-event tracking remains bounded"); + assert_eq!(internal_limit.code(), "ERR_AGENTOS_COORDINATOR_LIMIT"); + assert!( + internal_limit + .to_string() + .contains(INTERNAL_EVENT_OPERATION_LIMIT_PATH), + "internal event rejection must name its independent configuration path" + ); + + drop(deferred); + drop(internal); + let lifecycle_cancel = OperationCancellation::new(); + let mut lifecycle = Box::pin( + vm.begin_lifecycle(lifecycle_cancel) + .expect("begin VM lifecycle") + .wait(), + ); + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!(matches!(lifecycle.as_mut().poll(&mut cx), Poll::Pending)); + assert_eq!(vm.snapshot().lifecycle, VmLifecyclePhase::Pending); + + let internal_during_lifecycle = coordinator + .admit_internal_vm_event(&metadata, OperationCancellation::new()) + .map(expect_internal_admitted) + .expect("internal event bypasses pending VM lifecycle exclusion"); + drop(ordinary); + assert!(matches!(lifecycle.as_mut().poll(&mut cx), Poll::Pending)); + drop(internal_during_lifecycle); + let lifecycle = lifecycle + .await + .expect("lifecycle starts after internal event registration drains"); + drop(lifecycle); + + let disposal_cancellation = OperationCancellation::new(); + let disposal_tracked = coordinator + .admit_internal_vm_event(&metadata, disposal_cancellation.clone()) + .map(expect_internal_admitted) + .expect("internal event registers for disposal"); + let disposal = coordinator + .begin_vm_disposal( + &OwnershipScope::vm("connection-a", "session-a", "vm-a"), + OperationCancellationReason::ConnectionClosed, + ) + .expect("VM enters Closing"); + assert_eq!( + disposal_cancellation.reason(), + Some(OperationCancellationReason::ConnectionClosed), + "VM disposal must cancel internal event work" + ); + let rejected = coordinator + .admit_internal_vm_event(&metadata, OperationCancellation::new()) + .expect_err("closing VM rejects new internal events"); + assert_eq!(rejected.code(), "ERR_AGENTOS_COORDINATOR_CLOSING"); + + let mut drained = Box::pin(disposal.wait_drained()); + assert!(matches!(drained.as_mut().poll(&mut cx), Poll::Pending)); + drop(disposal_tracked); + drained.as_mut().await; + drop(drained); + disposal.complete().expect("complete drained VM disposal"); + assert!(session.vm("vm-a").is_err()); + } + + #[tokio::test(flavor = "current_thread")] + async fn deferred_internal_vm_event_is_cancelled_and_drained_by_disposal() { + let coordinator = OwnershipCoordinator::new(OwnershipCoordinatorLimits { + max_connections: 1, + max_sessions_per_connection: 1, + max_vms_per_session: 1, + max_operations_per_entity: 1, + max_internal_event_operations_per_entity: 1, + }); + let connection = coordinator + .register_connection("connection-a") + .expect("register connection"); + let session = connection.open_session("session-a").expect("open session"); + let _vm = session.open_vm("vm-a").expect("open VM"); + let metadata = vm_metadata("connection-a", "session-a", "vm-a"); + let active_cancellation = OperationCancellation::new(); + let active = coordinator + .admit_internal_vm_event(&metadata, active_cancellation.clone()) + .map(expect_internal_admitted) + .expect("fill active internal-event capacity"); + let deferred_cancellation = OperationCancellation::new(); + let deferred = coordinator + .admit_internal_vm_event(&metadata, deferred_cancellation.clone()) + .map(expect_internal_deferred) + .expect("track exact deferred internal event"); + + let disposal = coordinator + .begin_vm_disposal( + &OwnershipScope::vm("connection-a", "session-a", "vm-a"), + OperationCancellationReason::Explicit, + ) + .expect("begin VM disposal"); + assert_eq!( + active_cancellation.reason(), + Some(OperationCancellationReason::Explicit) + ); + assert_eq!( + deferred_cancellation.reason(), + Some(OperationCancellationReason::Explicit), + "the deferred claimed event must be cancelled exactly like active work" + ); + + let mut drained = Box::pin(disposal.wait_drained()); + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!(matches!(drained.as_mut().poll(&mut cx), Poll::Pending)); + drop(active); + assert!( + matches!(drained.as_mut().poll(&mut cx), Poll::Pending), + "disposal must not pass the exact deferred target" + ); + drop(deferred); + drained.as_mut().await; + drop(drained); + disposal + .complete() + .expect("deferred target release completes disposal"); + assert!(session.vm("vm-a").is_err()); + } + + #[tokio::test(flavor = "current_thread")] + async fn session_disposal_closes_admission_cancels_owned_work_and_drains() { + let (coordinator, connection, session, vm_a, _) = configured(); + let cancellation = OperationCancellation::new(); + let operation = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-a"), + cancellation.clone(), + ) + .await + .expect("owned operation starts"); + let disposal = coordinator + .begin_session_disposal( + &OwnershipScope::session("connection-a", "session-a"), + OperationCancellationReason::ConnectionClosed, + ) + .expect("session enters Closing"); + assert_eq!(session.snapshot().phase, CoordinatorPhase::Closing); + assert_eq!(vm_a.snapshot().phase, CoordinatorPhase::Closing); + assert_eq!( + cancellation.reason(), + Some(OperationCancellationReason::ConnectionClosed) + ); + + let rejected = coordinator + .admit( + &vm_metadata("connection-a", "session-a", "vm-a"), + OperationCancellation::new(), + ) + .await + .expect_err("closing session rejects new owned operations"); + assert_eq!(rejected.code(), "ERR_AGENTOS_COORDINATOR_CLOSING"); + + let mut drained = Box::pin(disposal.wait_drained()); + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!(matches!(drained.as_mut().poll(&mut cx), Poll::Pending)); + drop(operation); + drained.as_mut().await; + drop(drained); + disposal.complete().expect("complete drained disposal"); + assert!(connection.session("session-a").is_err()); + } + + #[tokio::test(flavor = "current_thread")] + async fn cross_connection_ordering_key_cannot_address_owned_vm() { + let (coordinator, _, _, _, _) = configured(); + let connection_b = coordinator + .register_connection("connection-b") + .expect("register connection B"); + let session_b = connection_b.open_session("session-a").expect("session B"); + session_b.open_vm("vm-a").expect("VM B"); + + let metadata = RequestOperationMetadata::new( + OwnershipScope::vm("connection-b", "session-a", "vm-a"), + "forged operation", + RequestOrderingKey::VmOperation { + connection_id: String::from("connection-a"), + session_id: String::from("session-a"), + vm_id: String::from("vm-a"), + }, + ); + let error = coordinator + .admit(&metadata, OperationCancellation::new()) + .await + .expect_err("ordering key cannot cross connection ownership"); + assert_eq!(error.code(), "ERR_AGENTOS_COORDINATOR_OWNERSHIP"); + assert_eq!(connection_b.snapshot().active_operations, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn opaque_extension_ordering_keys_reject_only_same_connection_conflicts() { + let (coordinator, _, _, _, _) = configured(); + coordinator + .register_connection("connection-b") + .expect("register connection B"); + + let first = coordinator + .admit( + &extension_metadata("connection-a", b"same-key"), + OperationCancellation::new(), + ) + .await + .expect("first keyed operation starts"); + let same_key = coordinator + .admit( + &extension_metadata("connection-a", b"same-key"), + OperationCancellation::new(), + ) + .await + .expect_err("same connection and key conflict"); + assert_eq!(same_key.code(), "ERR_AGENTOS_ORDERING_CONFLICT"); + + let different_key = coordinator + .admit( + &extension_metadata("connection-a", b"different-key"), + OperationCancellation::new(), + ) + .await + .expect("different key progresses concurrently"); + let different_connection = coordinator + .admit( + &extension_metadata("connection-b", b"same-key"), + OperationCancellation::new(), + ) + .await + .expect("same key is isolated by connection"); + + drop(different_connection); + drop(different_key); + drop(first); + coordinator + .admit( + &extension_metadata("connection-a", b"same-key"), + OperationCancellation::new(), + ) + .await + .expect("key is released with terminal ownership permit"); + } + + #[tokio::test(flavor = "current_thread")] + async fn opaque_extension_key_cannot_cross_connection_ownership() { + let (coordinator, _, _, _, _) = configured(); + let metadata = RequestOperationMetadata::new( + OwnershipScope::connection("connection-a"), + "forged extension operation", + RequestOrderingKey::Extension { + namespace: String::from("test.extension"), + connection_id: String::from("connection-b"), + key: b"key".to_vec(), + policy: ExtensionOrderingPolicy::CoreExclusive, + }, + ); + let error = coordinator + .admit(&metadata, OperationCancellation::new()) + .await + .expect_err("extension ordering key cannot cross connection ownership"); + assert_eq!(error.code(), "ERR_AGENTOS_COORDINATOR_OWNERSHIP"); + } + + #[test] + fn coordinator_membership_and_operation_state_are_bounded() { + let coordinator = OwnershipCoordinator::new(OwnershipCoordinatorLimits { + max_connections: 1, + max_sessions_per_connection: 1, + max_vms_per_session: 1, + max_operations_per_entity: 1, + max_internal_event_operations_per_entity: 1, + }); + let connection = coordinator + .register_connection("connection-a") + .expect("first connection"); + let error = coordinator + .register_connection("connection-b") + .expect_err("connection bound"); + assert_eq!(error.code(), "ERR_AGENTOS_COORDINATOR_LIMIT"); + assert!(error.to_string().contains(CONNECTION_LIMIT_PATH)); + + let active = connection + .register_operation(OperationCancellation::new()) + .expect("first connection operation"); + let error = connection + .register_operation(OperationCancellation::new()) + .expect_err("per-entity operation bound"); + assert!(error.to_string().contains(IN_FLIGHT_REQUEST_COUNT_PATH)); + drop(active); + + let session = connection.open_session("session-a").expect("first session"); + let error = connection + .open_session("session-b") + .expect_err("session bound"); + assert!(error.to_string().contains(SESSION_LIMIT_PATH)); + session.open_vm("vm-a").expect("first VM"); + let error = session.open_vm("vm-b").expect_err("VM bound"); + assert!(error.to_string().contains(VM_LIMIT_PATH)); + } +} diff --git a/crates/native-sidecar/src/process_event_broker.rs b/crates/native-sidecar/src/process_event_broker.rs new file mode 100644 index 0000000000..2282b54935 --- /dev/null +++ b/crates/native-sidecar/src/process_event_broker.rs @@ -0,0 +1,1419 @@ +//! Bounded ownership-aware routing for durable process events. +//! +//! Producers publish through [`ProcessEventIngress`]. One broker driver owns +//! the receiver and moves envelopes into target-specific durable queues. A +//! consumer keeps an [`ProcessEventWaiter`] lease while it owns a target; its +//! async wait retains no `NativeSidecar`, VM, or coordinator state. + +use crate::request_operations::{OperationCancellation, OperationCancellationReason}; +use crate::state::ProcessEventEnvelope; +use crate::wire::OwnershipScope; +use agentos_runtime::RuntimeConfig; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use tokio::sync::{mpsc, Notify, OwnedSemaphorePermit, Semaphore}; + +pub(crate) const PROCESS_EVENT_LIMIT_PATH: &str = "runtime.protocol.maxProcessEvents"; +pub(crate) const PROCESS_EVENT_CONSUMER_LIMIT_PATH: &str = "runtime.protocol.maxInFlightRequests"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProcessEventBrokerLimits { + pub(crate) max_events: usize, + pub(crate) max_consumers: usize, +} + +impl ProcessEventBrokerLimits { + pub(crate) fn from_runtime_config(config: &RuntimeConfig) -> Self { + Self { + max_events: config.protocol.max_process_events, + max_consumers: config.protocol.max_in_flight_requests, + } + } + + fn validate(self) -> Result { + if self.max_events == 0 { + return Err(ProcessEventBrokerError::InvalidLimit { + configuration_path: PROCESS_EVENT_LIMIT_PATH, + }); + } + if self.max_consumers == 0 { + return Err(ProcessEventBrokerError::InvalidLimit { + configuration_path: PROCESS_EVENT_CONSUMER_LIMIT_PATH, + }); + } + Ok(self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProcessEventTarget { + pub(crate) connection_id: String, + pub(crate) session_id: String, + pub(crate) vm_id: String, + pub(crate) process_id: String, +} + +impl ProcessEventTarget { + pub(crate) fn new( + connection_id: impl Into, + session_id: impl Into, + vm_id: impl Into, + process_id: impl Into, + ) -> Result { + let target = Self { + connection_id: connection_id.into(), + session_id: session_id.into(), + vm_id: vm_id.into(), + process_id: process_id.into(), + }; + target.validate()?; + Ok(target) + } + + pub(crate) fn for_owned_process( + ownership: &OwnershipScope, + process_id: impl Into, + ) -> Result { + let OwnershipScope::VmOwnership(scope) = ownership else { + return Err(ProcessEventBrokerError::ProcessTargetRequiresVmOwnership); + }; + Self::new( + scope.connection_id.clone(), + scope.session_id.clone(), + scope.vm_id.clone(), + process_id, + ) + } + + fn from_envelope(envelope: &ProcessEventEnvelope) -> Result { + Self::new( + envelope.connection_id.clone(), + envelope.session_id.clone(), + envelope.vm_id.clone(), + envelope.process_id.clone(), + ) + } + + fn validate(&self) -> Result<(), ProcessEventBrokerError> { + for (field, value) in [ + ("connection_id", self.connection_id.as_str()), + ("session_id", self.session_id.as_str()), + ("vm_id", self.vm_id.as_str()), + ("process_id", self.process_id.as_str()), + ] { + if value.is_empty() { + return Err(ProcessEventBrokerError::InvalidTarget { field }); + } + } + Ok(()) + } + + fn is_owned_by(&self, ownership: &OwnershipScope) -> bool { + match ownership { + OwnershipScope::ConnectionOwnership(scope) => scope.connection_id == self.connection_id, + OwnershipScope::SessionOwnership(scope) => { + scope.connection_id == self.connection_id && scope.session_id == self.session_id + } + OwnershipScope::VmOwnership(scope) => { + scope.connection_id == self.connection_id + && scope.session_id == self.session_id + && scope.vm_id == self.vm_id + } + } + } + + fn ownership_description(&self) -> String { + format!( + "{}/{}/{}/{}", + self.connection_id, self.session_id, self.vm_id, self.process_id + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ProcessEventBrokerError { + InvalidLimit { + configuration_path: &'static str, + }, + Limit { + resource: &'static str, + current: usize, + limit: usize, + configuration_path: &'static str, + }, + InvalidTarget { + field: &'static str, + }, + OwnershipMismatch { + target: String, + }, + ProcessTargetRequiresVmOwnership, + DuplicateConsumer { + target: String, + }, + StaleConsumer { + target: String, + }, + Cancelled { + reason: OperationCancellationReason, + }, + Closed { + reason: OperationCancellationReason, + }, + Poisoned, +} + +impl ProcessEventBrokerError { + pub(crate) fn code(&self) -> &'static str { + match self { + Self::InvalidLimit { .. } => "ERR_AGENTOS_PROCESS_EVENT_BROKER_CONFIG", + Self::Limit { .. } => "ERR_AGENTOS_PROCESS_EVENT_BROKER_LIMIT", + Self::InvalidTarget { .. } => "ERR_AGENTOS_PROCESS_EVENT_TARGET", + Self::OwnershipMismatch { .. } => "ERR_AGENTOS_PROCESS_EVENT_OWNERSHIP", + Self::ProcessTargetRequiresVmOwnership => { + "ERR_AGENTOS_PROCESS_EVENT_VM_OWNERSHIP_REQUIRED" + } + Self::DuplicateConsumer { .. } => "ERR_AGENTOS_PROCESS_EVENT_CONSUMER_EXISTS", + Self::StaleConsumer { .. } => "ERR_AGENTOS_PROCESS_EVENT_CONSUMER_STALE", + Self::Cancelled { .. } => "ERR_AGENTOS_PROCESS_EVENT_CANCELLED", + Self::Closed { .. } => "ERR_AGENTOS_PROCESS_EVENT_BROKER_CLOSED", + Self::Poisoned => "ERR_AGENTOS_PROCESS_EVENT_BROKER_POISONED", + } + } +} + +impl fmt::Display for ProcessEventBrokerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLimit { configuration_path } => write!( + formatter, + "{}: {configuration_path} must be greater than zero", + self.code() + ), + Self::Limit { + resource, + current, + limit, + configuration_path, + } => write!( + formatter, + "{}: {resource} used {current}, limit {limit}; raise {configuration_path}", + self.code() + ), + Self::InvalidTarget { field } => { + write!(formatter, "{}: target {field} is empty", self.code()) + } + Self::OwnershipMismatch { target } => write!( + formatter, + "{}: ownership does not cover process target {target}", + self.code() + ), + Self::ProcessTargetRequiresVmOwnership => write!( + formatter, + "{}: a process-targeted waiter requires VM ownership", + self.code() + ), + Self::DuplicateConsumer { target } => write!( + formatter, + "{}: process target {target} already has a consumer", + self.code() + ), + Self::StaleConsumer { target } => write!( + formatter, + "{}: process target {target} no longer belongs to this consumer", + self.code() + ), + Self::Cancelled { reason } => { + write!(formatter, "{}: waiter cancelled ({reason:?})", self.code()) + } + Self::Closed { reason } => { + write!(formatter, "{}: broker closed ({reason:?})", self.code()) + } + Self::Poisoned => write!(formatter, "{}: broker state lock poisoned", self.code()), + } + } +} + +impl std::error::Error for ProcessEventBrokerError {} + +#[derive(Debug)] +pub(crate) struct ProcessEventPublishFailure { + pub(crate) error: ProcessEventBrokerError, + pub(crate) envelope: ProcessEventEnvelope, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ProcessEventBrokerSnapshot { + pub(crate) pending_events: usize, + pub(crate) active_consumers: usize, + pub(crate) retained_targets: usize, + /// Includes ingress frames and durable pending events. Both share one + /// semaphore, so this can never exceed the configured event limit. + pub(crate) event_budget_in_use: usize, + pub(crate) wake_signals: u64, + pub(crate) delivered_events: u64, + pub(crate) cancelled_consumers: u64, + pub(crate) disposed_events: u64, + pub(crate) closed: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProcessEventBroker { + inner: Arc, +} + +#[derive(Debug)] +struct ProcessEventBrokerInner { + limits: ProcessEventBrokerLimits, + event_budget: Arc, + state: Mutex, + closed_notify: Notify, +} + +#[derive(Debug, Default)] +struct ProcessEventBrokerState { + pending: BTreeMap>, + consumers: BTreeMap, + /// Process targets reserved for targeted routing. Claims outlive an + /// individual one-shot poll so the generic public event pump cannot steal + /// an adapter response between sequential polls. + claimed_targets: BTreeSet, + next_generation: u64, + wake_signals: u64, + delivered_events: u64, + cancelled_consumers: u64, + disposed_events: u64, + closed: Option, +} + +#[derive(Debug)] +struct ConsumerSlot { + generation: u64, + notify: Arc, + cancellation: OperationCancellation, +} + +#[derive(Debug)] +struct RetainedProcessEvent { + envelope: ProcessEventEnvelope, + _permit: OwnedSemaphorePermit, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProcessEventIngress { + sender: mpsc::Sender, + event_budget: Arc, + max_events: usize, +} + +#[derive(Debug)] +pub(crate) struct ProcessEventBrokerDriver { + broker: ProcessEventBroker, + receiver: mpsc::Receiver, +} + +#[derive(Debug)] +pub(crate) struct ProcessEventWaiter { + broker: Weak, + target: ProcessEventTarget, + generation: u64, + notify: Arc, + cancellation: OperationCancellation, +} + +/// Checked-out event that remains charged to the broker until handling is +/// committed. Dropping an uncommitted lease requeues the exact envelope at the +/// front of its target queue, which makes cancellation between wake and +/// coordinator admission lossless. +#[derive(Debug)] +pub(crate) struct ProcessEventLease { + broker: Weak, + target: ProcessEventTarget, + retained: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProcessEventWaiterCancellation { + broker: Weak, + target: ProcessEventTarget, + generation: u64, + cancellation: OperationCancellation, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ProcessEventDisposalReport { + pub(crate) cancelled_consumers: usize, + pub(crate) discarded_events: usize, +} + +enum ConsumerProbe { + Event(RetainedProcessEvent), + Pending, +} + +impl ProcessEventBroker { + pub(crate) fn max_events(&self) -> usize { + self.inner.limits.max_events + } + + pub(crate) fn new( + config: &RuntimeConfig, + ) -> Result<(Self, ProcessEventIngress, ProcessEventBrokerDriver), ProcessEventBrokerError> + { + Self::with_limits(ProcessEventBrokerLimits::from_runtime_config(config)) + } + + pub(crate) fn with_limits( + limits: ProcessEventBrokerLimits, + ) -> Result<(Self, ProcessEventIngress, ProcessEventBrokerDriver), ProcessEventBrokerError> + { + let limits = limits.validate()?; + let event_budget = Arc::new(Semaphore::new(limits.max_events)); + let (sender, receiver) = mpsc::channel(limits.max_events); + let broker = Self { + inner: Arc::new(ProcessEventBrokerInner { + limits, + event_budget: Arc::clone(&event_budget), + state: Mutex::new(ProcessEventBrokerState::default()), + closed_notify: Notify::new(), + }), + }; + let ingress = ProcessEventIngress { + sender, + event_budget, + max_events: limits.max_events, + }; + let driver = ProcessEventBrokerDriver { + broker: broker.clone(), + receiver, + }; + Ok((broker, ingress, driver)) + } + + pub(crate) fn register_waiter( + &self, + ownership: &OwnershipScope, + target: ProcessEventTarget, + cancellation: OperationCancellation, + ) -> Result { + target.validate()?; + if !target.is_owned_by(ownership) { + return Err(ProcessEventBrokerError::OwnershipMismatch { + target: target.ownership_description(), + }); + } + let mut state = self.lock_state()?; + if let Some(reason) = state.closed { + return Err(ProcessEventBrokerError::Closed { reason }); + } + if state.consumers.contains_key(&target) { + return Err(ProcessEventBrokerError::DuplicateConsumer { + target: target.ownership_description(), + }); + } + if state.consumers.len() >= self.inner.limits.max_consumers { + return Err(ProcessEventBrokerError::Limit { + resource: "process event consumers", + current: state.consumers.len(), + limit: self.inner.limits.max_consumers, + configuration_path: PROCESS_EVENT_CONSUMER_LIMIT_PATH, + }); + } + state.next_generation = state.next_generation.wrapping_add(1).max(1); + let generation = state.next_generation; + let notify = Arc::new(Notify::new()); + state.consumers.insert( + target.clone(), + ConsumerSlot { + generation, + notify: Arc::clone(¬ify), + cancellation: cancellation.clone(), + }, + ); + Ok(ProcessEventWaiter { + broker: Arc::downgrade(&self.inner), + target, + generation, + notify, + cancellation, + }) + } + + pub(crate) fn claim_target( + &self, + ownership: &OwnershipScope, + target: ProcessEventTarget, + ) -> Result { + target.validate()?; + if !target.is_owned_by(ownership) { + return Err(ProcessEventBrokerError::OwnershipMismatch { + target: target.ownership_description(), + }); + } + let mut state = self.lock_state()?; + if let Some(reason) = state.closed { + return Err(ProcessEventBrokerError::Closed { reason }); + } + if state.claimed_targets.contains(&target) { + return Ok(false); + } + if state.claimed_targets.len() >= self.inner.limits.max_events { + return Err(ProcessEventBrokerError::Limit { + resource: "claimed process event targets", + current: state.claimed_targets.len(), + limit: self.inner.limits.max_events, + configuration_path: PROCESS_EVENT_LIMIT_PATH, + }); + } + state.claimed_targets.insert(target); + Ok(true) + } + + pub(crate) fn target_is_claimed( + &self, + target: &ProcessEventTarget, + ) -> Result { + Ok(self.lock_state()?.claimed_targets.contains(target)) + } + + pub(crate) fn dispose_connection( + &self, + connection_id: &str, + reason: OperationCancellationReason, + ) -> Result { + self.dispose_matching(|target| target.connection_id == connection_id, reason) + } + + pub(crate) fn dispose_session( + &self, + connection_id: &str, + session_id: &str, + reason: OperationCancellationReason, + ) -> Result { + self.dispose_matching( + |target| target.connection_id == connection_id && target.session_id == session_id, + reason, + ) + } + + pub(crate) fn dispose_vm( + &self, + connection_id: &str, + session_id: &str, + vm_id: &str, + reason: OperationCancellationReason, + ) -> Result { + self.dispose_matching( + |target| { + target.connection_id == connection_id + && target.session_id == session_id + && target.vm_id == vm_id + }, + reason, + ) + } + + pub(crate) fn dispose_process( + &self, + target: &ProcessEventTarget, + reason: OperationCancellationReason, + ) -> Result { + self.dispose_matching(|candidate| candidate == target, reason) + } + + pub(crate) fn shutdown( + &self, + reason: OperationCancellationReason, + ) -> Result { + let report = { + let mut state = self.lock_state()?; + state.closed.get_or_insert(reason); + // Close admission before releasing retained permits. Otherwise a + // producer already waiting for capacity can acquire the first + // disposal-released permit and race one last enqueue past shutdown. + self.inner.event_budget.close(); + Self::dispose_matching_state(&mut state, |_| true, reason) + }; + // Wake the receiver owner as well as producers parked on the shared + // ingress/pending budget. + self.inner.closed_notify.notify_waiters(); + Ok(report) + } + + pub(crate) fn snapshot(&self) -> Result { + let state = self.lock_state()?; + let retained_targets = state + .pending + .keys() + .chain(state.consumers.keys()) + .chain(state.claimed_targets.iter()) + .collect::>() + .len(); + Ok(ProcessEventBrokerSnapshot { + pending_events: state.pending.values().map(VecDeque::len).sum(), + active_consumers: state.consumers.len(), + retained_targets, + event_budget_in_use: self + .inner + .limits + .max_events + .saturating_sub(self.inner.event_budget.available_permits()), + wake_signals: state.wake_signals, + delivered_events: state.delivered_events, + cancelled_consumers: state.cancelled_consumers, + disposed_events: state.disposed_events, + closed: state.closed.is_some(), + }) + } + + fn route(&self, retained: RetainedProcessEvent) -> Result<(), ProcessEventBrokerError> { + let target = ProcessEventTarget::from_envelope(&retained.envelope)?; + let mut state = self.lock_state()?; + if let Some(reason) = state.closed { + return Err(ProcessEventBrokerError::Closed { reason }); + } + let was_empty = state.pending.get(&target).is_none_or(VecDeque::is_empty); + state + .pending + .entry(target.clone()) + .or_default() + .push_back(retained); + if was_empty { + let notify = state + .consumers + .get(&target) + .map(|consumer| Arc::clone(&consumer.notify)); + if let Some(notify) = notify { + state.wake_signals = state.wake_signals.saturating_add(1); + notify.notify_one(); + } + } + Ok(()) + } + + fn probe_consumer( + &self, + target: &ProcessEventTarget, + generation: u64, + ) -> Result { + let mut state = self.lock_state()?; + if let Some(reason) = state.closed { + return Err(ProcessEventBrokerError::Closed { reason }); + } + let current = state.consumers.get(target); + if current.map(|slot| slot.generation) != Some(generation) { + return Err(ProcessEventBrokerError::StaleConsumer { + target: target.ownership_description(), + }); + } + if let Some(reason) = current.and_then(|slot| slot.cancellation.reason()) { + return Err(ProcessEventBrokerError::Cancelled { reason }); + } + let retained = state.pending.get_mut(target).and_then(VecDeque::pop_front); + if state.pending.get(target).is_some_and(VecDeque::is_empty) { + state.pending.remove(target); + } + if let Some(retained) = retained { + Ok(ConsumerProbe::Event(retained)) + } else { + Ok(ConsumerProbe::Pending) + } + } + + fn cancel_consumer( + &self, + target: &ProcessEventTarget, + generation: u64, + reason: OperationCancellationReason, + ) -> Result { + let mut state = self.lock_state()?; + let matching = state + .consumers + .get(target) + .is_some_and(|slot| slot.generation == generation); + if !matching { + return Ok(false); + } + let Some(slot) = state.consumers.remove(target) else { + return Ok(false); + }; + slot.cancellation.signal(reason); + slot.notify.notify_waiters(); + state.cancelled_consumers = state.cancelled_consumers.saturating_add(1); + Ok(true) + } + + fn unregister_consumer( + &self, + target: &ProcessEventTarget, + generation: u64, + ) -> Result { + let mut state = self.lock_state()?; + if state + .consumers + .get(target) + .is_some_and(|slot| slot.generation == generation) + { + state.consumers.remove(target); + Ok(true) + } else { + Ok(false) + } + } + + fn dispose_matching( + &self, + matches: F, + reason: OperationCancellationReason, + ) -> Result + where + F: Fn(&ProcessEventTarget) -> bool, + { + let mut state = self.lock_state()?; + Ok(Self::dispose_matching_state(&mut state, matches, reason)) + } + + fn dispose_matching_state( + state: &mut ProcessEventBrokerState, + matches: F, + reason: OperationCancellationReason, + ) -> ProcessEventDisposalReport + where + F: Fn(&ProcessEventTarget) -> bool, + { + let consumer_targets = state + .consumers + .keys() + .filter(|target| matches(target)) + .cloned() + .collect::>(); + for target in &consumer_targets { + if let Some(slot) = state.consumers.remove(target) { + slot.cancellation.signal(reason); + slot.notify.notify_waiters(); + } + } + let event_targets = state + .pending + .keys() + .filter(|target| matches(target)) + .cloned() + .collect::>(); + let discarded_events = event_targets + .iter() + .filter_map(|target| state.pending.remove(target)) + .map(|events| events.len()) + .sum::(); + state.claimed_targets.retain(|target| !matches(target)); + state.cancelled_consumers = state + .cancelled_consumers + .saturating_add(consumer_targets.len() as u64); + state.disposed_events = state + .disposed_events + .saturating_add(discarded_events as u64); + ProcessEventDisposalReport { + cancelled_consumers: consumer_targets.len(), + discarded_events, + } + } + + fn closed_reason( + &self, + ) -> Result, ProcessEventBrokerError> { + Ok(self.lock_state()?.closed) + } + + fn record_discarded_ingress(&self, count: usize) -> Result<(), ProcessEventBrokerError> { + let mut state = self.lock_state()?; + state.disposed_events = state.disposed_events.saturating_add(count as u64); + Ok(()) + } + + fn record_delivered(&self) -> Result<(), ProcessEventBrokerError> { + let mut state = self.lock_state()?; + state.delivered_events = state.delivered_events.saturating_add(1); + Ok(()) + } + + fn requeue_lease( + &self, + target: ProcessEventTarget, + retained: RetainedProcessEvent, + ) -> Result<(), ProcessEventBrokerError> { + let mut state = self.lock_state()?; + if let Some(reason) = state.closed { + return Err(ProcessEventBrokerError::Closed { reason }); + } + let was_empty = state.pending.get(&target).is_none_or(VecDeque::is_empty); + state + .pending + .entry(target.clone()) + .or_default() + .push_front(retained); + if was_empty { + let notify = state + .consumers + .get(&target) + .map(|consumer| Arc::clone(&consumer.notify)); + if let Some(notify) = notify { + state.wake_signals = state.wake_signals.saturating_add(1); + notify.notify_one(); + } + } + Ok(()) + } + + fn lock_state( + &self, + ) -> Result, ProcessEventBrokerError> { + self.inner + .state + .lock() + .map_err(|_| ProcessEventBrokerError::Poisoned) + } +} + +impl ProcessEventIngress { + pub(crate) async fn publish( + &self, + envelope: ProcessEventEnvelope, + ) -> Result<(), ProcessEventPublishFailure> { + if let Err(error) = ProcessEventTarget::from_envelope(&envelope) { + return Err(ProcessEventPublishFailure { error, envelope }); + } + let permit = match Arc::clone(&self.event_budget).acquire_owned().await { + Ok(permit) => permit, + Err(_) => { + return Err(ProcessEventPublishFailure { + error: ProcessEventBrokerError::Closed { + reason: OperationCancellationReason::Shutdown, + }, + envelope, + }); + } + }; + match self + .sender + .send(RetainedProcessEvent { + envelope, + _permit: permit, + }) + .await + { + Ok(()) => Ok(()), + Err(error) => Err(ProcessEventPublishFailure { + error: ProcessEventBrokerError::Closed { + reason: OperationCancellationReason::Shutdown, + }, + envelope: error.0.envelope, + }), + } + } + + pub(crate) fn try_publish( + &self, + envelope: ProcessEventEnvelope, + ) -> Result<(), ProcessEventPublishFailure> { + if let Err(error) = ProcessEventTarget::from_envelope(&envelope) { + return Err(ProcessEventPublishFailure { error, envelope }); + } + let permit = match Arc::clone(&self.event_budget).try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + return Err(ProcessEventPublishFailure { + error: ProcessEventBrokerError::Limit { + resource: "process events", + current: self.max_events, + limit: self.max_events, + configuration_path: PROCESS_EVENT_LIMIT_PATH, + }, + envelope, + }); + } + }; + match self.sender.try_send(RetainedProcessEvent { + envelope, + _permit: permit, + }) { + Ok(()) => Ok(()), + Err(error) => { + let retained = error.into_inner(); + let broker_error = if self.sender.is_closed() { + ProcessEventBrokerError::Closed { + reason: OperationCancellationReason::Shutdown, + } + } else { + ProcessEventBrokerError::Limit { + resource: "process event ingress", + current: self.max_events, + limit: self.max_events, + configuration_path: PROCESS_EVENT_LIMIT_PATH, + } + }; + Err(ProcessEventPublishFailure { + error: broker_error, + envelope: retained.envelope, + }) + } + } + } +} + +impl ProcessEventBrokerDriver { + pub(crate) async fn run(mut self) -> Result<(), ProcessEventBrokerError> { + loop { + // Register the shutdown edge before checking state so closure + // cannot land between the probe and the select. + let inner = Arc::clone(&self.broker.inner); + let closed = inner.closed_notify.notified(); + if self.broker.closed_reason()?.is_some() { + return self.drain_after_close().await; + } + tokio::select! { + biased; + _ = closed => return self.drain_after_close().await, + retained = self.receiver.recv() => { + let Some(retained) = retained else { + return Ok(()); + }; + match self.broker.route(retained) { + Ok(()) => {} + Err(ProcessEventBrokerError::Closed { .. }) => { + self.broker.record_discarded_ingress(1)?; + return self.drain_after_close().await; + } + Err(error) => return Err(error), + } + } + } + } + } + + async fn drain_after_close(&mut self) -> Result<(), ProcessEventBrokerError> { + self.receiver.close(); + let mut discarded = 0usize; + while let Some(_retained) = self.receiver.recv().await { + discarded = discarded.saturating_add(1); + } + if discarded > 0 { + self.broker.record_discarded_ingress(discarded)?; + } + Ok(()) + } +} + +impl ProcessEventWaiter { + pub(crate) fn cancellation_handle(&self) -> ProcessEventWaiterCancellation { + ProcessEventWaiterCancellation { + broker: self.broker.clone(), + target: self.target.clone(), + generation: self.generation, + cancellation: self.cancellation.clone(), + } + } + + pub(crate) async fn next(&self) -> Result { + self.next_lease().await?.commit() + } + + pub(crate) async fn next_lease(&self) -> Result { + loop { + // Register both async edges before probing durable state so neither + // an event nor cancellation can be lost between probe and await. + let notified = self.notify.notified(); + let cancelled = self.cancellation.cancelled(); + if let Some(reason) = self.cancellation.reason() { + return Err(ProcessEventBrokerError::Cancelled { reason }); + } + let Some(inner) = self.broker.upgrade() else { + return Err(ProcessEventBrokerError::Closed { + reason: OperationCancellationReason::Shutdown, + }); + }; + let broker = ProcessEventBroker { inner }; + match broker.probe_consumer(&self.target, self.generation) { + Ok(ConsumerProbe::Event(retained)) => { + return Ok(ProcessEventLease { + broker: self.broker.clone(), + target: self.target.clone(), + retained: Some(retained), + }); + } + Ok(ConsumerProbe::Pending) => {} + Err(ProcessEventBrokerError::StaleConsumer { .. }) => { + if let Some(reason) = self.cancellation.reason() { + return Err(ProcessEventBrokerError::Cancelled { reason }); + } + return Err(ProcessEventBrokerError::StaleConsumer { + target: self.target.ownership_description(), + }); + } + Err(error) => return Err(error), + } + tokio::select! { + _ = notified => {} + reason = cancelled => { + return Err(ProcessEventBrokerError::Cancelled { reason }); + } + } + } + } +} + +impl ProcessEventLease { + pub(crate) fn commit(mut self) -> Result { + let Some(inner) = self.broker.upgrade() else { + return Err(ProcessEventBrokerError::Closed { + reason: OperationCancellationReason::Shutdown, + }); + }; + ProcessEventBroker { inner }.record_delivered()?; + Ok(self + .retained + .take() + .expect("uncommitted process event lease") + .envelope) + } +} + +impl Drop for ProcessEventLease { + fn drop(&mut self) { + let Some(retained) = self.retained.take() else { + return; + }; + let Some(inner) = self.broker.upgrade() else { + return; + }; + if let Err(error) = + (ProcessEventBroker { inner }).requeue_lease(self.target.clone(), retained) + { + if !matches!(error, ProcessEventBrokerError::Closed { .. }) { + eprintln!("ERR_AGENTOS_PROCESS_EVENT_LEASE_REQUEUE: {error}"); + } + } + } +} + +impl Drop for ProcessEventWaiter { + fn drop(&mut self) { + let Some(inner) = self.broker.upgrade() else { + return; + }; + if let Err(error) = + (ProcessEventBroker { inner }).unregister_consumer(&self.target, self.generation) + { + eprintln!("ERR_AGENTOS_PROCESS_EVENT_WAITER_DROP: {error}"); + } + } +} + +impl ProcessEventWaiterCancellation { + pub(crate) fn cancel( + &self, + reason: OperationCancellationReason, + ) -> Result { + let Some(inner) = self.broker.upgrade() else { + self.cancellation.signal(reason); + return Ok(false); + }; + (ProcessEventBroker { inner }).cancel_consumer(&self.target, self.generation, reason) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::ActiveExecutionEvent; + use std::future::Future as _; + use std::task::{Context, Poll}; + use std::time::Duration; + + fn limits(max_events: usize, max_consumers: usize) -> ProcessEventBrokerLimits { + ProcessEventBrokerLimits { + max_events, + max_consumers, + } + } + + fn target(vm: &str, process: &str) -> ProcessEventTarget { + ProcessEventTarget::new("connection-a", "session-a", vm, process).expect("target") + } + + fn event(target: &ProcessEventTarget, value: u8) -> ProcessEventEnvelope { + ProcessEventEnvelope { + connection_id: target.connection_id.clone(), + session_id: target.session_id.clone(), + vm_id: target.vm_id.clone(), + process_id: target.process_id.clone(), + child_path: Vec::new(), + event: ActiveExecutionEvent::Stdout(vec![value]), + } + } + + fn stdout(envelope: ProcessEventEnvelope) -> Vec { + match envelope.event { + ActiveExecutionEvent::Stdout(bytes) => bytes, + other => panic!("expected stdout event, received {other:?}"), + } + } + + async fn wait_for_pending(broker: &ProcessEventBroker, expected: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if broker.snapshot().expect("snapshot").pending_events == expected { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("broker routed event"); + } + + #[test] + fn runtime_defaults_define_broker_bounds() { + let config = RuntimeConfig::default(); + let (broker, _ingress, _driver) = ProcessEventBroker::new(&config).expect("default broker"); + assert_eq!( + broker.inner.limits.max_events, + config.protocol.max_process_events + ); + assert_eq!( + broker.inner.limits.max_consumers, + config.protocol.max_in_flight_requests + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn long_waiter_on_vm_a_does_not_block_vm_b() { + let (broker, ingress, driver) = + ProcessEventBroker::with_limits(limits(4, 4)).expect("broker"); + let driver_task = tokio::spawn(driver.run()); + let target_a = target("vm-a", "process-a"); + let target_b = target("vm-b", "process-b"); + let waiter_a = broker + .register_waiter( + &OwnershipScope::vm("connection-a", "session-a", "vm-a"), + target_a, + OperationCancellation::new(), + ) + .expect("VM A waiter"); + let waiter_b = broker + .register_waiter( + &OwnershipScope::vm("connection-a", "session-a", "vm-b"), + target_b.clone(), + OperationCancellation::new(), + ) + .expect("VM B waiter"); + let mut blocked_a = Box::pin(waiter_a.next()); + let mut context = Context::from_waker(std::task::Waker::noop()); + assert!(matches!( + blocked_a.as_mut().poll(&mut context), + Poll::Pending + )); + + ingress + .publish(event(&target_b, 7)) + .await + .expect("publish VM B event"); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), waiter_b.next()) + .await + .expect("VM B waiter was independent") + .map(stdout) + .expect("VM B event"), + vec![7] + ); + assert!(matches!( + blocked_a.as_mut().poll(&mut context), + Poll::Pending + )); + drop(blocked_a); + drop(waiter_b); + drop(waiter_a); + drop(ingress); + driver_task.await.expect("join driver").expect("driver"); + } + + #[tokio::test(flavor = "current_thread")] + async fn waiter_wakes_only_for_matching_ownership_and_process() { + let (broker, ingress, driver) = + ProcessEventBroker::with_limits(limits(4, 4)).expect("broker"); + let driver_task = tokio::spawn(driver.run()); + let matching = target("vm-a", "process-a"); + let other_process = target("vm-a", "process-b"); + let other_vm = target("vm-b", "process-a"); + let waiter = broker + .register_waiter( + &OwnershipScope::vm("connection-a", "session-a", "vm-a"), + matching.clone(), + OperationCancellation::new(), + ) + .expect("matching waiter"); + let ownership_error = broker + .register_waiter( + &OwnershipScope::vm("connection-a", "session-a", "vm-b"), + matching.clone(), + OperationCancellation::new(), + ) + .expect_err("VM B cannot consume VM A process"); + assert_eq!( + ownership_error.code(), + "ERR_AGENTOS_PROCESS_EVENT_OWNERSHIP" + ); + + ingress + .publish(event(&other_process, 1)) + .await + .expect("publish other process"); + ingress + .publish(event(&other_vm, 2)) + .await + .expect("publish other VM"); + wait_for_pending(&broker, 2).await; + let mut waiting = Box::pin(waiter.next()); + let mut context = Context::from_waker(std::task::Waker::noop()); + assert!(matches!(waiting.as_mut().poll(&mut context), Poll::Pending)); + + ingress + .publish(event(&matching, 3)) + .await + .expect("publish match"); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("matching event wakes waiter") + .map(stdout) + .expect("matching event"), + vec![3] + ); + assert_eq!(broker.snapshot().expect("snapshot").pending_events, 2); + drop(waiter); + broker + .shutdown(OperationCancellationReason::Shutdown) + .expect("shutdown"); + drop(ingress); + driver_task.await.expect("join driver").expect("driver"); + } + + #[tokio::test(flavor = "current_thread")] + async fn durable_queue_coalesces_repeated_target_wakes() { + let (broker, ingress, driver) = + ProcessEventBroker::with_limits(limits(3, 1)).expect("broker"); + let driver_task = tokio::spawn(driver.run()); + let target = target("vm-a", "process-a"); + let waiter = broker + .register_waiter( + &OwnershipScope::vm("connection-a", "session-a", "vm-a"), + target.clone(), + OperationCancellation::new(), + ) + .expect("waiter"); + ingress + .publish(event(&target, 1)) + .await + .expect("first event"); + ingress + .publish(event(&target, 2)) + .await + .expect("second event"); + wait_for_pending(&broker, 2).await; + assert_eq!(broker.snapshot().expect("snapshot").wake_signals, 1); + assert_eq!( + stdout(waiter.next().await.expect("first routed event")), + vec![1] + ); + assert_eq!( + stdout(waiter.next().await.expect("second routed event")), + vec![2] + ); + drop(waiter); + drop(ingress); + driver_task.await.expect("join driver").expect("driver"); + } + + #[tokio::test(flavor = "current_thread")] + async fn dropped_checked_out_lease_requeues_before_sequential_response() { + let (broker, ingress, driver) = + ProcessEventBroker::with_limits(limits(2, 1)).expect("broker"); + let driver_task = tokio::spawn(driver.run()); + let target = target("vm-a", "process-a"); + let ownership = OwnershipScope::vm("connection-a", "session-a", "vm-a"); + assert!(broker + .claim_target(&ownership, target.clone()) + .expect("claim target")); + let waiter = broker + .register_waiter(&ownership, target.clone(), OperationCancellation::new()) + .expect("waiter"); + ingress + .publish(event(&target, 1)) + .await + .expect("first response"); + let lease = waiter.next_lease().await.expect("checked-out response"); + assert_eq!(broker.snapshot().expect("snapshot").event_budget_in_use, 1); + drop(lease); + assert_eq!( + stdout(waiter.next().await.expect("requeued first response")), + vec![1] + ); + drop(waiter); + + assert!(!broker + .claim_target(&ownership, target.clone()) + .expect("claim persists between polls")); + assert!(broker.target_is_claimed(&target).expect("claim snapshot")); + let sequential = broker + .register_waiter(&ownership, target.clone(), OperationCancellation::new()) + .expect("sequential waiter"); + ingress + .publish(event(&target, 2)) + .await + .expect("sequential response"); + assert_eq!( + stdout(sequential.next().await.expect("sequential response routed")), + vec![2] + ); + broker + .dispose_process(&target, OperationCancellationReason::Explicit) + .expect("dispose claimed process"); + assert!(!broker.target_is_claimed(&target).expect("claim released")); + drop(sequential); + drop(ingress); + driver_task.await.expect("join driver").expect("driver"); + } + + #[test] + fn duplicate_consumer_is_rejected_and_drop_releases_slot() { + let (broker, _ingress, _driver) = + ProcessEventBroker::with_limits(limits(2, 1)).expect("broker"); + let target = target("vm-a", "process-a"); + let ownership = OwnershipScope::vm("connection-a", "session-a", "vm-a"); + let first = broker + .register_waiter(&ownership, target.clone(), OperationCancellation::new()) + .expect("first waiter"); + let duplicate = broker + .register_waiter(&ownership, target.clone(), OperationCancellation::new()) + .expect_err("duplicate waiter rejected"); + assert_eq!( + duplicate.code(), + "ERR_AGENTOS_PROCESS_EVENT_CONSUMER_EXISTS" + ); + assert_eq!(broker.snapshot().expect("snapshot").active_consumers, 1); + drop(first); + assert_eq!(broker.snapshot().expect("snapshot").active_consumers, 0); + broker + .register_waiter(&ownership, target, OperationCancellation::new()) + .expect("slot reused after drop"); + } + + #[tokio::test(flavor = "current_thread")] + async fn cancellation_and_session_disposal_release_waiter_and_event_budget() { + let (broker, ingress, driver) = + ProcessEventBroker::with_limits(limits(2, 2)).expect("broker"); + let driver_task = tokio::spawn(driver.run()); + let process_a = target("vm-a", "process-a"); + let process_b = target("vm-a", "process-b"); + let ownership = OwnershipScope::vm("connection-a", "session-a", "vm-a"); + let waiter_a = broker + .register_waiter(&ownership, process_a.clone(), OperationCancellation::new()) + .expect("waiter A"); + let cancel_a = waiter_a.cancellation_handle(); + let waiting_a = tokio::spawn(async move { waiter_a.next().await }); + assert!(cancel_a + .cancel(OperationCancellationReason::Explicit) + .expect("cancel waiter")); + let cancelled = waiting_a + .await + .expect("join waiter") + .expect_err("cancelled"); + assert_eq!( + cancelled, + ProcessEventBrokerError::Cancelled { + reason: OperationCancellationReason::Explicit + } + ); + assert_eq!(broker.snapshot().expect("snapshot").active_consumers, 0); + + let waiter_b = broker + .register_waiter(&ownership, process_b.clone(), OperationCancellation::new()) + .expect("waiter B"); + ingress + .publish(event(&process_b, 9)) + .await + .expect("publish retained event"); + wait_for_pending(&broker, 1).await; + let report = broker + .dispose_session( + "connection-a", + "session-a", + OperationCancellationReason::ConnectionClosed, + ) + .expect("dispose session"); + assert_eq!( + report, + ProcessEventDisposalReport { + cancelled_consumers: 1, + discarded_events: 1 + } + ); + let disposed = waiter_b.next().await.expect_err("disposed"); + assert_eq!( + disposed, + ProcessEventBrokerError::Cancelled { + reason: OperationCancellationReason::ConnectionClosed + } + ); + let snapshot = broker.snapshot().expect("snapshot"); + assert_eq!(snapshot.active_consumers, 0); + assert_eq!(snapshot.pending_events, 0); + assert_eq!(snapshot.event_budget_in_use, 0); + drop(ingress); + driver_task.await.expect("join driver").expect("driver"); + } + + #[tokio::test(flavor = "current_thread")] + async fn shared_budget_bounds_ingress_and_pending_and_shutdown_wakes_producer() { + let (broker, ingress, driver) = + ProcessEventBroker::with_limits(limits(1, 1)).expect("broker"); + let driver_task = tokio::spawn(driver.run()); + let target = target("vm-a", "process-a"); + ingress + .publish(event(&target, 1)) + .await + .expect("fill event budget"); + wait_for_pending(&broker, 1).await; + let rejected = ingress + .try_publish(event(&target, 2)) + .expect_err("combined ingress and pending budget is full"); + assert_eq!( + rejected.error.code(), + "ERR_AGENTOS_PROCESS_EVENT_BROKER_LIMIT" + ); + assert_eq!(stdout(rejected.envelope), vec![2]); + + let blocked_ingress = ingress.clone(); + let blocked_target = target.clone(); + let blocked = + tokio::spawn(async move { blocked_ingress.publish(event(&blocked_target, 3)).await }); + tokio::task::yield_now().await; + broker + .shutdown(OperationCancellationReason::Shutdown) + .expect("shutdown broker"); + let rejected = tokio::time::timeout(Duration::from_secs(1), blocked) + .await + .expect("shutdown wakes producer") + .expect("join producer") + .expect_err("producer receives typed closure"); + assert_eq!( + rejected.error, + ProcessEventBrokerError::Closed { + reason: OperationCancellationReason::Shutdown + } + ); + assert_eq!(stdout(rejected.envelope), vec![3]); + assert_eq!(broker.snapshot().expect("snapshot").event_budget_in_use, 0); + drop(ingress); + driver_task.await.expect("join driver").expect("driver"); + } +} diff --git a/crates/native-sidecar/src/service.rs b/crates/native-sidecar/src/service.rs index 1532e11231..cf01c26fff 100644 --- a/crates/native-sidecar/src/service.rs +++ b/crates/native-sidecar/src/service.rs @@ -19,10 +19,15 @@ pub(crate) use crate::execution::{ }; use crate::extension::{ Extension, ExtensionBufferedProcessOutput, ExtensionContext, ExtensionFuture, ExtensionHost, - ExtensionSnapshot, + ExtensionServices, ExtensionSnapshot, +}; +use crate::filesystem::{ + guest_filesystem_call as filesystem_guest_filesystem_call, guest_filesystem_call_vm, }; -use crate::filesystem::guest_filesystem_call as filesystem_guest_filesystem_call; use crate::limits::DEFAULT_ACP_STDOUT_BUFFER_BYTE_LIMIT; +use crate::process_event_broker::{ + ProcessEventBroker, ProcessEventBrokerError, ProcessEventIngress, ProcessEventTarget, +}; use crate::protocol::{ CloseStdinRequest, DisposeReason, EventFrame, EventPayload, ExecuteRequest, ExtEnvelope, GuestFilesystemCallRequest, GuestFilesystemResultResponse, JavascriptChildProcessSpawnOptions, @@ -33,11 +38,12 @@ use crate::protocol::{ SidecarResponseTrackerError, SignalDispositionAction, StdinClosedResponse, StdinWrittenResponse, VmLifecycleState, WriteStdinRequest, }; +use crate::request_operations::OperationCancellationReason; use crate::state::{ ActiveExecutionEvent, BridgeError, ConnectionState, EventSinkTransport, JavascriptSocketFamily, JavascriptSocketPathContext, ProcessEventEnvelope, QuarantinedVmGeneration, SessionState, - SharedBridge, SharedEventSink, SharedSidecarRequestClient, SidecarRequestTransport, VmState, - EXECUTION_DRIVER_NAME, + SharedBridge, SharedEventSink, SharedSidecarRequestClient, SidecarRequestTransport, VmRegistry, + VmState, EXECUTION_DRIVER_NAME, }; use crate::NativeSidecarBridge; use agentos_bridge::queue_tracker::{register_queue, QueueGauge, TrackedLimit}; @@ -47,9 +53,8 @@ use agentos_bridge::{ NetworkAccess, NetworkPermissionRequest, StructuredEventRecord, }; use agentos_execution::{ - record_sync_bridge_request_observed, JavascriptExecutionEngine, JavascriptExecutionError, - JavascriptSyncRpcRequest, PythonExecutionEngine, PythonExecutionError, WasmExecutionEngine, - WasmExecutionError, + record_sync_bridge_request_observed, JavascriptExecutionError, JavascriptSyncRpcRequest, + PythonExecutionError, WasmExecutionError, }; use agentos_kernel::kernel::KernelError; use agentos_kernel::mount_plugin::{FileSystemPluginRegistry, PluginError}; @@ -76,11 +81,15 @@ use agentos_vm_config::{FsPermissionScope, PermissionMode, PermissionsPolicy}; use agentos_kernel::vfs::VfsError; use serde::Deserialize; use serde_json::{json, Value}; +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use std::fs; +use std::future::Future; use std::os::unix::fs::PermissionsExt; use std::path::{Component, Path, PathBuf}; +use std::pin::Pin; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll, Waker}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -134,7 +143,7 @@ fn wire_protocol_error(error: crate::wire::ProtocolCodecError) -> SidecarError { SidecarError::InvalidState(format!("invalid generated wire protocol frame: {error}")) } -fn wire_dispatch_result( +pub(crate) fn wire_dispatch_result( result: DispatchResult, ) -> Result { crate::wire::dispatch_result_from_compat(crate::wire::CompatDispatchResult { @@ -148,6 +157,251 @@ pub use agentos_native_sidecar_core::DispatchResult; // NativeSidecarConfig and SidecarError moved to crate::state pub use crate::state::{NativeSidecarConfig, SidecarError}; +/// An extension request detached from the mutable sidecar coordinator. +/// +/// The transport prepares this record while it has short-lived access to the +/// process registry, then executes it as an independently tracked task. Any +/// sidecar operation the extension needs is performed through the cloneable +/// service backend in its context rather than by retaining `&mut NativeSidecar`. +pub(crate) struct PreparedExtensionRequest { + request: RequestFrame, + namespace: String, + payload: Vec, + extension: Arc, + services: Arc, + snapshot: ExtensionSnapshot, +} + +pub(crate) struct CompletedExtensionRequest { + request: RequestFrame, + namespace: String, + result: Result, +} + +impl CompletedExtensionRequest { + pub(crate) fn failed(&self) -> bool { + self.result.is_err() + } +} + +impl PreparedExtensionRequest { + pub(crate) async fn execute(self) -> CompletedExtensionRequest { + let PreparedExtensionRequest { + request, + namespace, + payload, + extension, + services, + snapshot, + } = self; + let ctx = ExtensionContext::with_services(snapshot, services); + let result = { + let request_future = extension.handle_request(ctx, payload); + request_future.await + }; + CompletedExtensionRequest { + request, + namespace, + result, + } + } +} + +type PreparedRequestFuture = + Pin> + 'static>>; + +/// Bounded coordinator-side effects produced by one detached request. +/// +/// The owned operation records effects without retaining the central sidecar. +/// Completion drains them while the coordinator is briefly available, before +/// the terminal response is made visible. `Rc>` is intentional: the +/// unified protocol loop executes these non-`Send` futures on one local task. +#[derive(Clone)] +pub(crate) struct RequestCompletionEffects { + state: Rc>, +} + +struct RequestCompletionEffectState { + exited_process_ids: BTreeSet, + exited_process_limit: usize, + exited_process_limit_path: &'static str, + warned: bool, +} + +impl Default for RequestCompletionEffects { + fn default() -> Self { + Self::new(1, "limits.execution.maxCompletedExecutions") + } +} + +impl RequestCompletionEffects { + fn new(exited_process_limit: usize, exited_process_limit_path: &'static str) -> Self { + Self { + state: Rc::new(RefCell::new(RequestCompletionEffectState { + exited_process_ids: BTreeSet::new(), + exited_process_limit: exited_process_limit.max(1), + exited_process_limit_path, + warned: false, + })), + } + } + + pub(crate) fn record_exited_process(&self, process_id: &str) -> Result<(), SidecarError> { + let mut state = self.state.borrow_mut(); + if state.exited_process_ids.contains(process_id) { + return Ok(()); + } + let observed = state.exited_process_ids.len().saturating_add(1); + if observed > state.exited_process_limit { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_REQUEST_COMPLETION_EFFECT_LIMIT: exited-process completion effects observed={observed} limit={} limit_path={}; raise {}", + state.exited_process_limit, + state.exited_process_limit_path, + state.exited_process_limit_path, + ))); + } + state.exited_process_ids.insert(process_id.to_owned()); + let warning_threshold = state + .exited_process_limit + .saturating_mul(3) + .div_ceil(4) + .max(1); + if !state.warned && state.exited_process_ids.len() >= warning_threshold { + state.warned = true; + tracing::warn!( + limit = state.exited_process_limit, + limit_path = state.exited_process_limit_path, + used = state.exited_process_ids.len(), + "request completion exited-process effects are near their bound" + ); + } + Ok(()) + } + + pub(crate) fn remaining_exited_process_capacity(&self, reserved: usize) -> usize { + let state = self.state.borrow(); + state + .exited_process_limit + .saturating_sub(state.exited_process_ids.len()) + .saturating_sub(reserved) + } + + fn take_exited_processes(&self) -> BTreeSet { + std::mem::take(&mut self.state.borrow_mut().exited_process_ids) + } +} + +/// A non-extension request that no longer borrows the process coordinator. +/// +/// Preparation performs only the short ownership/state critical section needed +/// to clone or snapshot request inputs. The owned future may then run under the +/// request supervisor without an `Arc>>` or another +/// whole-sidecar lease. +#[derive(Clone)] +pub(crate) enum PreparedMembershipCommit { + Connection { + connection_id: String, + auth_token: String, + }, + Session { + connection_id: String, + session_id: String, + placement: crate::protocol::SidecarPlacement, + metadata: BTreeMap, + }, +} + +pub(crate) struct PreparedRequest { + request: RequestFrame, + operation: PreparedRequestFuture, + effects: RequestCompletionEffects, + committed_membership: Option, +} + +/// Terminal value returned by an independently executing [`PreparedRequest`]. +/// The original request is retained so success and failure both preserve the +/// request ID and ownership scope. +pub(crate) struct CompletedRequest { + request: RequestFrame, + result: Result, + effects: RequestCompletionEffects, + committed_membership: Option, +} + +impl PreparedRequest { + pub(crate) fn from_future(request: RequestFrame, operation: F) -> Self + where + F: Future> + 'static, + { + Self::from_future_with_effects(request, operation, RequestCompletionEffects::default()) + } + + fn from_future_with_effects( + request: RequestFrame, + operation: F, + effects: RequestCompletionEffects, + ) -> Self + where + F: Future> + 'static, + { + Self { + request, + operation: Box::pin(operation), + effects, + committed_membership: None, + } + } + + fn from_vm_command(request: RequestFrame, operation: F) -> Self + where + F: Future> + 'static, + { + Self::from_future(request, operation) + } + + fn failed(request: RequestFrame, error: SidecarError) -> Self { + Self::from_future(request, async move { Err(error) }) + } + + fn from_future_with_membership( + request: RequestFrame, + operation: F, + committed_membership: PreparedMembershipCommit, + ) -> Self + where + F: Future> + 'static, + { + let mut prepared = Self::from_future(request, operation); + prepared.committed_membership = Some(committed_membership); + prepared + } + + pub(crate) fn committed_membership(&self) -> Option<&PreparedMembershipCommit> { + self.committed_membership.as_ref() + } + + pub(crate) async fn execute(self) -> CompletedRequest { + let PreparedRequest { + request, + operation, + effects, + committed_membership, + } = self; + CompletedRequest { + request, + result: operation.await, + effects, + committed_membership, + } + } +} + +impl CompletedRequest { + pub(crate) fn failed(&self) -> bool { + self.result.is_err() + } +} + // SharedBridge struct and Clone impl moved to crate::state #[derive(Debug, Default, Deserialize)] @@ -668,9 +922,30 @@ where return false; } - // Stale queued events must still be drained through handle_process_event_envelope() - // so the sidecar can emit the expected fail-closed log when teardown wins the race. - let _ = sidecar; + let target = match ProcessEventTarget::new( + event.connection_id.clone(), + event.session_id.clone(), + event.vm_id.clone(), + event.process_id.clone(), + ) { + Ok(target) => target, + Err(error) => { + eprintln!("ERR_AGENTOS_PROCESS_EVENT_TARGET: {error}"); + return false; + } + }; + match sidecar.process_event_broker.target_is_claimed(&target) { + Ok(true) => return false, + Ok(false) => {} + Err(error) => { + eprintln!("ERR_AGENTOS_PROCESS_EVENT_CLAIM_CHECK: {error}"); + return false; + } + } + + // Stale unclaimed events must still be drained through + // handle_process_event_envelope() so the sidecar can emit the expected + // fail-closed log when teardown wins the race. true } @@ -747,16 +1022,13 @@ pub struct NativeSidecar { pub(crate) bridge: SharedBridge, pub(crate) mount_plugins: FileSystemPluginRegistry>, pub(crate) cache_root: PathBuf, - pub(crate) javascript_engine: JavascriptExecutionEngine, - pub(crate) python_engine: PythonExecutionEngine, - pub(crate) wasm_engine: WasmExecutionEngine, pub(crate) next_connection_id: usize, pub(crate) next_session_id: usize, pub(crate) next_vm_id: usize, pub(crate) next_sidecar_request_id: RequestId, pub(crate) connections: BTreeMap, pub(crate) sessions: BTreeMap, - pub(crate) vms: BTreeMap, + pub(crate) vms: VmRegistry, /// Detached generations whose asynchronous ownership has not reconciled. /// The combined active + quarantined generation count is admitted against /// `runtime.resources.maxCapabilities`, keeping this collection bounded. @@ -765,11 +1037,20 @@ pub struct NativeSidecar { pub(crate) process_event_sender: Sender, pub(crate) process_event_receiver: Option>, pub(crate) process_event_notify: Arc, + /// Ownership-aware handoff for process-targeted consumers. The legacy raw + /// receiver remains the compatibility source while callers migrate; a + /// short coordinator command transfers one matching envelope into this + /// broker before the caller performs its long wait independently. + pub(crate) process_event_broker: ProcessEventBroker, + pub(crate) process_event_ingress: ProcessEventIngress, /// The single process-level deadline task that wakes cooperative kernel /// zombie reaping. It is replaced only when a genuinely earlier deadline /// appears; never one task or OS thread per process. pub(crate) kernel_reaper_task: Option>, pub(crate) kernel_reaper_deadline: Option, + /// One receiver envelope whose temporary public-queue admission failed. + /// It is retried before reading the channel, preserving exact FIFO order. + pub(crate) deferred_process_event_envelope: Option, pub(crate) pending_process_events: VecDeque, pub(crate) pending_sidecar_responses: SidecarResponseTracker, pub(crate) outbound_sidecar_requests: VecDeque, @@ -783,6 +1064,9 @@ pub struct NativeSidecar { pub(crate) sidecar_requests: SharedSidecarRequestClient, pub(crate) event_sink: SharedEventSink, pub(crate) extensions: BTreeMap>, + /// Cloneable transport-agnostic services used by direct extension + /// dispatch. Extension futures never receive or retain `&mut NativeSidecar`. + pub(crate) extension_services: Option>, pub(crate) extension_sessions: BTreeMap<(String, String), ExtensionSessionResources>, pub(crate) extension_process_output_buffers: BTreeMap<(String, String), ExtensionBufferedProcessOutput>, @@ -870,6 +1154,11 @@ where "native sidecar expected_auth_token must not be empty", ))); } + if config.acp_termination_grace.is_zero() { + return Err(SidecarError::InvalidState(String::from( + "ERR_AGENTOS_RUNTIME_CONFIG: NativeSidecarConfig.acp_termination_grace must be greater than zero", + ))); + } let dns_resolver: agentos_kernel::dns::SharedDnsResolver = Arc::new( agentos_kernel::dns::HickoryDnsResolver::with_runtime(runtime_context.clone()), ); @@ -894,13 +1183,19 @@ where let (process_event_sender, process_event_receiver) = channel(protocol_limits.max_process_events); let process_event_notify = Arc::new(tokio::sync::Notify::new()); - let mut javascript_engine = JavascriptExecutionEngine::new(runtime_context.clone()); - javascript_engine.set_event_notify(Some(Arc::clone(&process_event_notify))); - let mut python_engine = PythonExecutionEngine::new(runtime_context.clone()); - python_engine.set_event_notify(Some(Arc::clone(&process_event_notify))); - let mut wasm_engine = WasmExecutionEngine::new(runtime_context.clone()); - wasm_engine.set_event_notify(Some(Arc::clone(&process_event_notify))); - + let (process_event_broker, process_event_ingress, process_event_broker_driver) = + ProcessEventBroker::new(&config.runtime).map_err(|error| { + SidecarError::InvalidState(format!( + "failed to initialize process event broker: {error}" + )) + })?; + runtime_context + .spawn(agentos_runtime::TaskClass::Runtime, async move { + if let Err(error) = process_event_broker_driver.run().await { + eprintln!("ERR_AGENTOS_PROCESS_EVENT_BROKER_DRIVER: {error}"); + } + }) + .map_err(SidecarError::from)?; Ok(Self { config, runtime_context: Some(runtime_context), @@ -908,22 +1203,22 @@ where bridge, mount_plugins, cache_root, - javascript_engine, - python_engine, - wasm_engine, next_connection_id: 0, next_session_id: 0, next_vm_id: 0, next_sidecar_request_id: -1, connections: BTreeMap::new(), sessions: BTreeMap::new(), - vms: BTreeMap::new(), + vms: VmRegistry::default(), quarantined_vms: BTreeMap::new(), process_event_sender, process_event_receiver: Some(process_event_receiver), process_event_notify, + process_event_broker, + process_event_ingress, kernel_reaper_task: None, kernel_reaper_deadline: None, + deferred_process_event_envelope: None, pending_process_events: VecDeque::new(), pending_sidecar_responses: SidecarResponseTracker::default(), outbound_sidecar_requests: VecDeque::new(), @@ -952,6 +1247,7 @@ where sidecar_requests: SharedSidecarRequestClient::default(), event_sink: SharedEventSink::default(), extensions: BTreeMap::new(), + extension_services: None, extension_sessions: BTreeMap::new(), extension_process_output_buffers: BTreeMap::new(), #[cfg(test)] @@ -1014,9 +1310,6 @@ where /// which was previously removed only on a successful handoff and leaked on VM /// or session disposal (M6). pub(crate) fn reclaim_vm_tracking(&mut self, session_id: &str, vm_id: &str) { - self.javascript_engine.dispose_vm(vm_id); - self.python_engine.dispose_vm(vm_id); - self.wasm_engine.dispose_vm(vm_id); self.prune_extension_vm_resource(vm_id); self.extension_process_output_buffers .retain(|(buffer_vm_id, _process_id), _| buffer_vm_id != vm_id); @@ -1025,6 +1318,84 @@ where } } + /// Probe buffered extension process output without retaining the sidecar + /// coordinator across the caller's wait. When `finalize_if_empty` is false, + /// `None` means the caller should wait on `process_event_notify` and probe + /// again. The final probe binds the process resource and transfers ownership + /// of the buffer even when no output arrived before the deadline. + pub(crate) async fn probe_extension_process_output_handoff( + &mut self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + finalize_if_empty: bool, + ) -> Result, SidecarError> { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let key = (vm_id.clone(), process_id.clone()); + self.pump_process_events(&ownership).await?; + while let Some(envelope) = self.take_matching_process_event_envelope(&vm_id, &process_id)? { + if self.capture_extension_process_output_event(&vm_id, &process_id, &envelope.event) { + continue; + } + self.queue_pending_process_event(envelope)?; + break; + } + let buffered = self + .extension_process_output_buffers + .get(&key) + .is_some_and(|buffer| !buffer.stdout.is_empty() || !buffer.stderr.is_empty()); + if !buffered && !finalize_if_empty { + return Ok(None); + } + self.bind_extension_process_resource(ownership, namespace, ext_session_id, process_id)?; + self.extension_process_output_buffers + .remove(&key) + .map(Some) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "extension process output buffering was not started", + )) + }) + } + + pub(crate) fn probe_extension_process_output_handoff_nowait( + &mut self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + process_id: String, + finalize_if_empty: bool, + ) -> Result, SidecarError> { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let key = (vm_id.clone(), process_id.clone()); + while let Some(envelope) = self.take_matching_process_event_envelope(&vm_id, &process_id)? { + if self.capture_extension_process_output_event(&vm_id, &process_id, &envelope.event) { + continue; + } + self.queue_pending_process_event(envelope)?; + break; + } + let buffered = self + .extension_process_output_buffers + .get(&key) + .is_some_and(|buffer| !buffer.stdout.is_empty() || !buffer.stderr.is_empty()); + if !buffered && !finalize_if_empty { + return Ok(None); + } + self.bind_extension_process_resource(ownership, namespace, ext_session_id, process_id)?; + self.extension_process_output_buffers + .remove(&key) + .map(Some) + .ok_or_else(|| { + SidecarError::InvalidState(String::from( + "extension process output buffering was not started", + )) + }) + } + pub(crate) fn reap_reconciled_quarantined_vms(&mut self) { let before = self.quarantined_vms.len(); let mut reaped = Vec::new(); @@ -1116,7 +1487,7 @@ where } } - fn bind_extension_process_resource( + pub(crate) fn bind_extension_process_resource( &mut self, ownership: OwnershipScope, namespace: String, @@ -1161,7 +1532,7 @@ where Ok(()) } - fn bind_extension_vm_resource( + pub(crate) fn bind_extension_vm_resource( &mut self, ownership: OwnershipScope, namespace: String, @@ -1196,6 +1567,56 @@ where Ok(()) } + pub(crate) fn start_buffering_process_output_nowait( + &mut self, + ownership: OwnershipScope, + process_id: String, + ) -> Result<(), SidecarError> { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let key = (vm_id, process_id); + if self.extension_process_output_buffers.contains_key(&key) { + return Err(SidecarError::Conflict(String::from( + "extension process output buffering already started", + ))); + } + self.extension_process_output_buffers + .insert(key, ExtensionBufferedProcessOutput::default()); + Ok(()) + } + + pub(crate) fn detach_extension_session_resources_for_owned_disposal( + &mut self, + ownership: OwnershipScope, + namespace: String, + ext_session_id: String, + ) -> Result, SidecarError> { + let key = (namespace, ext_session_id); + let Some(resources) = self.extension_sessions.get(&key) else { + return Ok(Vec::new()); + }; + if resources.ownership != ownership { + return Err(SidecarError::InvalidState(String::from( + "extension session ownership did not match dispose request", + ))); + } + let resources = self + .extension_sessions + .remove(&key) + .expect("extension resources existed before removal"); + let (_, _, vm_id) = self.vm_scope_for(&ownership)?; + for process_id in resources.process_ids { + if self + .vms + .get(&vm_id) + .is_some_and(|vm| vm.active_processes.contains_key(&process_id)) + { + self.kill_process_internal(&vm_id, &process_id, "SIGTERM")?; + } + } + Ok(resources.vm_ids.into_iter().collect()) + } + pub fn sidecar_id(&self) -> &str { &self.config.sidecar_id } @@ -1215,6 +1636,10 @@ where self.event_sink.set_transport(transport); } + pub(crate) fn set_extension_services(&mut self, services: Arc) { + self.extension_services = Some(services); + } + pub fn register_extension( &mut self, extension: Box, @@ -1312,6 +1737,136 @@ where Ok(()) } + pub(crate) fn process_event_broker(&self) -> ProcessEventBroker { + self.process_event_broker.clone() + } + + /// Transfer at most one raw event for an ownership-validated process into + /// the independent broker. Pumping and registry access are short-lived; + /// the caller awaits its broker lease after this method releases `&mut + /// NativeSidecar`. + pub(crate) async fn route_owned_process_event_to_broker( + &mut self, + ownership: OwnershipScope, + target: ProcessEventTarget, + ) -> Result { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + if target.connection_id != connection_id + || target.session_id != session_id + || target.vm_id != vm_id + { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_OWNERSHIP: target {}/{}/{} is not owned by {connection_id}/{session_id}/{vm_id}", + target.connection_id, target.session_id, target.vm_id + ))); + } + self.process_event_broker + .claim_target(&ownership, target.clone()) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + self.pump_process_events(&ownership).await?; + let Some(envelope) = + self.take_matching_process_event_envelope(&target.vm_id, &target.process_id)? + else { + return Ok(false); + }; + match self.process_event_ingress.try_publish(envelope) { + Ok(()) => Ok(true), + Err(failure) => { + // Preserve ordering and ownership if broker admission loses a + // race with another target. The caller gets the typed limit and + // can retry after capacity is released. + self.queue_front_pending_process_event(failure.envelope)?; + Err(SidecarError::InvalidState(failure.error.to_string())) + } + } + } + + /// One bounded broker handoff without polling or awaiting a runtime. The + /// coalesced process-event supervisor owns runtime polling; extension + /// waiters merely route already-durable events here. + pub(crate) fn route_owned_process_event_to_broker_nowait( + &mut self, + ownership: &OwnershipScope, + target: &ProcessEventTarget, + ) -> Result { + let (connection_id, session_id, vm_id) = self.vm_scope_for(ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + if target.connection_id != connection_id + || target.session_id != session_id + || target.vm_id != vm_id + { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_OWNERSHIP: target {}/{}/{} is not owned by {connection_id}/{session_id}/{vm_id}", + target.connection_id, target.session_id, target.vm_id + ))); + } + self.process_event_broker + .claim_target(ownership, target.clone()) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + let Some(envelope) = + self.take_matching_process_event_envelope(&target.vm_id, &target.process_id)? + else { + return Ok(false); + }; + match self.process_event_ingress.try_publish(envelope) { + Ok(()) => Ok(true), + Err(failure) => { + self.queue_front_pending_process_event(failure.envelope)?; + Err(SidecarError::InvalidState(failure.error.to_string())) + } + } + } + + /// Move every currently retained claimed-target envelope into the broker in + /// the same coordinator turn that drained its producer. This is the + /// durable handoff for the competing stdio wake consumer: whichever branch + /// receives `process_event_notify` routes claimed adapter output and wakes + /// the exact broker waiter. + pub(crate) fn route_claimed_pending_process_events(&mut self) -> Result { + let mut routed = 0usize; + let mut index = 0usize; + while index < self.pending_process_events.len() { + let claimed = { + let envelope = &self.pending_process_events[index]; + let target = ProcessEventTarget::new( + envelope.connection_id.clone(), + envelope.session_id.clone(), + envelope.vm_id.clone(), + envelope.process_id.clone(), + ) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + self.process_event_broker + .target_is_claimed(&target) + .map_err(|error| SidecarError::InvalidState(error.to_string()))? + }; + if !claimed { + index += 1; + continue; + } + let envelope = self + .pending_process_events + .remove(index) + .expect("claimed pending process event index"); + match self.process_event_ingress.try_publish(envelope) { + Ok(()) => routed = routed.saturating_add(1), + Err(failure) => { + self.pending_process_events.insert(index, failure.envelope); + self.observe_pending_process_event_depth(); + return match failure.error { + ProcessEventBrokerError::Limit { .. } => Ok(routed), + error => Err(SidecarError::InvalidState(error.to_string())), + }; + } + } + } + if routed > 0 { + self.observe_pending_process_event_depth(); + self.rearm_deferred_process_event_after_capacity_release(); + } + Ok(routed) + } + pub(crate) fn queue_front_pending_process_event( &mut self, envelope: ProcessEventEnvelope, @@ -1330,28 +1885,38 @@ where .runtime .protocol .max_process_events - .saturating_sub(self.pending_process_events.len()) + .saturating_sub( + self.pending_process_events + .len() + .saturating_add(usize::from(self.deferred_process_event_envelope.is_some())), + ) } pub(crate) fn check_pending_process_event_capacity( &self, envelope: &ProcessEventEnvelope, ) -> Result<(), SidecarError> { + self.validate_process_event_envelope_locator(envelope)?; let global_limit = self.config.runtime.protocol.max_process_events; - if self.pending_process_events.len() >= global_limit { + if self + .pending_process_events + .len() + .saturating_add(usize::from(self.deferred_process_event_envelope.is_some())) + >= global_limit + { return Err(process_event_queue_overflow_error(global_limit)); } - let defaults = agentos_native_sidecar_core::limits::ProcessLimits::default(); let limits = self .vms .get(&envelope.vm_id) - .map(|vm| &vm.limits.process) - .unwrap_or(&defaults); + .map(|vm| vm.limits.process.clone()) + .unwrap_or_default(); let mut vm_count = 0usize; let mut vm_bytes = 0usize; for pending in self .pending_process_events .iter() + .chain(self.deferred_process_event_envelope.iter()) .filter(|pending| pending.vm_id == envelope.vm_id) { vm_count = vm_count.saturating_add(1); @@ -1373,18 +1938,60 @@ where Ok(()) } + pub(crate) fn validate_process_event_envelope_locator( + &self, + envelope: &ProcessEventEnvelope, + ) -> Result<(), SidecarError> { + let global_limit = self.config.runtime.protocol.max_process_events; + if envelope.child_path.len() > global_limit { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_PATH_LIMIT: process-event child path has {} segments, exceeding runtime.protocol.maxProcessEvents ({global_limit}); raise runtime.protocol.maxProcessEvents", + envelope.child_path.len() + ))); + } + let child_path_bytes = envelope + .child_path + .iter() + .try_fold(0usize, |total, segment| { + if segment.len() > self.config.max_frame_bytes { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_PATH_LIMIT: process-event child path segment has {} bytes, exceeding maxFrameBytes ({}); raise maxFrameBytes", + segment.len(), self.config.max_frame_bytes + ))); + } + Ok(total.saturating_add(segment.len())) + })?; + if child_path_bytes > self.config.max_frame_bytes { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_PROCESS_EVENT_PATH_LIMIT: process-event child path has {child_path_bytes} bytes, exceeding maxFrameBytes ({}); raise maxFrameBytes", + self.config.max_frame_bytes + ))); + } + Ok(()) + } + pub(crate) fn observe_pending_process_event_depth(&self) { self.pending_process_events_gauge .observe_depth(self.pending_process_events.len()); self.pending_process_event_bytes_gauge.observe_depth( self.pending_process_events .iter() + .chain(self.deferred_process_event_envelope.iter()) .fold(0usize, |bytes, event| { bytes.saturating_add(event.retained_bytes()) }), ); } + /// A deferred receiver envelope is retried only after a consumer releases + /// public queue capacity. Never call this when initially staging it: that + /// would turn a stable full queue into a notification hot-spin. + pub(crate) fn rearm_deferred_process_event_after_capacity_release(&self) { + if self.deferred_process_event_envelope.is_some() { + self.process_event_notify.notify_one(); + } + } + pub fn dispatch_blocking( &mut self, request: RequestFrame, @@ -1511,10 +2118,8 @@ where } let result = match route { - RequestRoute::Authenticate(payload) => { - self.authenticate_connection(&request, payload).await - } - RequestRoute::OpenSession(payload) => self.open_session(&request, payload).await, + RequestRoute::Authenticate(payload) => self.authenticate_connection(&request, payload), + RequestRoute::OpenSession(payload) => self.open_session(&request, payload), RequestRoute::CreateVm(payload) => self.create_vm(&request, payload).await, RequestRoute::DisposeVm(payload) => self.dispose_vm(&request, payload).await, RequestRoute::BootstrapRootFilesystem(payload) => { @@ -1523,7 +2128,7 @@ where } RequestRoute::ConfigureVm(payload) => self.configure_vm(&request, payload).await, RequestRoute::RegisterHostCallbacks(payload) => { - register_host_callbacks(self, &request, payload) + register_host_callbacks(self, &request, payload).await } RequestRoute::CreateLayer(payload) => self.create_layer(&request, payload).await, RequestRoute::SealLayer(payload) => self.seal_layer(&request, payload).await, @@ -1541,10 +2146,20 @@ where } RequestRoute::ListMounts(payload) => self.list_mounts(&request, payload).await, RequestRoute::ExecutionOperation(payload) => { - self.execute_language_operation(&request, payload).await + let effects = self.request_completion_effects(&request); + let result = self + .execute_language_operation(&request, payload, effects.clone()) + .await; + self.apply_request_completion_effects(&effects); + result } RequestRoute::ExecutionLifecycle(payload) => { - self.handle_execution_lifecycle(&request, payload).await + let effects = self.request_completion_effects(&request); + let result = self + .handle_execution_lifecycle(&request, payload, effects.clone()) + .await; + self.apply_request_completion_effects(&effects); + result } RequestRoute::Execute(payload) => self.execute(&request, payload).await, RequestRoute::WriteStdin(payload) => self.write_stdin(&request, payload).await, @@ -1593,20 +2208,564 @@ where wire_dispatch_result(result) } - pub async fn poll_event_wire( + /// Prepare a non-extension request that can finish without retaining the + /// process coordinator. Returns `Ok(None)` when the route still requires + /// entity-owned mutable state and must use the coordinated dispatch path. + /// + /// Immutable queries are snapshotted synchronously. Mutable VM filesystem + /// and kernel calls clone only their VM handle and execute under that VM's + /// short critical section, so neither route retains the process + /// coordinator or blocks operations owned by another VM. + pub(crate) fn prepare_request_wire( &mut self, - ownership: &crate::wire::OwnershipScope, - timeout: Duration, - ) -> Result, SidecarError> { - let ownership = crate::wire::ownership_scope_to_compat(ownership.clone()); - self.poll_event(&ownership, timeout) - .await? - .map(crate::wire::event_frame_from_compat) - .transpose() - .map_err(wire_protocol_error) - } - - async fn dispatch_extension_request( + request: crate::wire::RequestFrame, + ) -> Result, SidecarError> { + let request = crate::wire::request_frame_to_compat(request).map_err(wire_protocol_error)?; + let route = route_request_payload(&request); + let detachable = matches!( + &route, + RequestRoute::Authenticate(_) + | RequestRoute::OpenSession(_) + | RequestRoute::RegisterHostCallbacks(_) + | RequestRoute::GetProcessSnapshot(_) + | RequestRoute::GetResourceSnapshot(_) + | RequestRoute::GetZombieTimerCount(_) + | RequestRoute::ProvidedCommands(_) + | RequestRoute::ListMounts(_) + | RequestRoute::GuestFilesystemCall(_) + | RequestRoute::GuestKernelCall(_) + | RequestRoute::BootstrapRootFilesystem(_) + | RequestRoute::ConfigureVm(_) + | RequestRoute::CreateLayer(_) + | RequestRoute::SealLayer(_) + | RequestRoute::ImportSnapshot(_) + | RequestRoute::ExportSnapshot(_) + | RequestRoute::CreateOverlay(_) + | RequestRoute::SnapshotRootFilesystem(_) + | RequestRoute::LinkPackage(_) + | RequestRoute::Execute(_) + | RequestRoute::ExecutionOperation(_) + | RequestRoute::ExecutionLifecycle(_) + | RequestRoute::WriteStdin(_) + | RequestRoute::ResizePty(_) + | RequestRoute::CloseStdin(_) + | RequestRoute::KillProcess(_) + | RequestRoute::FindListener(_) + | RequestRoute::FindBoundUdp(_) + | RequestRoute::VmFetch(_) + | RequestRoute::GetSignalState(_) + | RequestRoute::UnsupportedHostCallbackDirection + ) || matches!( + &route, + RequestRoute::Ext(envelope) if !self.extensions.contains_key(&envelope.namespace) + ); + if !detachable { + return Ok(None); + } + + self.reap_reconciled_quarantined_vms(); + let preparation = (|| { + self.ensure_request_within_frame_limit(&request)?; + if let OwnershipScope::VmOwnership(ownership) = &request.ownership { + if let Some(report) = self + .vms + .get(&ownership.vm_id) + .and_then(|vm| vm.runtime_context.terminal_failure()) + { + return Err(SidecarError::Execution(format!( + "ERR_AGENTOS_VM_TASK_FAILED: vm_id={} class={:?} owner={} reason={:?}; dispose and recreate this VM generation", + ownership.vm_id, report.class, report.owner, report.reason + ))); + } + } + Ok(()) + })(); + if let Err(error) = preparation { + return Ok(Some(PreparedRequest::from_future(request, async move { + Err(error) + }))); + } + + match route { + RequestRoute::Authenticate(payload) => { + if let Err(error) = self.connection_id_for(&request.ownership) { + return Ok(Some(PreparedRequest::failed(request, error))); + } + if let Err(error) = self.validate_auth_token(&payload.auth_token) { + let bridge = self.bridge.clone(); + let sidecar_id = self.config.sidecar_id.clone(); + let client_name = payload.client_name; + let ownership = request.ownership.clone(); + return Ok(Some(PreparedRequest::from_future(request, async move { + let mut fields = audit_fields([ + (String::from("source"), client_name), + (String::from("reason"), error.to_string()), + ]); + if let OwnershipScope::ConnectionOwnership(inner) = ownership { + fields.insert(String::from("connection_id"), inner.connection_id); + } + emit_security_audit_event( + &bridge, + &sidecar_id, + "security.auth.failed", + fields, + ); + Err(error) + }))); + } + if let Err(error) = + validate_authenticate_versions(&payload).map_err(|error| match error { + AuthenticateVersionError::ProtocolVersionMismatch(message) => { + SidecarError::ProtocolVersionMismatch(message) + } + AuthenticateVersionError::BridgeVersionMismatch(message) => { + SidecarError::BridgeVersionMismatch(message) + } + }) + { + return Ok(Some(PreparedRequest::failed(request, error))); + } + let Some(connection_number) = self.next_connection_id.checked_add(1) else { + return Ok(Some(PreparedRequest::failed( + request, + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_CONNECTION_ID_EXHAUSTED: connection identifier space exhausted", + )), + ))); + }; + let connection_id = format!("conn-{connection_number}"); + let response = shared_authenticated_response( + request.request_id, + self.config.sidecar_id.clone(), + connection_id.clone(), + self.config.max_frame_bytes as u32, + ); + let membership = PreparedMembershipCommit::Connection { + connection_id, + auth_token: payload.auth_token, + }; + return Ok(Some(PreparedRequest::from_future_with_membership( + request, + async move { + Ok(DispatchResult { + response, + events: Vec::new(), + }) + }, + membership, + ))); + } + RequestRoute::OpenSession(payload) => { + let connection_id = match self.connection_id_for(&request.ownership) { + Ok(connection_id) => connection_id, + Err(error) => return Ok(Some(PreparedRequest::failed(request, error))), + }; + if let Err(error) = self.require_authenticated_connection(&connection_id) { + return Ok(Some(PreparedRequest::failed(request, error))); + } + let Some(session_number) = self.next_session_id.checked_add(1) else { + return Ok(Some(PreparedRequest::failed( + request, + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_SESSION_ID_EXHAUSTED: session identifier space exhausted", + )), + ))); + }; + let session_id = format!("session-{session_number}"); + let response = session_opened_response( + request.request_id, + connection_id.clone(), + session_id.clone(), + ); + let membership = PreparedMembershipCommit::Session { + connection_id, + session_id, + placement: payload.placement, + metadata: payload.metadata.into_iter().collect(), + }; + return Ok(Some(PreparedRequest::from_future_with_membership( + request, + async move { + Ok(DispatchResult { + response, + events: Vec::new(), + }) + }, + membership, + ))); + } + RequestRoute::RegisterHostCallbacks(payload) => { + let future = register_host_callbacks(self, &request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::GetProcessSnapshot(payload) => { + let future = self.get_process_snapshot(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::GetResourceSnapshot(payload) => { + let future = self.get_resource_snapshot(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::GetZombieTimerCount(payload) => { + let future = self.get_zombie_timer_count(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::ProvidedCommands(payload) => { + let future = self.provided_commands(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::ListMounts(payload) => { + let future = self.list_mounts(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::BootstrapRootFilesystem(payload) => { + let future = self.bootstrap_root_filesystem(&request, payload.entries); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::ConfigureVm(payload) => { + let future = self.configure_vm(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::CreateLayer(payload) => { + let future = self.create_layer(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::SealLayer(payload) => { + let future = self.seal_layer(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::ImportSnapshot(payload) => { + let future = self.import_snapshot(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::ExportSnapshot(payload) => { + let future = self.export_snapshot(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::CreateOverlay(payload) => { + let future = self.create_overlay(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::SnapshotRootFilesystem(payload) => { + let future = self.snapshot_root_filesystem(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::LinkPackage(payload) => { + let future = self.link_package(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::Execute(payload) => { + let future = self.execute(&request, payload); + return Ok(Some(PreparedRequest::from_future(request, future))); + } + RequestRoute::ExecutionOperation(payload) => { + let effects = self.request_completion_effects(&request); + let future = self.execute_language_operation(&request, payload, effects.clone()); + return Ok(Some(PreparedRequest::from_future_with_effects( + request, future, effects, + ))); + } + RequestRoute::ExecutionLifecycle(payload) => { + let effects = self.request_completion_effects(&request); + let future = self.handle_execution_lifecycle(&request, payload, effects.clone()); + return Ok(Some(PreparedRequest::from_future_with_effects( + request, future, effects, + ))); + } + RequestRoute::WriteStdin(payload) => { + let future = self.write_stdin(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::ResizePty(payload) => { + let future = self.resize_pty(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::CloseStdin(payload) => { + let future = self.close_stdin(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::KillProcess(payload) => { + let future = self.kill_process(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::FindListener(payload) => { + let future = self.find_listener(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::FindBoundUdp(payload) => { + let future = self.find_bound_udp(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::VmFetch(payload) => { + let future = self.vm_fetch(&request, payload); + return Ok(Some(PreparedRequest::from_future(request, future))); + } + RequestRoute::GetSignalState(payload) => { + let future = self.get_signal_state(&request, payload); + return Ok(Some(PreparedRequest::from_vm_command(request, future))); + } + RequestRoute::GuestFilesystemCall(payload) => { + let (connection_id, session_id, vm_id) = match self.vm_scope_for(&request.ownership) + { + Ok(scope) => scope, + Err(error) => return Ok(Some(PreparedRequest::failed(request, error))), + }; + if let Err(error) = self.require_owned_vm(&connection_id, &session_id, &vm_id) { + return Ok(Some(PreparedRequest::failed(request, error))); + } + let Some(handle) = self.vms.handle(&vm_id) else { + return Ok(Some(PreparedRequest::failed( + request, + SidecarError::InvalidState(format!( + "VM {vm_id} no longer exists for guest filesystem call" + )), + ))); + }; + let response_request = request.clone(); + return Ok(Some(PreparedRequest::from_vm_command( + request, + async move { + let response = handle.try_command("guest filesystem call", |vm| { + guest_filesystem_call_vm(vm, &payload) + })?; + Ok(DispatchResult { + response: shared_respond( + &response_request, + ResponsePayload::GuestFilesystemResult(response), + ), + events: Vec::new(), + }) + }, + ))); + } + RequestRoute::GuestKernelCall(payload) => { + let (connection_id, session_id, vm_id) = match self.vm_scope_for(&request.ownership) + { + Ok(scope) => scope, + Err(error) => return Ok(Some(PreparedRequest::failed(request, error))), + }; + if let Err(error) = self.require_owned_vm(&connection_id, &session_id, &vm_id) { + return Ok(Some(PreparedRequest::failed(request, error))); + } + let Some(handle) = self.vms.handle(&vm_id) else { + return Ok(Some(PreparedRequest::failed( + request, + SidecarError::InvalidState(format!( + "VM {vm_id} no longer exists for guest kernel call" + )), + ))); + }; + let response_request = request.clone(); + return Ok(Some(PreparedRequest::from_vm_command( + request, + async move { + let response = handle.try_command("guest kernel call", |vm| { + let kernel_pid = vm + .active_processes + .get(&payload.execution_id) + .map(|process| process.kernel_pid) + .ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {vm_id} has no active process {} for guest kernel call", + payload.execution_id + )) + })?; + agentos_native_sidecar_core::handle_guest_kernel_call( + &mut vm.kernel, + kernel_pid, + EXECUTION_DRIVER_NAME, + &payload.operation, + &payload.payload, + ) + .map_err(crate::execution::guest_kernel_core_error) + })?; + Ok(DispatchResult { + response: shared_respond( + &response_request, + ResponsePayload::GuestKernelResult( + crate::protocol::GuestKernelResultResponse { + payload: response, + }, + ), + ), + events: Vec::new(), + }) + }, + ))); + } + RequestRoute::UnsupportedHostCallbackDirection => { + let response_request = request.clone(); + return Ok(Some(PreparedRequest::from_future(request, async move { + Ok(unsupported_host_callback_direction_dispatch( + &response_request, + )) + }))); + } + RequestRoute::Ext(envelope) => { + let response_request = request.clone(); + return Ok(Some(PreparedRequest::from_future(request, async move { + Ok(DispatchResult { + response: shared_reject( + &response_request, + "unknown_extension", + &format!( + "no extension registered for namespace {}", + envelope.namespace + ), + ), + events: Vec::new(), + }) + }))); + } + RequestRoute::CreateVm(_) | RequestRoute::DisposeVm(_) => { + unreachable!("VM creation and disposal use dedicated prepared routes") + } + } + } + + /// Finalize a detached non-extension request without awaiting business work. + fn request_completion_effects(&self, request: &RequestFrame) -> RequestCompletionEffects { + let limit = match &request.ownership { + OwnershipScope::VmOwnership(ownership) => self + .vms + .get(&ownership.vm_id) + .map(|vm| { + vm.limits + .execution + .max_completed_executions + .saturating_add(1) + }) + .unwrap_or(1), + OwnershipScope::ConnectionOwnership(_) | OwnershipScope::SessionOwnership(_) => 1, + }; + RequestCompletionEffects::new(limit, "limits.execution.maxCompletedExecutions") + } + + fn apply_request_completion_effects(&mut self, effects: &RequestCompletionEffects) { + for process_id in effects.take_exited_processes() { + self.prune_extension_process_resource(&process_id); + } + } + + pub(crate) fn complete_request( + &mut self, + completed: CompletedRequest, + ) -> Result { + let CompletedRequest { + request, + result, + effects, + committed_membership, + } = completed; + if result.is_ok() { + if let Some(membership) = committed_membership.as_ref() { + self.commit_prepared_membership(membership)?; + } + } + self.apply_request_completion_effects(&effects); + let dispatch = match result { + Ok(dispatch) => dispatch, + Err(error @ SidecarError::Io(_)) => return Err(error), + Err(error) => DispatchResult { + response: self.reject_error(&request, &error), + events: Vec::new(), + }, + }; + wire_dispatch_result(dispatch) + } + + pub async fn poll_event_wire( + &mut self, + ownership: &crate::wire::OwnershipScope, + timeout: Duration, + ) -> Result, SidecarError> { + let ownership = crate::wire::ownership_scope_to_compat(ownership.clone()); + self.poll_event(&ownership, timeout) + .await? + .map(crate::wire::event_frame_from_compat) + .transpose() + .map_err(wire_protocol_error) + } + + /// Detach an extension request from the process coordinator so its long + /// wait can run under the request supervisor. Returns `Ok(None)` for + /// non-extension and unknown-extension requests; those retain the normal + /// dispatch path, including its canonical unknown-extension rejection. + pub(crate) fn prepare_extension_request_wire( + &self, + request: crate::wire::RequestFrame, + services: Arc, + ) -> Result, SidecarError> { + let request = crate::wire::request_frame_to_compat(request).map_err(wire_protocol_error)?; + let RequestPayload::Ext(envelope) = &request.payload else { + return Ok(None); + }; + let Some(extension) = self.extensions.get(&envelope.namespace).cloned() else { + return Ok(None); + }; + let namespace = envelope.namespace.clone(); + let payload = envelope.payload.clone(); + let snapshot = ExtensionSnapshot::new( + namespace.clone(), + request.ownership.clone(), + self.sidecar_requests.clone(), + self.event_sink.clone(), + ); + Ok(Some(PreparedExtensionRequest { + request, + namespace, + payload, + extension, + services, + snapshot, + })) + } + + /// Convert a detached extension task's result into the same canonical wire + /// response used by inline dispatch. This is deliberately short and never + /// waits on extension work or output capacity. + pub(crate) fn complete_extension_request( + &self, + completed: CompletedExtensionRequest, + ) -> Result { + let CompletedExtensionRequest { + request, + namespace, + result, + } = completed; + let dispatch = match result { + Ok(response) => DispatchResult { + response: self.respond( + &request, + ResponsePayload::ExtResult(ExtEnvelope { + namespace, + payload: response.payload, + }), + ), + events: response.events, + }, + Err(error @ SidecarError::Io(_)) => return Err(error), + Err(error) => DispatchResult { + response: self.reject_error(&request, &error), + events: Vec::new(), + }, + }; + wire_dispatch_result(dispatch) + } + + pub(crate) fn reject_wire_request_error( + &self, + request: crate::wire::RequestFrame, + error: &SidecarError, + ) -> Result { + let request = crate::wire::request_frame_to_compat(request).map_err(wire_protocol_error)?; + wire_dispatch_result(DispatchResult { + response: self.reject_error(&request, error), + events: Vec::new(), + }) + } + + async fn dispatch_extension_request( &mut self, request: &RequestFrame, envelope: ExtEnvelope, @@ -1628,7 +2787,12 @@ where self.sidecar_requests.clone(), self.event_sink.clone(), ); - let ctx = ExtensionContext::new(snapshot, self); + let services = self.extension_services.clone().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_EXTENSION_SERVICES_UNAVAILABLE: extension dispatch requires cloneable owned services", + )) + })?; + let ctx = ExtensionContext::with_services(snapshot, services); let response = extension.handle_request(ctx, envelope.payload).await?; Ok(DispatchResult { response: self.respond( @@ -1662,6 +2826,7 @@ where continue; }; self.observe_pending_process_event_depth(); + self.rearm_deferred_process_event_after_capacity_release(); if let Some(frame) = self.handle_process_event_envelope(envelope).await? { return Ok(Some(frame)); } @@ -1731,52 +2896,156 @@ where } } - pub(crate) async fn handle_process_event_envelope( + /// Probe durable public event queues once without awaiting runtime work. + /// Runtime polling is owned by the coalesced process-event supervisor. + pub(crate) fn poll_event_nowait( &mut self, - envelope: ProcessEventEnvelope, + ownership: &OwnershipScope, ) -> Result, SidecarError> { - let handle_start = Instant::now(); - let ProcessEventEnvelope { - connection_id, - session_id, - vm_id, - process_id, - event, - } = envelope; - - let is_exit_event = matches!(event, ActiveExecutionEvent::Exited(_)); - - if is_exit_event { - record_execute_exit_event_queue_wait( - "process_exit_event_queue_wait", - &vm_id, - &process_id, - ); - let mut trailing = Vec::new(); - let mut deferred = VecDeque::new(); - let phase_start = Instant::now(); - while let Some(pending) = self.pending_process_events.pop_front() { - if pending.vm_id == vm_id - && pending.process_id == process_id - && !matches!(pending.event, ActiveExecutionEvent::Exited(_)) - { - trailing.push(pending.event); - } else { - deferred.push_back(pending); + loop { + if let Some(index) = self + .pending_process_events + .iter() + .position(|event| public_process_event_matches_ownership(self, ownership, event)) + { + let Some(envelope) = self.pending_process_events.remove(index) else { + continue; + }; + self.observe_pending_process_event_depth(); + self.rearm_deferred_process_event_after_capacity_release(); + if let Some(frame) = self.handle_public_process_event_envelope_nowait(envelope)? { + return Ok(Some(frame)); } + continue; } - self.pending_process_events = deferred; - self.observe_pending_process_event_depth(); - record_execute_phase("process_exit_trailing_pending_scan", phase_start.elapsed()); - if !trailing.is_empty() { - if self.pending_process_event_capacity() < trailing.len() { - return Err(process_event_queue_overflow_error( - self.config.runtime.protocol.max_process_events, - )); - } - let emit_now = if self.pending_process_event_capacity() == trailing.len() { - Some(trailing.remove(0)) - } else { + + let queued_envelopes = { + let pending_capacity = self.pending_process_event_capacity(); + let receiver = self.process_event_receiver.as_mut().ok_or_else(|| { + SidecarError::InvalidState(String::from("process event receiver unavailable")) + })?; + let mut queued = Vec::new(); + loop { + if queued.len() >= pending_capacity { + if receiver.is_empty() { + break; + } + return Err(process_event_queue_overflow_error( + self.config.runtime.protocol.max_process_events, + )); + } + match receiver.try_recv() { + Ok(envelope) => queued.push(envelope), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + } + queued + }; + let mut matching = None; + for envelope in queued_envelopes { + if matching.is_none() + && public_process_event_matches_ownership(self, ownership, &envelope) + { + matching = Some(envelope); + } else { + self.queue_pending_process_event(envelope)?; + } + } + let Some(envelope) = matching else { + return Ok(None); + }; + if let Some(frame) = self.handle_public_process_event_envelope_nowait(envelope)? { + return Ok(Some(frame)); + } + } + } + + /// Borrowed-host compatibility path for process-targeted polling. The stdio + /// extension path uses the broker-backed owned service instead, so its + /// timeout never retains the sidecar coordinator. + pub async fn poll_process_event( + &mut self, + ownership: &OwnershipScope, + process_id: &str, + timeout: Duration, + ) -> Result, SidecarError> { + let target = ProcessEventTarget::for_owned_process(ownership, process_id) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + let (connection_id, session_id, vm_id) = self.vm_scope_for(ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let deadline = Instant::now() + timeout; + let process_event_notify = Arc::clone(&self.process_event_notify); + loop { + let notified = process_event_notify.notified(); + self.pump_process_events(ownership).await?; + while let Some(envelope) = + self.take_matching_process_event_envelope(&target.vm_id, &target.process_id)? + { + if let Some(frame) = self.handle_process_event_envelope(envelope).await? { + return Ok(Some(frame)); + } + } + if timeout.is_zero() || Instant::now() >= deadline { + return Ok(None); + } + tokio::select! { + _ = notified => {} + _ = time::sleep(deadline.saturating_duration_since(Instant::now())) => { + return Ok(None); + } + } + } + } + + pub(crate) async fn handle_process_event_envelope( + &mut self, + envelope: ProcessEventEnvelope, + ) -> Result, SidecarError> { + let handle_start = Instant::now(); + let ProcessEventEnvelope { + connection_id, + session_id, + vm_id, + child_path, + process_id, + event, + } = envelope; + + let is_exit_event = matches!(event, ActiveExecutionEvent::Exited(_)); + + if is_exit_event { + record_execute_exit_event_queue_wait( + "process_exit_event_queue_wait", + &vm_id, + &process_id, + ); + let mut trailing = Vec::new(); + let mut deferred = VecDeque::new(); + let phase_start = Instant::now(); + while let Some(pending) = self.pending_process_events.pop_front() { + if pending.vm_id == vm_id + && pending.process_id == process_id + && !matches!(pending.event, ActiveExecutionEvent::Exited(_)) + { + trailing.push(pending.event); + } else { + deferred.push_back(pending); + } + } + self.pending_process_events = deferred; + self.observe_pending_process_event_depth(); + self.rearm_deferred_process_event_after_capacity_release(); + record_execute_phase("process_exit_trailing_pending_scan", phase_start.elapsed()); + if !trailing.is_empty() { + if self.pending_process_event_capacity() < trailing.len() { + return Err(process_event_queue_overflow_error( + self.config.runtime.protocol.max_process_events, + )); + } + let emit_now = if self.pending_process_event_capacity() == trailing.len() { + Some(trailing.remove(0)) + } else { None }; let phase_start = Instant::now(); @@ -1785,6 +3054,7 @@ where connection_id: connection_id.clone(), session_id: session_id.clone(), vm_id: vm_id.clone(), + child_path: child_path.clone(), process_id: process_id.clone(), event, })?; @@ -1793,6 +3063,7 @@ where connection_id: connection_id.clone(), session_id: session_id.clone(), vm_id: vm_id.clone(), + child_path: child_path.clone(), process_id: process_id.clone(), event, })?; @@ -1820,6 +3091,21 @@ where .handle_execution_event(&vm_id, &process_id, event) .await; if is_exit_event { + let target = ProcessEventTarget::new( + connection_id, + session_id, + vm_id.clone(), + process_id.clone(), + ) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + if let Err(error) = self + .process_event_broker + .dispose_process(&target, OperationCancellationReason::Explicit) + { + eprintln!( + "ERR_AGENTOS_PROCESS_EVENT_PROCESS_DISPOSAL: vm_id={vm_id} process_id={process_id} error={error}" + ); + } record_execute_phase( "process_exit_event_handle_envelope_total", handle_start.elapsed(), @@ -1828,6 +3114,101 @@ where result } + /// Synchronous completion-side form used by the detached extension/event + /// supervisors. Broker envelopes contain only public events; internal RPC + /// work is serviced before publication. + pub(crate) fn handle_public_process_event_envelope_nowait( + &mut self, + envelope: ProcessEventEnvelope, + ) -> Result, SidecarError> { + let ProcessEventEnvelope { + connection_id, + session_id, + vm_id, + child_path, + process_id, + event, + } = envelope; + let is_exit_event = matches!(event, ActiveExecutionEvent::Exited(_)); + + if is_exit_event { + record_execute_exit_event_queue_wait( + "process_exit_event_queue_wait", + &vm_id, + &process_id, + ); + let mut trailing = Vec::new(); + let mut deferred = VecDeque::new(); + while let Some(pending) = self.pending_process_events.pop_front() { + if pending.vm_id == vm_id + && pending.process_id == process_id + && !matches!(pending.event, ActiveExecutionEvent::Exited(_)) + { + trailing.push(pending.event); + } else { + deferred.push_back(pending); + } + } + self.pending_process_events = deferred; + self.observe_pending_process_event_depth(); + self.rearm_deferred_process_event_after_capacity_release(); + if !trailing.is_empty() { + if self.pending_process_event_capacity() < trailing.len() { + return Err(process_event_queue_overflow_error( + self.config.runtime.protocol.max_process_events, + )); + } + let emit_now = if self.pending_process_event_capacity() == trailing.len() { + Some(trailing.remove(0)) + } else { + None + }; + mark_execute_exit_event_queued(&vm_id, &process_id); + self.queue_front_pending_process_event(ProcessEventEnvelope { + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + child_path: child_path.clone(), + process_id: process_id.clone(), + event, + })?; + for event in trailing.into_iter().rev() { + self.queue_front_pending_process_event(ProcessEventEnvelope { + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + child_path: child_path.clone(), + process_id: process_id.clone(), + event, + })?; + } + return emit_now.map_or(Ok(None), |event| { + self.handle_public_execution_event_nowait(&vm_id, &process_id, event) + }); + } + } + + let result = self.handle_public_execution_event_nowait(&vm_id, &process_id, event); + if is_exit_event { + let target = ProcessEventTarget::new( + connection_id, + session_id, + vm_id.clone(), + process_id.clone(), + ) + .map_err(|error| SidecarError::InvalidState(error.to_string()))?; + if let Err(error) = self + .process_event_broker + .dispose_process(&target, OperationCancellationReason::Explicit) + { + eprintln!( + "ERR_AGENTOS_PROCESS_EVENT_PROCESS_DISPOSAL: vm_id={vm_id} process_id={process_id} error={error}" + ); + } + } + result + } + // try_poll_event moved to crate::execution pub async fn close_session( @@ -1872,6 +3253,15 @@ where } } + if let Err(error) = self + .process_event_broker + .dispose_connection(connection_id, OperationCancellationReason::ConnectionClosed) + { + eprintln!( + "ERR_AGENTOS_PROCESS_EVENT_CONNECTION_DISPOSAL: connection_id={connection_id} error={error}" + ); + } + self.connections.remove(connection_id); if let Some(error) = first_error { return Err(error); @@ -1879,7 +3269,87 @@ where Ok(events) } - async fn authenticate_connection( + /// Apply the bounded central-state portion of a prepared membership request. + /// + /// The stdio router calls this immediately after preparation so pipelined + /// dependent frames observe membership without waiting for terminal output. + /// `complete_request` calls it again for in-process users, so the operation + /// is deliberately idempotent. + pub(crate) fn commit_prepared_membership( + &mut self, + membership: &PreparedMembershipCommit, + ) -> Result<(), SidecarError> { + match membership { + PreparedMembershipCommit::Connection { + connection_id, + auth_token, + } => { + if let Some(existing) = self.connections.get(connection_id) { + if existing.auth_token != *auth_token { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_CONNECTION_COMMIT_CONFLICT: {connection_id} already has different authentication state" + ))); + } + return Ok(()); + } + let expected = format!("conn-{}", self.next_connection_id.saturating_add(1)); + if connection_id != &expected { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_CONNECTION_COMMIT_ORDER: prepared {connection_id}, expected {expected}" + ))); + } + self.next_connection_id = self.next_connection_id.saturating_add(1); + self.connections.insert( + connection_id.clone(), + ConnectionState { + auth_token: auth_token.clone(), + sessions: BTreeSet::new(), + }, + ); + } + PreparedMembershipCommit::Session { + connection_id, + session_id, + placement, + metadata, + } => { + self.require_authenticated_connection(connection_id)?; + if let Some(existing) = self.sessions.get(session_id) { + if existing.connection_id != *connection_id { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_SESSION_COMMIT_CONFLICT: {session_id} is already owned by {}", + existing.connection_id + ))); + } + return Ok(()); + } + let expected = format!("session-{}", self.next_session_id.saturating_add(1)); + if session_id != &expected { + return Err(SidecarError::InvalidState(format!( + "ERR_AGENTOS_SESSION_COMMIT_ORDER: prepared {session_id}, expected {expected}" + ))); + } + self.next_session_id = self.next_session_id.saturating_add(1); + self.sessions.insert( + session_id.clone(), + SessionState { + connection_id: connection_id.clone(), + placement: placement.clone(), + metadata: metadata.clone(), + vm_ids: BTreeSet::new(), + }, + ); + self.connections + .get_mut(connection_id) + .expect("authenticated connection checked before session commit") + .sessions + .insert(session_id.clone()); + } + } + Ok(()) + } + + fn authenticate_connection( &mut self, request: &RequestFrame, payload: crate::protocol::AuthenticateRequest, @@ -1934,7 +3404,7 @@ where }) } - async fn open_session( + fn open_session( &mut self, request: &RequestFrame, payload: OpenSessionRequest, @@ -1988,6 +3458,21 @@ where ) -> Result, SidecarError> { self.require_owned_session(connection_id, session_id)?; + let cancellation_reason = match &reason { + DisposeReason::Requested => OperationCancellationReason::Explicit, + DisposeReason::ConnectionClosed => OperationCancellationReason::ConnectionClosed, + DisposeReason::HostShutdown => OperationCancellationReason::Shutdown, + }; + if let Err(error) = self.process_event_broker.dispose_session( + connection_id, + session_id, + cancellation_reason, + ) { + eprintln!( + "ERR_AGENTOS_PROCESS_EVENT_SESSION_DISPOSAL: connection_id={connection_id} session_id={session_id} error={error}" + ); + } + let vm_ids = self .sessions .get(session_id) @@ -2144,7 +3629,7 @@ where }; let now = Instant::now(); - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { log_stale_process_event(&self.bridge, vm_id, process_id, "deferred kernel wait RPC"); return Ok(None); }; @@ -2153,19 +3638,24 @@ where // probe and the waiter's wait bumps the generation, so the wait // returns immediately instead of losing the wakeup. let generation = wait_handle.snapshot(); - let Some(process) = vm.active_processes.get_mut(process_id) else { - log_stale_process_event(&self.bridge, vm_id, process_id, "deferred kernel wait RPC"); - return Ok(None); - }; let requested_timeout_ms = if request.method == "process.fd_write" { Some(vm.limits.reactor.operation_deadline_ms) } else { requested_timeout_ms }; + let VmState { + kernel, + active_processes, + .. + } = &mut *vm; + let Some(process) = active_processes.get_mut(process_id) else { + log_stale_process_event(&self.bridge, vm_id, process_id, "deferred kernel wait RPC"); + return Ok(None); + }; // Reading from the pipe frees capacity. Top it off before every root // process read/poll probe, matching the descendant-process path, and // deliver a deferred close only after all accepted bytes are written. - flush_pending_kernel_stdin(&mut vm.kernel, process)?; + flush_pending_kernel_stdin(kernel, process)?; let kernel_pid = process.kernel_pid; let kernel_stdin_reader_fd = process.kernel_stdin_reader_fd; let deadline = match &process.deferred_kernel_wait_rpc { @@ -2174,12 +3664,12 @@ where }; let probe = match request.method.as_str() { "process.fd_write" => { - service_javascript_kernel_fd_write_sync_rpc(&mut vm.kernel, process, request) + service_javascript_kernel_fd_write_sync_rpc(kernel, process, request) } "__kernel_stdin_read" => { let (max_bytes, _) = parse_kernel_stdin_read_args(request)?; kernel_stdin_read_response( - &mut vm.kernel, + kernel, kernel_pid, kernel_stdin_reader_fd, max_bytes, @@ -2259,6 +3749,7 @@ where connection_id, session_id, vm_id: envelope_vm_id, + child_path: Vec::new(), process_id: envelope_process_id, event: ActiveExecutionEvent::JavascriptSyncRpcRequest(waiter_request), }) @@ -2273,7 +3764,7 @@ where } }) .map_err(SidecarError::from)?; - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { return Ok(None); }; let Some(process) = vm.active_processes.get_mut(process_id) else { @@ -2290,11 +3781,15 @@ where request: JavascriptSyncRpcRequest, ) -> Result<(), SidecarError> { record_sync_bridge_request_observed(request.id, &request.method); - let Some(vm) = self.vms.get(vm_id) else { + let Some(process_active) = self + .vms + .get(vm_id) + .map(|vm| vm.active_processes.contains_key(process_id)) + else { log_stale_process_event(&self.bridge, vm_id, process_id, "javascript sync RPC"); return Ok(()); }; - if !vm.active_processes.contains_key(process_id) { + if !process_active { log_stale_process_event(&self.bridge, vm_id, process_id, "javascript sync RPC"); return Ok(()); } @@ -2322,33 +3817,35 @@ where } } "child_process.spawn" => { - let Some(vm) = self.vms.get(vm_id) else { - log_stale_process_event( - &self.bridge, - vm_id, - process_id, - "javascript sync RPC child_process.spawn", - ); - return Ok(()); + let payload = { + let Some(vm) = self.vms.get(vm_id) else { + log_stale_process_event( + &self.bridge, + vm_id, + process_id, + "javascript sync RPC child_process.spawn", + ); + return Ok(()); + }; + parse_javascript_child_process_spawn_request(&vm, &request.args)?.0 }; - let (payload, _) = - parse_javascript_child_process_spawn_request(vm, &request.args)?; - self.spawn_javascript_child_process(vm_id, process_id, payload) + self.spawn_descendant_javascript_child_process(vm_id, process_id, &[], payload) .await .map(Into::into) } "child_process.spawn_sync" => { - let Some(vm) = self.vms.get(vm_id) else { - log_stale_process_event( - &self.bridge, - vm_id, - process_id, - "javascript sync RPC child_process.spawn_sync", - ); - return Ok(()); + let (payload, max_buffer) = { + let Some(vm) = self.vms.get(vm_id) else { + log_stale_process_event( + &self.bridge, + vm_id, + process_id, + "javascript sync RPC child_process.spawn_sync", + ); + return Ok(()); + }; + parse_javascript_child_process_spawn_request(&vm, &request.args)? }; - let (payload, max_buffer) = - parse_javascript_child_process_spawn_request(vm, &request.args)?; self.defer_javascript_child_process_sync(vm_id, process_id, payload, max_buffer) .await } @@ -2408,32 +3905,34 @@ where .map(|()| Value::Null.into()) } "process.exec_fd_image_commit" => { - let Some(vm) = self.vms.get(vm_id) else { - log_stale_process_event( - &self.bridge, - vm_id, - process_id, - "javascript sync RPC process.exec_fd_image_commit", - ); - return Ok(()); + let payload = { + let Some(vm) = self.vms.get(vm_id) else { + log_stale_process_event( + &self.bridge, + vm_id, + process_id, + "javascript sync RPC process.exec_fd_image_commit", + ); + return Ok(()); + }; + parse_javascript_child_process_spawn_request(&vm, &request.args)?.0 }; - let (payload, _) = - parse_javascript_child_process_spawn_request(vm, &request.args)?; self.commit_wasm_fd_process_image(vm_id, process_id, &[], payload)?; Ok(json!({ "committed": true }).into()) } "process.exec" => { - let Some(vm) = self.vms.get(vm_id) else { - log_stale_process_event( - &self.bridge, - vm_id, - process_id, - "javascript sync RPC process.exec", - ); - return Ok(()); + let payload = { + let Some(vm) = self.vms.get(vm_id) else { + log_stale_process_event( + &self.bridge, + vm_id, + process_id, + "javascript sync RPC process.exec", + ); + return Ok(()); + }; + parse_javascript_child_process_spawn_request(&vm, &request.args)?.0 }; - let (payload, _) = - parse_javascript_child_process_spawn_request(vm, &request.args)?; let local_replacement = payload.options.local_replacement; match self.exec_javascript_process_image(vm_id, process_id, &[], payload) { Ok(()) if local_replacement => Ok(json!({ "committed": true }).into()), @@ -2585,7 +4084,7 @@ where "process.signal_state" => { let (signal, registration) = parse_process_signal_state_request(&request.args) .map_err(|error| SidecarError::InvalidState(error.to_string()))?; - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { log_stale_process_event( &self.bridge, vm_id, @@ -2632,7 +4131,7 @@ where NetworkOperation::Http, format_tcp_resource(&payload.host, payload.port), )?; - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { log_stale_process_event( &self.bridge, vm_id, @@ -2641,7 +4140,7 @@ where ); return Ok(()); }; - let socket_paths = build_javascript_socket_path_context(vm)?; + let socket_paths = build_javascript_socket_path_context(&mut vm)?; let target_is_current = [JavascriptSocketFamily::Ipv4, JavascriptSocketFamily::Ipv6] .iter() @@ -2659,10 +4158,17 @@ where payload.host, payload.port, payload.server_id, payload.process_id ))); } - let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); - let capabilities = vm.capabilities.clone(); - let Some(target_process) = vm.active_processes.get_mut(&payload.process_id) - else { + let VmState { + dns, + kernel, + kernel_socket_readiness, + capabilities, + active_processes, + .. + } = &mut *vm; + let kernel_readiness = Arc::clone(kernel_socket_readiness); + let capabilities = capabilities.clone(); + let Some(target_process) = active_processes.get_mut(&payload.process_id) else { return Err(SidecarError::InvalidState(format!( "unknown HTTP loopback process {}", payload.process_id @@ -2671,9 +4177,9 @@ where dispatch_loopback_http_request_deferred(LoopbackHttpDispatchRequest { bridge: &self.bridge, vm_id, - dns: &vm.dns, + dns, socket_paths: &socket_paths, - kernel: &mut vm.kernel, + kernel, kernel_readiness, process: target_process, server_id: payload.server_id, @@ -2682,22 +4188,24 @@ where }) } "__kernel_stdio_write" - if self - .vms - .get(vm_id) - .and_then(|vm| vm.active_processes.get(process_id)) - .is_some_and(|process| process.tty_master_owner.is_some()) => + if self.vms.get(vm_id).is_some_and(|vm| { + vm.active_processes + .get(process_id) + .is_some_and(|process| process.tty_master_owner.is_some()) + }) => { let (writer_kernel_pid, owner) = { - let process = self - .vms + self.vms .get(vm_id) - .and_then(|vm| vm.active_processes.get(process_id)) - .expect("guarded by match arm"); - ( - process.kernel_pid, - process.tty_master_owner.expect("guarded by match arm"), - ) + .and_then(|vm| { + vm.active_processes.get(process_id).map(|process| { + ( + process.kernel_pid, + process.tty_master_owner.expect("guarded by match arm"), + ) + }) + }) + .expect("guarded by match arm") }; self.service_shared_tty_stdio_write(vm_id, writer_kernel_pid, owner, &request) .map(Into::into) @@ -2714,7 +4222,7 @@ where } } _ => { - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { log_stale_process_event( &self.bridge, vm_id, @@ -2723,10 +4231,18 @@ where ); return Ok(()); }; - let socket_paths = build_javascript_socket_path_context(vm)?; - let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); - let capabilities = vm.capabilities.clone(); - let Some(process) = vm.active_processes.get_mut(process_id) else { + let socket_paths = build_javascript_socket_path_context(&mut vm)?; + let VmState { + dns, + kernel, + kernel_socket_readiness, + capabilities, + active_processes, + .. + } = &mut *vm; + let kernel_readiness = Arc::clone(kernel_socket_readiness); + let capabilities = capabilities.clone(); + let Some(process) = active_processes.get_mut(process_id) else { log_stale_process_event( &self.bridge, vm_id, @@ -2738,9 +4254,9 @@ where service_javascript_sync_rpc(JavascriptSyncRpcServiceRequest { bridge: &self.bridge, vm_id, - dns: &vm.dns, + dns, socket_paths: &socket_paths, - kernel: &mut vm.kernel, + kernel, kernel_readiness, process, sync_request: &request, @@ -2806,6 +4322,7 @@ where connection_id, session_id, vm_id: envelope_vm_id, + child_path: Vec::new(), process_id: envelope_process_id, event: ActiveExecutionEvent::JavascriptSyncRpcCompletion( crate::state::JavascriptSyncRpcCompletion { request_id, result }, @@ -2828,17 +4345,17 @@ where }; if response.is_ok() && javascript_sync_rpc_may_make_fd_readable(&request) { - if let Some(vm) = self.vms.get_mut(vm_id) { - Self::wake_ready_deferred_fd_reads(vm)?; + if let Some(mut vm) = self.vms.get_mut(vm_id) { + Self::wake_ready_deferred_fd_reads(&mut vm)?; } } if response.is_ok() && javascript_sync_rpc_may_make_fd_writable(&request) { - if let Some(vm) = self.vms.get_mut(vm_id) { - Self::wake_ready_deferred_fd_writes(vm)?; + if let Some(mut vm) = self.vms.get_mut(vm_id) { + Self::wake_ready_deferred_fd_writes(&mut vm)?; } } - let Some(vm) = self.vms.get_mut(vm_id) else { + let Some(mut vm) = self.vms.get_mut(vm_id) else { log_stale_process_event( &self.bridge, vm_id, @@ -2916,9 +4433,12 @@ where let action = self .vms .get(vm_id) - .and_then(|vm| vm.signal_states.get(process_id)) - .and_then(|handlers| handlers.get(&(parsed_signal as u32))) - .map(|registration| registration.action.clone()) + .and_then(|vm| { + vm.signal_states + .get(process_id) + .and_then(|handlers| handlers.get(&(parsed_signal as u32))) + .map(|registration| registration.action.clone()) + }) .unwrap_or(SignalDispositionAction::Default); if action == SignalDispositionAction::Default && parsed_signal != 0 @@ -2927,9 +4447,14 @@ where Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG") ) { - if let Some(vm) = self.vms.get_mut(vm_id) { - if let Some(process) = vm.active_processes.get_mut(process_id) { - apply_active_process_default_signal(&mut vm.kernel, process, parsed_signal)?; + if let Some(mut vm) = self.vms.get_mut(vm_id) { + let VmState { + kernel, + active_processes, + .. + } = &mut *vm; + if let Some(process) = active_processes.get_mut(process_id) { + apply_active_process_default_signal(kernel, process, parsed_signal)?; } } } @@ -3088,6 +4613,7 @@ where { let envelope = self.pending_process_events.remove(index); self.observe_pending_process_event_depth(); + self.rearm_deferred_process_event_after_capacity_release(); return Ok(envelope); } @@ -3444,6 +4970,12 @@ impl Drop for NativeSidecar { if let Some(task) = self.kernel_reaper_task.take() { task.abort(); } + if let Err(error) = self + .process_event_broker + .shutdown(OperationCancellationReason::Shutdown) + { + eprintln!("ERR_AGENTOS_PROCESS_EVENT_BROKER_SHUTDOWN: {error}"); + } } } @@ -3452,6 +4984,10 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { + fn acp_termination_grace<'a>(&'a mut self) -> ExtensionFuture<'a, Duration> { + Box::pin(async move { Ok(self.config.acp_termination_grace) }) + } + fn vm_acp_limits<'a>( &'a mut self, ownership: OwnershipScope, @@ -3548,6 +5084,17 @@ where Box::pin(async move { NativeSidecar::poll_event(self, &ownership, timeout).await }) } + fn poll_process_event<'a>( + &'a mut self, + ownership: OwnershipScope, + process_id: String, + timeout: Duration, + ) -> ExtensionFuture<'a, Option> { + Box::pin(async move { + NativeSidecar::poll_process_event(self, &ownership, &process_id, timeout).await + }) + } + fn projected_agents<'a>( &'a mut self, ownership: OwnershipScope, @@ -3698,55 +5245,29 @@ where timeout: Duration, ) -> ExtensionFuture<'a, ExtensionBufferedProcessOutput> { Box::pin(async move { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - let key = (vm_id.clone(), process_id.clone()); let deadline = Instant::now() + timeout; let process_event_notify = Arc::clone(&self.process_event_notify); loop { - // Register before probing the durable process-event queues so - // an arrival racing this turn cannot be lost before the await. let notified = process_event_notify.notified(); - self.pump_process_events(&ownership).await?; - while let Some(envelope) = - self.take_matching_process_event_envelope(&vm_id, &process_id)? + let finalize = timeout.is_zero() || Instant::now() >= deadline; + if let Some(buffer) = self + .probe_extension_process_output_handoff( + ownership.clone(), + namespace.clone(), + ext_session_id.clone(), + process_id.clone(), + finalize, + ) + .await? { - if self.capture_extension_process_output_event( - &vm_id, - &process_id, - &envelope.event, - ) { - continue; - } - self.queue_pending_process_event(envelope)?; - break; - } - let buffered = self - .extension_process_output_buffers - .get(&key) - .is_some_and(|buffer| !buffer.stdout.is_empty() || !buffer.stderr.is_empty()); - if buffered || timeout.is_zero() || Instant::now() >= deadline { - break; + return Ok(buffer); } let remaining = deadline.saturating_duration_since(Instant::now()); tokio::select! { _ = notified => {} - _ = time::sleep(remaining) => break, + _ = time::sleep(remaining) => {} } } - self.bind_extension_process_resource( - ownership, - namespace, - ext_session_id, - process_id.clone(), - )?; - self.extension_process_output_buffers - .remove(&key) - .ok_or_else(|| { - SidecarError::InvalidState(String::from( - "extension process output buffering was not started", - )) - }) }) } } @@ -4073,6 +5594,1182 @@ fn symlinked_node_modules_hint(stderr: &str) -> Option<&'static str> { None } +#[cfg(test)] +mod prepared_request_tests { + use super::*; + use crate::stdio::LocalBridge; + + fn test_sidecar() -> NativeSidecar { + NativeSidecar::new(LocalBridge::default()).expect("build test sidecar") + } + + fn wire_request(request: RequestFrame) -> crate::wire::RequestFrame { + match crate::protocol::to_generated_protocol_frame( + &crate::protocol::ProtocolFrame::Request(request), + ) + .expect("encode request") + { + crate::wire::ProtocolFrame::RequestFrame(request) => request, + _ => panic!("request encoded as another frame class"), + } + } + + fn compat_response(dispatch: crate::wire::WireDispatchResult) -> ResponseFrame { + match crate::protocol::from_generated_protocol_frame( + crate::wire::ProtocolFrame::ResponseFrame(dispatch.response), + ) + .expect("decode response") + { + crate::protocol::ProtocolFrame::Response(response) => response, + _ => panic!("response decoded as another frame class"), + } + } + + async fn create_test_vms( + sidecar: &mut NativeSidecar, + count: usize, + ) -> Vec { + let connection_id = "vm-handle-test-connection"; + let session_id = "vm-handle-test-session"; + sidecar.connections.insert( + connection_id.to_owned(), + ConnectionState { + auth_token: String::new(), + sessions: BTreeSet::from([session_id.to_owned()]), + }, + ); + sidecar.sessions.insert( + session_id.to_owned(), + SessionState { + connection_id: connection_id.to_owned(), + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: BTreeMap::new(), + vm_ids: BTreeSet::new(), + }, + ); + + let mut vm_ids = Vec::with_capacity(count); + for request_id in 1..=count { + let request = RequestFrame::new( + request_id as RequestId, + OwnershipScope::session(connection_id, session_id), + RequestPayload::CreateVm(crate::protocol::CreateVmRequest::legacy_test_config( + crate::protocol::GuestRuntimeKind::JavaScript, + std::collections::HashMap::new(), + Default::default(), + Some(crate::wire::PermissionsPolicy::allow_all()), + )), + ); + let RequestPayload::CreateVm(payload) = request.payload.clone() else { + unreachable!("test request is create VM"); + }; + let response = sidecar + .create_vm(&request, payload) + .await + .expect("create test VM"); + let ResponsePayload::VmCreated(created) = response.response.payload else { + panic!("test VM creation returned a different response"); + }; + vm_ids.push(created.vm_id); + } + vm_ids + } + + fn cleanup_test_ownership(vm_id: &str) -> OwnershipScope { + OwnershipScope::vm("vm-handle-test-connection", "vm-handle-test-session", vm_id) + } + + fn extension_tracks_process( + sidecar: &NativeSidecar, + extension_session_id: &str, + process_id: &str, + ) -> bool { + sidecar + .extension_sessions + .get(&( + String::from("test.cleanup"), + extension_session_id.to_owned(), + )) + .is_some_and(|resources| resources.process_ids.contains(process_id)) + } + + fn insert_bound_resident_cleanup_fixture( + sidecar: &mut NativeSidecar, + vm_id: &str, + execution_id: &str, + process_id: &str, + context: bool, + completed: bool, + expires_at_ms: Option, + ) -> String { + let extension_session_id = format!("ext-{process_id}"); + let mut vm = sidecar.vms.get_mut(vm_id).expect("cleanup test VM"); + let guest_env = vm.guest_env.clone(); + let kernel_handle = vm + .kernel + .create_virtual_process( + EXECUTION_DRIVER_NAME, + EXECUTION_DRIVER_NAME, + crate::state::JAVASCRIPT_COMMAND, + vec![String::from(crate::state::JAVASCRIPT_COMMAND)], + agentos_kernel::kernel::VirtualProcessOptions { + env: guest_env, + ..Default::default() + }, + ) + .expect("spawn cleanup fixture kernel process"); + let kernel_pid = kernel_handle.pid(); + let runtime_context = vm.runtime_context.clone(); + let limits = vm.limits.clone(); + vm.active_processes.insert( + process_id.to_owned(), + crate::state::ActiveProcess::new( + kernel_pid, + kernel_handle, + runtime_context, + limits, + agentos_runtime::DEFAULT_PROTOCOL_MAX_PROCESS_EVENTS, + crate::protocol::GuestRuntimeKind::JavaScript, + crate::state::ActiveExecution::Binding(crate::state::BindingExecution::default()), + ), + ); + let descriptor = crate::protocol::ExecutionDescriptor { + execution_id: execution_id.to_owned(), + generation: 0, + state: crate::protocol::ExecutionState::Idle, + retained_language: None, + process_id: None, + pid: None, + created_at_ms: 1, + last_started_at_ms: None, + last_completed_at_ms: completed.then_some(2), + last_outcome: completed.then_some(crate::protocol::ExecutionOutcome::Succeeded), + last_exit_code: completed.then_some(0), + }; + let result = completed.then(|| crate::protocol::ExecutionCompletedResponse { + execution: Some(descriptor.clone()), + outcome: crate::protocol::ExecutionOutcome::Succeeded, + exit_code: Some(0), + error: None, + stdout: None, + stderr: None, + stdout_truncated: None, + stderr_truncated: None, + evaluation_value: None, + type_script_check_result: None, + }); + vm.executions.insert( + execution_id.to_owned(), + crate::state::ManagedLanguageExecution { + public: true, + context, + descriptor, + result, + events: VecDeque::new(), + retained_event_bytes: 0, + output_truncated: false, + next_sequence: 0, + stdout: Vec::new(), + stderr: Vec::new(), + stdout_truncated: false, + stderr_truncated: false, + output_limit_bytes: 0, + output_limit_setting: "limits.execution.maxCompletedExecutions", + capture: crate::protocol::ExecutionOutputCapture::None, + retain_events: false, + event_limit: 1, + event_bytes_limit: 1, + uses_pty: false, + value_kind: crate::state::ExecutionValueKind::None, + semantic_result_path: None, + pending_outcome: None, + deadline_ms: None, + expires_at_ms, + deadline_task: None, + resident_process_id: Some(process_id.to_owned()), + }, + ); + vm.execution_processes + .insert(process_id.to_owned(), execution_id.to_owned()); + drop(vm); + sidecar + .bind_extension_process_resource( + cleanup_test_ownership(vm_id), + String::from("test.cleanup"), + extension_session_id.clone(), + process_id.to_owned(), + ) + .expect("bind resident process to extension session"); + extension_session_id + } + + async fn execute_cleanup_request( + sidecar: &mut NativeSidecar, + request_id: RequestId, + vm_id: &str, + payload: RequestPayload, + ) -> CompletedRequest { + sidecar + .prepare_request_wire(wire_request(RequestFrame::new( + request_id, + cleanup_test_ownership(vm_id), + payload, + ))) + .expect("prepare cleanup request") + .expect("cleanup request is detachable") + .execute() + .await + } + + fn complete_cleanup_request( + sidecar: &mut NativeSidecar, + completed: CompletedRequest, + ) -> ResponseFrame { + compat_response( + sidecar + .complete_request(completed) + .expect("complete cleanup request"), + ) + } + + #[tokio::test(flavor = "current_thread")] + async fn retention_expiry_completion_prunes_extension_resident_process() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1).await.remove(0); + let process_id = "retention-expired-process"; + let extension_session_id = insert_bound_resident_cleanup_fixture( + &mut sidecar, + &vm_id, + "retention-expired-execution", + process_id, + false, + true, + Some(0), + ); + + let completed = execute_cleanup_request( + &mut sidecar, + 301, + &vm_id, + RequestPayload::ListExecutions(crate::protocol::ListExecutionsRequest {}), + ) + .await; + assert!(extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + complete_cleanup_request(&mut sidecar, completed); + assert!(!extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn retention_eviction_completion_prunes_extension_resident_process() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1).await.remove(0); + sidecar + .vms + .get_mut(&vm_id) + .expect("cleanup test VM") + .limits + .execution + .max_completed_executions = 0; + let process_id = "retention-evicted-process"; + let extension_session_id = insert_bound_resident_cleanup_fixture( + &mut sidecar, + &vm_id, + "retention-evicted-execution", + process_id, + false, + true, + None, + ); + + let completed = execute_cleanup_request( + &mut sidecar, + 302, + &vm_id, + RequestPayload::ListExecutions(crate::protocol::ListExecutionsRequest {}), + ) + .await; + assert!(extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + complete_cleanup_request(&mut sidecar, completed); + assert!(!extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn reset_execution_completion_prunes_extension_resident_process() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1).await.remove(0); + let execution_id = "reset-context"; + let process_id = "reset-resident-process"; + let extension_session_id = insert_bound_resident_cleanup_fixture( + &mut sidecar, + &vm_id, + execution_id, + process_id, + true, + false, + None, + ); + + let completed = execute_cleanup_request( + &mut sidecar, + 303, + &vm_id, + RequestPayload::ResetExecution(crate::protocol::ResetExecutionRequest { + execution_id: execution_id.to_owned(), + }), + ) + .await; + assert!(extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + complete_cleanup_request(&mut sidecar, completed); + assert!(!extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn delete_execution_completion_prunes_extension_resident_process() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1).await.remove(0); + let execution_id = "delete-context"; + let process_id = "delete-resident-process"; + let extension_session_id = insert_bound_resident_cleanup_fixture( + &mut sidecar, + &vm_id, + execution_id, + process_id, + true, + false, + None, + ); + + let completed = execute_cleanup_request( + &mut sidecar, + 304, + &vm_id, + RequestPayload::DeleteExecution(crate::protocol::DeleteExecutionRequest { + execution_id: execution_id.to_owned(), + }), + ) + .await; + assert!(extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + complete_cleanup_request(&mut sidecar, completed); + assert!(!extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn reused_resident_start_failure_completion_prunes_extension_process() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1).await.remove(0); + let execution_id = "failed-reuse-context"; + let process_id = "failed-reuse-resident-process"; + let extension_session_id = insert_bound_resident_cleanup_fixture( + &mut sidecar, + &vm_id, + execution_id, + process_id, + true, + false, + None, + ); + + let completed = execute_cleanup_request( + &mut sidecar, + 305, + &vm_id, + RequestPayload::JavaScriptExecution(crate::protocol::JavaScriptExecutionRequest { + process: crate::protocol::ProcessExecutionOptions { + identity: crate::protocol::ExecutionIdentityOptions { + context_id: Some(execution_id.to_owned()), + }, + output: crate::protocol::ExecutionOutputOptions { + capture: None, + retain_events: None, + }, + operation_id: None, + background: None, + cwd: None, + env: None, + args: Vec::new(), + stdin: None, + timeout_ms: None, + pty: None, + }, + source: String::from("globalThis.unreachable = true"), + format: Some(crate::protocol::JavaScriptModuleFormat::CommonJs), + file_path: None, + inputs: None, + }), + ) + .await; + assert!(extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + complete_cleanup_request(&mut sidecar, completed); + assert!(!extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn prepared_completion_prunes_extension_process_effect_even_on_error() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1).await.remove(0); + let process_id = "failed-request-effect-process"; + let extension_session_id = insert_bound_resident_cleanup_fixture( + &mut sidecar, + &vm_id, + "failed-request-effect-execution", + process_id, + true, + false, + None, + ); + let request = RequestFrame::new( + 306, + cleanup_test_ownership(&vm_id), + RequestPayload::ListExecutions(crate::protocol::ListExecutionsRequest {}), + ); + let effects = sidecar.request_completion_effects(&request); + effects + .record_exited_process(process_id) + .expect("record exited process effect"); + let prepared = PreparedRequest::from_future_with_effects( + request, + async { + Err(SidecarError::InvalidState(String::from( + "failure after process cleanup", + ))) + }, + effects, + ); + + let completed = prepared.execute().await; + assert!(completed.failed()); + let response = complete_cleanup_request(&mut sidecar, completed); + assert!(matches!(response.payload, ResponsePayload::Rejected(_))); + assert!(!extension_tracks_process( + &sidecar, + &extension_session_id, + process_id + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_prepared_request_waits_without_borrowing_coordinator_and_preserves_identity() { + fn assert_owned() {} + assert_owned::(); + + let mut sidecar = test_sidecar(); + let request = RequestFrame::new( + 41, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + RequestPayload::GetZombieTimerCount( + crate::protocol::GetZombieTimerCountRequest::default(), + ), + ); + let response = agentos_native_sidecar_core::zombie_timer_count_response(&request, 7); + let (release, wait) = tokio::sync::oneshot::channel(); + let prepared = PreparedRequest::from_future(request.clone(), async move { + wait.await + .map_err(|_| SidecarError::Execution(String::from("test gate dropped")))?; + Ok(DispatchResult { + response, + events: Vec::new(), + }) + }); + + let mut task = Box::pin(prepared.execute()); + assert!( + poll_future_once(task.as_mut()).is_none(), + "the owned operation should remain independently pending" + ); + release.send(()).expect("release prepared request"); + let completed = task.await; + assert_eq!(completed.request.request_id, request.request_id); + assert_eq!(completed.request.ownership, request.ownership); + assert!(!completed.failed()); + + let response = compat_response( + sidecar + .complete_request(completed) + .expect("complete prepared request"), + ); + assert_eq!(response.request_id, request.request_id); + assert_eq!(response.ownership, request.ownership); + assert!(matches!( + response.payload, + ResponsePayload::ZombieTimerCount(crate::protocol::ZombieTimerCountResponse { + count: 7 + }) + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn gated_vm_a_operation_does_not_delay_vm_b_command() { + let mut sidecar = test_sidecar(); + let vm_ids = create_test_vms(&mut sidecar, 2).await; + let vm_a = sidecar.vms.handle(&vm_ids[0]).expect("VM A handle"); + let vm_b = sidecar.vms.handle(&vm_ids[1]).expect("VM B handle"); + let request = RequestFrame::new( + 101, + OwnershipScope::vm( + "vm-handle-test-connection", + "vm-handle-test-session", + vm_ids[0].clone(), + ), + RequestPayload::GetZombieTimerCount( + crate::protocol::GetZombieTimerCountRequest::default(), + ), + ); + let response = agentos_native_sidecar_core::zombie_timer_count_response(&request, 0); + let (release, wait) = tokio::sync::oneshot::channel(); + let prepared = PreparedRequest::from_future(request, async move { + wait.await + .map_err(|_| SidecarError::Execution(String::from("test gate dropped")))?; + vm_a.try_command("gated VM A test command", |vm| { + vm.attached_child_event_cursor = 1; + Ok(()) + })?; + Ok(DispatchResult { + response, + events: Vec::new(), + }) + }); + let mut vm_a_operation = Box::pin(prepared.execute()); + + assert!( + poll_future_once(vm_a_operation.as_mut()).is_none(), + "VM A operation must be waiting at the deterministic gate" + ); + vm_b.try_command("independent VM B test command", |vm| { + vm.attached_child_event_cursor = 7; + Ok(()) + }) + .expect("VM B command completes while VM A is gated"); + assert_eq!( + vm_b.try_read("verify VM B command", |vm| vm.attached_child_event_cursor) + .expect("read VM B"), + 7 + ); + + release.send(()).expect("release VM A gate"); + let completed = vm_a_operation.await; + assert!(!completed.failed()); + } + + #[tokio::test(flavor = "current_thread")] + async fn vm_execution_engines_are_partitioned_by_vm() { + let mut sidecar = test_sidecar(); + let vm_ids = create_test_vms(&mut sidecar, 2).await; + let vm_a = sidecar.vms.handle(&vm_ids[0]).expect("VM A handle"); + let vm_b = sidecar.vms.handle(&vm_ids[1]).expect("VM B handle"); + let engines_a = vm_a + .try_read("clone VM A engines", |vm| vm.execution_engines.clone()) + .expect("VM A engines"); + let engines_b = vm_b + .try_read("clone VM B engines", |vm| vm.execution_engines.clone()) + .expect("VM B engines"); + + let _held_a = engines_a + .javascript("gate VM A JavaScript engine") + .expect("borrow VM A JavaScript engine"); + vm_a.try_command("mutate VM A state outside its held engine", |vm| { + vm.attached_child_event_cursor = 11; + Ok(()) + }) + .expect("VM A state remains accessible while its engine is held"); + assert_eq!( + vm_a.try_read("verify VM A state outside its held engine", |vm| { + vm.attached_child_event_cursor + }) + .expect("read VM A state while its engine is held"), + 11 + ); + let _independent_b = engines_b + .javascript("enter VM B JavaScript engine") + .expect("VM B engine remains independently available"); + let conflict = engines_a + .javascript("re-enter VM A JavaScript engine") + .expect_err("same-VM engine re-entry must be a typed conflict"); + assert!(conflict + .to_string() + .contains("ERR_AGENTOS_VM_EXECUTION_CONFLICT")); + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_and_language_routes_are_prepared_without_central_fallback() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1) + .await + .pop() + .expect("test VM id"); + let ownership = OwnershipScope::vm( + "vm-handle-test-connection", + "vm-handle-test-session", + vm_id.clone(), + ); + let execute = RequestFrame::new( + 111, + ownership.clone(), + RequestPayload::Execute(ExecuteRequest { + process_id: String::from("prepared-execute"), + command: Some(String::from("node")), + runtime: None, + entrypoint: None, + args: vec![String::from("-e"), String::from("0")], + env: Default::default(), + cwd: None, + wasm_permission_tier: None, + }), + ); + let prepared_execute = sidecar + .prepare_request_wire(wire_request(execute)) + .expect("prepare execute") + .expect("Execute must not use coordinated fallback"); + drop(prepared_execute); + + let lifecycle = RequestFrame::new( + 112, + ownership, + RequestPayload::ListExecutions(crate::protocol::ListExecutionsRequest {}), + ); + let prepared_lifecycle = sidecar + .prepare_request_wire(wire_request(lifecycle)) + .expect("prepare language lifecycle") + .expect("ExecutionLifecycle must not use coordinated fallback"); + drop(prepared_lifecycle); + } + + #[tokio::test(flavor = "current_thread")] + async fn prepared_filesystem_command_avoids_central_fallback() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1) + .await + .pop() + .expect("test VM id"); + let request = RequestFrame::new( + 102, + OwnershipScope::vm("vm-handle-test-connection", "vm-handle-test-session", vm_id), + RequestPayload::GuestFilesystemCall(GuestFilesystemCallRequest { + operation: crate::protocol::GuestFilesystemOperation::Stat, + path: String::from("/"), + destination_path: None, + target: None, + content: None, + encoding: None, + recursive: false, + max_depth: None, + mode: None, + uid: None, + gid: None, + atime_ms: None, + mtime_ms: None, + len: None, + offset: None, + }), + ); + + let prepared = sidecar + .prepare_request_wire(wire_request(request)) + .expect("prepare filesystem command") + .expect("filesystem command is detachable"); + drop(prepared); + } + + #[tokio::test(flavor = "current_thread")] + async fn invalid_owned_guest_calls_become_prepared_rejections() { + let mut sidecar = test_sidecar(); + let filesystem = RequestFrame::new( + 103, + OwnershipScope::connection("not-vm-owned"), + RequestPayload::GuestFilesystemCall(GuestFilesystemCallRequest { + operation: crate::protocol::GuestFilesystemOperation::Stat, + path: String::from("/"), + destination_path: None, + target: None, + content: None, + encoding: None, + recursive: false, + max_depth: None, + mode: None, + uid: None, + gid: None, + atime_ms: None, + mtime_ms: None, + len: None, + offset: None, + }), + ); + let filesystem = sidecar + .prepare_request_wire(wire_request(filesystem)) + .expect("invalid filesystem ownership is not a router failure") + .expect("filesystem call is always prepared") + .execute() + .await; + assert!(filesystem.failed()); + + let kernel = RequestFrame::new( + 104, + OwnershipScope::connection("not-vm-owned"), + RequestPayload::GuestKernelCall(crate::protocol::GuestKernelCallRequest { + execution_id: String::from("missing-execution"), + operation: String::from("noop"), + payload: Vec::new(), + }), + ); + let kernel = sidecar + .prepare_request_wire(wire_request(kernel)) + .expect("invalid kernel ownership is not a router failure") + .expect("kernel call is always prepared") + .execute() + .await; + assert!(kernel.failed()); + } + + #[tokio::test(flavor = "current_thread")] + async fn overlapping_vm_commands_return_typed_conflict_without_panicking() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1) + .await + .pop() + .expect("test VM id"); + let outer = sidecar.vms.handle(&vm_id).expect("outer VM handle"); + let nested = outer.clone(); + + let error = outer + .try_command("outer test command", |_| { + nested.try_command("nested test command", |_| Ok(())) + }) + .expect_err("nested command must report a typed conflict"); + assert!(error + .to_string() + .contains("ERR_AGENTOS_VM_COORDINATOR_CONFLICT")); + } + + #[tokio::test(flavor = "current_thread")] + async fn live_vm_handle_makes_disposal_conflict_typed_and_non_destructive() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1) + .await + .pop() + .expect("test VM id"); + let live_handle = sidecar.vms.handle(&vm_id).expect("live VM handle"); + + let error = match sidecar.vms.try_remove(&vm_id, "dispose") { + Ok(_) => panic!("live handle must exclude disposal"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("ERR_AGENTOS_VM_COORDINATOR_CONFLICT")); + assert!(sidecar.vms.contains_key(&vm_id)); + + drop(live_handle); + assert!(sidecar + .vms + .try_remove(&vm_id, "dispose") + .expect("removal after handle release") + .is_some()); + } + + #[tokio::test(flavor = "current_thread")] + async fn preparation_keeps_cross_connection_ownership_rejection_terminal() { + let mut sidecar = test_sidecar(); + sidecar.connections.insert( + String::from("owner"), + ConnectionState { + auth_token: String::new(), + sessions: BTreeSet::from([String::from("session-1")]), + }, + ); + sidecar.connections.insert( + String::from("attacker"), + ConnectionState { + auth_token: String::new(), + sessions: BTreeSet::new(), + }, + ); + sidecar.sessions.insert( + String::from("session-1"), + SessionState { + connection_id: String::from("owner"), + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: BTreeMap::new(), + vm_ids: BTreeSet::from([String::from("vm-1")]), + }, + ); + let request = RequestFrame::new( + 77, + OwnershipScope::vm("attacker", "session-1", "vm-1"), + RequestPayload::ProvidedCommands(crate::protocol::ProvidedCommandsRequest {}), + ); + + let prepared = sidecar + .prepare_request_wire(wire_request(request.clone())) + .expect("prepare query route") + .expect("query route is detachable"); + let completed = prepared.execute().await; + assert!(completed.failed()); + let response = compat_response( + sidecar + .complete_request(completed) + .expect("ownership error becomes a canonical response"), + ); + assert_eq!(response.request_id, request.request_id); + assert_eq!(response.ownership, request.ownership); + match response.payload { + ResponsePayload::Rejected(rejected) => { + assert!(rejected + .message + .contains("session session-1 is not owned by connection attacker")); + } + payload => panic!("expected ownership rejection, got {payload:?}"), + } + } + + #[tokio::test(flavor = "current_thread")] + async fn short_coordinator_routes_and_unknown_extension_never_fall_back() { + let mut sidecar = test_sidecar(); + let invalid_authenticate = RequestFrame::new( + 200, + OwnershipScope::connection("client-hint"), + RequestPayload::Authenticate(crate::protocol::AuthenticateRequest { + client_name: String::from("invalid-prepared-route-test"), + auth_token: String::new(), + protocol_version: crate::protocol::PROTOCOL_VERSION + 1, + bridge_version: agentos_bridge::bridge_contract().version, + }), + ); + let invalid_authenticate = sidecar + .prepare_request_wire(wire_request(invalid_authenticate)) + .expect("invalid authenticate is a prepared rejection") + .expect("authenticate is always prepared") + .execute() + .await; + assert!(invalid_authenticate.failed()); + + let unauthenticated_open = RequestFrame::new( + 201, + OwnershipScope::connection("unknown-connection"), + RequestPayload::OpenSession(OpenSessionRequest { + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: std::collections::HashMap::new(), + }), + ); + let unauthenticated_open = sidecar + .prepare_request_wire(wire_request(unauthenticated_open)) + .expect("unauthenticated open is a prepared rejection") + .expect("open session is always prepared") + .execute() + .await; + assert!(unauthenticated_open.failed()); + + let authenticate = RequestFrame::new( + 202, + OwnershipScope::connection("client-hint"), + RequestPayload::Authenticate(crate::protocol::AuthenticateRequest { + client_name: String::from("prepared-route-test"), + auth_token: String::new(), + protocol_version: crate::protocol::PROTOCOL_VERSION, + bridge_version: agentos_bridge::bridge_contract().version, + }), + ); + let authenticated = sidecar + .prepare_request_wire(wire_request(authenticate)) + .expect("prepare authenticate") + .expect("authenticate is always prepared"); + assert!(sidecar.connections.is_empty()); + assert_eq!(sidecar.next_connection_id, 0); + let authenticated = authenticated.execute().await; + assert!( + sidecar.connections.is_empty(), + "executing the owned response must not mutate central membership before completion" + ); + let authenticated = compat_response( + sidecar + .complete_request(authenticated) + .expect("complete authenticate"), + ); + let ResponsePayload::Authenticated(authenticated) = authenticated.payload else { + panic!("authenticate returned a different response"); + }; + assert!(sidecar + .connections + .contains_key(&authenticated.connection_id)); + + let open_session = RequestFrame::new( + 203, + OwnershipScope::connection(authenticated.connection_id.clone()), + RequestPayload::OpenSession(OpenSessionRequest { + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: std::collections::HashMap::new(), + }), + ); + let opened = sidecar + .prepare_request_wire(wire_request(open_session)) + .expect("prepare open session") + .expect("open session is always prepared"); + assert!(sidecar.sessions.is_empty()); + assert_eq!(sidecar.next_session_id, 0); + let opened = opened.execute().await; + assert!( + sidecar.sessions.is_empty(), + "owned response execution stages central session membership until completion" + ); + let opened = compat_response( + sidecar + .complete_request(opened) + .expect("complete open session"), + ); + assert!(matches!(opened.payload, ResponsePayload::SessionOpened(_))); + + let unknown_extension = RequestFrame::new( + 204, + OwnershipScope::connection(authenticated.connection_id), + RequestPayload::Ext(ExtEnvelope { + namespace: String::from("missing.prepared.extension"), + payload: Vec::new(), + }), + ); + let rejected = sidecar + .prepare_request_wire(wire_request(unknown_extension)) + .expect("prepare unknown extension") + .expect("unknown extension has a prepared canonical rejection") + .execute() + .await; + let rejected = compat_response( + sidecar + .complete_request(rejected) + .expect("complete unknown extension rejection"), + ); + assert!(matches!( + rejected.payload, + ResponsePayload::Rejected(RejectedResponse { ref code, .. }) if code == "unknown_extension" + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn host_callback_registration_and_snapshot_queries_never_fall_back() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1) + .await + .pop() + .expect("test VM id"); + let ownership = OwnershipScope::vm( + "vm-handle-test-connection", + "vm-handle-test-session", + &vm_id, + ); + let registration = RequestFrame::new( + 204, + ownership.clone(), + RequestPayload::RegisterHostCallbacks(crate::protocol::RegisterHostCallbacksRequest { + name: String::from("prepared-bindings"), + description: String::from("prepared route test bindings"), + command_aliases: vec![String::from("agentos-prepared-bindings")], + registry_command_aliases: vec![String::from("agentos")], + callbacks: std::collections::HashMap::from([( + String::from("call"), + crate::protocol::RegisteredHostCallbackDefinition { + description: String::from("prepared route test callback"), + input_schema: String::from( + r#"{"type":"object","additionalProperties":false}"#, + ), + timeout_ms: None, + examples: Vec::new(), + }, + )]), + }), + ); + let prepared_registration = sidecar + .prepare_request_wire(wire_request(registration)) + .expect("prepare host callback registration") + .expect("host callback registration is prepared"); + assert!( + !sidecar + .vms + .get(&vm_id) + .expect("test VM") + .bindings + .contains_key("prepared-bindings"), + "preparing host callback registration must not mutate VM state" + ); + let completed_registration = prepared_registration.execute().await; + assert!( + sidecar + .vms + .get(&vm_id) + .expect("test VM") + .bindings + .contains_key("prepared-bindings"), + "owned host callback registration runs only when its prepared future executes" + ); + sidecar + .complete_request(completed_registration) + .expect("complete host callback registration"); + + let queries = [ + RequestPayload::GetProcessSnapshot(crate::protocol::GetProcessSnapshotRequest {}), + RequestPayload::GetResourceSnapshot(crate::protocol::GetResourceSnapshotRequest {}), + RequestPayload::GetZombieTimerCount( + crate::protocol::GetZombieTimerCountRequest::default(), + ), + RequestPayload::ProvidedCommands(crate::protocol::ProvidedCommandsRequest {}), + RequestPayload::ListMounts(crate::protocol::ListMountsRequest {}), + ]; + for (index, payload) in queries.into_iter().enumerate() { + let request = RequestFrame::new(210 + index as RequestId, ownership.clone(), payload); + assert!(sidecar + .prepare_request_wire(wire_request(request)) + .expect("prepare snapshot/list query") + .is_some()); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn owned_host_callback_restore_failure_rolls_back_registry_and_permissions() { + let mut sidecar = test_sidecar(); + let vm_id = create_test_vms(&mut sidecar, 1) + .await + .pop() + .expect("test VM id"); + let (original_permissions, original_bindings, original_paths) = { + let vm = sidecar.vms.get(&vm_id).expect("test VM"); + ( + vm.configuration.permissions.clone(), + vm.bindings.clone(), + vm.command_guest_paths.clone(), + ) + }; + sidecar + .bridge + .queue_set_vm_permissions_result(Ok(())) + .expect("queue temporary allow-all permission set"); + sidecar + .bridge + .queue_set_vm_permissions_result(Err(SidecarError::Bridge(String::from( + "injected owned registration restore failure", + )))) + .expect("queue original permission restore failure"); + + let request = RequestFrame::new( + 230, + cleanup_test_ownership(&vm_id), + RequestPayload::RegisterHostCallbacks(crate::protocol::RegisterHostCallbacksRequest { + name: String::from("rollback-bindings"), + description: String::from("rollback binding collection"), + command_aliases: vec![String::from("rollback-command")], + registry_command_aliases: vec![String::from("agentos")], + callbacks: std::collections::HashMap::from([( + String::from("call"), + crate::protocol::RegisteredHostCallbackDefinition { + description: String::from("rollback callback"), + input_schema: String::from( + r#"{"type":"object","additionalProperties":false}"#, + ), + timeout_ms: None, + examples: Vec::new(), + }, + )]), + }), + ); + let completed = sidecar + .prepare_request_wire(wire_request(request)) + .expect("prepare callback registration") + .expect("callback registration is detachable") + .execute() + .await; + assert!(completed.failed()); + let response = compat_response( + sidecar + .complete_request(completed) + .expect("complete failed callback registration"), + ); + let ResponsePayload::Rejected(rejected) = response.payload else { + panic!("expected callback registration rejection"); + }; + assert!(rejected + .message + .contains("injected owned registration restore failure")); + + let vm = sidecar.vms.get(&vm_id).expect("test VM"); + assert_eq!(vm.bindings, original_bindings); + assert_eq!(vm.command_guest_paths, original_paths); + assert!(!vm.kernel.commands().contains_key("rollback-command")); + drop(vm); + assert_eq!( + sidecar + .bridge + .permissions + .lock() + .expect("read bridge permissions") + .get(&vm_id), + Some(&original_permissions), + ); + } + + #[test] + fn mutating_vm_request_uses_detached_vm_handle_path() { + let mut sidecar = test_sidecar(); + let request = RequestFrame::new( + 91, + OwnershipScope::vm("conn-1", "session-1", "vm-1"), + RequestPayload::WriteStdin(WriteStdinRequest { + process_id: String::from("process-1"), + chunk: vec![1, 2, 3], + }), + ); + assert!( + sidecar + .prepare_request_wire(wire_request(request)) + .expect("classify mutating request") + .is_some(), + "mutating VM state must use the detached VM-handle path" + ); + } +} + #[cfg(test)] mod legacy_child_spawn_options_tests { use super::*; @@ -4197,7 +6894,7 @@ mod symlinked_node_modules_hint_tests { let stderr = "Error: ENOENT: no such file or directory, open '/root/node_modules/.pnpm/@mariozechner+pi-coding-agent@0.60.0_x/node_modules/@mariozechner/pi-coding-agent/dist/package.json'"; let hint = symlinked_node_modules_hint(stderr).expect("expected hoisted guidance"); assert!(hint.contains("agentos can't load mounted node_modules")); - assert!(!hint.contains("agentos")); + assert!(!hint.contains("/root/node_modules/.pnpm/")); } #[test] @@ -4394,7 +7091,7 @@ mod dispose_lifecycle_tests { fn handle_request<'a>( &'a self, - _ctx: ExtensionContext<'a>, + _ctx: ExtensionContext, _payload: Vec, ) -> ExtensionFuture<'a, ExtensionResponse> { Box::pin(async { Ok(ExtensionResponse::new(Vec::new())) }) diff --git a/crates/native-sidecar/src/state.rs b/crates/native-sidecar/src/state.rs index 8675b372be..825915b3cb 100644 --- a/crates/native-sidecar/src/state.rs +++ b/crates/native-sidecar/src/state.rs @@ -15,8 +15,9 @@ use agentos_bridge::{ BridgeTypes, FilesystemSnapshot, }; use agentos_execution::{ - v8_host::V8SessionHandle, JavascriptExecution, JavascriptSyncRpcRequest, PythonExecution, - PythonVfsRpcRequest, WasmExecution, + v8_host::V8SessionHandle, JavascriptExecution, JavascriptExecutionEngine, + JavascriptSyncRpcRequest, PythonExecution, PythonExecutionEngine, PythonVfsRpcRequest, + PythonVfsRpcResponder, WasmExecution, WasmExecutionEngine, }; use agentos_kernel::fd_table::TransferredFd; use agentos_kernel::kernel::{KernelProcessHandle, KernelVm}; @@ -32,13 +33,18 @@ use rusqlite::Connection; use serde::{Deserialize, Serialize}; use serde_json::Value; use socket2::Socket; +use std::borrow::Borrow as StdBorrow; +use std::cell::{Ref, RefCell, RefMut}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::error::Error; use std::fmt; use std::fs::File; +use std::future::Future; use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; +use std::pin::Pin; +use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak}; use std::time::{Duration, Instant}; @@ -657,6 +663,18 @@ pub trait SidecarRequestTransport: Send + Sync { request: SidecarRequestFrame, timeout: Duration, ) -> Result; + + /// Async callback delivery for extension request tasks. Transports with a + /// native async waiter override this so a callback never parks the sidecar + /// runtime thread. The default preserves compatibility for in-process test + /// transports that only implement the synchronous API. + fn send_request_async<'a>( + &'a self, + request: SidecarRequestFrame, + timeout: Duration, + ) -> Pin> + 'a>> { + Box::pin(async move { self.send_request(request, timeout) }) + } } #[derive(Clone)] @@ -704,6 +722,32 @@ impl SharedSidecarRequestClient { } Ok(response.payload) } + + pub(crate) async fn invoke_async( + &self, + ownership: crate::protocol::OwnershipScope, + payload: SidecarRequestPayload, + timeout: Duration, + ) -> Result { + let transport = self.transport.as_ref().ok_or_else(|| { + SidecarError::Unsupported(String::from("sidecar request transport is not configured")) + })?; + let request_id = self.next_request_id.fetch_sub(1, Ordering::Relaxed); + let request = SidecarRequestFrame::new(request_id, ownership.clone(), payload); + let response = transport.send_request_async(request, timeout).await?; + if response.request_id != request_id { + return Err(SidecarError::InvalidState(format!( + "sidecar response {} did not match request {request_id}", + response.request_id + ))); + } + if response.ownership != ownership { + return Err(SidecarError::InvalidState(String::from( + "sidecar response ownership did not match request ownership", + ))); + } + Ok(response.payload) + } } /// Fire-and-forget live event sink. Lets an extension emit a `session/update` @@ -826,6 +870,92 @@ impl Default for VmConfiguration { } } +/// VM-local language-engine ownership. +/// +/// Engine context maps are mutable and must not remain process-global: a long +/// launch in one VM would otherwise serialize every other VM behind the same +/// engine owner. A launch clones this owner out through [`VmHandle`], releases +/// the outer `VmState` borrow, and may then hold only the selected language's +/// `RefCell` borrow across startup awaits. That deliberately preserves narrow +/// per-VM/per-runtime ordering while leaving the same VM's kernel, filesystem, +/// sockets, and other language engines independently accessible. +#[derive(Clone)] +pub(crate) struct VmExecutionEngines { + inner: Rc, +} + +struct VmExecutionEnginesInner { + vm_id: String, + javascript: RefCell, + python: RefCell, + wasm: RefCell, +} + +impl VmExecutionEngines { + pub(crate) fn new( + vm_id: String, + runtime: agentos_runtime::RuntimeContext, + event_notify: Arc, + ) -> Self { + let mut javascript = JavascriptExecutionEngine::new(runtime.clone()); + javascript.set_event_notify(Some(Arc::clone(&event_notify))); + let mut python = PythonExecutionEngine::new(runtime.clone()); + python.set_event_notify(Some(Arc::clone(&event_notify))); + let mut wasm = WasmExecutionEngine::new(runtime); + wasm.set_event_notify(Some(event_notify)); + Self { + inner: Rc::new(VmExecutionEnginesInner { + vm_id, + javascript: RefCell::new(javascript), + python: RefCell::new(python), + wasm: RefCell::new(wasm), + }), + } + } + + pub(crate) fn javascript( + &self, + operation: &'static str, + ) -> Result, SidecarError> { + self.inner.javascript.try_borrow_mut().map_err(|_| { + execution_engine_conflict_error(&self.inner.vm_id, "JavaScript", operation) + }) + } + + pub(crate) fn python( + &self, + operation: &'static str, + ) -> Result, SidecarError> { + self.inner + .python + .try_borrow_mut() + .map_err(|_| execution_engine_conflict_error(&self.inner.vm_id, "Python", operation)) + } + + pub(crate) fn wasm( + &self, + operation: &'static str, + ) -> Result, SidecarError> { + self.inner.wasm.try_borrow_mut().map_err(|_| { + execution_engine_conflict_error(&self.inner.vm_id, "WebAssembly", operation) + }) + } +} + +impl Drop for VmExecutionEnginesInner { + fn drop(&mut self) { + self.javascript.get_mut().dispose_vm(&self.vm_id); + self.python.get_mut().dispose_vm(&self.vm_id); + self.wasm.get_mut().dispose_vm(&self.vm_id); + } +} + +fn execution_engine_conflict_error(vm_id: &str, runtime: &str, operation: &str) -> SidecarError { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_VM_EXECUTION_CONFLICT: {runtime} execution state for VM {vm_id} is already in use during {operation}" + )) +} + #[allow(dead_code)] pub(crate) struct VmState { pub(crate) connection_id: String, @@ -843,6 +973,8 @@ pub(crate) struct VmState { /// VM-scoped admission view over the process's single Tokio runtime and /// fixed blocking executor. This owns no runtime or worker of its own. pub(crate) runtime_context: agentos_runtime::RuntimeContext, + /// Language runtime context maps owned by this VM generation. + pub(crate) execution_engines: VmExecutionEngines, /// One resolved SQLite transport shared by every durable VM subsystem. pub(crate) database: Option, /// Common lifecycle/identity registry for native and kernel backends. @@ -917,6 +1049,243 @@ pub(crate) struct VmState { pub(crate) unix_socket_host_dir: PathBuf, } +/// Cloneable, thread-affine access to one VM's mutable state. +/// +/// Guest execution is deliberately driven by the protocol process's +/// `LocalSet`, so this is an `Rc` boundary rather than a cross-thread mutex. +/// Callers must release a borrow before awaiting external work; cloning a +/// handle never grants access to another VM. +#[derive(Clone)] +pub(crate) struct VmHandle { + inner: Rc>, +} + +impl VmHandle { + pub(crate) fn new(state: VmState) -> Self { + Self { + inner: Rc::new(RefCell::new(state)), + } + } + + pub(crate) fn borrow(&self) -> Ref<'_, VmState> { + self.inner.as_ref().borrow() + } + + pub(crate) fn borrow_mut(&self) -> RefMut<'_, VmState> { + self.inner.borrow_mut() + } + + pub(crate) fn try_borrow_mut( + &self, + operation: &'static str, + ) -> Result, SidecarError> { + self.inner.try_borrow_mut().map_err(|_| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: VM state is already in a critical section during {operation}" + )) + }) + } + + pub(crate) fn try_read( + &self, + operation: &'static str, + read: impl FnOnce(&VmState) -> T, + ) -> Result { + let state = self.inner.try_borrow().map_err(|_| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: VM state is already in a mutable critical section during {operation}" + )) + })?; + Ok(read(&state)) + } + + pub(crate) fn try_command( + &self, + operation: &'static str, + command: impl FnOnce(&mut VmState) -> Result, + ) -> Result { + let mut state = self.inner.try_borrow_mut().map_err(|_| { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: VM state is already in a critical section during {operation}" + )) + })?; + command(&mut state) + } +} + +impl std::fmt::Debug for VmHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = self.borrow(); + formatter + .debug_struct("VmHandle") + .field("connection_id", &state.connection_id) + .field("session_id", &state.session_id) + .field("generation", &state.generation) + .finish_non_exhaustive() + } +} + +/// Registry facade that preserves the sidecar's existing short-borrow API +/// while storing each VM behind an independently cloneable handle. +#[derive(Debug, Default)] +pub(crate) struct VmRegistry { + vms: BTreeMap, +} + +impl VmRegistry { + pub(crate) fn len(&self) -> usize { + self.vms.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.vms.is_empty() + } + + pub(crate) fn contains_key(&self, key: &Q) -> bool + where + String: StdBorrow, + Q: Ord + ?Sized, + { + self.vms.contains_key(key) + } + + pub(crate) fn get(&self, key: &Q) -> Option> + where + String: StdBorrow, + Q: Ord + ?Sized, + { + self.vms.get(key).map(VmHandle::borrow) + } + + pub(crate) fn get_mut(&self, key: &Q) -> Option> + where + String: StdBorrow, + Q: Ord + ?Sized, + { + self.vms.get(key).map(VmHandle::borrow_mut) + } + + pub(crate) fn handle(&self, key: &Q) -> Option + where + String: StdBorrow, + Q: Ord + ?Sized, + { + self.vms.get(key).cloned() + } + + pub(crate) fn insert( + &mut self, + key: String, + state: VmState, + ) -> Result, SidecarError> { + let replacement = match self.vms.remove(&key) { + Some(handle) => match Rc::try_unwrap(handle.inner) { + Ok(state) => Some(state.into_inner()), + Err(inner) => { + self.vms.insert(key.clone(), VmHandle { inner }); + return Err(vm_handle_conflict_error(&key, "replace")); + } + }, + None => None, + }; + self.vms.insert(key, VmHandle::new(state)); + Ok(replacement) + } + + pub(crate) fn try_remove( + &mut self, + key: &Q, + operation: &'static str, + ) -> Result, SidecarError> + where + String: StdBorrow, + Q: Ord + ?Sized, + { + let Some((owned_key, handle)) = self.vms.remove_entry(key) else { + return Ok(None); + }; + match Rc::try_unwrap(handle.inner) { + Ok(state) => Ok(Some(state.into_inner())), + Err(inner) => { + self.vms.insert(owned_key.clone(), VmHandle { inner }); + Err(vm_handle_conflict_error(&owned_key, operation)) + } + } + } + + pub(crate) fn clear(&mut self) -> Result<(), SidecarError> { + if let Some((key, _)) = self + .vms + .iter() + .find(|(_, handle)| Rc::strong_count(&handle.inner) != 1) + { + return Err(vm_handle_conflict_error(key, "clear")); + } + for (key, handle) in std::mem::take(&mut self.vms) { + match Rc::try_unwrap(handle.inner) { + Ok(state) => drop(state.into_inner()), + Err(inner) => { + self.vms.insert(key.clone(), VmHandle { inner }); + return Err(vm_handle_conflict_error(&key, "clear")); + } + } + } + Ok(()) + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&String, &mut VmState) -> bool) { + self.vms.retain(|key, handle| { + match handle.inner.try_borrow_mut() { + Ok(mut state) => { + let retained = keep(key, &mut state); + if !retained && Rc::strong_count(&handle.inner) != 1 { + eprintln!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: deferring removal of VM {key} while an owned operation retains its handle" + ); + true + } else { + retained + } + } + Err(_) => { + eprintln!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: deferring retain-filter for VM {key} while a short critical section is active" + ); + true + } + } + }); + } + + pub(crate) fn keys(&self) -> impl Iterator { + self.vms.keys() + } + + pub(crate) fn iter(&self) -> impl Iterator)> { + self.vms.iter().map(|(key, handle)| (key, handle.borrow())) + } + + pub(crate) fn values(&self) -> impl Iterator> { + self.vms.values().map(VmHandle::borrow) + } + + pub(crate) fn values_mut(&self) -> impl Iterator> { + self.vms.values().filter_map(|handle| { + handle.inner.try_borrow_mut().map_err(|_| { + eprintln!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: deferring mutable VM iteration while a short critical section is active" + ); + }).ok() + }) + } +} + +fn vm_handle_conflict_error(vm_id: &str, operation: &str) -> SidecarError { + SidecarError::InvalidState(format!( + "ERR_AGENTOS_VM_COORDINATOR_CONFLICT: cannot {operation} VM {vm_id} while an owned operation retains its handle" + )) +} + #[derive(Debug)] pub(crate) enum VmFetchBodyMode { Empty, @@ -1249,6 +1618,10 @@ pub(crate) struct ActiveProcess { pub(crate) pending_execution_event_count_gauge: Arc, pub(crate) pending_execution_event_bytes_gauge: Arc, pub(crate) vm_pending_event_bytes_budget: Arc, + /// Serializes push delivery from this child into its parent V8 session. + /// Once an event leaves the child queue for bounded retry, later output or + /// exit events stay on the source child until that exact relay completes. + pub(crate) child_bridge_relay_in_flight: Arc, pub(crate) pending_javascript_net_connects: BTreeMap>>, pub(crate) pending_self_signal_exit: Option, @@ -1351,7 +1724,10 @@ pub(crate) struct PendingChildProcessSync { pub(crate) enum PendingChildProcessSyncCompletion { Javascript(tokio::sync::oneshot::Sender>), - Python { request_id: u64 }, + Python { + request_id: u64, + responder: PythonVfsRpcResponder, + }, } #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] @@ -2760,6 +3136,8 @@ pub(crate) struct ProcessEventEnvelope { pub(crate) connection_id: String, pub(crate) session_id: String, pub(crate) vm_id: String, + /// Exact descendant locator relative to `process_id`. Empty targets the root. + pub(crate) child_path: Vec, pub(crate) process_id: String, pub(crate) event: ActiveExecutionEvent, } diff --git a/crates/native-sidecar/src/vm.rs b/crates/native-sidecar/src/vm.rs index 56fccb7820..32b3153f74 100644 --- a/crates/native-sidecar/src/vm.rs +++ b/crates/native-sidecar/src/vm.rs @@ -7,8 +7,10 @@ use crate::bootstrap::{ apply_root_filesystem_entry, discover_command_guest_paths, root_snapshot_entries, root_snapshot_entry, root_snapshot_from_entries, }; -use crate::bridge::{bridge_permissions, MountPluginContext}; +use crate::bridge::{bridge_permissions, build_mount_plugin_registry, MountPluginContext}; use crate::execution::{sync_process_host_writes_to_kernel, terminate_child_process_tree}; +use crate::extension::Extension; +use crate::process_event_broker::ProcessEventBroker; use crate::protocol::{ AgentosProjectedAgent, ConfigureVmRequest, CreateLayerRequest, CreateOverlayRequest, DisposeReason, EventFrame, ExportSnapshotRequest, ImportSnapshotRequest, LinkPackageRequest, @@ -17,6 +19,7 @@ use crate::protocol::{ RootFilesystemEntryEncoding, RootFilesystemLowerDescriptor, SealLayerRequest, SnapshotRootFilesystemRequest, VmLifecycleState, }; +use crate::request_operations::OperationCancellationReason; use crate::service::{ audit_fields, dirname, emit_security_audit_event, emit_structured_event, kernel_error, normalize_path, plugin_error, root_filesystem_error, validate_permissions_policy, vfs_error, @@ -24,9 +27,9 @@ use crate::service::{ use crate::state::{ BridgeError, KernelSocketReadinessEvent, KernelSocketReadinessRegistry, KernelSocketReadinessTarget, QuarantinedVmGeneration, VmConfiguration, VmDnsConfig, - VmListenPolicy, VmPendingByteBudget, VmQuarantineReason, VmReconciliationSnapshot, VmState, - DISPOSE_VM_SIGKILL_GRACE, DISPOSE_VM_SIGTERM_GRACE, EXECUTION_DRIVER_NAME, JAVASCRIPT_COMMAND, - PYTHON_COMMAND, WASM_COMMAND, + VmExecutionEngines, VmHandle, VmListenPolicy, VmPendingByteBudget, VmQuarantineReason, + VmReconciliationSnapshot, VmState, DISPOSE_VM_SIGKILL_GRACE, DISPOSE_VM_SIGTERM_GRACE, + EXECUTION_DRIVER_NAME, JAVASCRIPT_COMMAND, PYTHON_COMMAND, WASM_COMMAND, }; use crate::{DispatchResult, NativeSidecar, NativeSidecarBridge, SidecarError}; @@ -56,7 +59,8 @@ use agentos_native_sidecar_core::{ provided_commands_response, root_filesystem_bootstrapped_response, root_filesystem_protocol_descriptor_from_config, root_filesystem_snapshot_response, snapshot_exported_response, snapshot_imported_response, vm_configured_response, - vm_created_response, vm_disposed_response, VmLayerStore, + vm_created_response, vm_disposed_response, vm_lifecycle_event as shared_vm_lifecycle_event, + VmLayerStore, }; use agentos_runtime::accounting::{ResourceClass, ResourceLedger, ResourceLimit}; use agentos_runtime::capability::CapabilityRegistry; @@ -214,6 +218,130 @@ fn projected_commands_from_guest_paths( }) .collect() } + +/// Owned request context for work serialized by one VM's lifecycle ordering key. +/// +/// Preparing this value performs the central ownership lookup once and clones the +/// per-VM handle. Executing the operation can then happen after the +/// `NativeSidecar` coordinator borrow has ended. +#[derive(Clone)] +pub(crate) struct OwnedVmLifecycleRequest { + request: crate::protocol::RequestFrame, + connection_id: String, + session_id: String, + vm_id: String, + vm: VmHandle, +} + +/// Cloneable dependencies needed by the owned `ConfigureVm` implementation. +pub(crate) struct ConfigureVmOwnedInput { + lifecycle: OwnedVmLifecycleRequest, + bridge: crate::state::SharedBridge, + snapshot_runtime_context: agentos_runtime::RuntimeContext, + sidecar_requests: crate::state::SharedSidecarRequestClient, +} + +impl Clone for ConfigureVmOwnedInput { + fn clone(&self) -> Self { + Self { + lifecycle: self.lifecycle.clone(), + bridge: self.bridge.clone(), + snapshot_runtime_context: self.snapshot_runtime_context.clone(), + sidecar_requests: self.sidecar_requests.clone(), + } + } +} + +/// Cloneable dependencies needed by the owned `LinkPackage` implementation. +pub(crate) struct LinkPackageOwnedInput { + lifecycle: OwnedVmLifecycleRequest, + bridge: crate::state::SharedBridge, + sidecar_requests: crate::state::SharedSidecarRequestClient, +} + +/// Create work detached from the process coordinator. +/// +/// VM identity and resource admission are reserved during preparation. Database +/// resolution, schema migration, filesystem bootstrap, and kernel construction +/// happen when this owned value is executed. +pub(crate) struct PreparedCreateVm { + request: crate::protocol::RequestFrame, + payload: crate::protocol::CreateVmRequest, + connection_id: String, + session_id: String, + vm_id: String, + vm_generation: u64, + create_config: vm_config::CreateVmConfig, + root_filesystem: RootFilesystemDescriptor, + permissions_policy: agentos_vm_config::PermissionsPolicy, + limits: crate::limits::VmLimits, + vm_resources: Arc, + vm_runtime_context: agentos_runtime::RuntimeContext, + dns: VmDnsConfig, + listen_policy: VmListenPolicy, + create_loopback_exempt_ports: BTreeSet, + bridge: crate::state::SharedBridge, + dns_resolver: agentos_kernel::dns::SharedDnsResolver, + sidecar_requests: crate::state::SharedSidecarRequestClient, + process_event_notify: Arc, + extensions: Vec>, +} + +/// Fully constructed VM awaiting a short session/registry publication command. +pub(crate) struct CompletedCreateVm { + request: crate::protocol::RequestFrame, + connection_id: String, + session_id: String, + vm_id: String, + vm: VmState, + events: Vec, + bridge: crate::state::SharedBridge, +} + +/// Validated dispose intent. The stdio owner begins VM disposal and waits for +/// operation drain before converting this plan into [`PreparedDisposeVm`]. +pub(crate) struct DisposeVmPlan { + request: Option, + connection_id: String, + session_id: String, + vm_id: String, + reason: DisposeReason, + bridge: crate::state::SharedBridge, + sidecar_requests: crate::state::SharedSidecarRequestClient, + process_event_broker: ProcessEventBroker, +} + +/// Detached VM teardown payload. +/// +/// Construction is a short coordinator command performed only after the +/// ownership coordinator has entered `Closing` and drained VM operations. The +/// value then owns all state needed for bounded teardown and reconciliation, so +/// no lifecycle permit or `&mut NativeSidecar` is retained across its awaits. +pub(crate) struct PreparedDisposeVm { + plan: DisposeVmPlan, + vm: VmState, +} + +/// Teardown result awaiting short central tracking/quarantine finalization. +pub(crate) struct CompletedDisposeVm { + request: Option, + connection_id: String, + session_id: String, + vm_id: String, + events: Vec, + quarantine: Option, + result: Result<(), SidecarError>, +} + +impl Clone for LinkPackageOwnedInput { + fn clone(&self) -> Self { + Self { + lifecycle: self.lifecycle.clone(), + bridge: self.bridge.clone(), + sidecar_requests: self.sidecar_requests.clone(), + } + } +} // --------------------------------------------------------------------------- // NativeSidecar VM lifecycle methods // --------------------------------------------------------------------------- @@ -223,6 +351,301 @@ where B: NativeSidecarBridge + Send + 'static, BridgeError: fmt::Debug + Send + Sync + 'static, { + pub(crate) fn prepare_vm_lifecycle_request( + &self, + request: &crate::protocol::RequestFrame, + ) -> Result { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + let vm = self.vms.handle(&vm_id).ok_or_else(|| { + SidecarError::InvalidState(format!( + "VM {vm_id} no longer exists while preparing its lifecycle operation" + )) + })?; + Ok(OwnedVmLifecycleRequest { + request: request.clone(), + connection_id, + session_id, + vm_id, + vm, + }) + } + + pub(crate) fn prepare_configure_vm_request( + &self, + request: &crate::protocol::RequestFrame, + ) -> Result, SidecarError> { + let lifecycle = self.prepare_vm_lifecycle_request(request)?; + let snapshot_runtime_context = self.runtime_context.as_ref().cloned().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_RUNTIME_UNAVAILABLE: snapshot pre-warm requires RuntimeContext", + )) + })?; + Ok(ConfigureVmOwnedInput { + lifecycle, + bridge: self.bridge.clone(), + snapshot_runtime_context, + sidecar_requests: self.sidecar_requests.clone(), + }) + } + + pub(crate) fn prepare_link_package_request( + &self, + request: &crate::protocol::RequestFrame, + ) -> Result, SidecarError> { + Ok(LinkPackageOwnedInput { + lifecycle: self.prepare_vm_lifecycle_request(request)?, + bridge: self.bridge.clone(), + sidecar_requests: self.sidecar_requests.clone(), + }) + } + + pub(crate) fn prepare_create_vm( + &mut self, + request: &crate::protocol::RequestFrame, + payload: crate::protocol::CreateVmRequest, + ) -> Result, SidecarError> { + let (connection_id, session_id) = self.session_scope_for(&request.ownership)?; + self.require_owned_session(&connection_id, &session_id)?; + let create_config: vm_config::CreateVmConfig = serde_json::from_str(&payload.config) + .map_err(|error| { + SidecarError::InvalidState(format!("invalid create VM config JSON: {error}")) + })?; + create_config + .validate(self.config.max_frame_bytes) + .map_err(|error| { + SidecarError::InvalidState(format!("invalid create VM config: {error}")) + })?; + let root_filesystem = + root_filesystem_protocol_descriptor_from_config(&create_config.root_filesystem); + let permissions_policy = create_config + .permissions + .clone() + .unwrap_or_else(deny_all_policy); + validate_permissions_policy(&permissions_policy)?; + let limits = crate::limits::vm_limits_from_config( + create_config.limits.as_ref(), + self.config.max_frame_bytes, + )?; + let dns = vm_dns_config_from_config(create_config.dns.as_ref())?; + let listen_policy = vm_listen_policy_from_config(create_config.listen.as_ref())?; + let create_loopback_exempt_ports = create_config + .loopback_exempt_ports + .iter() + .copied() + .collect(); + let (vm_id, vm_generation) = self.allocate_vm_identity()?; + let process_runtime_context = self.runtime_context.as_ref().cloned().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "ERR_AGENTOS_RUNTIME_UNAVAILABLE: VM admission requires RuntimeContext", + )) + })?; + let vm_resources = Arc::new(vm_resource_ledger( + &vm_id, + vm_generation, + &limits, + Arc::clone(process_runtime_context.resources()), + )?); + let vm_runtime_context = + process_runtime_context.scoped_for_vm(Arc::clone(&vm_resources), vm_generation); + + Ok(PreparedCreateVm { + request: request.clone(), + payload, + connection_id, + session_id, + vm_id, + vm_generation, + create_config, + root_filesystem, + permissions_policy, + limits, + vm_resources, + vm_runtime_context, + dns, + listen_policy, + create_loopback_exempt_ports, + bridge: self.bridge.clone(), + dns_resolver: Arc::clone(&self.dns_resolver), + sidecar_requests: self.sidecar_requests.clone(), + process_event_notify: Arc::clone(&self.process_event_notify), + extensions: self.extensions.values().cloned().collect(), + }) + } + + pub(crate) fn complete_create_vm( + &mut self, + completed: CompletedCreateVm, + ) -> Result { + let CompletedCreateVm { + request, + connection_id, + session_id, + vm_id, + vm, + events, + bridge, + } = completed; + if let Err(error) = self.require_owned_session(&connection_id, &session_id) { + cleanup_unpublished_vm(&bridge, &vm_id, &vm); + return Err(error); + } + if self.vms.contains_key(&vm_id) { + cleanup_unpublished_vm(&bridge, &vm_id, &vm); + return Err(SidecarError::InvalidState(format!( + "VM {vm_id} already exists during create finalization" + ))); + } + let cleanup_cwd = vm.cwd.clone(); + let cleanup_socket_dir = vm.unix_socket_host_dir.clone(); + if let Err(error) = self.vms.insert(vm_id.clone(), vm) { + cleanup_path(&cleanup_cwd, "unpublished VM shadow root"); + cleanup_path(&cleanup_socket_dir, "unpublished VM Unix socket namespace"); + if let Err(cleanup_error) = bridge.clear_vm_permissions(&vm_id) { + eprintln!( + "ERR_AGENTOS_VM_CREATE_CLEANUP: vm_id={vm_id} phase=permission_reset error={cleanup_error}" + ); + } + return Err(error); + } + self.sessions + .get_mut(&session_id) + .expect("owned session should exist during create finalization") + .vm_ids + .insert(vm_id.clone()); + self.observe_active_vm_generations(); + Ok(DispatchResult { + response: vm_created_response(&request, vm_id), + events, + }) + } + + pub(crate) fn prepare_dispose_vm( + &self, + request: &crate::protocol::RequestFrame, + payload: crate::protocol::DisposeVmRequest, + ) -> Result, SidecarError> { + let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; + self.prepare_owned_vm_disposal( + connection_id, + session_id, + vm_id, + payload.reason, + Some(request.clone()), + ) + } + + /// Prepare teardown for a VM owned by a non-protocol lifecycle source. + /// + /// Extension session cleanup uses this entry point for its bound VMs. It + /// deliberately has no request envelope: callers complete it with + /// [`Self::complete_owned_vm_disposal`] and receive only lifecycle events. + pub(crate) fn prepare_internal_vm_disposal( + &self, + connection_id: String, + session_id: String, + vm_id: String, + reason: DisposeReason, + ) -> Result, SidecarError> { + self.prepare_owned_vm_disposal(connection_id, session_id, vm_id, reason, None) + } + + fn prepare_owned_vm_disposal( + &self, + connection_id: String, + session_id: String, + vm_id: String, + reason: DisposeReason, + request: Option, + ) -> Result, SidecarError> { + self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + Ok(DisposeVmPlan { + request, + connection_id, + session_id, + vm_id, + reason, + bridge: self.bridge.clone(), + sidecar_requests: self.sidecar_requests.clone(), + process_event_broker: self.process_event_broker.clone(), + }) + } + + /// Detach a VM after ownership coordination has entered `Closing` and all + /// previously admitted VM operations have drained. + pub(crate) fn detach_vm_for_disposal( + &mut self, + plan: DisposeVmPlan, + ) -> Result, SidecarError> { + self.require_owned_vm(&plan.connection_id, &plan.session_id, &plan.vm_id)?; + let cancellation_reason = dispose_cancellation_reason(&plan.reason); + if let Err(error) = plan.process_event_broker.dispose_vm( + &plan.connection_id, + &plan.session_id, + &plan.vm_id, + cancellation_reason, + ) { + eprintln!( + "ERR_AGENTOS_PROCESS_EVENT_VM_DISPOSAL: connection_id={} session_id={} vm_id={} error={error}", + plan.connection_id, plan.session_id, plan.vm_id + ); + } + let vm = self + .vms + .try_remove(&plan.vm_id, "begin owned dispose")? + .expect("owned VM should exist during dispose detachment"); + Ok(PreparedDisposeVm { plan, vm }) + } + + pub(crate) fn complete_dispose_vm( + &mut self, + completed: CompletedDisposeVm, + ) -> Result { + let request = completed.request.clone().ok_or_else(|| { + SidecarError::InvalidState(String::from( + "protocol VM disposal completed without a request envelope", + )) + })?; + let vm_id = completed.vm_id.clone(); + let events = self.complete_owned_vm_disposal(completed)?; + Ok(DispatchResult { + response: vm_disposed_response(&request, vm_id), + events, + }) + } + + /// Finalize a detached VM teardown without constructing a protocol + /// response. This is the short central-state mutation paired with + /// [`Self::prepare_internal_vm_disposal`]. + pub(crate) fn complete_owned_vm_disposal( + &mut self, + completed: CompletedDisposeVm, + ) -> Result, SidecarError> { + let CompletedDisposeVm { + request: _, + connection_id, + session_id, + vm_id, + mut events, + quarantine, + result, + } = completed; + self.reclaim_vm_tracking(&session_id, &vm_id); + if let Some(quarantine) = quarantine { + self.retain_quarantined_vm(quarantine)?; + } else { + self.observe_active_vm_generations(); + } + result?; + events.push(shared_vm_lifecycle_event( + &connection_id, + &session_id, + &vm_id, + VmLifecycleState::Disposed, + )); + Ok(events) + } + pub(crate) fn allocate_vm_identity(&mut self) -> Result<(String, u64), SidecarError> { self.reap_reconciled_quarantined_vms(); self.ensure_vm_generation_capacity()?; @@ -249,6 +672,17 @@ where &mut self, request: &crate::protocol::RequestFrame, payload: crate::protocol::CreateVmRequest, + ) -> Result { + let prepared = self.prepare_create_vm(request, payload)?; + let completed = prepared.execute().await?; + self.complete_create_vm(completed) + } + + #[allow(dead_code)] + async fn create_vm_legacy_impl( + &mut self, + request: &crate::protocol::RequestFrame, + payload: crate::protocol::CreateVmRequest, ) -> Result { let __t = Instant::now(); let (connection_id, session_id) = self.session_scope_for(&request.ownership)?; @@ -519,6 +953,11 @@ where pending_stdin_bytes_budget, pending_event_bytes_budget, resources: vm_resources, + execution_engines: VmExecutionEngines::new( + vm_id.clone(), + vm_runtime_context.clone(), + Arc::clone(&self.process_event_notify), + ), runtime_context: vm_runtime_context, database, capabilities, @@ -566,7 +1005,7 @@ where unix_address_registry: Arc::new(Mutex::new(BTreeMap::new())), unix_socket_host_dir, }, - ); + )?; self.observe_active_vm_generations(); let events = vec![ @@ -591,522 +1030,144 @@ where request: &crate::protocol::RequestFrame, payload: crate::protocol::DisposeVmRequest, ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - let events = self - .dispose_vm_internal(&connection_id, &session_id, &vm_id, payload.reason) - .await?; - - Ok(DispatchResult { - response: vm_disposed_response(request, vm_id), - events, - }) + let plan = self.prepare_dispose_vm(request, payload)?; + let prepared = self.detach_vm_for_disposal(plan)?; + let completed = prepared.execute().await; + self.complete_dispose_vm(completed) } - pub(crate) async fn bootstrap_root_filesystem( + pub(crate) fn bootstrap_root_filesystem( &mut self, request: &crate::protocol::RequestFrame, entries: Vec, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let root = vm.kernel.root_filesystem_mut().ok_or_else(|| { - SidecarError::InvalidState(String::from("VM root filesystem is unavailable")) - })?; - for entry in &entries { - apply_root_filesystem_entry(root, entry)?; - } - - Ok(DispatchResult { - response: root_filesystem_bootstrapped_response(request, entries.len() as u32), - events: Vec::new(), - }) + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { bootstrap_root_filesystem_owned(input?, entries).await } } - pub(crate) async fn configure_vm( + pub(crate) fn configure_vm( &mut self, request: &crate::protocol::RequestFrame, payload: ConfigureVmRequest, - ) -> Result { - let __t = Instant::now(); - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; + ) -> impl std::future::Future> + 'static { + let input = self.prepare_configure_vm_request(request); + async move { configure_vm_owned(input?, payload) } + } - let mount_plugins = &self.mount_plugins; - let bridge = self.bridge.clone(); - let snapshot_runtime_context = self.runtime_context.as_ref().cloned().ok_or_else(|| { - SidecarError::InvalidState(String::from( - "ERR_AGENTOS_RUNTIME_UNAVAILABLE: snapshot pre-warm requires RuntimeContext", - )) - })?; - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let max_pread_bytes = vm.kernel.resource_limits().max_pread_bytes; - let original_permissions = vm.configuration.permissions.clone(); - let configured_permissions = payload - .permissions - .clone() - .map(crate::wire::permissions_policy_config_from_wire) - .unwrap_or_else(|| original_permissions.clone()); - validate_permissions_policy(&configured_permissions)?; - bridge.set_vm_permissions(&vm_id, &allow_all_policy())?; - let mut effective_mounts = payload.mounts.clone(); - append_module_access_mount(&mut effective_mounts, payload.module_access_cwd.as_ref())?; - let package_descriptors = package_descriptors_from_wire(&payload.packages)?; - let mut provided_commands: BTreeMap> = BTreeMap::new(); - for descriptor in &package_descriptors { - provided_commands.insert( - descriptor.name.clone(), - descriptor - .commands + /// Runtime dynamic `linkSoftware`: add one package's tar/current/bin leaf + /// mounts to the live VM so commands appear under `/opt/agentos/bin` + /// immediately, with no reboot. Returns the linked command names. + pub(crate) fn link_package( + &mut self, + request: &crate::protocol::RequestFrame, + payload: LinkPackageRequest, + ) -> impl std::future::Future> + 'static { + let input = self.prepare_link_package_request(request); + async move { link_package_owned(input?, payload).await } + } + + pub(crate) fn provided_commands( + &mut self, + request: &crate::protocol::RequestFrame, + _payload: ProvidedCommandsRequest, + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { + let input = input?; + let packages = input.vm.try_read("list provided commands", |vm| { + vm.provided_commands .iter() - .map(|target| target.command.clone()) - .collect(), - ); + .map(|(package_name, commands)| PackageCommands { + package_name: package_name.clone(), + commands: commands.clone(), + }) + .collect() + })?; + Ok(DispatchResult { + response: provided_commands_response(&input.request, packages), + events: Vec::new(), + }) } - let snapshot_userland_code = resolve_agent_snapshot_bundle(&package_descriptors)?; - let package_mounts = - build_packages_projection(&vm_id, &package_descriptors, &payload.packages_mount_at)?; - effective_mounts.extend(package_mounts); - apply_package_provides_env(&mut vm.guest_env, &package_descriptors); - append_package_provides_mounts(&mut effective_mounts, &package_descriptors)?; - let reconfigure_result = reconcile_mounts( - mount_plugins, - vm, - &effective_mounts, - MountPluginContext { - bridge: bridge.clone(), - runtime_context: vm.runtime_context.clone(), - connection_id: connection_id.clone(), - session_id: session_id.clone(), - vm_id: vm_id.clone(), - sidecar_requests: self.sidecar_requests.clone(), - database: vm.database.clone(), - max_pread_bytes, - }, - ) - .and_then(|()| { - vm.command_guest_paths = discover_command_guest_paths(&mut vm.kernel); - // The `{ packageDir }` projection lands each package's `bin/` at - // `/opt/agentos/bin/` (on `$PATH`) but does NOT populate - // `/__agentos/commands`, so `discover_command_guest_paths` alone misses - // projected commands and every projected wasm/js command resolves to - // ENOEXEC (absolute path) / ENOENT (bare name). Register each projected - // command by name -> its `/opt/agentos/bin/` entrypoint so both the - // kernel command table (via `execution_commands` below) and the sidecar - // entrypoint resolver (`resolve_guest_command_entrypoint`) can find it. - for commands in provided_commands.values() { - for command in commands { - let entrypoint = - format!("{}/{command}", crate::package_projection::OPT_AGENTOS_BIN); - vm.command_guest_paths - .entry(command.clone()) - .or_insert(entrypoint); - } - } - refresh_guest_command_path_env(&mut vm.guest_env, &vm.command_guest_paths); - let mut execution_commands = - vec![String::from(JAVASCRIPT_COMMAND), String::from(WASM_COMMAND)]; - execution_commands.extend(payload.bootstrap_commands.iter().cloned()); - execution_commands.extend(payload.binding_shim_commands.iter().cloned()); - execution_commands.extend(vm.command_guest_paths.keys().cloned()); - vm.kernel - .register_driver(CommandDriver::new( - EXECUTION_DRIVER_NAME, - execution_commands, - )) - .map_err(kernel_error)?; - vm.command_permissions = payload.command_permissions.clone().into_iter().collect(); - let mut loopback_exempt_ports = vm.create_loopback_exempt_ports.clone(); - loopback_exempt_ports.extend(payload.loopback_exempt_ports.iter().copied()); - vm.kernel.set_loopback_exempt_ports(loopback_exempt_ports); - vm.configuration = VmConfiguration { - mounts: effective_mounts.clone(), - software: payload.software.clone(), - permissions: configured_permissions.clone(), - module_access_cwd: payload.module_access_cwd.clone(), - instructions: payload.instructions.clone(), - projected_modules: payload.projected_modules.clone(), - command_permissions: payload.command_permissions.clone().into_iter().collect(), - provided_commands: provided_commands.clone(), - // jsRuntime is create-time only; preserve what create_vm stored. - js_runtime: vm.configuration.js_runtime.clone(), - snapshot_userland_code: snapshot_userland_code.clone(), - loopback_exempt_ports: payload.loopback_exempt_ports.clone(), - }; - vm.provided_commands = provided_commands; - Ok(()) - }); - match reconfigure_result { - Ok(()) => { - bridge.set_vm_permissions(&vm_id, &configured_permissions)?; - } - Err(error) => { - match bridge.restore_vm_permissions_fail_closed( - &vm_id, - &original_permissions, - "configure_vm rollback", - &error, - ) { - Ok(()) => return Err(error), - Err(rollback_error) => { - self.vms - .get_mut(&vm_id) - .expect("owned VM should exist") - .configuration - .permissions = deny_all_policy(); - return Err(rollback_error); - } - } - } - } - - let applied_mounts = effective_mounts.len() as u32; - let configured_software = payload.software.len() as u32; - let projected_commands = projected_commands_from_guest_paths(&vm.command_guest_paths); - let agents = projected_agents_from_descriptors(&package_descriptors); - vm.projected_agent_launch = projected_agent_launch_from_descriptors(&package_descriptors); - let _ = vm; - // Pre-warm the agent-SDK snapshot when a configured package opts in with - // `agent.snapshot`. The sidecar reads the bundle from the host package dir - // it already projects, so the first session is warm without shipping the - // source over the client wire. - if let Some(userland) = snapshot_userland_code { - let requested_bytes = userland.len(); - let runtime_for_job = snapshot_runtime_context.clone(); - match snapshot_runtime_context - .blocking() - .run(requested_bytes, move || { - agentos_execution::v8_host::pre_warm_agent_snapshot(&runtime_for_job, &userland) - }) - .await - { - Ok(Ok(())) => {} - Ok(Err(error)) => eprintln!("agent snapshot pre-warm failed: {error}"), - Err(error) => { - eprintln!("agent snapshot pre-warm admission or execution failed: {error}") - } - } - } - - tracing::info!(target: "agentos_native_sidecar::perf", phase = "configure_vm", elapsed_ms = __t.elapsed().as_millis() as u64, applied_mounts = applied_mounts as u64, "vm phase"); - Ok(DispatchResult { - response: vm_configured_response( - request, - applied_mounts, - configured_software, - projected_commands, - agents, - ), - events: Vec::new(), - }) - } - - /// Runtime dynamic `linkSoftware`: add one package's tar/current/bin leaf - /// mounts to the live VM so commands appear under `/opt/agentos/bin` - /// immediately, with no reboot. Returns the linked command names. - pub(crate) async fn link_package( - &mut self, - request: &crate::protocol::RequestFrame, - payload: LinkPackageRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let descriptor = - crate::package_projection::read_package_manifest_from_path(&payload.package.path)?; - let new_mounts = build_packages_projection( - &vm_id, - std::slice::from_ref(&descriptor), - crate::package_projection::OPT_AGENTOS_ROOT, - )?; - if new_mounts.iter().all(|mount| { - vm.configuration - .mounts - .iter() - .any(|existing| existing.guest_path == mount.guest_path) - }) { - let projected_commands = descriptor - .commands - .iter() - .map(|target| ProjectedCommand { - name: target.command.clone(), - guest_path: projected_command_guest_path(&target.command), - }) - .collect(); - let agents = projected_agents_from_descriptors(std::slice::from_ref(&descriptor)); - return Ok(DispatchResult { - response: package_linked_response(request, projected_commands, agents), - events: Vec::new(), - }); - } - for mount in &new_mounts { - if vm - .configuration - .mounts - .iter() - .any(|existing| existing.guest_path == mount.guest_path) - { - if let Some(command) = mount - .guest_path - .strip_prefix(crate::package_projection::OPT_AGENTOS_BIN) - .and_then(|path| path.strip_prefix('/')) - .filter(|path| !path.is_empty()) - { - return Err(SidecarError::InvalidState(format!( - "command {command:?} is already provided by another package" - ))); - } - return Err(SidecarError::InvalidState(format!( - "agentos package mount already exists at {}", - mount.guest_path - ))); - } - } - let mount_context = MountPluginContext { - bridge: self.bridge.clone(), - runtime_context: vm.runtime_context.clone(), - connection_id: connection_id.clone(), - session_id: session_id.clone(), - vm_id: vm_id.clone(), - sidecar_requests: self.sidecar_requests.clone(), - database: vm.database.clone(), - max_pread_bytes: vm.kernel.resource_limits().max_pread_bytes, - }; - mount_leaf_descriptors(&self.mount_plugins, vm, &new_mounts, mount_context)?; - vm.configuration.mounts.extend(new_mounts); - - let commands = descriptor - .commands - .iter() - .map(|target| target.command.clone()) - .collect::>(); - vm.provided_commands - .insert(descriptor.name.clone(), commands.clone()); - vm.configuration - .provided_commands - .insert(descriptor.name.clone(), commands.clone()); - for command in &commands { - let entrypoint = projected_command_guest_path(command); - vm.command_guest_paths - .entry(command.clone()) - .or_insert(entrypoint); - } - refresh_guest_command_path_env(&mut vm.guest_env, &vm.command_guest_paths); - let mut execution_commands = - vec![String::from(JAVASCRIPT_COMMAND), String::from(WASM_COMMAND)]; - execution_commands.extend(vm.command_guest_paths.keys().cloned()); - vm.kernel - .register_driver(CommandDriver::new( - EXECUTION_DRIVER_NAME, - execution_commands, - )) - .map_err(kernel_error)?; - let projected_commands = commands - .iter() - .map(|command| ProjectedCommand { - name: command.clone(), - guest_path: projected_command_guest_path(command), - }) - .collect(); - let agents = projected_agents_from_descriptors(std::slice::from_ref(&descriptor)); - if let Some(vm) = self.vms.get_mut(&vm_id) { - vm.projected_agent_launch - .extend(projected_agent_launch_from_descriptors( - std::slice::from_ref(&descriptor), - )); - } - - Ok(DispatchResult { - response: package_linked_response(request, projected_commands, agents), - events: Vec::new(), - }) - } - - pub(crate) async fn provided_commands( - &mut self, - request: &crate::protocol::RequestFrame, - _payload: ProvidedCommandsRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let packages = self - .vms - .get(&vm_id) - .map(|vm| { - vm.provided_commands - .iter() - .map(|(package_name, commands)| PackageCommands { - package_name: package_name.clone(), - commands: commands.clone(), - }) - .collect() - }) - .unwrap_or_default(); - - Ok(DispatchResult { - response: provided_commands_response(request, packages), - events: Vec::new(), - }) } - pub(crate) async fn create_layer( + pub(crate) fn create_layer( &mut self, request: &crate::protocol::RequestFrame, - _payload: CreateLayerRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let layer_id = vm - .layers - .create_writable_layer() - .map_err(sidecar_core_error)?; - - Ok(DispatchResult { - response: layer_created_response(request, layer_id), - events: Vec::new(), - }) + payload: CreateLayerRequest, + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { create_layer_owned(input?, payload).await } } - pub(crate) async fn seal_layer( + pub(crate) fn seal_layer( &mut self, request: &crate::protocol::RequestFrame, payload: SealLayerRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let layer_id = vm - .layers - .seal_layer(&payload.layer_id) - .map_err(sidecar_core_error)?; - - Ok(DispatchResult { - response: layer_sealed_response(request, layer_id), - events: Vec::new(), - }) + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { seal_layer_owned(input?, payload).await } } - pub(crate) async fn import_snapshot( + pub(crate) fn import_snapshot( &mut self, request: &crate::protocol::RequestFrame, payload: ImportSnapshotRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let layer_id = vm - .layers - .import_snapshot(root_snapshot_from_entries(&payload.entries)?) - .map_err(sidecar_core_error)?; - - Ok(DispatchResult { - response: snapshot_imported_response(request, layer_id), - events: Vec::new(), - }) + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { import_snapshot_owned(input?, payload).await } } - pub(crate) async fn export_snapshot( + pub(crate) fn export_snapshot( &mut self, request: &crate::protocol::RequestFrame, payload: ExportSnapshotRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let snapshot = vm - .layers - .export_snapshot(&payload.layer_id) - .map_err(sidecar_core_error)?; - - Ok(DispatchResult { - response: snapshot_exported_response( - request, - payload.layer_id, - root_snapshot_entries(&snapshot), - ), - events: Vec::new(), - }) + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { export_snapshot_owned(input?, payload).await } } - pub(crate) async fn create_overlay( + pub(crate) fn create_overlay( &mut self, request: &crate::protocol::RequestFrame, payload: CreateOverlayRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let layer_id = vm - .layers - .create_overlay_layer( - protocol_root_filesystem_mode(payload.mode), - payload.upper_layer_id, - payload.lower_layer_ids, - ) - .map_err(sidecar_core_error)?; - - Ok(DispatchResult { - response: overlay_created_response(request, layer_id), - events: Vec::new(), - }) + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { create_overlay_owned(input?, payload).await } } - pub(crate) async fn snapshot_root_filesystem( + pub(crate) fn snapshot_root_filesystem( &mut self, request: &crate::protocol::RequestFrame, payload: SnapshotRootFilesystemRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get_mut(&vm_id).expect("owned VM should exist"); - let snapshot = vm - .kernel - .snapshot_root_filesystem_bounded(payload.max_bytes) - .map_err(kernel_error)?; - - Ok(DispatchResult { - response: root_filesystem_snapshot_response( - request, - snapshot.entries.iter().map(root_snapshot_entry).collect(), - ), - events: Vec::new(), - }) + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { snapshot_root_filesystem_owned(input?, payload).await } } - pub(crate) async fn list_mounts( + pub(crate) fn list_mounts( &mut self, request: &crate::protocol::RequestFrame, _payload: ListMountsRequest, - ) -> Result { - let (connection_id, session_id, vm_id) = self.vm_scope_for(&request.ownership)?; - self.require_owned_vm(&connection_id, &session_id, &vm_id)?; - - let vm = self.vms.get(&vm_id).expect("owned VM should exist"); - let mounts = vm - .kernel - .mounted_filesystems() - .into_iter() - .map(|mount| MountInfo { - path: mount.path, - kind: mount.plugin_id, - read_only: mount.read_only, + ) -> impl std::future::Future> + 'static { + let input = self.prepare_vm_lifecycle_request(request); + async move { + let input = input?; + let mounts = input.vm.try_read("list mounts", |vm| { + vm.kernel + .mounted_filesystems() + .into_iter() + .map(|mount| MountInfo { + path: mount.path, + kind: mount.plugin_id, + read_only: mount.read_only, + }) + .collect() + })?; + Ok(DispatchResult { + response: mounts_listed_response(&input.request, mounts), + events: Vec::new(), }) - .collect(); - - Ok(DispatchResult { - response: mounts_listed_response(request, mounts), - events: Vec::new(), - }) + } } pub(crate) async fn dispose_vm_internal( @@ -1114,10 +1175,26 @@ where connection_id: &str, session_id: &str, vm_id: &str, - _reason: DisposeReason, + reason: DisposeReason, ) -> Result, SidecarError> { self.require_owned_vm(connection_id, session_id, vm_id)?; + let cancellation_reason = match &reason { + DisposeReason::Requested => OperationCancellationReason::Explicit, + DisposeReason::ConnectionClosed => OperationCancellationReason::ConnectionClosed, + DisposeReason::HostShutdown => OperationCancellationReason::Shutdown, + }; + if let Err(error) = self.process_event_broker.dispose_vm( + connection_id, + session_id, + vm_id, + cancellation_reason, + ) { + eprintln!( + "ERR_AGENTOS_PROCESS_EVENT_VM_DISPOSAL: connection_id={connection_id} session_id={session_id} vm_id={vm_id} error={error}" + ); + } + let mut events = vec![self.vm_lifecycle_event( connection_id, session_id, @@ -1129,7 +1206,7 @@ where // down: a process that refuses to die must not strand the VM's tracking // entries for the process lifetime. let terminate_result = self.terminate_vm_processes(vm_id, &mut events).await; - if let Some(vm) = self.vms.get_mut(vm_id) { + if let Some(mut vm) = self.vms.get_mut(vm_id) { if let Some(task) = vm.execution_retention_wake_task.take() { task.abort(); } @@ -1148,22 +1225,23 @@ where // mandatory final write fail with ERR_AGENTOS_BLOCKING_EXECUTOR_SHUTDOWN. // Dispose requests are serialized by the sidecar, so retire admission // after the process drain but before detaching the VM from its registry. - let vm_before_disposal = self + let (vm_runtime_context, vm_capabilities, vm_generation) = self .vms .get(vm_id) + .map(|vm| { + ( + vm.runtime_context.clone(), + vm.capabilities.clone(), + vm.generation, + ) + }) .expect("owned VM should exist before disposal"); - let capability_admission_error = close_vm_admission( - &vm_before_disposal.runtime_context, - &vm_before_disposal.capabilities, - ) - .err(); + let capability_admission_error = + close_vm_admission(&vm_runtime_context, &vm_capabilities).err(); if let Some(error) = capability_admission_error.as_ref() { eprintln!("ERR_AGENTOS_VM_CAPABILITY_ADMISSION_CLOSE: vm_id={vm_id} error={error}"); } - let fairness_retirement_result = retire_vm_fairness( - &vm_before_disposal.runtime_context, - vm_before_disposal.generation, - ); + let fairness_retirement_result = retire_vm_fairness(&vm_runtime_context, vm_generation); if let Err(error) = fairness_retirement_result.as_ref() { eprintln!("ERR_AGENTOS_VM_FAIRNESS_RETIRE: vm_id={vm_id} error={error}"); } @@ -1172,7 +1250,7 @@ where // no `?` below can leave the registry entry (or any per-VM map) behind. let mut vm = self .vms - .remove(vm_id) + .try_remove(vm_id, "dispose")? .expect("owned VM should exist before disposal"); // `continue_on_error = true` => `shutdown_configured_mounts` never returns @@ -1323,134 +1401,1227 @@ where None }; - if let Err(error) = self - .bridge - .emit_lifecycle(vm_id, LifecycleState::Terminated) - { - record_vm_teardown_error(vm_id, "lifecycle", error, &mut first_error); - } - if let Err(error) = vm.kernel.dispose().map_err(kernel_error) { - record_vm_teardown_error(vm_id, "kernel", error, &mut first_error); + if let Err(error) = self + .bridge + .emit_lifecycle(vm_id, LifecycleState::Terminated) + { + record_vm_teardown_error(vm_id, "lifecycle", error, &mut first_error); + } + if let Err(error) = vm.kernel.dispose().map_err(kernel_error) { + record_vm_teardown_error(vm_id, "kernel", error, &mut first_error); + } + if let Some(snapshot) = snapshot { + if let Err(error) = self.bridge.with_mut(|bridge| { + bridge.flush_filesystem_state(FlushFilesystemStateRequest { + vm_id: vm_id.to_owned(), + snapshot, + }) + }) { + record_vm_teardown_error(vm_id, "filesystem_flush", error, &mut first_error); + } + } + if let Err(error) = self.bridge.clear_vm_permissions(vm_id) { + record_vm_teardown_error(vm_id, "permission_reset", error, &mut first_error); + } + first_error.map_or(Ok(()), Err) + } + + pub(crate) async fn terminate_vm_processes( + &mut self, + vm_id: &str, + events: &mut Vec, + ) -> Result<(), SidecarError> { + let process_ids = self + .vms + .get(vm_id) + .map(|vm| vm.active_processes.keys().cloned().collect::>()) + .unwrap_or_default(); + if process_ids.is_empty() { + return Ok(()); + } + + for process_id in process_ids { + if self + .vms + .get(vm_id) + .is_some_and(|vm| vm.active_processes.contains_key(&process_id)) + { + self.kill_process_internal(vm_id, &process_id, "SIGTERM")?; + } + } + self.wait_for_vm_processes_to_exit(vm_id, DISPOSE_VM_SIGTERM_GRACE, events) + .await?; + + if !self.vm_has_active_processes(vm_id) { + return Ok(()); + } + + let remaining = self + .vms + .get(vm_id) + .map(|vm| vm.active_processes.keys().cloned().collect::>()) + .unwrap_or_default(); + for process_id in remaining { + if self + .vms + .get(vm_id) + .is_some_and(|vm| vm.active_processes.contains_key(&process_id)) + { + self.kill_process_internal(vm_id, &process_id, "SIGKILL")?; + } + } + self.wait_for_vm_processes_to_exit(vm_id, DISPOSE_VM_SIGKILL_GRACE, events) + .await?; + + if self.vm_has_active_processes(vm_id) { + // A shared V8 execution can take longer than the bounded event-pump + // grace to report its exit after SIGKILL. VM teardown must still + // finalize process-owned bridge state before snapshotting the root + // filesystem; dropping ActiveProcess after the snapshot loses + // committed host-materialized SQLite/WAL pages. + let mut vm = self + .vms + .get_mut(vm_id) + .expect("active VM should exist during process teardown"); + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let unix_address_registry = Arc::clone(&vm.unix_address_registry); + let remaining = std::mem::take(&mut vm.active_processes); + eprintln!( + "ERR_AGENTOS_VM_FORCED_PROCESS_FINALIZE: vm_id={vm_id} process_count={}", + remaining.len() + ); + for (process_id, mut process) in remaining { + let should_sync_host_writes = process.host_write_dirty_recursive() + || !process.clean_host_writes_are_observable_recursive(); + if should_sync_host_writes { + sync_process_host_writes_to_kernel(&mut vm, &process)?; + } + terminate_child_process_tree( + &mut vm.kernel, + &mut process, + &kernel_readiness, + &unix_address_registry, + ); + process.kernel_handle.finish(137); + let _ = vm.kernel.wait_and_reap(process.kernel_pid); + vm.signal_states.remove(&process_id); + } + } + + Ok(()) + } + + pub(crate) async fn wait_for_vm_processes_to_exit( + &mut self, + vm_id: &str, + timeout: Duration, + events: &mut Vec, + ) -> Result<(), SidecarError> { + let ownership = self.vm_ownership(vm_id)?; + let deadline = Instant::now() + timeout; + + while self.vm_has_active_processes(vm_id) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + if let Some(event) = self.poll_event(&ownership, remaining).await? { + events.push(event); + } + } + + Ok(()) + } +} + +impl PreparedCreateVm +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + pub(crate) async fn execute(self) -> Result, SidecarError> { + let __t = Instant::now(); + let PreparedCreateVm { + request, + payload, + connection_id, + session_id, + vm_id, + vm_generation, + create_config, + root_filesystem, + permissions_policy, + limits, + vm_resources, + vm_runtime_context, + dns, + listen_policy, + create_loopback_exempt_ports, + bridge, + dns_resolver, + sidecar_requests, + process_event_notify, + extensions, + } = self; + let cwd = create_vm_shadow_root(&vm_id)?; + let cleanup_bridge = bridge.clone(); + let cleanup_vm_id = vm_id.clone(); + let cleanup_cwd = cwd.clone(); + let result = async move { + let (guest_cwd, host_cwd) = resolve_vm_cwds(create_config.cwd.as_ref(), &cwd)?; + fs::create_dir_all(&host_cwd) + .map_err(|error| SidecarError::Io(format!("failed to create VM cwd: {error}")))?; + let resource_limits = limits.resources.clone(); + let database = match create_config.database.as_ref() { + Some(descriptor) => { + let database = crate::vm_sqlite::resolve_vm_sqlite( + descriptor, + vm_runtime_context.clone(), + limits.sqlite.max_result_bytes, + ) + .await + .map_err(|error| { + SidecarError::InvalidState(format!( + "failed to resolve VM SQLite database: {error}" + )) + })?; + crate::plugins::chunked_actor_sqlite::bootstrap_schema(database.as_ref()) + .await + .map_err(|error| { + SidecarError::InvalidState(format!( + "failed to migrate VM SQLite database: {error}" + )) + })?; + for extension in extensions { + extension + .bootstrap_vm_database(database.clone()) + .await + .map_err(|error| { + SidecarError::InvalidState(format!( + "failed to migrate extension VM database schema: {error}" + )) + })?; + } + Some(database) + } + None => None, + }; + let capabilities = CapabilityRegistry::new(vm_generation, Arc::clone(&vm_resources)); + bridge.set_vm_permissions(&vm_id, &permissions_policy)?; + let permissions = bridge_permissions(bridge.clone(), &vm_id); + let mut guest_env = filter_env(&vm_id, &create_config.env, &permissions); + // Bootstrap runs under the trusted sidecar policy, then restores the + // caller's guest-visible permissions before publication. + bridge.set_vm_permissions(&vm_id, &allow_all_policy())?; + let native_root = native_root_plugin_from_config(create_config.native_root.as_ref())?; + let loaded_snapshot = if native_root.is_some() { + None + } else { + bridge.with_mut(|bridge| { + bridge.load_filesystem_state(LoadFilesystemStateRequest { + vm_id: vm_id.clone(), + }) + })? + }; + if native_root.is_none() { + materialize_shadow_root_snapshot_entries( + &cwd, + &root_filesystem, + loaded_snapshot.as_ref(), + &resource_limits, + )?; + } + + let mut config = KernelVmConfig::new(vm_id.clone()); + config.cwd = guest_cwd.clone(); + config.env = guest_env.clone(); + if let Some(user) = create_config.user.as_ref() { + config.user = agentos_kernel::user::UserConfig { + uid: user.uid, + gid: user.gid, + euid: user.euid, + egid: user.egid, + username: user.username.clone(), + homedir: user.homedir.clone(), + shell: user.shell.clone(), + gecos: user.gecos.clone(), + group_name: user.group_name.clone(), + supplementary_gids: user.supplementary_gids.clone().unwrap_or_default(), + accounts: user + .accounts + .as_deref() + .unwrap_or_default() + .iter() + .map(|account| agentos_kernel::user::UserAccount { + uid: account.uid, + gid: account.gid, + username: account.username.clone(), + homedir: account.homedir.clone(), + shell: account.shell.clone(), + gecos: account.gecos.clone().unwrap_or_default(), + supplementary_gids: account.supplementary_gids.clone(), + }) + .collect(), + groups: user + .groups + .as_deref() + .unwrap_or_default() + .iter() + .map(|group| agentos_kernel::user::GroupRecord { + gid: group.gid, + name: group.name.clone(), + members: group.members.clone(), + }) + .collect(), + }; + } + config.permissions = permissions; + config.dns = agentos_kernel::dns::DnsConfig { + name_servers: dns.name_servers.clone(), + overrides: dns.overrides.clone(), + }; + config.dns_resolver = dns_resolver; + config.loopback_exempt_ports = create_loopback_exempt_ports.clone(); + let mount_plugins = build_mount_plugin_registry::()?; + let root_mount_table = if let Some(native_root) = native_root.as_ref() { + build_native_root_mount_table( + &mount_plugins, + native_root, + &root_filesystem, + MountPluginContext { + bridge: bridge.clone(), + runtime_context: vm_runtime_context.clone(), + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + sidecar_requests, + database: database.clone(), + max_pread_bytes: resource_limits.max_pread_bytes, + }, + )? + } else { + agentos_native_sidecar_core::build_root_mount_table_with_loaded_snapshot( + &create_config.root_filesystem, + loaded_snapshot.as_ref(), + &resource_limits, + ) + .map_err(|error| SidecarError::InvalidState(error.to_string()))? + }; + config.resources = resource_limits; + let mut kernel = KernelVm::new(root_mount_table, config); + kernel + .set_socket_resource_ledger(Arc::clone(&vm_resources)) + .map_err(kernel_error)?; + let kernel_socket_readiness: KernelSocketReadinessRegistry = Arc::new( + crate::state::KernelSocketReadinessRegistryState::new( + limits.reactor.max_capabilities, + ), + ); + let readiness_targets = Arc::clone(&kernel_socket_readiness); + kernel.set_socket_readiness_sink(Some(move |readiness: SocketReadiness| { + for target in readiness_targets.targets(readiness.socket_id) { + send_kernel_socket_readiness_event(target, readiness); + } + })); + let command_guest_paths = discover_command_guest_paths(&mut kernel); + refresh_guest_command_path_env(&mut guest_env, &command_guest_paths); + let mut execution_commands = vec![ + String::from(JAVASCRIPT_COMMAND), + String::from(PYTHON_COMMAND), + String::from("python3"), + String::from(WASM_COMMAND), + ]; + if let Some(bootstrap_commands) = &create_config.bootstrap_commands { + execution_commands.extend(bootstrap_commands.iter().cloned()); + } + execution_commands.extend(command_guest_paths.keys().cloned()); + kernel + .register_driver(CommandDriver::new( + EXECUTION_DRIVER_NAME, + execution_commands, + )) + .map_err(kernel_error)?; + if let Some(root) = kernel.root_filesystem_mut() { + root.finish_bootstrap(); + } + bridge.set_vm_permissions(&vm_id, &permissions_policy)?; + bridge.emit_lifecycle(&vm_id, LifecycleState::Starting)?; + bridge.emit_lifecycle(&vm_id, LifecycleState::Ready)?; + bridge.emit_log( + &vm_id, + format!("created VM {vm_id} for session {session_id}"), + )?; + + let shadow_sync_inventory = crate::execution::initial_shadow_sync_inventory(&cwd)?; + let unix_socket_host_dir = create_vm_unix_socket_host_dir()?; + let pending_stdin_bytes_budget = VmPendingByteBudget::new( + limits.process.pending_stdin_bytes, + agentos_bridge::queue_tracker::TrackedLimit::PendingKernelStdinBytes, + ); + let pending_event_bytes_budget = VmPendingByteBudget::new( + limits.process.pending_event_bytes, + agentos_bridge::queue_tracker::TrackedLimit::PendingExecutionEventBytes, + ); + let events = vec![ + shared_vm_lifecycle_event( + &connection_id, + &session_id, + &vm_id, + VmLifecycleState::Creating, + ), + shared_vm_lifecycle_event( + &connection_id, + &session_id, + &vm_id, + VmLifecycleState::Ready, + ), + ]; + let vm = VmState { + connection_id: connection_id.clone(), + session_id: session_id.clone(), + generation: vm_generation, + limits, + pending_stdin_bytes_budget, + pending_event_bytes_budget, + resources: vm_resources, + execution_engines: VmExecutionEngines::new( + vm_id.clone(), + vm_runtime_context.clone(), + process_event_notify, + ), + runtime_context: vm_runtime_context, + database, + capabilities, + dns, + listen_policy, + create_loopback_exempt_ports, + guest_env, + requested_runtime: payload.runtime, + root_filesystem_mode: protocol_root_filesystem_mode(root_filesystem.mode), + guest_cwd, + cwd, + host_cwd, + kernel, + kernel_socket_readiness, + host_net_transfer_descriptions: Arc::new(Mutex::new(BTreeMap::new())), + loaded_snapshot, + configuration: VmConfiguration { + permissions: permissions_policy, + js_runtime: create_config.js_runtime.clone(), + ..VmConfiguration::default() + }, + layers: VmLayerStore::default(), + command_guest_paths, + provided_commands: BTreeMap::new(), + command_permissions: BTreeMap::new(), + bindings: BTreeMap::new(), + active_processes: BTreeMap::new(), + vm_fetch_streams: BTreeMap::new(), + next_vm_fetch_stream_id: 0, + executions: BTreeMap::new(), + execution_processes: BTreeMap::new(), + next_public_execution_id: 0, + execution_retention_wake_deadline_ms: None, + execution_retention_wake_task: None, + package_mutation_execution_id: None, + typescript_compiler_staged: false, + exited_process_snapshots: VecDeque::new(), + detached_child_processes: BTreeSet::new(), + attached_child_event_cursor: 0, + detached_child_event_cursor: 0, + signal_states: BTreeMap::new(), + packages_staging_root: None, + projected_agent_launch: BTreeMap::new(), + shadow_sync_inventory, + unix_address_registry: Arc::new(Mutex::new(BTreeMap::new())), + unix_socket_host_dir, + }; + tracing::info!(target: "agentos_native_sidecar::perf", phase = "create_vm", elapsed_ms = __t.elapsed().as_millis() as u64, "vm phase"); + Ok(CompletedCreateVm { + request, + connection_id, + session_id, + vm_id, + vm, + events, + bridge, + }) + } + .await; + if result.is_err() { + cleanup_path(&cleanup_cwd, "failed VM create shadow root"); + if let Err(error) = cleanup_bridge.clear_vm_permissions(&cleanup_vm_id) { + eprintln!( + "ERR_AGENTOS_VM_CREATE_CLEANUP: vm_id={cleanup_vm_id} phase=permission_reset error={error}" + ); + } + } + result + } +} + +impl PreparedDisposeVm +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + pub(crate) async fn execute(mut self) -> CompletedDisposeVm { + let DisposeVmPlan { + request, + connection_id, + session_id, + vm_id, + reason: _, + bridge, + sidecar_requests, + process_event_broker: _, + } = self.plan; + let mut events = vec![shared_vm_lifecycle_event( + &connection_id, + &session_id, + &vm_id, + VmLifecycleState::Disposing, + )]; + + let terminate_result = + terminate_detached_vm_processes::(&bridge, &vm_id, &mut self.vm).await; + if let Some(task) = self.vm.execution_retention_wake_task.take() { + task.abort(); + } + self.vm.execution_retention_wake_deadline_ms = None; + for execution in self.vm.executions.values_mut() { + if let Some(task) = execution.deadline_task.take() { + task.abort(); + } + } + + let capability_admission_error = + close_vm_admission(&self.vm.runtime_context, &self.vm.capabilities).err(); + if let Some(error) = capability_admission_error.as_ref() { + eprintln!("ERR_AGENTOS_VM_CAPABILITY_ADMISSION_CLOSE: vm_id={vm_id} error={error}"); + } + let fairness_retirement_result = + retire_vm_fairness(&self.vm.runtime_context, self.vm.generation); + if let Err(error) = fairness_retirement_result.as_ref() { + eprintln!("ERR_AGENTOS_VM_FAIRNESS_RETIRE: vm_id={vm_id} error={error}"); + } + + let mount_context = MountPluginContext { + bridge: bridge.clone(), + runtime_context: self.vm.runtime_context.clone(), + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + sidecar_requests, + database: self.vm.database.clone(), + max_pread_bytes: self.vm.kernel.resource_limits().max_pread_bytes, + }; + if let Err(error) = + shutdown_configured_mounts(&mut self.vm, &mount_context, "dispose_vm", true) + { + eprintln!( + "ERR_AGENTOS_VM_TEARDOWN_CLEANUP: vm_id={vm_id} phase=mount_shutdown error={error}" + ); + } + let teardown_result = finish_vm_teardown_owned(&bridge, &vm_id, &mut self.vm); + + cleanup_path(&self.vm.cwd, "disposed VM shadow root"); + if let Some(staging_root) = self.vm.packages_staging_root.take() { + cleanup_path(&staging_root, "disposed VM package staging root"); + } + cleanup_path( + &self.vm.unix_socket_host_dir, + "disposed VM Unix socket namespace", + ); + + let shutdown_deadline = Duration::from_millis(self.vm.limits.reactor.shutdown_deadline_ms); + let (reconciliation, deadline_expired) = wait_for_vm_reconciliation( + self.vm.resources.as_ref(), + &self.vm.runtime_context, + &self.vm.capabilities, + shutdown_deadline, + ) + .await; + let quarantine_reason = vm_quarantine_reason( + capability_admission_error.is_some(), + fairness_retirement_result.is_err(), + reconciliation, + deadline_expired, + ); + + let (quarantine, result) = if let Some(reason) = quarantine_reason { + let diagnostic = quarantine_diagnostic( + &vm_id, + &self.vm, + reconciliation, + reason.clone(), + capability_admission_error.as_deref(), + fairness_retirement_result.as_ref().err(), + ); + eprintln!("{diagnostic}"); + if let Err(error) = terminate_result.as_ref() { + eprintln!( + "ERR_AGENTOS_VM_TEARDOWN_CLEANUP: vm_id={vm_id} phase=processes error={error}" + ); + } + if let Err(error) = teardown_result.as_ref() { + eprintln!( + "ERR_AGENTOS_VM_TEARDOWN_CLEANUP: vm_id={vm_id} phase=kernel_or_bridge error={error}" + ); + } + ( + Some(QuarantinedVmGeneration { + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + generation: self.vm.generation, + resources: Arc::clone(&self.vm.resources), + runtime_context: self.vm.runtime_context.clone(), + capabilities: self.vm.capabilities.clone(), + reason, + }), + Err(SidecarError::Execution(diagnostic)), + ) + } else { + let result = fairness_retirement_result + .and(terminate_result) + .and(teardown_result); + (None, result) + }; + + CompletedDisposeVm { + request, + connection_id, + session_id, + vm_id, + events: std::mem::take(&mut events), + quarantine, + result, + } + } +} + +async fn terminate_detached_vm_processes( + bridge: &crate::state::SharedBridge, + vm_id: &str, + vm: &mut VmState, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let mut first_error = None; + let process_ids = vm.active_processes.keys().cloned().collect::>(); + for process_id in &process_ids { + if let Err(error) = + NativeSidecar::::kill_process_in_vm(bridge, vm, vm_id, process_id, "SIGTERM") + { + record_vm_teardown_error(vm_id, "sigterm", error, &mut first_error); + } + } + if !process_ids.is_empty() { + tokio::time::sleep(DISPOSE_VM_SIGTERM_GRACE).await; + } + for process_id in &process_ids { + if let Err(error) = + NativeSidecar::::kill_process_in_vm(bridge, vm, vm_id, process_id, "SIGKILL") + { + record_vm_teardown_error(vm_id, "sigkill", error, &mut first_error); + } + } + if !process_ids.is_empty() { + tokio::time::sleep(DISPOSE_VM_SIGKILL_GRACE).await; + } + + let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let unix_address_registry = Arc::clone(&vm.unix_address_registry); + let remaining = std::mem::take(&mut vm.active_processes); + if !remaining.is_empty() { + eprintln!( + "ERR_AGENTOS_VM_FORCED_PROCESS_FINALIZE: vm_id={vm_id} process_count={}", + remaining.len() + ); + } + for (process_id, mut process) in remaining { + let should_sync_host_writes = process.host_write_dirty_recursive() + || !process.clean_host_writes_are_observable_recursive(); + if should_sync_host_writes { + if let Err(error) = sync_process_host_writes_to_kernel(vm, &process) { + record_vm_teardown_error(vm_id, "process_host_write_sync", error, &mut first_error); + } + } + terminate_child_process_tree( + &mut vm.kernel, + &mut process, + &kernel_readiness, + &unix_address_registry, + ); + process.kernel_handle.finish(137); + if let Err(error) = vm.kernel.wait_and_reap(process.kernel_pid) { + record_vm_teardown_error(vm_id, "process_reap", kernel_error(error), &mut first_error); + } + vm.signal_states.remove(&process_id); + } + first_error.map_or(Ok(()), Err) +} + +fn finish_vm_teardown_owned( + bridge: &crate::state::SharedBridge, + vm_id: &str, + vm: &mut VmState, +) -> Result<(), SidecarError> +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let mut first_error = None; + let snapshot = if vm.kernel.root_filesystem_mut().is_some() { + match vm + .kernel + .snapshot_root_filesystem() + .map_err(kernel_error) + .and_then(|snapshot| encode_root_snapshot(&snapshot).map_err(root_filesystem_error)) + { + Ok(bytes) => Some(FilesystemSnapshot { + format: String::from(ROOT_FILESYSTEM_SNAPSHOT_FORMAT), + bytes, + }), + Err(error) => { + record_vm_teardown_error(vm_id, "snapshot", error, &mut first_error); + None + } + } + } else { + None + }; + if let Err(error) = bridge.emit_lifecycle(vm_id, LifecycleState::Terminated) { + record_vm_teardown_error(vm_id, "lifecycle", error, &mut first_error); + } + if let Err(error) = vm.kernel.dispose().map_err(kernel_error) { + record_vm_teardown_error(vm_id, "kernel", error, &mut first_error); + } + if let Some(snapshot) = snapshot { + if let Err(error) = bridge.with_mut(|bridge| { + bridge.flush_filesystem_state(FlushFilesystemStateRequest { + vm_id: vm_id.to_owned(), + snapshot, + }) + }) { + record_vm_teardown_error(vm_id, "filesystem_flush", error, &mut first_error); + } + } + if let Err(error) = bridge.clear_vm_permissions(vm_id) { + record_vm_teardown_error(vm_id, "permission_reset", error, &mut first_error); + } + first_error.map_or(Ok(()), Err) +} + +fn quarantine_diagnostic( + vm_id: &str, + vm: &VmState, + reconciliation: VmReconciliationSnapshot, + reason: VmQuarantineReason, + capability_admission_error: Option<&str>, + fairness_retirement_error: Option<&SidecarError>, +) -> String { + match reason { + VmQuarantineReason::TeardownDeadline => format!( + "ERR_AGENTOS_VM_TEARDOWN_DEADLINE: vm_id={vm_id} generation={} active_tasks={} outstanding_capabilities={} ledger_zero={} deadline_ms={}; raise limits.reactor.shutdownDeadlineMs", + vm.generation, + reconciliation.active_tasks, + reconciliation.outstanding_capabilities, + reconciliation.ledger_zero, + vm.limits.reactor.shutdown_deadline_ms + ), + VmQuarantineReason::ResourceIntegrity => format!( + "ERR_AGENTOS_VM_RESOURCE_INTEGRITY: vm_id={vm_id} generation={} accounting integrity failed; generation cannot be reaped", + vm.generation + ), + VmQuarantineReason::CapabilityRegistryIntegrity => format!( + "ERR_AGENTOS_VM_CAPABILITY_INTEGRITY: vm_id={vm_id} generation={} capability admission could not be closed; generation cannot be reaped; error={}", + vm.generation, + capability_admission_error.unwrap_or("unknown") + ), + VmQuarantineReason::FairnessIntegrity => format!( + "ERR_AGENTOS_VM_FAIRNESS_INTEGRITY: vm_id={vm_id} generation={} fairness membership could not be retired; generation cannot be reaped; error={}", + vm.generation, + fairness_retirement_error + .map(ToString::to_string) + .unwrap_or_else(|| String::from("unknown")) + ), + } +} + +fn dispose_cancellation_reason(reason: &DisposeReason) -> OperationCancellationReason { + match reason { + DisposeReason::Requested => OperationCancellationReason::Explicit, + DisposeReason::ConnectionClosed => OperationCancellationReason::ConnectionClosed, + DisposeReason::HostShutdown => OperationCancellationReason::Shutdown, + } +} + +fn cleanup_unpublished_vm(bridge: &crate::state::SharedBridge, vm_id: &str, vm: &VmState) +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + cleanup_path(&vm.cwd, "unpublished VM shadow root"); + cleanup_path( + &vm.unix_socket_host_dir, + "unpublished VM Unix socket namespace", + ); + if let Err(error) = bridge.clear_vm_permissions(vm_id) { + eprintln!( + "ERR_AGENTOS_VM_CREATE_CLEANUP: vm_id={vm_id} phase=permission_reset error={error}" + ); + } +} + +fn cleanup_path(path: &Path, label: &str) { + if let Err(error) = fs::remove_dir_all(path) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!( + "ERR_AGENTOS_VM_PATH_CLEANUP: label={label:?} path={} error={error}", + path.display() + ); + } + } +} + +pub(crate) async fn bootstrap_root_filesystem_owned( + input: OwnedVmLifecycleRequest, + entries: Vec, +) -> Result { + input.vm.try_command("bootstrap root filesystem", |vm| { + let root = vm.kernel.root_filesystem_mut().ok_or_else(|| { + SidecarError::InvalidState(String::from("VM root filesystem is unavailable")) + })?; + for entry in &entries { + apply_root_filesystem_entry(root, entry)?; + } + Ok(()) + })?; + + Ok(DispatchResult { + response: root_filesystem_bootstrapped_response(&input.request, entries.len() as u32), + events: Vec::new(), + }) +} + +pub(crate) fn configure_vm_owned( + input: ConfigureVmOwnedInput, + payload: ConfigureVmRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let __t = Instant::now(); + let ConfigureVmOwnedInput { + lifecycle, + bridge, + snapshot_runtime_context, + sidecar_requests, + } = input; + let OwnedVmLifecycleRequest { + request, + connection_id, + session_id, + vm_id, + vm, + } = lifecycle; + + let original_permissions = vm.try_read("read configure VM permissions", |vm| { + vm.configuration.permissions.clone() + })?; + let configured_permissions = payload + .permissions + .clone() + .map(crate::wire::permissions_policy_config_from_wire) + .unwrap_or_else(|| original_permissions.clone()); + validate_permissions_policy(&configured_permissions)?; + + let mut effective_mounts = payload.mounts.clone(); + append_module_access_mount(&mut effective_mounts, payload.module_access_cwd.as_ref())?; + let package_descriptors = package_descriptors_from_wire(&payload.packages)?; + let mut provided_commands: BTreeMap> = BTreeMap::new(); + for descriptor in &package_descriptors { + provided_commands.insert( + descriptor.name.clone(), + descriptor + .commands + .iter() + .map(|target| target.command.clone()) + .collect(), + ); + } + let snapshot_userland_code = resolve_agent_snapshot_bundle(&package_descriptors)?; + let package_mounts = + build_packages_projection(&vm_id, &package_descriptors, &payload.packages_mount_at)?; + effective_mounts.extend(package_mounts); + append_package_provides_mounts(&mut effective_mounts, &package_descriptors)?; + let mount_plugins = build_mount_plugin_registry::()?; + + bridge.set_vm_permissions(&vm_id, &allow_all_policy())?; + let reconfigure_result = vm.try_command("configure VM", |vm| { + apply_package_provides_env(&mut vm.guest_env, &package_descriptors); + let mount_context = MountPluginContext { + bridge: bridge.clone(), + runtime_context: vm.runtime_context.clone(), + connection_id: connection_id.clone(), + session_id: session_id.clone(), + vm_id: vm_id.clone(), + sidecar_requests, + database: vm.database.clone(), + max_pread_bytes: vm.kernel.resource_limits().max_pread_bytes, + }; + reconcile_mounts(&mount_plugins, vm, &effective_mounts, mount_context)?; + + vm.command_guest_paths = discover_command_guest_paths(&mut vm.kernel); + // Package command leaves live under `/opt/agentos/bin`, outside the + // legacy discovery tree. Register them explicitly for both absolute + // and PATH-based execution. + for commands in provided_commands.values() { + for command in commands { + vm.command_guest_paths + .entry(command.clone()) + .or_insert_with(|| projected_command_guest_path(command)); + } + } + let command_guest_paths = vm.command_guest_paths.clone(); + refresh_guest_command_path_env(&mut vm.guest_env, &command_guest_paths); + let mut execution_commands = + vec![String::from(JAVASCRIPT_COMMAND), String::from(WASM_COMMAND)]; + execution_commands.extend(payload.bootstrap_commands.iter().cloned()); + execution_commands.extend(payload.binding_shim_commands.iter().cloned()); + execution_commands.extend(vm.command_guest_paths.keys().cloned()); + vm.kernel + .register_driver(CommandDriver::new( + EXECUTION_DRIVER_NAME, + execution_commands, + )) + .map_err(kernel_error)?; + vm.command_permissions = payload.command_permissions.clone().into_iter().collect(); + let mut loopback_exempt_ports = vm.create_loopback_exempt_ports.clone(); + loopback_exempt_ports.extend(payload.loopback_exempt_ports.iter().copied()); + vm.kernel.set_loopback_exempt_ports(loopback_exempt_ports); + vm.configuration = VmConfiguration { + mounts: effective_mounts.clone(), + software: payload.software.clone(), + permissions: configured_permissions.clone(), + module_access_cwd: payload.module_access_cwd.clone(), + instructions: payload.instructions.clone(), + projected_modules: payload.projected_modules.clone(), + command_permissions: payload.command_permissions.clone().into_iter().collect(), + provided_commands: provided_commands.clone(), + // jsRuntime is create-time only; preserve what create_vm stored. + js_runtime: vm.configuration.js_runtime.clone(), + snapshot_userland_code: snapshot_userland_code.clone(), + loopback_exempt_ports: payload.loopback_exempt_ports.clone(), + }; + vm.provided_commands = provided_commands.clone(); + let projected_commands = projected_commands_from_guest_paths(&vm.command_guest_paths); + vm.projected_agent_launch = projected_agent_launch_from_descriptors(&package_descriptors); + Ok(projected_commands) + }); + + let projected_commands = match reconfigure_result { + Ok(projected_commands) => { + bridge.set_vm_permissions(&vm_id, &configured_permissions)?; + projected_commands } - if let Some(snapshot) = snapshot { - if let Err(error) = self.bridge.with_mut(|bridge| { - bridge.flush_filesystem_state(FlushFilesystemStateRequest { - vm_id: vm_id.to_owned(), - snapshot, - }) - }) { - record_vm_teardown_error(vm_id, "filesystem_flush", error, &mut first_error); + Err(error) => { + match bridge.restore_vm_permissions_fail_closed( + &vm_id, + &original_permissions, + "configure_vm rollback", + &error, + ) { + Ok(()) => return Err(error), + Err(rollback_error) => { + vm.try_command("configure VM fail-closed rollback", |vm| { + vm.configuration.permissions = deny_all_policy(); + Ok(()) + })?; + return Err(rollback_error); + } } } - if let Err(error) = self.bridge.clear_vm_permissions(vm_id) { - record_vm_teardown_error(vm_id, "permission_reset", error, &mut first_error); + }; + + let applied_mounts = effective_mounts.len() as u32; + let configured_software = payload.software.len() as u32; + let agents = projected_agents_from_descriptors(&package_descriptors); + + // No VM state borrow survives this point. Pre-warm continues as a + // supervised runtime task so lifecycle ordering is released immediately; + // its own bounded blocking admission and result remain fully observable. + if let Some(userland) = snapshot_userland_code { + let requested_bytes = userland.len(); + let runtime_for_task = snapshot_runtime_context.clone(); + if let Err(error) = snapshot_runtime_context.spawn( + agentos_runtime::TaskClass::Runtime, + async move { + let runtime_for_job = runtime_for_task.clone(); + match runtime_for_task + .blocking() + .run(requested_bytes, move || { + agentos_execution::v8_host::pre_warm_agent_snapshot( + &runtime_for_job, + &userland, + ) + }) + .await + { + Ok(Ok(())) => tracing::debug!( + target: "agentos_native_sidecar::vm", + "agent snapshot pre-warm completed" + ), + Ok(Err(error)) => eprintln!( + "ERR_AGENTOS_SNAPSHOT_PREWARM: snapshot build failed: {error}" + ), + Err(error) => eprintln!( + "ERR_AGENTOS_SNAPSHOT_PREWARM_ADMISSION: blocking admission or execution failed: {error}" + ), + } + }, + ) { + eprintln!( + "ERR_AGENTOS_SNAPSHOT_PREWARM_TASK_ADMISSION: supervised task admission failed: {error}" + ); } - first_error.map_or(Ok(()), Err) } - pub(crate) async fn terminate_vm_processes( - &mut self, - vm_id: &str, - events: &mut Vec, - ) -> Result<(), SidecarError> { - let process_ids = self - .vms - .get(vm_id) - .map(|vm| vm.active_processes.keys().cloned().collect::>()) - .unwrap_or_default(); - if process_ids.is_empty() { - return Ok(()); - } + tracing::info!(target: "agentos_native_sidecar::perf", phase = "configure_vm", elapsed_ms = __t.elapsed().as_millis() as u64, applied_mounts = applied_mounts as u64, "vm phase"); + Ok(DispatchResult { + response: vm_configured_response( + &request, + applied_mounts, + configured_software, + projected_commands, + agents, + ), + events: Vec::new(), + }) +} - for process_id in process_ids { - if self - .vms - .get(vm_id) - .is_some_and(|vm| vm.active_processes.contains_key(&process_id)) - { - self.kill_process_internal(vm_id, &process_id, "SIGTERM")?; - } - } - self.wait_for_vm_processes_to_exit(vm_id, DISPOSE_VM_SIGTERM_GRACE, events) - .await?; +pub(crate) async fn link_package_owned( + input: LinkPackageOwnedInput, + payload: LinkPackageRequest, +) -> Result +where + B: NativeSidecarBridge + Send + 'static, + BridgeError: fmt::Debug + Send + Sync + 'static, +{ + let LinkPackageOwnedInput { + lifecycle, + bridge, + sidecar_requests, + } = input; + let OwnedVmLifecycleRequest { + request, + connection_id, + session_id, + vm_id, + vm, + } = lifecycle; + let descriptor = + crate::package_projection::read_package_manifest_from_path(&payload.package.path)?; + let new_mounts = build_packages_projection( + &vm_id, + std::slice::from_ref(&descriptor), + crate::package_projection::OPT_AGENTOS_ROOT, + )?; + let commands = descriptor + .commands + .iter() + .map(|target| target.command.clone()) + .collect::>(); + let mount_plugins = build_mount_plugin_registry::()?; - if !self.vm_has_active_processes(vm_id) { + vm.try_command("link VM package", |vm| { + if new_mounts.iter().all(|mount| { + vm.configuration + .mounts + .iter() + .any(|existing| existing.guest_path == mount.guest_path) + }) { return Ok(()); } - - let remaining = self - .vms - .get(vm_id) - .map(|vm| vm.active_processes.keys().cloned().collect::>()) - .unwrap_or_default(); - for process_id in remaining { - if self - .vms - .get(vm_id) - .is_some_and(|vm| vm.active_processes.contains_key(&process_id)) + for mount in &new_mounts { + if vm + .configuration + .mounts + .iter() + .any(|existing| existing.guest_path == mount.guest_path) { - self.kill_process_internal(vm_id, &process_id, "SIGKILL")?; - } - } - self.wait_for_vm_processes_to_exit(vm_id, DISPOSE_VM_SIGKILL_GRACE, events) - .await?; - - if self.vm_has_active_processes(vm_id) { - // A shared V8 execution can take longer than the bounded event-pump - // grace to report its exit after SIGKILL. VM teardown must still - // finalize process-owned bridge state before snapshotting the root - // filesystem; dropping ActiveProcess after the snapshot loses - // committed host-materialized SQLite/WAL pages. - let vm = self - .vms - .get_mut(vm_id) - .expect("active VM should exist during process teardown"); - let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); - let unix_address_registry = Arc::clone(&vm.unix_address_registry); - let remaining = std::mem::take(&mut vm.active_processes); - eprintln!( - "ERR_AGENTOS_VM_FORCED_PROCESS_FINALIZE: vm_id={vm_id} process_count={}", - remaining.len() - ); - for (process_id, mut process) in remaining { - let should_sync_host_writes = process.host_write_dirty_recursive() - || !process.clean_host_writes_are_observable_recursive(); - if should_sync_host_writes { - sync_process_host_writes_to_kernel(vm, &process)?; + if let Some(command) = mount + .guest_path + .strip_prefix(crate::package_projection::OPT_AGENTOS_BIN) + .and_then(|path| path.strip_prefix('/')) + .filter(|path| !path.is_empty()) + { + return Err(SidecarError::InvalidState(format!( + "command {command:?} is already provided by another package" + ))); } - terminate_child_process_tree( - &mut vm.kernel, - &mut process, - &kernel_readiness, - &unix_address_registry, - ); - process.kernel_handle.finish(137); - let _ = vm.kernel.wait_and_reap(process.kernel_pid); - vm.signal_states.remove(&process_id); + return Err(SidecarError::InvalidState(format!( + "agentos package mount already exists at {}", + mount.guest_path + ))); } } - + let mount_context = MountPluginContext { + bridge, + runtime_context: vm.runtime_context.clone(), + connection_id, + session_id, + vm_id: vm_id.clone(), + sidecar_requests, + database: vm.database.clone(), + max_pread_bytes: vm.kernel.resource_limits().max_pread_bytes, + }; + mount_leaf_descriptors(&mount_plugins, vm, &new_mounts, mount_context)?; + vm.configuration.mounts.extend(new_mounts); + vm.provided_commands + .insert(descriptor.name.clone(), commands.clone()); + vm.configuration + .provided_commands + .insert(descriptor.name.clone(), commands.clone()); + for command in &commands { + vm.command_guest_paths + .entry(command.clone()) + .or_insert_with(|| projected_command_guest_path(command)); + } + let command_guest_paths = vm.command_guest_paths.clone(); + refresh_guest_command_path_env(&mut vm.guest_env, &command_guest_paths); + let mut execution_commands = + vec![String::from(JAVASCRIPT_COMMAND), String::from(WASM_COMMAND)]; + execution_commands.extend(vm.command_guest_paths.keys().cloned()); + vm.kernel + .register_driver(CommandDriver::new( + EXECUTION_DRIVER_NAME, + execution_commands, + )) + .map_err(kernel_error)?; + vm.projected_agent_launch + .extend(projected_agent_launch_from_descriptors( + std::slice::from_ref(&descriptor), + )); Ok(()) - } + })?; - pub(crate) async fn wait_for_vm_processes_to_exit( - &mut self, - vm_id: &str, - timeout: Duration, - events: &mut Vec, - ) -> Result<(), SidecarError> { - let ownership = self.vm_ownership(vm_id)?; - let deadline = Instant::now() + timeout; + let projected_commands = commands + .iter() + .map(|command| ProjectedCommand { + name: command.clone(), + guest_path: projected_command_guest_path(command), + }) + .collect(); + let agents = projected_agents_from_descriptors(std::slice::from_ref(&descriptor)); + Ok(DispatchResult { + response: package_linked_response(&request, projected_commands, agents), + events: Vec::new(), + }) +} - while self.vm_has_active_processes(vm_id) && Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - if let Some(event) = self.poll_event(&ownership, remaining).await? { - events.push(event); - } - } +pub(crate) async fn create_layer_owned( + input: OwnedVmLifecycleRequest, + _payload: CreateLayerRequest, +) -> Result { + let layer_id = input.vm.try_command("create VM layer", |vm| { + vm.layers + .create_writable_layer() + .map_err(sidecar_core_error) + })?; + Ok(DispatchResult { + response: layer_created_response(&input.request, layer_id), + events: Vec::new(), + }) +} - Ok(()) - } +pub(crate) async fn seal_layer_owned( + input: OwnedVmLifecycleRequest, + payload: SealLayerRequest, +) -> Result { + let layer_id = input.vm.try_command("seal VM layer", |vm| { + vm.layers + .seal_layer(&payload.layer_id) + .map_err(sidecar_core_error) + })?; + Ok(DispatchResult { + response: layer_sealed_response(&input.request, layer_id), + events: Vec::new(), + }) +} + +pub(crate) async fn import_snapshot_owned( + input: OwnedVmLifecycleRequest, + payload: ImportSnapshotRequest, +) -> Result { + let snapshot = root_snapshot_from_entries(&payload.entries)?; + let layer_id = input.vm.try_command("import VM snapshot", |vm| { + vm.layers + .import_snapshot(snapshot) + .map_err(sidecar_core_error) + })?; + Ok(DispatchResult { + response: snapshot_imported_response(&input.request, layer_id), + events: Vec::new(), + }) +} + +pub(crate) async fn export_snapshot_owned( + input: OwnedVmLifecycleRequest, + payload: ExportSnapshotRequest, +) -> Result { + let snapshot = input.vm.try_command("export VM snapshot", |vm| { + vm.layers + .export_snapshot(&payload.layer_id) + .map_err(sidecar_core_error) + })?; + Ok(DispatchResult { + response: snapshot_exported_response( + &input.request, + payload.layer_id, + root_snapshot_entries(&snapshot), + ), + events: Vec::new(), + }) +} + +pub(crate) async fn create_overlay_owned( + input: OwnedVmLifecycleRequest, + payload: CreateOverlayRequest, +) -> Result { + let layer_id = input.vm.try_command("create VM overlay", |vm| { + vm.layers + .create_overlay_layer( + protocol_root_filesystem_mode(payload.mode), + payload.upper_layer_id, + payload.lower_layer_ids, + ) + .map_err(sidecar_core_error) + })?; + Ok(DispatchResult { + response: overlay_created_response(&input.request, layer_id), + events: Vec::new(), + }) +} + +pub(crate) async fn snapshot_root_filesystem_owned( + input: OwnedVmLifecycleRequest, + payload: SnapshotRootFilesystemRequest, +) -> Result { + let snapshot = input.vm.try_command("snapshot VM root filesystem", |vm| { + vm.kernel + .snapshot_root_filesystem_bounded(payload.max_bytes) + .map_err(kernel_error) + })?; + Ok(DispatchResult { + response: root_filesystem_snapshot_response( + &input.request, + snapshot.entries.iter().map(root_snapshot_entry).collect(), + ), + events: Vec::new(), + }) } fn record_vm_teardown_error( @@ -3190,8 +4361,9 @@ mod tests { use crate::bridge::MountPluginContext; use crate::plugins::chunked_local::ChunkedLocalMountPlugin; use crate::protocol::{ - RootFilesystemDescriptor, RootFilesystemEntry, RootFilesystemEntryKind, - RootFilesystemLowerDescriptor, + ConfigureVmRequest, CreateLayerRequest, CreateVmRequest, DisposeReason, DisposeVmRequest, + GuestRuntimeKind, OwnershipScope, RequestFrame, RequestPayload, RootFilesystemDescriptor, + RootFilesystemEntry, RootFilesystemEntryKind, RootFilesystemLowerDescriptor, }; use crate::service::NativeSidecar; use crate::state::{ @@ -3281,6 +4453,138 @@ mod tests { .block_on(future) } + #[test] + fn vm_lifecycle_wrappers_return_static_futures_without_borrowing_the_sidecar() { + fn assert_static(_: &F) {} + + let mut sidecar = NativeSidecar::new(LocalBridge::default()).expect("test sidecar"); + let request = RequestFrame::new( + 1, + OwnershipScope::vm("connection-missing", "session-missing", "vm-missing"), + RequestPayload::CreateLayer(CreateLayerRequest {}), + ); + let operation = sidecar.create_layer(&request, CreateLayerRequest {}); + assert_static(&operation); + + // This mutation is a compile-time assertion that `operation` did not + // retain the method receiver's `&mut NativeSidecar` borrow. + sidecar.next_vm_id = 41; + let result = block_on(operation); + assert!(matches!(result, Err(crate::SidecarError::InvalidState(_)))); + assert_eq!(sidecar.next_vm_id, 41); + + let configure_payload = ConfigureVmRequest { + mounts: Vec::new(), + software: Vec::new(), + permissions: None, + module_access_cwd: None, + instructions: Vec::new(), + projected_modules: Vec::new(), + command_permissions: std::collections::HashMap::new(), + loopback_exempt_ports: Vec::new(), + packages: Vec::new(), + packages_mount_at: String::new(), + bootstrap_commands: Vec::new(), + binding_shim_commands: Vec::new(), + }; + let configure_request = RequestFrame::new( + 2, + OwnershipScope::vm("connection-missing", "session-missing", "vm-missing"), + RequestPayload::ConfigureVm(configure_payload.clone()), + ); + let configure_operation = sidecar.configure_vm(&configure_request, configure_payload); + assert_static(&configure_operation); + sidecar.next_vm_id = 42; + let result = block_on(configure_operation); + assert!(matches!(result, Err(crate::SidecarError::InvalidState(_)))); + assert_eq!(sidecar.next_vm_id, 42); + } + + #[test] + fn create_and_dispose_release_central_state_during_owned_work() { + let mut sidecar = NativeSidecar::new(LocalBridge::default()).expect("test sidecar"); + let connection_id = String::from("connection-owned-lifecycle"); + let session_id = String::from("session-owned-lifecycle"); + sidecar.connections.insert( + connection_id.clone(), + ConnectionState { + auth_token: String::new(), + sessions: BTreeSet::from([session_id.clone()]), + }, + ); + sidecar.sessions.insert( + session_id.clone(), + SessionState { + connection_id: connection_id.clone(), + placement: crate::protocol::SidecarPlacement::SidecarPlacementShared( + crate::protocol::SidecarPlacementShared { pool: None }, + ), + metadata: BTreeMap::new(), + vm_ids: BTreeSet::new(), + }, + ); + + let create_payload = CreateVmRequest::legacy_test_config( + GuestRuntimeKind::JavaScript, + Default::default(), + Default::default(), + None, + ); + let create_request = RequestFrame::new( + 100, + OwnershipScope::session(&connection_id, &session_id), + RequestPayload::CreateVm(create_payload.clone()), + ); + let prepared = sidecar + .prepare_create_vm(&create_request, create_payload) + .expect("prepare owned VM create"); + assert!(sidecar.vms.is_empty()); + sidecar.next_sidecar_request_id = -41; + let completed = block_on(prepared.execute()).expect("execute owned VM create"); + assert!(sidecar.vms.is_empty()); + assert_eq!(sidecar.next_sidecar_request_id, -41); + sidecar + .complete_create_vm(completed) + .expect("publish completed VM create"); + let vm_id = sidecar + .sessions + .get(&session_id) + .and_then(|session| session.vm_ids.iter().next()) + .cloned() + .expect("created VM session membership"); + assert!(sidecar.vms.contains_key(&vm_id)); + + let dispose_payload = DisposeVmRequest { + reason: DisposeReason::Requested, + }; + let dispose_request = RequestFrame::new( + 101, + OwnershipScope::vm(&connection_id, &session_id, &vm_id), + RequestPayload::DisposeVm(dispose_payload.clone()), + ); + let plan = sidecar + .prepare_dispose_vm(&dispose_request, dispose_payload) + .expect("prepare owned VM dispose"); + let prepared = sidecar + .detach_vm_for_disposal(plan) + .expect("detach VM after operation drain"); + assert!(!sidecar.vms.contains_key(&vm_id)); + assert!(sidecar + .sessions + .get(&session_id) + .is_some_and(|session| session.vm_ids.contains(&vm_id))); + sidecar.next_sidecar_request_id = -42; + let completed = block_on(prepared.execute()); + assert_eq!(sidecar.next_sidecar_request_id, -42); + sidecar + .complete_dispose_vm(completed) + .expect("finalize completed VM dispose"); + assert!(sidecar + .sessions + .get(&session_id) + .is_some_and(|session| !session.vm_ids.contains(&vm_id))); + } + fn active_vm_metric(sidecar: &NativeSidecar) -> usize { sidecar .runtime_context