Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions crates/tui/src/core/engine/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
55 changes: 55 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Op>(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::<Event>(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::<Event>(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)"
);
}
11 changes: 8 additions & 3 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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 `@<path>` at the composer's cursor with surrounding
Expand Down
Loading