From 6552339d1b0298109f92dad07325da2199ab7d1e Mon Sep 17 00:00:00 2001 From: Raphael Vigee Date: Wed, 10 Jun 2026 22:29:13 +0200 Subject: [PATCH] perf(tui): batch-drain build events to kill post-completion hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A warm `heph run test //...` emits typed events for the whole transitive closure (ResultStart/End + cache-hit per dep) — tens of thousands, flooded into the unbounded channel near-instantly. Both backends folded at most one event per `select!` iteration, so the burst drained one-at-a-time over seconds after the headline `44/44 done` had already painted. Add `fold_buffered`: on each build-event wake, greedily `try_recv` the rest of the buffered backlog in one pass. N select iterations collapse to one; the 80ms ticker still repaints. Engine event emission is unchanged — events stay typed, collapsed only at render. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tui/backend/ci.rs | 14 +++++-- src/tui/backend/interactive.rs | 17 +++++--- src/tui/backend/mod.rs | 71 ++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/tui/backend/ci.rs b/src/tui/backend/ci.rs index d4c4fe4b..ceae9ecd 100644 --- a/src/tui/backend/ci.rs +++ b/src/tui/backend/ci.rs @@ -39,7 +39,15 @@ pub async fn run( } } => { match maybe_evt { - Some(ev) => view.apply(&ev), + // Greedily drain the rest of the buffered backlog in this + // same wake so an emitted burst folds in one pass instead of + // one event per `select!` iteration. + Some(ev) => { + view.apply(&ev); + if let Some(r) = events.as_mut() { + super::fold_buffered(r, &mut view, |v, e| v.apply(e)); + } + } // Sender dropped — stop polling, keep awaiting the app. None => events = None, } @@ -50,9 +58,7 @@ pub async fn run( // Drain any events buffered before the sender dropped so the final // summary is accurate even if the app future completed first. if let Some(r) = events.as_mut() { - while let Ok(ev) = r.try_recv() { - view.apply(&ev); - } + super::fold_buffered(r, &mut view, |v, ev| v.apply(ev)); } view.finish(); diff --git a/src/tui/backend/interactive.rs b/src/tui/backend/interactive.rs index 43e5d5de..12862e8c 100644 --- a/src/tui/backend/interactive.rs +++ b/src/tui/backend/interactive.rs @@ -300,8 +300,17 @@ pub async fn run( }, if !paused => { match maybe_build_evt { // No per-event redraw: fold into the view here, the 80ms - // ticker repaints the pinned progress block. - Some(e) => view.apply(&e), + // ticker repaints the pinned progress block. Greedily drain + // the rest of the buffered backlog in this same wake so an + // emitted burst folds in one pass rather than one event per + // `select!` iteration (which left the fold seconds behind a + // warm run's flood of typed events). + Some(e) => { + view.apply(&e); + if let Some(r) = build_events.as_mut() { + super::fold_buffered(r, &mut view, |v, ev| v.apply(ev)); + } + } // Sender dropped (request finished) — stop polling. None => build_events = None, } @@ -341,9 +350,7 @@ pub async fn run( // request state drops). Drain them so the final summary reflects the full // stream rather than the snapshot at the last tick. if let Some(r) = build_events.as_mut() { - while let Ok(e) = r.try_recv() { - view.apply(&e); - } + super::fold_buffered(r, &mut view, |v, e| v.apply(e)); } // Persistent final summary, printed straight to stderr below the diff --git a/src/tui/backend/mod.rs b/src/tui/backend/mod.rs index 7a0cdb05..ca75606b 100644 --- a/src/tui/backend/mod.rs +++ b/src/tui/backend/mod.rs @@ -1,2 +1,73 @@ pub mod ci; pub mod interactive; + +use tokio::sync::mpsc::UnboundedReceiver; + +use crate::engine::event::BuildEvent; + +/// Fold every event currently buffered in `rx` into `view`, returning how many +/// were folded. Non-blocking: `try_recv` stops at the first empty (or closed) +/// read, so this drains the present backlog without ever waiting for more. +/// +/// The backends call this when the build-event select arm wakes, so an emitted +/// burst (a warm run resolves its whole transitive closure near-instantly, +/// emitting tens of thousands of typed events) folds in one pass between two +/// render ticks instead of one event per `select!` iteration. +pub(crate) fn fold_buffered( + rx: &mut UnboundedReceiver, + view: &mut V, + apply: impl Fn(&mut V, &BuildEvent), +) -> usize { + let mut folded = 0; + while let Ok(ev) = rx.try_recv() { + apply(view, &ev); + folded += 1; + } + folded +} + +#[cfg(test)] +mod tests { + use super::fold_buffered; + use crate::engine::event::{BuildEvent, BuildEventKind}; + use tokio::sync::mpsc; + + fn result_end(addr: &str) -> BuildEvent { + BuildEvent { + at_unix_ms: 0, + kind: BuildEventKind::ResultEnd { + addr: addr.to_string(), + error: None, + }, + } + } + + /// One wake folds the entire buffered backlog and leaves the channel empty — + /// the contract that replaces the one-event-per-`select!`-iteration drain. + #[tokio::test] + async fn drains_whole_backlog_in_one_pass() { + let (tx, mut rx) = mpsc::unbounded_channel(); + const K: usize = 5_000; + for i in 0..K { + tx.send(result_end(&format!("//pkg:t{i}"))).expect("send"); + } + + // Stand in for a view: count folds. The drain mechanics are what's under + // test, not a specific aggregator. + let mut count = 0usize; + let folded = fold_buffered(&mut rx, &mut count, |c, _ev| *c += 1); + + assert_eq!(folded, K, "should fold every buffered event in one call"); + assert_eq!(count, K, "view sees every event"); + assert!(rx.try_recv().is_err(), "channel drained empty"); + } + + /// An empty channel folds nothing (and does not block). + #[tokio::test] + async fn empty_channel_folds_nothing() { + let (_tx, mut rx) = mpsc::unbounded_channel(); + let mut count = 0usize; + assert_eq!(fold_buffered(&mut rx, &mut count, |c, _e| *c += 1), 0); + assert_eq!(count, 0); + } +}