refactor(desktop): delete the global runtime tick

Phase 3. `WindowRuntimeStore`, `drive_window_runtime_tick`, `planes.rs` in its
entirety, `runtime_quiet_tick_allowed`, `window_runtime_tick_delay`,
`window_runtime_tick_needs_update`, `window_runtime_quiet_tick_has_due_work`, the
cadence constants and predicates that fed them, and the perf-heartbeat sampling state
they carried. Net 656 lines deleted against 123 added.

`runtime_quiet_tick_allowed` is the one worth naming. It was the coupling hub the audit
described: one boolean AND reaching into every feature state, which any new background
queue had to be added to or silently inherit up to 500ms of delivery latency. It began
at roughly thirty terms, reached nine over Phase 2, and is now gone -- so that class of
defect cannot recur, which was the point of the exercise rather than the tick's cost
itself.

The prerequisite in this commit is the remote-panel auto-refresh, the last thing the
tick still drove. Stats, GPU, NPU, Processes, Docker and the transfer cwd sync poll a
host on user-configured intervals, so that stays a poll; it now runs on a one-second
clock -- as fine as the finest interval it can service -- scoped to "some panel actually
wants refreshing", armed from `render` because what it depends on changes alongside a
repaint. **This is an interim owner.** The design puts these timers on the panel
entities Phase 4 extracts, armed on mount and dropped on unmount, which is better: the
panel that wants the data should own the timer that fetches it. Keeping the shape as one
scoped clock over a "does anything need this" predicate is what Phase 4 relocates rather
than redesigns.

Two things the deletion list got wrong, both checked rather than taken on trust.
`viewport_change_terminal_session_ids` was listed for deletion but is still used -- by
the viewport reconcile that moved into `render` in `4643cfd0` -- so it stays.
`terminal_render_work_pressure_active` likewise survives, feeding the recovery clock.
The other named helper, `terminal_performance_tick_session_ids`, had already moved in
`33ed0ad1` because it deduplicates rather than merely allocating.

Also retired: `has_background_work`, `prompt_has_pending_or_active_prompt`,
`start_pending_count`, `active_terminal_scroll_offset`, `visible_layout_cache_stats`
and `full_shell_paint_count` -- accessors that existed only for the quiet gate or the
heartbeat. `pending_count` and `buffer_search_is_open` survive as `#[cfg(test)]`,
which is what they now are.

The perf heartbeat goes with the tick rather than getting a timer. It sampled the tick
it reported on, so there is nothing left for it to measure; the data-plane drain has its
own slow diagnostic from `55c72702`, and `record_gpui_perf_sample` in `render` still
feeds the `NYATERM_GPUI_PERF` paint comparison Phase 4 needs.

Verified: cargo check --workspace --all-targets with zero warnings, cargo fmt per
package, cargo test --workspace --no-fail-fast at exactly the three documented Windows
baseline failures. cargo clippy output is byte-identical to the base apart from one
pre-existing finding shifting line number as lines were removed -- confirmed by stashing
and diffing both lists, after a first grep pattern of mine wrongly suggested two were
new.

