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
27 changes: 27 additions & 0 deletions crates/noa-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,30 @@ pub struct App {
/// spawn finds the slot empty and gets the normal shell. `Mutex` only
/// because `spawn_pane_surface` takes `&self`.
initial_command: Mutex<Option<Vec<String>>>,
/// Global throttle gate for the tab-switch-stall background pane-cache
/// refresh (see `helpers::dispatch::background_refresh_selection`):
/// when any occluded window last had its cache opportunistically
/// refreshed, across ALL windows — not per-window. A per-window gate
/// would admit up to one full-viewport rebuild per window per interval,
/// which with N busy occluded tabs stalls the event loop (and so the
/// foreground window) for up to `N` rebuilds every interval.
last_bg_refresh: Option<Instant>,
/// Occluded windows with pty output observed since their last background
/// refresh (or since becoming occluded, if never refreshed) — candidates
/// for the next globally-throttled refresh. Populated only by
/// `UserEvent::Redraw` (pty-driven), never by a self-armed wake-up, so an
/// app with no occluded pty activity never spends a cycle on this.
dirty_occluded_windows: HashSet<WindowId>,
/// One-shot trailing wake-up for the background-refresh backlog (kaizen
/// cycle 6, finding P2): armed whenever `dirty_occluded_windows` is
/// non-empty, at the earliest instant the global throttle reopens —
/// so a candidate blocked purely by timing (its output landed inside the
/// throttle window, and no further pty output ever arrives to re-trigger
/// a check) still gets exactly one retry, rather than sitting stale
/// until an unrelated event. `None` whenever the backlog is empty: the
/// `about_to_wait` + `WaitUntil` mechanism then arms no timer for this at
/// all, same as every other idle-power-sensitive tick here.
bg_refresh_wake_deadline: Option<Instant>,
}

/// The wgpu foundation prewarmed on a worker: adapter/device are requested
Expand Down Expand Up @@ -718,6 +742,9 @@ impl App {
prespawned_pty: Mutex::new(prespawned_pty),
startup_tasks: Some(startup_tasks),
initial_command: Mutex::new(initial_command),
last_bg_refresh: None,
dirty_occluded_windows: HashSet::new(),
bg_refresh_wake_deadline: None,
}
}

