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
This commit is contained in:
jif
2026-09-24 22:34:04 +00:00
committed by copyberry
parent 4a358b1ae9
commit 9442a175ba
2 changed files with 72 additions and 0 deletions
@@ -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<dyn MessageBoardHost>,
) -> 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();
@@ -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<AgentPath>> {
unreachable!("registry tests do not invoke host capabilities")
}
fn resolve_agent(&self, _path: AgentPath) -> BoxFuture<'_, Result<ThreadId>> {
unreachable!("registry tests do not invoke host capabilities")
}
fn current_time(&self, _caller: ThreadId) -> BoxFuture<'_, Result<DateTime<Utc>>> {
unreachable!("registry tests do not invoke host capabilities")
}
fn notify(
&self,
_recipient: ThreadId,
_post: PostPreview,
) -> BoxFuture<'_, Result<NotificationDelivery>> {
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<_>>(),
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<_>>(),
HashSet::from([identity]),
);
}