From 9442a175ba5224366406330117d38e46543d5224 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 24 Sep 2026 22:18:56 +0000 Subject: [PATCH] Prune expired in-memory message board registry entries (#47952) ## Why Dropping the last board handle releases its state but leaves a stale entry in `InMemoryMessageBoards`, allowing registry entries to accumulate across sessions. ## What changed Prune entries whose state has no remaining strong references whenever a board is opened, while preserving shared state for live boards. ## Testing Add a regression test that repeatedly opens and drops boards, checks that expired entries are removed, and verifies that reopening a live board shares its state. GitOrigin-RevId: c3f1c27b42ee17915f0ece01b85a22d0fdf632ce --- .../ext/agent-message-board/src/in_memory.rs | 6 ++ .../src/in_memory_tests.rs | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 codex-rs/ext/agent-message-board/src/in_memory_tests.rs diff --git a/codex-rs/ext/agent-message-board/src/in_memory.rs b/codex-rs/ext/agent-message-board/src/in_memory.rs index c780cdf96c..572ab1c401 100644 --- a/codex-rs/ext/agent-message-board/src/in_memory.rs +++ b/codex-rs/ext/agent-message-board/src/in_memory.rs @@ -1,5 +1,6 @@ //! Tree-owned message boards for training. Agent handles share state; nothing is written to disk. //! Mutations and recipient selection are atomic. Host callbacks run outside the state lock. +//! Opening a board prunes registry entries whose state has been released. use crate::ChannelSummary; use crate::CreateChannelRequest; @@ -31,6 +32,10 @@ use uuid::Uuid; mod queries; +#[cfg(test)] +#[path = "in_memory_tests.rs"] +mod tests; + /// Shares board state across a tree while giving each agent its own host handle. #[derive(Default)] pub struct InMemoryMessageBoards { @@ -44,6 +49,7 @@ impl InMemoryMessageBoards { host: Arc, ) -> InMemoryAgentMessageBoard { let mut states = self.states.lock().await; + states.retain(|_, state| state.strong_count() > 0); let entry = states.entry(identity).or_default(); let state = entry.upgrade().unwrap_or_else(|| { let state = Arc::default(); diff --git a/codex-rs/ext/agent-message-board/src/in_memory_tests.rs b/codex-rs/ext/agent-message-board/src/in_memory_tests.rs new file mode 100644 index 0000000000..c3fc0b51e2 --- /dev/null +++ b/codex-rs/ext/agent-message-board/src/in_memory_tests.rs @@ -0,0 +1,66 @@ +//! Verifies that registry cleanup releases expired entries and preserves live boards. + +use super::*; +use crate::NotificationDelivery; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; + +struct UnusedHost; + +impl MessageBoardHost for UnusedHost { + fn agent_path(&self, _caller: ThreadId) -> BoxFuture<'_, Result> { + unreachable!("registry tests do not invoke host capabilities") + } + + fn resolve_agent(&self, _path: AgentPath) -> BoxFuture<'_, Result> { + unreachable!("registry tests do not invoke host capabilities") + } + + fn current_time(&self, _caller: ThreadId) -> BoxFuture<'_, Result>> { + unreachable!("registry tests do not invoke host capabilities") + } + + fn notify( + &self, + _recipient: ThreadId, + _post: PostPreview, + ) -> BoxFuture<'_, Result> { + unreachable!("registry tests do not invoke host capabilities") + } +} + +#[tokio::test] +async fn opening_boards_prunes_expired_entries_and_preserves_live_state() { + let boards = InMemoryMessageBoards::default(); + let identity = SessionId::new(); + let live = boards.open(identity, Arc::new(UnusedHost)).await; + + for _ in 0..16 { + let next_identity = SessionId::new(); + let next = boards.open(next_identity, Arc::new(UnusedHost)).await; + assert_eq!( + boards + .states + .lock() + .await + .keys() + .copied() + .collect::>(), + HashSet::from([identity, next_identity]), + ); + drop(next); + } + + let shared = boards.open(identity, Arc::new(UnusedHost)).await; + assert!(Arc::ptr_eq(&live.state, &shared.state)); + assert_eq!( + boards + .states + .lock() + .await + .keys() + .copied() + .collect::>(), + HashSet::from([identity]), + ); +}