Worth a broad pass on Windows, since this removes the thing that was previously
covering for anything I have mis-scoped: open several sessions, flood one, drag the
window, switch tabs and panels, open the remote panels, lock and unlock, and leave it
idle at a prompt. Anything that used to be driven by the tick and now is not would show
up as something that never happens rather than something that happens late.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kang
2026-08-23 09:50:20 +08:00
co-authored by Claude Opus 5
parent 58bc0c66c6
commit 15994ef6ce
17 changed files with 123 additions and 656 deletions
+1 -9
View File
@@ -17,7 +17,7 @@ use nyaterm_ui::{
};
use crate::{
entities::{OverlayStore, StartupRestoreStore, UiStoreHandles, WindowRuntimeStore},
entities::{OverlayStore, StartupRestoreStore, UiStoreHandles},
features::NyaTermApp,
};
@@ -77,7 +77,6 @@ pub struct AppShell {
app: Option<Entity<NyaTermApp>>,
store_runtime: Option<StoreRuntime>,
pending_bootstrap: Option<StoreTask<nyaterm_store::BootstrapSnapshot>>,
window_runtime: Entity<WindowRuntimeStore>,
startup_restore: Entity<StartupRestoreStore>,
overlays: Entity<OverlayStore>,
_subscriptions: Vec<Subscription>,
@@ -114,7 +113,6 @@ impl AppShell {
app: None,
store_runtime: None,
pending_bootstrap: None,
window_runtime: cx.new(|_| WindowRuntimeStore::default()),
startup_restore,
overlays,
_subscriptions: subscriptions,
@@ -251,12 +249,6 @@ impl AppShell {
app.start_after_window_open(window, cx);
});
}
self.window_runtime.update(cx, |store, cx| {
if store.ensure_started(window, cx, app) {
cx.notify();
}
});
}
fn perform_native_menu_command(
@@ -9,7 +9,6 @@
mod handles;
mod overlay;
mod startup_restore;
mod window_runtime;
#[cfg(test)]
mod tests;
@@ -17,4 +16,3 @@ mod tests;
pub use handles::UiStoreHandles;
pub use overlay::{OverlayStore, QuickSwitchState};
pub use startup_restore::StartupRestoreStore;
pub use window_runtime::WindowRuntimeStore;
+1 -10
View File
@@ -1,13 +1,4 @@
use super::{OverlayStore, StartupRestoreStore, WindowRuntimeStore};
#[test]
fn window_runtime_store_starts_pump_once() {
let mut store = WindowRuntimeStore::default();
assert!(store.mark_started());
assert!(!store.mark_started());
assert!(store.pump_started());
}
use super::{OverlayStore, StartupRestoreStore};
#[test]
fn startup_restore_store_starts_after_window_open_once() {
@@ -1,75 +0,0 @@
use std::time::{Duration, Instant};
use gpui::{Context, Entity, Window};
use crate::features::NyaTermApp;
#[derive(Debug, Default)]
pub struct WindowRuntimeStore {
pump_started: bool,
}
impl WindowRuntimeStore {
pub fn ensure_started(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
app: Entity<NyaTermApp>,
) -> bool {
if !self.mark_started() {
return false;
}
app.update(cx, |app, _| app.mark_window_runtime_started());
window
.spawn(cx, async move |cx| {
let mut skipped_updates = 0u64;
let mut last_skip_log_at = Instant::now();
loop {
let delay = app.read_with(cx, |app, _| app.window_runtime_tick_delay());
cx.background_executor().timer(delay).await;
let keep_running = cx
.update(|window, cx| {
let now = Instant::now();
let should_update = app
.read_with(cx, |app, _| app.window_runtime_tick_needs_update(now));
if !should_update {
skipped_updates = skipped_updates.saturating_add(1);
if skipped_updates > 0
&& now.saturating_duration_since(last_skip_log_at)
>= Duration::from_secs(5)
{
tracing::info!(
diagnostic = "runtime_tick_skip",
skipped_updates,
next_tick_delay_ms = delay.as_millis(),
"skipped idle window runtime updates"
);
skipped_updates = 0;
last_skip_log_at = now;
}
return app.read_with(cx, |app, _| app.window_runtime_running());
}
app.update(cx, |app, cx| app.drive_window_runtime_tick(window, cx))
})
.unwrap_or(false);
if !keep_running {
break;
}
}
})
.detach();
true
}
pub fn mark_started(&mut self) -> bool {
if self.pump_started {
return false;
}
self.pump_started = true;
true
}
pub fn pump_started(&self) -> bool {
self.pump_started
}
}
@@ -297,10 +297,6 @@ impl AiFeatureState {
self.chat.pending
}
pub(in crate::features) fn has_background_work(&self) -> bool {
self.chat.pending || self.agent.loop_state.is_some() || self.discovery.pending
}
pub(in crate::features) fn chat_focus(&self) -> &FocusHandle {
&self.chat.focus
}
@@ -95,6 +95,9 @@ impl NyaTermApp {
// point.
self.ensure_pending_focus_clock(cx);
self.ensure_post_start_work_clock(cx);
// Which panel is open, and whether an SSH session is active, change only
// alongside a repaint -- so this is where the refresh clock starts.
self.ensure_remote_refresh_clock(cx);
self.try_restore_open_tabs(window, cx);
let pending_session_start = self.session.start_has_pending();
let should_pump = !self.session.restore_is_complete()
@@ -254,10 +254,6 @@ impl SessionFeatureState {
self.start.pending_status_source()
}
pub(in crate::features) fn start_pending_count(&self) -> usize {
self.start.pending_count()
}
pub(in crate::features) fn start_visible_tab_reservation_count(&self) -> usize {
self.start.visible_tab_reservation_count()
}
@@ -414,10 +410,6 @@ impl SessionFeatureState {
self.prompts.has_active_ssh_auth()
}
pub(in crate::features) fn prompt_has_pending_or_active_prompt(&self) -> bool {
self.prompts.has_pending_or_active_prompt()
}
pub(in crate::features) fn prompt_focus_keyboard_interactive_response(
&mut self,
prompt_id: &str,
@@ -1648,15 +1640,6 @@ impl SessionPromptState {
|| self.active_agent_prompt.is_some()
}
pub(in crate::features) fn has_pending_or_active_prompt(&self) -> bool {
self.has_active_ssh_auth()
|| self.active_duplicate_prompt.is_some()
|| self.host_key_prompts.has_pending()
|| self.credential_prompts.has_pending()
|| self.agent_prompts.has_pending()
|| self.duplicate_prompts.has_pending()
}
/// Install one wake across all four brokers, before any of them can be
/// handed to a transport thread.
fn new(otp_provider: Arc<NativeOtpProvider>, credential_focus: FocusHandle) -> Self {
@@ -2361,6 +2344,7 @@ impl SessionStartFeatureState {
!self.failed.is_empty()
}
#[cfg(test)]
pub(in crate::features) fn pending_count(&self) -> usize {
self.pending.len()
}
@@ -13,9 +13,6 @@ pub(super) const SESSION_EVENT_INPUT_WAKE_WALL_BUDGET: Duration = Duration::from
/// applies, so anything past this overran both.
pub(super) const RUNTIME_DATA_PLANE_DRAIN_SLOW: Duration = Duration::from_millis(16);
pub(super) const RUNTIME_IDLE_TICK_INTERVAL: Duration = Duration::from_millis(50);
pub(super) const RUNTIME_QUIET_TICK_INTERVAL: Duration = Duration::from_millis(500);
/// Match display frame pacing; 8ms stacked full ticks under pressure contended with window drag paints.
pub(super) const RUNTIME_PRESSURE_TICK_INTERVAL: Duration = Duration::from_millis(16);
/// After viewport size changes, hold pressure cadence for this long.
pub(super) const WINDOW_GEOMETRY_CHURN_HOLD: Duration = Duration::from_millis(200);
/// Window move drags may not resize the viewport, especially on Windows. Hold a
@@ -29,8 +26,6 @@ pub(in crate::features::shell) const CONNECT_SETTLE_HOLD: Duration = Duration::f
pub(super) const UI_PAINT_THROTTLE: Duration = Duration::from_millis(33);
pub(super) const TERMINAL_FRAME_APPLY_PRESSURE_INTERVAL: Duration = Duration::from_millis(16);
pub(super) const SLOW_DIAGNOSTIC_THROTTLE: Duration = Duration::from_secs(2);
pub(super) const TERMINAL_PERF_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
pub(super) const RUNTIME_TICK_SLOW_THRESHOLD: Duration = Duration::from_millis(40);
pub(super) const SESSION_EVENT_DRAIN_SLOW_TOTAL: Duration = Duration::from_millis(20);
pub(super) const SESSION_EVENT_DRAIN_SLOW_CHUNK: Duration = Duration::from_millis(8);
pub(super) const PENDING_SESSION_STILL_CONNECTING_AFTER: Duration = Duration::from_secs(15);
@@ -71,13 +66,6 @@ pub(super) struct RuntimeDataPlaneDrain {
pub(super) decision: TerminalFrameApplyDecision,
}
#[derive(Default)]
pub(super) struct RuntimeIdlePlaneResult {
pub(super) dirty: bool,
pub(super) render_request_output_pressure: bool,
pub(super) remote_refresh: Duration,
}
#[derive(Clone, Copy)]
pub(super) struct SessionEventDrainBudget {
pub(super) max_events: usize,
@@ -92,17 +80,6 @@ pub(super) enum PendingSessionAuthWait {
Agent { target: String },
}
pub(super) fn diagnostic_log_due(
last_at: Option<Instant>,
now: Instant,
throttle: Duration,
) -> bool {
last_at.is_none_or(|last_at| {
now.checked_duration_since(last_at)
.is_none_or(|elapsed| elapsed >= throttle)
})
}
pub(super) fn terminal_cell_metrics_refresh_needed(metrics: Option<(f32, f32)>) -> bool {
metrics.is_none()
}
@@ -125,14 +102,6 @@ pub(super) fn pending_session_status_message(
}
}
pub(super) fn runtime_tick_interval_for_pressure(output_pressure: bool) -> Duration {
if output_pressure {
RUNTIME_PRESSURE_TICK_INTERVAL
} else {
RUNTIME_IDLE_TICK_INTERVAL
}
}
pub(super) fn viewport_change_terminal_session_ids(visible_session_ids: &[&str]) -> Vec<String> {
visible_session_ids
.iter()
@@ -351,10 +320,6 @@ pub(in crate::features::shell::event_pump) fn terminal_render_work_pressure_acti
runtime_output_pressure || pending_session_start
}
pub(super) fn runtime_idle_plane_allowed(runtime_output_pressure: bool) -> bool {
!runtime_output_pressure
}
pub(super) fn session_event_drain_should_yield(
started_at: Instant,
has_pending_events: bool,
@@ -401,15 +366,13 @@ mod tests {
use super::{
CONNECT_SETTLE_HOLD, PendingSessionAuthWait, RUNTIME_IDLE_TICK_INTERVAL,
RUNTIME_PRESSURE_TICK_INTERVAL, RuntimeOutputPressureCounts, SESSION_EVENT_DRAIN_BATCH,
RuntimeOutputPressureCounts, SESSION_EVENT_DRAIN_BATCH,
SESSION_EVENT_DRAIN_IDLE_OUTPUT_BUDGET, SESSION_EVENT_DRAIN_PRESSURE_OUTPUT_BUDGET,
SESSION_EVENT_DRAIN_SLOW_CHUNK, SESSION_EVENT_DRAIN_SLOW_TOTAL,
SESSION_EVENT_DRAIN_WALL_BUDGET, SLOW_DIAGNOSTIC_THROTTLE,
TERMINAL_FRAME_APPLY_PRESSURE_INTERVAL, UI_PAINT_THROTTLE, WINDOW_GEOMETRY_CHURN_HOLD,
connect_settle_active, connect_settle_deadline, diagnostic_log_due,
SESSION_EVENT_DRAIN_WALL_BUDGET, TERMINAL_FRAME_APPLY_PRESSURE_INTERVAL, UI_PAINT_THROTTLE,
WINDOW_GEOMETRY_CHURN_HOLD, connect_settle_active, connect_settle_deadline,
pending_session_status_message, runtime_background_should_defer_terminal_frames,
runtime_data_plane_wake_delay, runtime_idle_plane_allowed,
runtime_output_pressure_active_from_counts, runtime_tick_interval_for_pressure,
runtime_data_plane_wake_delay, runtime_output_pressure_active_from_counts,
runtime_ui_notify_allowed, session_event_backlog_active, session_event_drain_budget,
session_event_drain_is_slow, session_event_drain_should_yield,
session_event_input_wake_drain_budget, terminal_cell_metrics_refresh_needed,
@@ -443,23 +406,6 @@ mod tests {
assert!(escaped.contains("失败"));
}
#[test]
fn diagnostic_log_due_respects_throttle_window() {
let start = Instant::now();
assert!(diagnostic_log_due(None, start, SLOW_DIAGNOSTIC_THROTTLE));
assert!(!diagnostic_log_due(
Some(start),
start + Duration::from_millis(1999),
SLOW_DIAGNOSTIC_THROTTLE
));
assert!(diagnostic_log_due(
Some(start),
start + SLOW_DIAGNOSTIC_THROTTLE,
SLOW_DIAGNOSTIC_THROTTLE
));
}
#[test]
fn terminal_cell_metrics_refreshes_only_after_invalidation() {
assert!(terminal_cell_metrics_refresh_needed(None));
@@ -501,18 +447,6 @@ mod tests {
);
}
#[test]
fn runtime_tick_interval_uses_fast_cadence_under_output_pressure() {
assert_eq!(
runtime_tick_interval_for_pressure(false),
RUNTIME_IDLE_TICK_INTERVAL
);
assert_eq!(
runtime_tick_interval_for_pressure(true),
RUNTIME_PRESSURE_TICK_INTERVAL
);
}
#[test]
fn viewport_change_terminal_session_ids_skips_empty_ids() {
assert_eq!(
@@ -899,12 +833,6 @@ mod tests {
assert!(terminal_render_work_pressure_active(false, true));
}
#[test]
fn runtime_idle_plane_waits_for_output_calm() {
assert!(runtime_idle_plane_allowed(false));
assert!(!runtime_idle_plane_allowed(true));
}
#[test]
fn session_event_drain_yields_when_backlog_remains_after_wall_budget() {
let start = Instant::now() - SESSION_EVENT_DRAIN_WALL_BUDGET;
@@ -5,12 +5,11 @@ use gpui::{Context, Window};
use crate::features::shell::event_pump::helpers::{
PENDING_SESSION_STILL_CONNECTING_AFTER, PendingSessionAuthWait, RUNTIME_DATA_PLANE_DRAIN_SLOW,
RUNTIME_IDLE_TICK_INTERVAL, RUNTIME_QUIET_TICK_INTERVAL, RuntimeDataPlaneDrain,
RuntimeOutputPressureCounts, SLOW_DIAGNOSTIC_THROTTLE, TITLE_DRAG_ACTIVE_HOLD,
TRANSFER_AUTO_SYNC_CWD_INTERVAL_SECONDS, TerminalFrameApplyDecision, connect_settle_active,
connect_settle_deadline, pending_session_status_message, remote_refresh_due,
runtime_background_should_defer_terminal_frames, runtime_data_plane_wake_delay,
runtime_output_pressure_active_from_counts, runtime_tick_interval_for_pressure,
RuntimeDataPlaneDrain, RuntimeOutputPressureCounts, SLOW_DIAGNOSTIC_THROTTLE,
TITLE_DRAG_ACTIVE_HOLD, TRANSFER_AUTO_SYNC_CWD_INTERVAL_SECONDS, TerminalFrameApplyDecision,
connect_settle_active, connect_settle_deadline, pending_session_status_message,
remote_refresh_due, runtime_background_should_defer_terminal_frames,
runtime_data_plane_wake_delay, runtime_output_pressure_active_from_counts,
runtime_ui_notify_allowed, terminal_cell_metrics_refresh_needed,
terminal_frame_apply_should_defer, terminal_input_idle_remaining_delay,
terminal_user_scroll_frame_apply_pending, viewport_change_terminal_session_ids,
@@ -42,7 +41,6 @@ pub(in crate::features) fn terminal_performance_pressure(
}
pub(super) use helpers::PENDING_SESSION_STATUS_INTERVAL;
mod planes;
mod session_events;
use crate::features::terminal::terminal_runtime::{
@@ -495,13 +493,6 @@ impl NyaTermApp {
.should_log(key, now, SLOW_DIAGNOSTIC_THROTTLE)
}
pub(in crate::features) fn visible_terminal_layout_cache_stats(&self) -> (u64, u64) {
self.terminal
.visible_layout_cache_stats(self.visible_terminal_session_ids())
}
/// Put the screen into its locked state. Whether it is *time* to is decided by
/// `shell::idle_lock`, which owns the deadline.
pub(in crate::features) fn lock_screen_for_idle(
&mut self,
window: &mut Window,
@@ -527,66 +518,6 @@ impl NyaTermApp {
true
}
pub(crate) fn mark_window_runtime_started(&mut self) {
self.shell.runtime.event_pump_started = true;
}
pub(crate) fn window_runtime_running(&self) -> bool {
self.shell.runtime.event_pump_started
}
pub(crate) fn window_runtime_tick_delay(&self) -> Duration {
// During recent viewport geometry churn (window resize/drag), prefer the
// idle cadence so full plane ticks do not stack on compositor paints.
let now = Instant::now();
if self.title_drag_active(now)
|| window_geometry_churn_active(self.shell.viewport.last_change_at, now)
{
return RUNTIME_IDLE_TICK_INTERVAL;
}
if self.runtime_quiet_tick_allowed() {
return RUNTIME_QUIET_TICK_INTERVAL;
}
runtime_tick_interval_for_pressure(self.runtime_output_pressure_active())
}
pub(crate) fn window_runtime_tick_needs_update(&self, now: Instant) -> bool {
if !self.shell.runtime.event_pump_started {
return false;
}
if self
.shell
.runtime
.connect_settle_until
.is_some_and(|until| now >= until)
{
return true;
}
if self.title_drag_active(now) {
return true;
}
let output_pressure = self.runtime_output_pressure_active();
let connect_settle = connect_settle_active(self.shell.runtime.connect_settle_until, now);
if runtime_ui_notify_allowed(
false,
self.shell.runtime.pending_ui_notify,
false,
output_pressure || connect_settle,
self.shell.runtime.last_ui_notify_at,
now,
) {
return true;
}
if !self.runtime_quiet_tick_allowed() {
return true;
}
// Nothing is left that the tick alone would notice: every remaining concern
// owns a clock or a wake. What keeps the tick alive is the idle plane's
// `drive_remote_auto_refresh`, which Phase 4 moves onto the panel entities.
false
}
pub(in crate::features) fn visible_terminal_performance_recovery_due(&self) -> bool {
self.terminal
.visible_performance_recovery_due(self.visible_terminal_session_ids())
@@ -612,29 +543,6 @@ impl NyaTermApp {
})
}
pub(in crate::features) fn runtime_quiet_tick_allowed(&self) -> bool {
!self.runtime_output_pressure_active()
&& !self.session.start_has_pending()
&& self.session.pending_events_are_empty()
&& !self.session.event_bridge_has_pending_ui_work()
&& !self.terminal_frame_backlog_active()
&& !self.session.has_protocol_runtime_sessions()
&& !self.session.prompt_has_pending_or_active_prompt()
&& !self.ai.has_background_work()
&& !((self.session.active_ssh_config().is_some()
&& matches!(
self.current_right_panel(),
Some(
NavItem::Stats
| NavItem::GpuMonitor
| NavItem::AscendNpuMonitor
| NavItem::Processes
| NavItem::Docker
)
))
|| self.current_left_panel() == Some(NavItem::Transfers))
}
pub(in crate::features) fn drive_pending_session_status(&mut self) -> bool {
let Some((name, requested_at)) = self.session.start_pending_status_source() else {
self.shell.runtime.last_pending_session_status_at = None;
@@ -758,13 +666,13 @@ impl NyaTermApp {
dirty
}
fn header_status_needs_gpu(&self) -> bool {
pub(in crate::features) fn header_status_needs_gpu(&self) -> bool {
self.settings.summary().ui_header_status_visible
&& HeaderStatusMode::from_setting(&self.settings.summary().ui_header_status_mode)
== HeaderStatusMode::Gpu
}
fn header_status_needs_npu(&self) -> bool {
pub(in crate::features) fn header_status_needs_npu(&self) -> bool {
self.settings.summary().ui_header_status_visible
&& HeaderStatusMode::from_setting(&self.settings.summary().ui_header_status_mode)
== HeaderStatusMode::Npu
@@ -814,8 +722,7 @@ mod tests {
};
use super::helpers::{
RUNTIME_QUIET_TICK_INTERVAL, SESSION_EVENT_DRAIN_IDLE_OUTPUT_BUDGET,
TERMINAL_FRAME_APPLY_PRESSURE_INTERVAL,
SESSION_EVENT_DRAIN_IDLE_OUTPUT_BUDGET, TERMINAL_FRAME_APPLY_PRESSURE_INTERVAL,
};
const SESSION_ID: &str = "event-pump-session";
@@ -831,8 +738,7 @@ mod tests {
))
}
/// A workspace with one visible local session and nothing outstanding: the
/// state `runtime_quiet_tick_allowed` is meant to recognise.
/// A workspace with one visible local session and nothing outstanding.
fn quiet_app_with_visible_session(cx: &mut TestAppContext) -> gpui::Entity<NyaTermApp> {
let root = unique_test_dir();
let runtime = AppRuntime::from_parts_for_test(
@@ -868,47 +774,12 @@ mod tests {
.seed_session_view(SESSION_ID.to_string(), String::new(), "UTF-8");
app.shell.show_workspace();
// A fresh app starts with the terminal window layout restore
// outstanding, which is itself a reason to stay off the quiet cadence.
// outstanding; complete it so these tests start from a settled app.
app.terminal.complete_terminal_windows_restore();
assert!(
app.runtime_quiet_tick_allowed(),
"fixture must start on the quiet cadence for these tests to mean anything"
);
});
app
}
/// The caret keeps its own cadence now, so the tick delay owes it nothing.
///
/// This is the Phase 0 clamp's test, re-pointed. `1c3d9e85` had to make
/// `window_runtime_tick_delay` wake on the blink deadline, because a 500ms quiet
/// cadence against a 530ms interval stretched the visible half-period to roughly
/// 1000ms. With blink on its own timer the clamp is gone and the quiet cadence is
/// plainly the quiet cadence -- which is also what lets Phase 3 delete this
/// function without taking the caret with it. What the caret actually does is
/// asserted in `shell::cursor_blink`.
#[test]
fn the_quiet_tick_delay_no_longer_bends_around_the_blink_deadline() {
let mut cx = TestAppContext::single();
let app = quiet_app_with_visible_session(&mut cx);
cx.update_entity(&app, |app, _| {
let mut summary = app.settings.summary().clone();
summary.cursor_blink = true;
app.settings.replace_summary(summary);
assert!(!app.visible_terminal_session_ids().is_empty());
assert_eq!(
app.window_runtime_tick_delay(),
RUNTIME_QUIET_TICK_INTERVAL,
"blink has its own clock; the tick must not shorten its delay for it"
);
// Nor during connect settle, where the phase is held either way.
app.enter_connect_settle();
assert_eq!(app.window_runtime_tick_delay(), RUNTIME_QUIET_TICK_INTERVAL);
});
}
/// A pending credential-prompt detection must not be stranded by the task parking.
///
/// Detection is *marked* while output is being processed but is gated on the output
@@ -1083,28 +954,6 @@ mod tests {
});
}
/// The quiet cadence no longer has to know about persistence at all.
///
/// `4644195a` had to add `ui_layout_persist_pending` to the gate because the idle
/// plane was the flag's only writer — the third time that defect shipped from
/// these three flags. Now they have their own debounce task, so a pending write
/// is *not* a reason to keep the runtime off the quiet cadence, and the gate has
/// one less thing to forget. The replacement coverage lives in
/// `shell::persistence_debounce`, which asserts the write actually happens with
/// no tick in the fixture at all.
#[test]
fn a_pending_ui_layout_save_no_longer_holds_the_quiet_cadence() {
let mut cx = TestAppContext::single();
let app = quiet_app_with_visible_session(&mut cx);
cx.update_entity(&app, |app, _| {
app.shell.mark_ui_layout_persist_pending();
assert!(
app.runtime_quiet_tick_allowed(),
"persistence has its own timer now; the gate must not be its keeper"
);
});
}
/// The recording-history reply path, which used to lean on a quiet-gate term.
/// Delivery wiring is covered by the gist test above; what is specific here is
/// which replies get applied.
@@ -1,251 +0,0 @@
use std::time::Instant;
use gpui::{Context, Window};
use crate::features::shell::event_pump::helpers::{
RUNTIME_TICK_SLOW_THRESHOLD, RuntimeIdlePlaneResult, TERMINAL_PERF_HEARTBEAT_INTERVAL,
connect_settle_active, diagnostic_log_due, runtime_idle_plane_allowed,
runtime_ui_notify_allowed, window_geometry_churn_active,
};
use crate::features::{
NyaTermApp, terminal::full_shell_paint_count, terminal::terminal_surface_paint_count,
};
use crate::models::NavItem;
impl NyaTermApp {
pub(crate) fn drive_window_runtime_tick(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let tick_started_at = Instant::now();
// Viewport/cell-metrics reconcile happens in `render`, the header clock and
// connect status on their own timers; see `shell::status_clocks`.
let mut dirty = false;
// Skip full planes when the compositor is moving/resizing the window, or
// when there is simply nothing pending (common during pure window drag).
let now = Instant::now();
if self
.shell
.runtime
.connect_settle_until
.is_some_and(|until| now >= until)
{
self.shell.runtime.connect_settle_until = None;
}
if self.title_drag_active(now) {
if dirty {
cx.notify();
}
return true;
}
let geometry_churn = window_geometry_churn_active(self.shell.viewport.last_change_at, now);
let calm_tick = self.runtime_quiet_tick_allowed();
if geometry_churn && calm_tick {
if dirty {
cx.notify();
}
return true;
}
// Ultra-light idle: focus + optional blink only. Used for pure window drag
// (viewport often unchanged) and quiet connected sessions with no sideband.
// Remote auto-refresh also feeds the title bar's resource and host modes.
let remote_panels_need_poll = (self.session.active_ssh_config().is_some()
&& (matches!(
self.current_right_panel(),
Some(
NavItem::Stats
| NavItem::GpuMonitor
| NavItem::AscendNpuMonitor
| NavItem::Processes
| NavItem::Docker
)
) || self.header_status_needs_remote_stats()
|| self.header_status_needs_gpu()
|| self.header_status_needs_npu()))
|| self.current_left_panel() == Some(NavItem::Transfers);
if calm_tick && !remote_panels_need_poll && !self.ai.has_background_work() {
if dirty {
cx.notify();
}
return true;
}
let idle = self.drive_runtime_idle_plane(window, cx);
dirty |= idle.dirty;
let visual_dirty = dirty;
let notify_started_at = Instant::now();
let notify_now = notify_started_at;
let connect_settle =
connect_settle_active(self.shell.runtime.connect_settle_until, notify_now);
let output_pressure_for_notify = self.runtime_output_pressure_active();
let throttle_active = output_pressure_for_notify || connect_settle;
if visual_dirty {
self.shell.runtime.pending_ui_notify = true;
}
let should_notify = runtime_ui_notify_allowed(
visual_dirty,
self.shell.runtime.pending_ui_notify,
false,
throttle_active,
self.shell.runtime.last_ui_notify_at,
notify_now,
);
if should_notify {
cx.notify();
self.shell.runtime.last_ui_notify_at = Some(notify_now);
self.shell.runtime.pending_ui_notify = false;
}
let notify_duration = notify_started_at.elapsed();
let output_pressure = self.runtime_output_pressure_active();
let tick_duration = tick_started_at.elapsed();
if tick_duration >= RUNTIME_TICK_SLOW_THRESHOLD
&& self.should_log_slow_diagnostic("runtime_tick", Instant::now())
{
tracing::warn!(
diagnostic = "runtime_tick",
total_ms = tick_duration.as_millis(),
render_requests_output_pressure = idle.render_request_output_pressure,
remote_refresh_ms = idle.remote_refresh.as_millis(),
notify_ms = notify_duration.as_millis(),
queued_events = self.shell.runtime.session_event_queued_events,
queued_output_bytes = self.shell.runtime.session_event_queued_output_bytes,
frame_command_count = self.terminal.frame_queue_metrics().command_count,
frame_command_output_bytes = self.terminal.frame_queue_metrics().output_bytes,
frame_event_count = self.terminal.frame_queue_metrics().event_count,
frame_event_wake_count = self.terminal.frame_queue_metrics().event_wake_count,
pending_frame_events = self.terminal.frame_queue_metrics().pending_event_count,
pending_session_starts = self.session.start_pending_count(),
output_pressure,
next_tick_delay_ms = self.window_runtime_tick_delay().as_millis(),
visual_dirty,
full_shell_paint_count = self.shell.runtime.full_shell_paint_count,
surface_frame_notify_count = self.shell.runtime.terminal_surface_frame_notify_count,
chrome_frame_notify_count = self.shell.runtime.terminal_chrome_frame_notify_count,
surface_paint_count = terminal_surface_paint_count(),
notify_requested = visual_dirty,
"slow runtime tick"
);
}
let heartbeat_now = Instant::now();
let heartbeat_due = diagnostic_log_due(
self.shell.runtime.last_terminal_perf_heartbeat_at,
heartbeat_now,
TERMINAL_PERF_HEARTBEAT_INTERVAL,
);
if heartbeat_due {
let full_shell_paints = full_shell_paint_count();
let surface_paints = terminal_surface_paint_count();
let surface_frame_notifies = self.shell.runtime.terminal_surface_frame_notify_count;
let chrome_frame_notifies = self.shell.runtime.terminal_chrome_frame_notify_count;
let (layout_cache_hits, layout_cache_misses) =
self.visible_terminal_layout_cache_stats();
let full_shell_paint_delta = full_shell_paints
.saturating_sub(self.shell.runtime.last_perf_full_shell_paint_count);
let surface_paint_delta =
surface_paints.saturating_sub(self.shell.runtime.last_perf_surface_paint_count);
let surface_frame_notify_delta = surface_frame_notifies
.saturating_sub(self.shell.runtime.last_perf_surface_frame_notify_count);
let chrome_frame_notify_delta = chrome_frame_notifies
.saturating_sub(self.shell.runtime.last_perf_chrome_frame_notify_count);
let layout_cache_hit_delta =
layout_cache_hits.saturating_sub(self.shell.runtime.last_perf_layout_cache_hits);
let layout_cache_miss_delta = layout_cache_misses
.saturating_sub(self.shell.runtime.last_perf_layout_cache_misses);
let active_session_id = self.session.active_id().unwrap_or("");
let active_scroll_offset = self.active_terminal_scroll_offset();
let active_display_offset = self.active_terminal_display_offset();
let visible_session_count = self.visible_terminal_session_ids().len();
let has_runtime_activity = !active_session_id.is_empty()
|| full_shell_paint_delta > 0
|| surface_paint_delta > 0
|| surface_frame_notify_delta > 0
|| chrome_frame_notify_delta > 0
|| self.shell.runtime.session_event_queued_events > 0
|| self.shell.runtime.session_event_queued_output_bytes > 0
|| self.session.event_bridge_queued_output_bytes() > 0
|| self.terminal.frame_queue_metrics().output_bytes > 0
|| self.terminal.frame_queue_metrics().event_count > 0
|| self.terminal.frame_queue_metrics().pending_event_count > 0;
if has_runtime_activity {
tracing::debug!(
diagnostic = "terminal_perf_heartbeat",
active_session_id,
visible_session_count,
active_scroll_offset,
active_display_offset,
connect_settle_active = self
.shell
.runtime
.connect_settle_until
.is_some_and(|until| heartbeat_now < until),
output_pressure,
visual_dirty,
tick_ms = tick_duration.as_millis(),
notify_ms = notify_duration.as_millis(),
queued_session_events = self.shell.runtime.session_event_queued_events,
queued_session_output_bytes =
self.shell.runtime.session_event_queued_output_bytes,
bridge_output_bytes = self.session.event_bridge_queued_output_bytes(),
frame_command_count = self.terminal.frame_queue_metrics().command_count,
frame_command_output_bytes = self.terminal.frame_queue_metrics().output_bytes,
frame_event_count = self.terminal.frame_queue_metrics().event_count,
frame_event_wake_count = self.terminal.frame_queue_metrics().event_wake_count,
pending_frame_events = self.terminal.frame_queue_metrics().pending_event_count,
full_shell_paint_delta,
surface_paint_delta,
surface_frame_notify_delta,
chrome_frame_notify_delta,
full_shell_paint_count = full_shell_paints,
surface_paint_count = surface_paints,
surface_frame_notify_count = surface_frame_notifies,
chrome_frame_notify_count = chrome_frame_notifies,
layout_cache_hit_delta,
layout_cache_miss_delta,
layout_cache_hits,
layout_cache_misses,
"terminal perf heartbeat"
);
}
self.shell.runtime.last_terminal_perf_heartbeat_at = Some(heartbeat_now);
self.shell.runtime.last_perf_full_shell_paint_count = full_shell_paints;
self.shell.runtime.last_perf_surface_paint_count = surface_paints;
self.shell.runtime.last_perf_surface_frame_notify_count = surface_frame_notifies;
self.shell.runtime.last_perf_chrome_frame_notify_count = chrome_frame_notifies;
self.shell.runtime.last_perf_layout_cache_hits = layout_cache_hits;
self.shell.runtime.last_perf_layout_cache_misses = layout_cache_misses;
}
self.shell.runtime.event_pump_started
}
pub(super) fn drive_runtime_idle_plane(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> RuntimeIdlePlaneResult {
let mut dirty = false;
let mut result = RuntimeIdlePlaneResult::default();
// Idle-plane work does not drain output; one pressure sample is enough for the stage.
let output_pressure = self.runtime_output_pressure_active();
let now = Instant::now();
let geometry_churn = window_geometry_churn_active(self.shell.viewport.last_change_at, now);
let connect_settle = connect_settle_active(self.shell.runtime.connect_settle_until, now);
// Geometry churn / connect settle: keep focus only (no remote/layout/DB).
let demote_idle = output_pressure || geometry_churn || connect_settle;
result.render_request_output_pressure = demote_idle;
if !runtime_idle_plane_allowed(demote_idle) {
result.dirty = dirty;
return result;
}
let stage_started_at = Instant::now();
dirty |= self.drive_remote_auto_refresh(window, cx);
result.remote_refresh = stage_started_at.elapsed();
result.dirty = dirty;
result
}
}
@@ -15,6 +15,7 @@ mod pending_focus;
mod persistence_debounce;
mod post_start_work;
mod quick_switch_runtime;
mod remote_refresh;
mod runtime_state;
mod state;
mod status_clocks;
@@ -0,0 +1,90 @@
//! Auto-refresh for the remote panels that poll a host.
//!
//! Stats, GPU, NPU, Processes, Docker and the transfer browser's cwd sync all refresh
//! on user-configured intervals while their panel is open. That is genuinely periodic
//! work -- there is no push from the remote host -- so it stays a poll.
//!
//! It used to be the runtime tick's idle plane, which is the last thing that kept the
//! tick alive. This clock is scoped to "some panel actually wants refreshing", which
//! means an app with no remote panel open costs nothing.
//!
//! **This is an interim owner.** The design puts these timers on the panel entities
//! that Phase 4 extracts, armed on mount and dropped on unmount, which is strictly
//! better: the panel that wants the data owns the timer that fetches it. Keeping the
//! shape here -- one scoped clock over a "does anything need this" predicate -- is what
//! Phase 4 relocates rather than redesigns, and it lets Phase 3 delete the tick without
//! waiting for that extraction.
use std::time::Duration;
use gpui::Context;
use crate::features::NyaTermApp;
use crate::models::NavItem;
/// How often to check whether any panel's refresh interval has come due.
///
/// The per-panel intervals are user settings in whole seconds, floored at one, so a
/// one-second clock is exactly as fine as the finest thing it can service. Each panel
/// still gates itself on its own interval; this only decides how often that is asked.
const REMOTE_REFRESH_POLL_INTERVAL: Duration = Duration::from_secs(1);
impl NyaTermApp {
/// Refresh the remote panels while any of them is open.
///
/// Idempotent. Armed from `render`, because what it depends on -- which panel is
/// showing, and whether a session with an SSH config is active -- changes only
/// alongside a repaint.
pub(in crate::features) fn ensure_remote_refresh_clock(&mut self, cx: &mut Context<Self>) {
if self.shell.remote_refresh_clock_is_armed() || !self.remote_panels_need_refresh() {
return;
}
self.shell.set_remote_refresh_clock_armed(true);
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(REMOTE_REFRESH_POLL_INTERVAL)
.await;
// `update_in`: each refresh submits a remote job, which needs the
// window.
let Ok(keep_running) = this.update_in(cx, |this, window, cx| {
if this.drive_remote_auto_refresh(window, cx) {
cx.notify();
}
let running = this.remote_panels_need_refresh();
if !running {
this.shell.set_remote_refresh_clock_armed(false);
}
running
}) else {
break;
};
if !keep_running {
break;
}
}
})
.detach();
}
/// Whether any remote panel or header mode currently wants periodic refreshing.
///
/// Lifted from the runtime tick, which computed exactly this to decide whether its
/// calm branch could skip the idle plane.
pub(in crate::features) fn remote_panels_need_refresh(&self) -> bool {
(self.session.active_ssh_config().is_some()
&& (matches!(
self.current_right_panel(),
Some(
NavItem::Stats
| NavItem::GpuMonitor
| NavItem::AscendNpuMonitor
| NavItem::Processes
| NavItem::Docker
)
) || self.header_status_needs_remote_stats()
|| self.header_status_needs_gpu()
|| self.header_status_needs_npu()))
|| self.current_left_panel() == Some(NavItem::Transfers)
}
}
@@ -9,7 +9,6 @@ use super::state::ShellFeatureState;
/// GPUI event-pump, repaint and shell-persistence scheduling state.
pub(super) struct ShellRuntimeState {
pub(super) event_pump_started: bool,
pub(super) session_event_backlog_active: bool,
pub(super) session_event_queued_events: usize,
pub(super) session_event_queued_output_bytes: usize,
@@ -63,14 +62,6 @@ pub(super) struct ShellRuntimeState {
pub(super) terminal_surface_frame_notify_count: u64,
/// Output frames that also dirtied chrome (unread/effects).
pub(super) terminal_chrome_frame_notify_count: u64,
/// Last periodic terminal performance heartbeat.
pub(super) last_terminal_perf_heartbeat_at: Option<Instant>,
pub(super) last_perf_full_shell_paint_count: u64,
pub(super) last_perf_surface_paint_count: u64,
pub(super) last_perf_surface_frame_notify_count: u64,
pub(super) last_perf_chrome_frame_notify_count: u64,
pub(super) last_perf_layout_cache_hits: u64,
pub(super) last_perf_layout_cache_misses: u64,
/// Open-tabs / window-layout settings need a durable write.
pub(super) open_tabs_persist_dirty: bool,
pub(super) window_layout_persist_dirty: bool,
@@ -99,6 +90,8 @@ pub(super) struct ShellRuntimeState {
post_start_work_clock_armed: bool,
/// True while the drop-highlight clock task is alive.
drop_hover_clock_armed: bool,
/// True while the remote-panel auto-refresh clock task is alive.
remote_refresh_clock_armed: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -125,7 +118,6 @@ impl Default for ShellRuntimeState {
fn default() -> Self {
let (persist_wake, persist_wake_rx) = EventWake::new();
Self {
event_pump_started: false,
session_event_backlog_active: false,
session_event_queued_events: 0,
session_event_queued_output_bytes: 0,
@@ -155,13 +147,6 @@ impl Default for ShellRuntimeState {
full_shell_paint_count: 0,
terminal_surface_frame_notify_count: 0,
terminal_chrome_frame_notify_count: 0,
last_terminal_perf_heartbeat_at: None,
last_perf_full_shell_paint_count: 0,
last_perf_surface_paint_count: 0,
last_perf_surface_frame_notify_count: 0,
last_perf_chrome_frame_notify_count: 0,
last_perf_layout_cache_hits: 0,
last_perf_layout_cache_misses: 0,
open_tabs_persist_dirty: false,
window_layout_persist_dirty: false,
ui_layout_persist_pending: false,
@@ -178,6 +163,7 @@ impl Default for ShellRuntimeState {
terminal_recovery_clock_armed: false,
post_start_work_clock_armed: false,
drop_hover_clock_armed: false,
remote_refresh_clock_armed: false,
}
}
}
@@ -305,6 +291,14 @@ impl ShellFeatureState {
self.runtime.drop_hover_clock_armed = armed;
}
pub(in crate::features) fn remote_refresh_clock_is_armed(&self) -> bool {
self.runtime.remote_refresh_clock_armed
}
pub(in crate::features) fn set_remote_refresh_clock_armed(&mut self, armed: bool) {
self.runtime.remote_refresh_clock_armed = armed;
}
pub(in crate::features) fn toggle_cursor_blink_phase(&mut self) {
self.runtime.cursor_blink_on = !self.runtime.cursor_blink_on;
}
@@ -34,7 +34,7 @@ pub(in crate::features) use state::{
LostTerminalSelectionRecovery, TerminalFeatureFocus, TerminalFeatureState,
};
pub(in crate::features) use terminal_surface_entity::{
FULL_SHELL_PAINT_COUNT, full_shell_paint_count, terminal_surface_paint_count,
FULL_SHELL_PAINT_COUNT, terminal_surface_paint_count,
};
pub(in crate::features) use window_state::{
TerminalWindowDockResult, TerminalWindowReconcileResult,
@@ -269,19 +269,6 @@ fn terminal_should_apply_session_cwd(
}
impl NyaTermApp {
pub(in crate::features) fn active_terminal_scroll_offset(&self) -> usize {
if let Some(session_id) = self.session.active_id() {
self.terminal
.view
.views
.get(session_id)
.map(|view| view.scroll_offset)
.unwrap_or(0)
} else {
self.terminal.view.scroll_offset
}
}
pub(in crate::features) fn active_terminal_display_offset(&self) -> usize {
self.terminal_display_offset_for_session(self.session.active_id())
}
@@ -256,10 +256,6 @@ pub(in crate::features) fn terminal_surface_paint_count() -> u64 {
TERMINAL_SURFACE_PAINT_COUNT.load(Ordering::Relaxed)
}
pub(in crate::features) fn full_shell_paint_count() -> u64 {
FULL_SHELL_PAINT_COUNT.load(Ordering::Relaxed)
}
/// Per-session GPUI entity that owns terminal grid paint state.
///
/// Output frames notify this entity only; chrome (tabs/sidebars/status) stays
@@ -167,22 +167,6 @@ impl TerminalFeatureState {
}
}
pub(in crate::features) fn visible_layout_cache_stats<'a>(
&self,
session_ids: impl IntoIterator<Item = &'a str>,
) -> (u64, u64) {
session_ids
.into_iter()
.filter_map(|session_id| self.view.views.get(session_id))
.filter_map(|view| view.render_cache.layout_cache.lock().ok())
.fold((0u64, 0u64), |(hits, misses), cache| {
(
hits.saturating_add(cache.hits),
misses.saturating_add(cache.misses),
)
})
}
pub(in crate::features) fn visible_performance_recovery_due<'a>(
&self,
session_ids: impl IntoIterator<Item = &'a str>,