Skip to content

Commit 8d58899

Browse files
authored
fix: MCP leaks in app-server (openai#17223)
The disconnect path now reuses the same teardown flow as explicit unsubscribe, and the thread-state bookkeeping consistently reports only threads that lost their last subscriber openai#16895
1 parent 8035cb0 commit 8d58899

3 files changed

Lines changed: 190 additions & 84 deletions

File tree

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 75 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3883,9 +3883,18 @@ impl CodexMessageProcessor {
38833883
self.command_exec_manager
38843884
.connection_closed(connection_id)
38853885
.await;
3886-
self.thread_state_manager
3886+
let thread_ids_with_no_subscribers = self
3887+
.thread_state_manager
38873888
.remove_connection(connection_id)
38883889
.await;
3890+
for thread_id in thread_ids_with_no_subscribers {
3891+
let Ok(thread) = self.thread_manager.get_thread(thread_id).await else {
3892+
self.finalize_thread_teardown(thread_id).await;
3893+
continue;
3894+
};
3895+
self.unload_thread_without_subscribers(thread_id, thread)
3896+
.await;
3897+
}
38893898
}
38903899

38913900
pub(crate) fn subscribe_running_assistant_turn_count(&self) -> watch::Receiver<usize> {
@@ -5591,6 +5600,66 @@ impl CodexMessageProcessor {
55915600
.await;
55925601
}
55935602

5603+
async fn unload_thread_without_subscribers(
5604+
&mut self,
5605+
thread_id: ThreadId,
5606+
thread: Arc<CodexThread>,
5607+
) {
5608+
// This connection was the last subscriber. Only now do we unload the thread.
5609+
info!("thread {thread_id} has no subscribers; shutting down");
5610+
let should_start_unload_task = self.pending_thread_unloads.lock().await.insert(thread_id);
5611+
5612+
// Any pending app-server -> client requests for this thread can no longer be
5613+
// answered; cancel their callbacks before shutdown/unload.
5614+
self.outgoing
5615+
.cancel_requests_for_thread(thread_id, /*error*/ None)
5616+
.await;
5617+
self.thread_state_manager
5618+
.remove_thread_state(thread_id)
5619+
.await;
5620+
5621+
if !should_start_unload_task {
5622+
return;
5623+
}
5624+
5625+
let outgoing = self.outgoing.clone();
5626+
let pending_thread_unloads = self.pending_thread_unloads.clone();
5627+
let thread_manager = self.thread_manager.clone();
5628+
let thread_watch_manager = self.thread_watch_manager.clone();
5629+
tokio::spawn(async move {
5630+
match Self::wait_for_thread_shutdown(&thread).await {
5631+
ThreadShutdownResult::Complete => {
5632+
if thread_manager.remove_thread(&thread_id).await.is_none() {
5633+
info!("thread {thread_id} was already removed before teardown finalized");
5634+
thread_watch_manager
5635+
.remove_thread(&thread_id.to_string())
5636+
.await;
5637+
pending_thread_unloads.lock().await.remove(&thread_id);
5638+
return;
5639+
}
5640+
thread_watch_manager
5641+
.remove_thread(&thread_id.to_string())
5642+
.await;
5643+
let notification = ThreadClosedNotification {
5644+
thread_id: thread_id.to_string(),
5645+
};
5646+
outgoing
5647+
.send_server_notification(ServerNotification::ThreadClosed(notification))
5648+
.await;
5649+
pending_thread_unloads.lock().await.remove(&thread_id);
5650+
}
5651+
ThreadShutdownResult::SubmitFailed => {
5652+
pending_thread_unloads.lock().await.remove(&thread_id);
5653+
warn!("failed to submit Shutdown to thread {thread_id}");
5654+
}
5655+
ThreadShutdownResult::TimedOut => {
5656+
pending_thread_unloads.lock().await.remove(&thread_id);
5657+
warn!("thread {thread_id} shutdown timed out; leaving thread loaded");
5658+
}
5659+
}
5660+
});
5661+
}
5662+
55945663
async fn thread_unsubscribe(
55955664
&self,
55965665
request_id: ConnectionRequestId,
@@ -5638,58 +5707,8 @@ impl CodexMessageProcessor {
56385707
}
56395708

56405709
if !self.thread_state_manager.has_subscribers(thread_id).await {
5641-
// This connection was the last subscriber. Only now do we unload the thread.
5642-
info!("thread {thread_id} has no subscribers; shutting down");
5643-
self.pending_thread_unloads.lock().await.insert(thread_id);
5644-
// Any pending app-server -> client requests for this thread can no longer be
5645-
// answered; cancel their callbacks before shutdown/unload.
5646-
self.outgoing
5647-
.cancel_requests_for_thread(thread_id, /*error*/ None)
5710+
self.unload_thread_without_subscribers(thread_id, thread)
56485711
.await;
5649-
self.thread_state_manager
5650-
.remove_thread_state(thread_id)
5651-
.await;
5652-
5653-
let outgoing = self.outgoing.clone();
5654-
let pending_thread_unloads = self.pending_thread_unloads.clone();
5655-
let thread_manager = self.thread_manager.clone();
5656-
let thread_watch_manager = self.thread_watch_manager.clone();
5657-
tokio::spawn(async move {
5658-
match Self::wait_for_thread_shutdown(&thread).await {
5659-
ThreadShutdownResult::Complete => {
5660-
if thread_manager.remove_thread(&thread_id).await.is_none() {
5661-
info!(
5662-
"thread {thread_id} was already removed before unsubscribe finalized"
5663-
);
5664-
thread_watch_manager
5665-
.remove_thread(&thread_id.to_string())
5666-
.await;
5667-
pending_thread_unloads.lock().await.remove(&thread_id);
5668-
return;
5669-
}
5670-
thread_watch_manager
5671-
.remove_thread(&thread_id.to_string())
5672-
.await;
5673-
let notification = ThreadClosedNotification {
5674-
thread_id: thread_id.to_string(),
5675-
};
5676-
outgoing
5677-
.send_server_notification(ServerNotification::ThreadClosed(
5678-
notification,
5679-
))
5680-
.await;
5681-
pending_thread_unloads.lock().await.remove(&thread_id);
5682-
}
5683-
ThreadShutdownResult::SubmitFailed => {
5684-
pending_thread_unloads.lock().await.remove(&thread_id);
5685-
warn!("failed to submit Shutdown to thread {thread_id}");
5686-
}
5687-
ThreadShutdownResult::TimedOut => {
5688-
pending_thread_unloads.lock().await.remove(&thread_id);
5689-
warn!("thread {thread_id} shutdown timed out; leaving thread loaded");
5690-
}
5691-
}
5692-
});
56935712
}
56945713

56955714
self.outgoing
@@ -10047,7 +10066,8 @@ mod tests {
1004710066
state.lock().await.cancel_tx = Some(cancel_tx);
1004810067
}
1004910068

10050-
manager.remove_connection(connection_a).await;
10069+
let threads_to_unload = manager.remove_connection(connection_a).await;
10070+
assert_eq!(threads_to_unload, Vec::<ThreadId>::new());
1005110071
assert!(
1005210072
tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx)
1005310073
.await
@@ -10068,7 +10088,8 @@ mod tests {
1006810088
let connection = ConnectionId(1);
1006910089

1007010090
manager.connection_initialized(connection).await;
10071-
manager.remove_connection(connection).await;
10091+
let threads_to_unload = manager.remove_connection(connection).await;
10092+
assert_eq!(threads_to_unload, Vec::<ThreadId>::new());
1007210093

1007310094
assert!(
1007410095
manager

codex-rs/app-server/src/thread_state.rs

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,8 @@ impl ThreadStateManager {
352352
true
353353
}
354354

355-
pub(crate) async fn remove_connection(&self, connection_id: ConnectionId) {
356-
let thread_states = {
355+
pub(crate) async fn remove_connection(&self, connection_id: ConnectionId) -> Vec<ThreadId> {
356+
{
357357
let mut state = self.state.lock().await;
358358
state.live_connections.remove(&connection_id);
359359
let thread_ids = state
@@ -367,36 +367,13 @@ impl ThreadStateManager {
367367
}
368368
thread_ids
369369
.into_iter()
370-
.map(|thread_id| {
371-
(
372-
thread_id,
373-
state
374-
.threads
375-
.get(&thread_id)
376-
.is_none_or(|thread_entry| thread_entry.connection_ids.is_empty()),
377-
state
378-
.threads
379-
.get(&thread_id)
380-
.map(|thread_entry| thread_entry.state.clone()),
381-
)
370+
.filter(|thread_id| {
371+
state
372+
.threads
373+
.get(thread_id)
374+
.is_some_and(|thread_entry| thread_entry.connection_ids.is_empty())
382375
})
383376
.collect::<Vec<_>>()
384-
};
385-
386-
for (thread_id, no_subscribers, thread_state) in thread_states {
387-
if !no_subscribers {
388-
continue;
389-
}
390-
let Some(thread_state) = thread_state else {
391-
continue;
392-
};
393-
let listener_generation = thread_state.lock().await.listener_generation;
394-
tracing::debug!(
395-
thread_id = %thread_id,
396-
connection_id = ?connection_id,
397-
listener_generation,
398-
"retaining thread listener after connection disconnect left zero subscribers"
399-
);
400377
}
401378
}
402379
}

codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use anyhow::Context;
22
use anyhow::Result;
33
use anyhow::bail;
44
use app_test_support::create_mock_responses_server_sequence_unchecked;
5+
use app_test_support::to_response;
56
use base64::Engine;
67
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
78
use codex_app_server_protocol::ClientInfo;
@@ -12,6 +13,10 @@ use codex_app_server_protocol::JSONRPCNotification;
1213
use codex_app_server_protocol::JSONRPCRequest;
1314
use codex_app_server_protocol::JSONRPCResponse;
1415
use codex_app_server_protocol::RequestId;
16+
use codex_app_server_protocol::ThreadLoadedListParams;
17+
use codex_app_server_protocol::ThreadLoadedListResponse;
18+
use codex_app_server_protocol::ThreadStartParams;
19+
use codex_app_server_protocol::ThreadStartResponse;
1520
use futures::SinkExt;
1621
use futures::StreamExt;
1722
use hmac::Hmac;
@@ -332,6 +337,37 @@ async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_defa
332337
Ok(())
333338
}
334339

340+
#[tokio::test]
341+
async fn websocket_disconnect_unloads_last_subscribed_thread() -> Result<()> {
342+
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
343+
let codex_home = TempDir::new()?;
344+
create_config_toml(codex_home.path(), &server.uri(), "never")?;
345+
346+
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
347+
348+
let mut ws1 = connect_websocket(bind_addr).await?;
349+
send_initialize_request(&mut ws1, /*id*/ 1, "ws_thread_owner").await?;
350+
read_response_for_id(&mut ws1, /*id*/ 1).await?;
351+
352+
let thread_id = start_thread(&mut ws1, /*id*/ 2).await?;
353+
assert_loaded_threads(&mut ws1, /*id*/ 3, &[thread_id.as_str()]).await?;
354+
355+
ws1.close(None).await.context("failed to close websocket")?;
356+
drop(ws1);
357+
358+
let mut ws2 = connect_websocket(bind_addr).await?;
359+
send_initialize_request(&mut ws2, /*id*/ 4, "ws_reconnect_client").await?;
360+
read_response_for_id(&mut ws2, /*id*/ 4).await?;
361+
362+
wait_for_loaded_threads(&mut ws2, /*first_id*/ 5, &[]).await?;
363+
364+
process
365+
.kill()
366+
.await
367+
.context("failed to stop websocket app-server process")?;
368+
Ok(())
369+
}
370+
335371
pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> {
336372
spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await
337373
}
@@ -564,6 +600,78 @@ pub(super) async fn send_initialize_request(
564600
.await
565601
}
566602

603+
async fn start_thread(stream: &mut WsClient, id: i64) -> Result<String> {
604+
send_request(
605+
stream,
606+
"thread/start",
607+
id,
608+
Some(serde_json::to_value(ThreadStartParams {
609+
model: Some("mock-model".to_string()),
610+
..Default::default()
611+
})?),
612+
)
613+
.await?;
614+
let response = read_response_for_id(stream, id).await?;
615+
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(response)?;
616+
Ok(thread.id)
617+
}
618+
619+
async fn assert_loaded_threads(stream: &mut WsClient, id: i64, expected: &[&str]) -> Result<()> {
620+
let response = request_loaded_threads(stream, id).await?;
621+
let mut actual = response.data;
622+
actual.sort();
623+
let mut expected = expected
624+
.iter()
625+
.map(|thread_id| (*thread_id).to_string())
626+
.collect::<Vec<_>>();
627+
expected.sort();
628+
assert_eq!(actual, expected);
629+
assert_eq!(response.next_cursor, None);
630+
Ok(())
631+
}
632+
633+
async fn wait_for_loaded_threads(
634+
stream: &mut WsClient,
635+
first_id: i64,
636+
expected: &[&str],
637+
) -> Result<()> {
638+
let mut next_id = first_id;
639+
let expected = expected
640+
.iter()
641+
.map(|thread_id| (*thread_id).to_string())
642+
.collect::<Vec<_>>();
643+
timeout(DEFAULT_READ_TIMEOUT, async {
644+
loop {
645+
let response = request_loaded_threads(stream, next_id).await?;
646+
next_id += 1;
647+
let mut actual = response.data;
648+
actual.sort();
649+
if actual == expected {
650+
return Ok::<(), anyhow::Error>(());
651+
}
652+
sleep(Duration::from_millis(50)).await;
653+
}
654+
})
655+
.await
656+
.context("timed out waiting for loaded thread list")??;
657+
Ok(())
658+
}
659+
660+
async fn request_loaded_threads(
661+
stream: &mut WsClient,
662+
id: i64,
663+
) -> Result<ThreadLoadedListResponse> {
664+
send_request(
665+
stream,
666+
"thread/loaded/list",
667+
id,
668+
Some(serde_json::to_value(ThreadLoadedListParams::default())?),
669+
)
670+
.await?;
671+
let response = read_response_for_id(stream, id).await?;
672+
to_response::<ThreadLoadedListResponse>(response)
673+
}
674+
567675
async fn send_config_read_request(stream: &mut WsClient, id: i64) -> Result<()> {
568676
send_request(
569677
stream,

0 commit comments

Comments
 (0)