Close remote delegate lifecycle races

This commit is contained in:
Channing Conger
2026-06-25 01:06:21 +00:00
parent 1add55e6a9
commit 1c685fb3ca
9 changed files with 319 additions and 64 deletions
+9 -1
View File
@@ -76,6 +76,10 @@ impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider {
}
}
/// Lazily maintains the shared sidecar connection used by a session provider.
///
/// It serializes process creation and allocates session IDs outside any one
/// connection so those IDs remain unique when the sidecar is replaced.
struct OwnedProcessHost {
host_program: PathBuf,
connection: StdMutex<Option<Arc<Connection>>>,
@@ -132,13 +136,17 @@ impl OwnedProcessHost {
}
}
/// Tracks a logical session's binding to the current sidecar connection.
enum SessionState {
New,
Open(Arc<Connection>),
Shutdown,
}
/// A logical code-mode session assigned to a process-owned host.
/// A logical code-mode session that lazily opens on a process-owned host.
///
/// It retains its client-assigned session and cell IDs if a failed sidecar must
/// be replaced, while the hosted runtime state itself starts over.
pub struct ProcessOwnedCodeModeSession {
process_host: Arc<OwnedProcessHost>,
session_id: SessionId,
@@ -38,6 +38,10 @@ mod stderr;
const IPC_CHANNEL_CAPACITY: usize = 128;
/// Owns one live sidecar process connection and its background IPC tasks.
///
/// Dropping the last connection handle cancels those tasks and causes the child
/// supervisor to terminate the sidecar.
pub(super) struct Connection {
state: Arc<ConnectionState>,
cancellation: CancellationToken,
@@ -327,6 +331,7 @@ impl Drop for Connection {
}
}
/// Shares response routing, delegates, and terminal failure state across IPC tasks.
struct ConnectionState {
outgoing_tx: mpsc::Sender<ClientToHost>,
pending: Mutex<HashMap<RequestId, oneshot::Sender<Result<HostResponse, String>>>>,
@@ -78,7 +78,7 @@ async fn handle_host_message(state: Arc<ConnectionState>, message: HostToClient)
.await;
return;
};
let cancellation = CancellationToken::new();
let cancellation = state.cancellation.child_token();
state
.delegate_cancellations
.lock()
@@ -129,3 +129,7 @@ async fn handle_host_message(state: Arc<ConnectionState>, message: HostToClient)
}
}
}
#[cfg(test)]
#[path = "reader_tests.rs"]
mod tests;
@@ -0,0 +1,97 @@
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use codex_code_mode_protocol::CellId;
use codex_code_mode_protocol::CodeModeNestedToolCall;
use codex_code_mode_protocol::CodeModeSessionDelegate;
use codex_code_mode_protocol::NotificationFuture;
use codex_code_mode_protocol::ToolInvocationFuture;
use codex_code_mode_protocol::host::DelegateRequestId;
use codex_code_mode_protocol::host::SessionId;
use pretty_assertions::assert_eq;
use tokio::sync::oneshot;
use super::*;
struct CancellationRecordingDelegate {
cancellation_tx: StdMutex<Option<oneshot::Sender<()>>>,
}
impl CodeModeSessionDelegate for CancellationRecordingDelegate {
fn invoke_tool<'a>(
&'a self,
_invocation: CodeModeNestedToolCall,
_cancellation_token: CancellationToken,
) -> ToolInvocationFuture<'a> {
Box::pin(async { Err("unexpected tool invocation".to_string()) })
}
fn notify<'a>(
&'a self,
_call_id: String,
_cell_id: CellId,
_text: String,
cancellation_token: CancellationToken,
) -> NotificationFuture<'a> {
let cancellation_tx = self
.cancellation_tx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
Box::pin(async move {
cancellation_token.cancelled().await;
if let Some(cancellation_tx) = cancellation_tx {
let _ = cancellation_tx.send(());
}
Ok(())
})
}
fn cell_closed(&self, _cell_id: &CellId) {}
}
#[tokio::test]
async fn delegate_started_after_connection_cancellation_is_cancelled() {
let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(/*max_capacity*/ 1);
let connection_cancellation = CancellationToken::new();
connection_cancellation.cancel();
let state = Arc::new(ConnectionState::new(outgoing_tx, connection_cancellation));
let session_id = SessionId::new("session-1").expect("session ID");
let delegate_request_id = DelegateRequestId::new(7);
let (cancellation_tx, cancellation_rx) = oneshot::channel();
state.delegates.lock().await.insert(
session_id.clone(),
Arc::new(CancellationRecordingDelegate {
cancellation_tx: StdMutex::new(Some(cancellation_tx)),
}),
);
handle_host_message(
Arc::clone(&state),
HostToClient::DelegateRequest {
id: delegate_request_id,
session_id,
request: DelegateRequest::Notify {
call_id: "call-1".to_string(),
cell_id: CellId::new("cell-1".to_string()).into(),
text: "hello".to_string(),
},
},
)
.await;
tokio::time::timeout(Duration::from_secs(1), cancellation_rx)
.await
.expect("delegate cancellation timeout")
.expect("delegate cancellation signal");
assert_eq!(
outgoing_rx.recv().await,
Some(ClientToHost::DelegateResponse {
id: delegate_request_id,
result: WireResult::Ok {
value: DelegateResponse::NotificationDelivered,
},
})
);
assert!(state.delegate_cancellations.lock().await.is_empty());
}