From d8af66cb31ac146e962bb07d28dd7adc2738d4dd Mon Sep 17 00:00:00 2001 From: Khaled Mansour Date: Mon, 6 Jul 2026 21:01:12 -0400 Subject: [PATCH] fix(agentos-sidecar): skip close_session teardown wait when the adapter already exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close_session runs SIGTERM -> wait_for_process_exit -> SIGKILL -> wait. That is correct for a live process, but wait_for_process_exit only polls the event stream for a future ProcessExited event and never checks current process state. When the adapter exited out-of-band before close_session (crash / OOM, or a lazily-observed idle-time crash), that event already fired and was drained, so both waits burn the full SESSION_CLOSE_TIMEOUT (~2x total) against a PID that is already gone. Because close_session is serial per sidecar, this also stalls a create_session issued right after it (session recovery / a returning user whose idle session was evicted). Detect the already-gone case from the SIGTERM result — reusing the existing is_adapter_gone_error classification plus the process-table ESRCH / "no such process" the signal path returns for a reaped PID — and skip straight to resource disposal. Live-process behavior is unchanged. Measured createSession-after-guest-death: ~10.4s without this change, ~0.3s with. --- crates/agentos-sidecar/src/acp_extension.rs | 35 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/agentos-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs index 342a9d2bad..b51513f193 100644 --- a/crates/agentos-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -626,13 +626,29 @@ impl AcpExtension { process_id: session.process_id.clone(), }) .await; - let _ = ctx + let sigterm = ctx .kill_process_wire(KillProcessRequest { process_id: session.process_id.clone(), signal: String::from("SIGTERM"), }) .await; - if !wait_for_process_exit(&mut ctx, &session.process_id, SESSION_CLOSE_TIMEOUT).await { + // The adapter process may already be gone: it can exit out-of-band (crash / + // OOM, or a lazily-observed idle-time crash) *before* the client sends + // close_session. When it does, its `ProcessExited` event has already been + // emitted and drained, so `wait_for_process_exit` — which only polls the + // event stream and never checks current process state — can never observe + // it and blocks the full `SESSION_CLOSE_TIMEOUT` for SIGTERM *and* again + // after SIGKILL (~2× the timeout) before we ever reach resource disposal. + // Because close_session is serial per sidecar, that stall also blocks a + // `create_session` the host issues right after it (session recovery / a + // returning user whose idle session was evicted eats the full stall on + // their next turn). Detect the already-gone case from the SIGTERM result — + // reusing the same "adapter is gone" classification the prompt path uses — + // and skip straight to resource disposal. + let already_gone = matches!(&sigterm, Err(error) if is_process_already_gone(error)); + if !already_gone + && !wait_for_process_exit(&mut ctx, &session.process_id, SESSION_CLOSE_TIMEOUT).await + { let _ = ctx .kill_process_wire(KillProcessRequest { process_id: session.process_id.clone(), @@ -2553,6 +2569,21 @@ fn is_adapter_gone_error(error: &SidecarError) -> bool { matches!(error, SidecarError::InvalidState(message) if message.contains(ADAPTER_NO_ACTIVE_PROCESS_MARKER)) } +/// True when a kill/signal request failed because the target process no longer +/// exists — i.e. it already exited before we tried to terminate it. Covers both +/// the adapter-gone classification (`is_adapter_gone_error`) and the lower-level +/// process-table `ESRCH` / "no such process" the signal path returns for a PID +/// that has already been reaped. Used by `close_session` to skip the +/// `wait_for_process_exit` dance (which can only observe a *future* exit event) +/// when the process is already gone. +fn is_process_already_gone(error: &SidecarError) -> bool { + if is_adapter_gone_error(error) { + return true; + } + let message = error.to_string().to_ascii_lowercase(); + message.contains("esrch") || message.contains("no such process") +} + /// Extract the adapter exit code from an `ADAPTER_EXITED_ERROR_MARKER` error /// message (`"... exited with code before response ..."`). Returns /// `None` for indirect observations (e.g. a stdin write that failed because