From cd11b82207d47b268e5976781d462e32489f36c3 Mon Sep 17 00:00:00 2001 From: CodeWhale Agent Date: Mon, 29 Jun 2026 17:00:36 -0700 Subject: [PATCH] fix(tui): use nonblocking send for ListSubAgents refresh events (#3802) --- crates/tui/src/core/engine.rs | 9 ++++- crates/tui/src/core/engine/handle.rs | 10 +++++ crates/tui/src/core/engine/tests.rs | 55 ++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 11 ++++-- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 3674019ec..3d635c2bc 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1528,7 +1528,14 @@ impl Engine { manager.cleanup(Duration::from_secs(60 * 60)); manager.list() }; - let _ = self.tx_event.send(Event::AgentList { agents }).await; + // #3802: use non-blocking send — this is a refresh event + // that can safely be dropped when the channel is full. + // The next drain cycle will re-request the list. + if let Err(_e) = self.tx_event.try_send(Event::AgentList { agents }) { + tracing::debug!( + "Event channel full; dropping ListSubAgents refresh (will retry next drain)" + ); + } } Op::ChangeMode { mode, diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index bbee00bd5..9503aa79b 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -20,6 +20,16 @@ impl EngineHandle { Ok(()) } + /// Try to send an operation without blocking. + /// + /// Returns `Err` if the channel is full or closed. Use this for + /// non-critical, refresh-type ops (e.g. `Op::ListSubAgents`) that can + /// safely be dropped and re-requested on the next drain cycle. + pub fn try_send(&self, op: Op) -> Result<()> { + self.tx_op.try_send(op)?; + Ok(()) + } + /// Cancel the current request (user-initiated path — keeps the /// public `cancel()` signature stable). Equivalent to /// `cancel_with_reason(CancelReason::User)`. diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index dcd05c460..3c1510d61 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -6018,3 +6018,58 @@ async fn post_edit_hook_skips_unknown_tool_names() { assert!(engine.pending_lsp_blocks.is_empty()); assert_eq!(fake.call_count(), 0); } + +// ── #3802: non-blocking send for ListSubAgents refresh events ───────────── + +#[test] +fn engine_handle_try_send_does_not_block_when_op_channel_is_full() { + use tokio::sync::mpsc; + + // Create a channel with the smallest possible capacity. + let (tx_op, _rx_op) = mpsc::channel::(1); + + // Construct a minimal EngineHandle with the tiny channel. + let cancel_token = CancellationToken::new(); + let handle = EngineHandle { + tx_op, + rx_event: Arc::new(RwLock::new(mpsc::channel::(1).1)), + cancel_token: Arc::new(StdMutex::new(cancel_token)), + cancel_reason: Arc::new(StdMutex::new(None)), + tx_approval: mpsc::channel(1).0, + tx_user_input: mpsc::channel(1).0, + tx_steer: mpsc::channel(1).0, + shared_paused: Arc::new(StdMutex::new(false)), + }; + + // Fill the op channel with one message (capacity = 1). + handle + .tx_op + .try_send(Op::ListSubAgents) + .expect("first send should succeed"); + + // try_send must return Err immediately — never block. + let result = handle.try_send(Op::ListSubAgents); + assert!(result.is_err(), "try_send should fail when channel is full"); +} + +#[tokio::test] +async fn list_subagents_event_try_send_does_not_block_when_event_channel_full() { + use tokio::sync::mpsc; + + // Simulate the engine's event channel with capacity 1. + let (tx_event, mut _rx_event) = mpsc::channel::(1); + + // Fill the channel. + tx_event + .try_send(Event::status("filler")) + .expect("first send should succeed"); + + // Reproduce the handler pattern: try_send an AgentList event. + // This must return Err immediately — the handler should never hang. + let agents = vec![]; + let result = tx_event.try_send(Event::AgentList { agents }); + assert!( + result.is_err(), + "try_send should fail when event channel is full (backpressure avoided)" + ); +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a3127fb0f..4ded780fc 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3168,8 +3168,11 @@ async fn run_event_loop( } // #freeze: one trailing-edge sub-agent list refresh per drain, no // matter how many spawn/complete/mailbox events arrived this batch. + // #3802: non-blocking send — ListSubAgents is a refresh op that can + // be dropped when the op channel is full; the next drain cycle + // will re-request. if subagent_list_refresh_requested { - let _ = engine_handle.send(Op::ListSubAgents).await; + let _ = engine_handle.try_send(Op::ListSubAgents); } if let Some(next) = queued_to_send { @@ -7322,7 +7325,8 @@ async fn apply_command_result( } } AppAction::ListSubAgents => { - let _ = engine_handle.send(Op::ListSubAgents).await; + // #3802: non-blocking send — refresh op, safe to drop. + let _ = engine_handle.try_send(Op::ListSubAgents); } AppAction::FetchModels => { app.status_message = Some("Fetching models...".to_string()); @@ -9409,7 +9413,8 @@ async fn handle_view_events( } ViewEvent::SubAgentsRefresh => { app.status_message = Some("Refreshing sub-agents...".to_string()); - let _ = engine_handle.send(Op::ListSubAgents).await; + // #3802: non-blocking send — refresh op, safe to drop. + let _ = engine_handle.try_send(Op::ListSubAgents); } ViewEvent::FilePickerSelected { path } => { // Insert `@` at the composer's cursor with surrounding