Expand Down
58 changes: 57 additions & 1 deletion crates/noa-app/src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ impl ApplicationHandler<UserEvent> for App {
&& let Some(state) = self.windows.get(&window_id)
{
state.window.request_redraw();
} else if pane_decision == TargetedRedrawDecision::Suppress {
// The window is occluded, so the request above is dropped —
// this pty output would otherwise sit invisible until the
// window reveals, at which point a long-hidden busy pane
// forces a full pane-cache rebuild synchronously (the
// tab-switch stall). Mark it dirty and let the GLOBAL
// (not per-window — see `background_refresh_selection`)
// throttle decide whether — and which occluded window's —
// cache gets opportunistically refreshed right now.
self.dirty_occluded_windows.insert(window_id);
self.maybe_background_refresh_pane_cache();
}
if overview_decision == TargetedRedrawDecision::Request {
self.request_overview_redraw();
Expand Down Expand Up @@ -433,22 +444,65 @@ impl ApplicationHandler<UserEvent> for App {
}
if !occluded {
self.reset_cursor_blink_phase();
// NOA_TAB_SWITCH_TRACE t0: begin the occlusion-reveal
// sample (see `tab_switch_trace` module docs).
crate::tab_switch_trace::on_reveal_start();
}
let gpu = self.gpu.as_ref();
if let Some(state) = self.windows.get_mut(&window_id) {
state.occluded = occluded;
if occluded {
// A stashed reveal snapshot from a previous fast-path
// frame that never got its guaranteed follow-up
// redraw (e.g. re-occluded before winit delivered it)
// must not simply be dropped (kaizen cycle 5): its
// capturing read already consumed the terminal's row
// damage, so a later rebuild trusting `row_dirty`
// would see those rows as clean and never re-derive
// them — even though this pane's render cache never
// actually applied that content, leaving it stuck
// showing pre-capture output indefinitely if nothing
// else ever re-dirties those exact rows (e.g. a
// one-shot in-place TUI redraw with no further
// output). `invalidate_pane` sidesteps needing the
// stash's content at all: it forces this pane's next
// rebuild (whichever comes first — a real redraw's
// fresh terminal read, or a background refresh) to
// rebuild every row unconditionally, straight from
// the terminal's actual (still-correct) live content
// at that time, bypassing `row_dirty` entirely. Only
// needed for panes that actually had a stash — a
// pane with none is already consistent.
for (&pane_id, surface) in state.surfaces.iter_mut() {
if surface.pending_reveal_snapshot.take().is_some() {
state.renderer.invalidate_pane(render_pane_id(pane_id));
}
}
}
if let Some(gpu) = gpu {
let trace_configure_start = crate::tab_switch_trace::configure_start();
configure_wgpu_surface(
&state.surface,
&gpu.device,
&state.surface_config,
state.occluded,
);
crate::tab_switch_trace::on_surface_configured(trace_configure_start);
}
}
if !occluded {
// A reveal redraws with fresh terminal content, so any
// pending background-refresh candidacy is obsolete; keep
// dirty state scoped to a single occlusion cycle so a
// stale candidate can't consume a refresh slot later.
self.dirty_occluded_windows.remove(&window_id);
self.sync_current_background_image_to_window(window_id);
if let Some(state) = self.windows.get(&window_id) {
if let Some(state) = self.windows.get_mut(&window_id) {
// Tab-switch stall fix: let the next `redraw()` present
// whatever the renderer already has cached (kept warm
// by background refreshes while occluded) instead of
// forcing a full rebuild synchronously on this frame.
state.reveal_fast_path_pending = true;
state.window.request_redraw();
}
}
Expand Down Expand Up @@ -803,6 +857,7 @@ impl ApplicationHandler<UserEvent> for App {
let live_wallpaper_deadline = self.tick_live_wallpaper();
let kitty_anim_deadline = self.tick_kitty_animations();
let memory_trim_deadline = self.tick_memory_trim();
let bg_refresh_wake_deadline = self.tick_bg_refresh_wake();
let deadline = [
blink_deadline,
resize_throttle_deadline,
Expand All @@ -817,6 +872,7 @@ impl ApplicationHandler<UserEvent> for App {
live_wallpaper_deadline,
kitty_anim_deadline,
memory_trim_deadline,
bg_refresh_wake_deadline,
]
.into_iter()
.flatten()
Expand Down
85 changes: 85 additions & 0 deletions crates/noa-app/src/app/helpers/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,91 @@ pub(crate) fn keyboard_preedit_should_swallow_key<Id: Eq>(
modal_preedit_owner.is_some_and(|owner| owner == window_id) || pane_preedit_active
}

/// Throttle interval for an occluded window's background pane-cache refresh
/// (tab-switch stall fix). Chosen so a long-hidden busy tab's row cache and
/// glyph atlas stay warm enough that the reveal frame's catch-up rebuild is
/// small, without running a rebuild on every single pty-output redraw a busy
/// occluded pane requests.
pub(crate) const BG_REFRESH_INTERVAL: Duration = Duration::from_millis(250);

/// Which (if any) occluded window's pane cache should be opportunistically
/// rebuilt right now (present-free, no swapchain touch), given every
/// currently-dirty occluded window and its own last-refresh time.
///
/// Throttle is GLOBAL, not per-window (kaizen cycle 4, finding P1-B): a
/// per-window `BG_REFRESH_INTERVAL` gate admits up to one full-viewport
/// rebuild PER WINDOW per interval, so N busy occluded tabs stall the event
/// loop — and so the foreground window's own rendering and input handling —
/// for up to N rebuilds every interval. At most one candidate is returned per
/// `interval`, app-wide.
///
/// Fairness among ready candidates: the one refreshed longest ago (or never,
/// which sorts as "most overdue") wins, so no single busy tab can starve the
/// others by continuously re-dirtying itself — every dirty occluded window
/// eventually gets its turn, oldest-refresh-first.
///
/// Called only from the `UserEvent::Redraw` path, which already fires
/// exclusively when the io thread observed new pty output — so this is never
/// invoked on a self-armed wake-up; an app with no occluded pty activity
/// never spends a cycle here.
pub(crate) fn background_refresh_selection<Id: Copy>(
dirty_windows: &[(Id, Option<Instant>)],
global_last_refresh: Option<Instant>,
now: Instant,
interval: Duration,
) -> Option<Id> {
if let Some(last) = global_last_refresh
&& now.saturating_duration_since(last) < interval
{
return None;
}
dirty_windows
.iter()
.max_by_key(|(_, last_refresh)| {
last_refresh.map_or(Duration::MAX, |t| now.saturating_duration_since(t))
})
.map(|(id, _)| *id)
}

/// Next trailing wake-up deadline for the background-refresh backlog
/// (kaizen cycle 6, finding P2): `None` while the backlog is empty (fully
/// idle — the caller arms no timer at all), otherwise the earliest instant
/// the global throttle reopens. Closes the gap where a dirty candidate is
/// blocked purely by timing (its output landed inside the throttle window,
/// and nothing else ever triggers another check) — without this, it would
/// sit stale until an unrelated event happened to redraw again.
///
/// `dirty_remaining` is `!dirty_occluded_windows.is_empty()` evaluated AFTER
/// whatever this invocation of `maybe_background_refresh_pane_cache` did (or
/// didn't do): the same rule applies whether this call just serviced one
/// candidate and others remain (chain to drain the existing backlog one
/// throttle interval at a time) or was itself blocked by the throttle with
/// candidates still waiting (arm the one retry). Either way, `last_refresh`
/// (the GLOBAL last-refresh instant) is the anchor: the throttle can't
/// reopen before `last_refresh + interval`, regardless of which of those two
/// cases produced this call.
pub(crate) fn bg_refresh_wake_deadline(
dirty_remaining: bool,
last_refresh: Option<Instant>,
interval: Duration,
) -> Option<Instant> {
if !dirty_remaining {
return None;
}
last_refresh.map(|last| last + interval)
}

/// Whether `redraw()`'s reveal frame may skip the pane-cache rebuild this
/// frame and present the renderer's already-built instances as-is.
/// `pending` is the window's one-shot post-`Occluded(false)` flag;
/// `has_renderable_frame` guards the case where the renderer has nothing
/// usable yet (never rendered, or the viewport changed since its last build)
/// — that case must fall back to the normal full rebuild instead of
/// presenting garbage or a wrongly-sized layout.
pub(crate) fn reveal_fast_path_decision(pending: bool, has_renderable_frame: bool) -> bool {
pending && has_renderable_frame
}

pub(crate) fn pane_user_event_redraw_decision(
pane_state: Option<(bool, bool)>,
) -> TargetedRedrawDecision {
Expand Down
102 changes: 102 additions & 0 deletions crates/noa-app/src/app/helpers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,108 @@ fn targeted_redraw_decision_drops_stale_and_suppresses_occluded_tabs() {
);
}

#[test]
fn background_refresh_selection_returns_none_with_no_dirty_windows() {
let now = Instant::now();
assert_eq!(
background_refresh_selection::<u32>(&[], None, now, BG_REFRESH_INTERVAL),
None
);
}

#[test]
fn background_refresh_selection_is_due_immediately_with_no_prior_global_refresh() {
let now = Instant::now();
assert_eq!(
background_refresh_selection(&[(1u32, None)], None, now, BG_REFRESH_INTERVAL),
Some(1)
);
}

/// The global gate, not a per-window one (kaizen cycle 4, finding P1-B): even
/// though window 2 has never been refreshed (individually "due"), a global
/// refresh within the interval blocks EVERY window's turn, not just window 1's.
#[test]
fn background_refresh_selection_throttles_globally_across_every_window() {
let start = Instant::now();
let dirty = [(1u32, Some(start)), (2u32, None)];
assert_eq!(
background_refresh_selection(
&dirty,
Some(start),
start + BG_REFRESH_INTERVAL - Duration::from_millis(1),
BG_REFRESH_INTERVAL
),
None
);
assert_eq!(
background_refresh_selection(
&dirty,
Some(start),
start + BG_REFRESH_INTERVAL,
BG_REFRESH_INTERVAL
),
Some(2)
);
}

/// Fairness: among ready candidates, the one refreshed longest ago (or never)
/// wins — a continuously-busy window can't starve a quieter one.
#[test]
fn background_refresh_selection_prefers_the_least_recently_refreshed_window() {
let now = Instant::now();
let long_ago = now - Duration::from_secs(10);
let recently = now - Duration::from_millis(1);
let dirty = [(1u32, Some(recently)), (2u32, Some(long_ago)), (3u32, None)];
// Window 3 has never been refreshed at all — more overdue than any
// timestamped window, however old.
assert_eq!(
background_refresh_selection(&dirty, None, now, BG_REFRESH_INTERVAL),
Some(3)
);
let dirty_without_never_refreshed = [(1u32, Some(recently)), (2u32, Some(long_ago))];
assert_eq!(
background_refresh_selection(
&dirty_without_never_refreshed,
None,
now,
BG_REFRESH_INTERVAL
),
Some(2)
);
}

#[test]
fn bg_refresh_wake_deadline_is_none_when_no_dirty_backlog_remains() {
// Fully idle: no dirty candidates left, so no timer is armed at all —
// regardless of when the last refresh happened.
let now = Instant::now();
assert_eq!(
bg_refresh_wake_deadline(false, Some(now), BG_REFRESH_INTERVAL),
None
);
assert_eq!(
bg_refresh_wake_deadline(false, None, BG_REFRESH_INTERVAL),
None
);
}

#[test]
fn bg_refresh_wake_deadline_is_the_next_throttle_reopening_when_backlog_remains() {
let last_refresh = Instant::now();
assert_eq!(
bg_refresh_wake_deadline(true, Some(last_refresh), BG_REFRESH_INTERVAL),
Some(last_refresh + BG_REFRESH_INTERVAL)
);
}

#[test]
fn reveal_fast_path_requires_both_pending_and_a_renderable_cached_frame() {
assert!(!reveal_fast_path_decision(false, true));
assert!(!reveal_fast_path_decision(true, false));
assert!(reveal_fast_path_decision(true, true));
}

#[test]
fn stale_pane_user_event_redraw_decision_noops_without_panicking() {
assert_eq!(
Expand Down
2 changes: 2 additions & 0 deletions crates/noa-app/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,8 @@ impl App {
title_override: None,
native_overlays: Default::default(),
applied_window_bg: None,
bg_refresh_last: None,
reveal_fast_path_pending: false,
},
);
if let Some(identity) = remote_card_identity.as_ref() {
Expand Down
2 changes: 2 additions & 0 deletions crates/noa-app/src/app/quick_terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,8 @@ impl App {
bell_flash_until: None,
native_overlays: Default::default(),
applied_window_bg: None,
bg_refresh_last: None,
reveal_fast_path_pending: false,
},
);
self.relayout_and_resize_window(window_id);
Expand Down
Loading