aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-18 04:37:11 +0300
committerGitHub <noreply@github.com>2026-08-18 04:37:11 +0300
commit2d24950ad9a02096921bc764e2e3dcd8900c3366 (patch)
treeb6884a29425922dfed083bb7108415ae56725a45 /src
parentd497ee14dd3f2b5325a16d0de779ed6bd62178ee (diff)
fix: isolate hidden pane render cadence (#2892)
refs #2890 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs6
-rw-r--r--src/app/runtime.rs30
-rw-r--r--src/render_signal.rs52
-rw-r--r--src/server/headless.rs89
4 files changed, 157 insertions, 20 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 2095b99e..501f39f6 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -149,7 +149,10 @@ pub struct App {
/// Parsed `ui.window_title` plus the hostname resolved when it was applied.
window_title_template: Option<(crate::config::WindowTitleTemplate, String)>,
pub(crate) persist_pane_history: bool,
+ /// Last render-loop attempt, including a throttled hidden-only PTY skip.
pub(crate) last_render_at: Option<Instant>,
+ /// Last attempt that could update a connected presentation surface.
+ pub(crate) last_presentation_at: Option<Instant>,
pub(crate) input_leases: input::InputLeaseTable,
pub render_notify: Arc<Notify>,
pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>,
@@ -783,6 +786,7 @@ impl App {
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
last_render_at: None,
+ last_presentation_at: None,
input_leases: input::InputLeaseTable::default(),
api_rx,
event_hub,
@@ -1133,7 +1137,7 @@ impl App {
self.render_dirty.request_generic();
self.render_notify.notify_one();
}
- self.last_render_at = Some(now);
+ self.record_render_attempt(now, true);
needs_render = false;
continue;
}
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index d7023727..8367a223 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -534,6 +534,22 @@ impl App {
}
}
+ pub(crate) fn can_present_now(&self, now: Instant) -> bool {
+ match self.last_presentation_at {
+ Some(last_presentation_at) => {
+ now.duration_since(last_presentation_at) >= MIN_RENDER_INTERVAL
+ }
+ None => true,
+ }
+ }
+
+ pub(crate) fn record_render_attempt(&mut self, now: Instant, presentation: bool) {
+ self.last_render_at = Some(now);
+ if presentation {
+ self.last_presentation_at = Some(now);
+ }
+ }
+
pub(crate) fn run_auto_update_check(&mut self) {
if !background_update_check_enabled(self.no_session, self.update_version_check_enabled) {
self.next_auto_update_check = None;
@@ -658,6 +674,20 @@ mod tests {
use crate::workspace::Workspace;
#[test]
+ fn hidden_render_attempt_keeps_presentation_cadence_available() {
+ let (mut app, _) = test_app_with_pane();
+ let initial_presentation = Instant::now();
+ app.record_render_attempt(initial_presentation, true);
+
+ let hidden_attempt = initial_presentation + MIN_RENDER_INTERVAL;
+ app.record_render_attempt(hidden_attempt, false);
+ let foreground_echo = hidden_attempt + Duration::from_millis(1);
+
+ assert!(!app.can_render_now(foreground_echo));
+ assert!(app.can_present_now(foreground_echo));
+ }
+
+ #[test]
fn interrupted_detached_process_wait_keeps_child_for_retry() {
let interrupted = std::io::Error::new(std::io::ErrorKind::Interrupted, "test interrupt");
diff --git a/src/render_signal.rs b/src/render_signal.rs
index 2f6d9cb8..f4b4391d 100644
--- a/src/render_signal.rs
+++ b/src/render_signal.rs
@@ -37,14 +37,41 @@ impl RenderSignal {
self.pending.store(true, Ordering::Release);
}
- /// Returns true when this request transitions the signal from idle to pending.
+ /// Returns true when the signal becomes pending or a new PTY source joins it.
+ ///
+ /// A new source may be visible even when the existing pending sources are
+ /// hidden, so the consumer must re-evaluate the coalesced request.
pub(crate) fn request_pty(&self, pane_id: PaneId) -> bool {
let mut request = self
.request
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
- request.pty_sources.insert(pane_id);
- !self.pending.swap(true, Ordering::AcqRel)
+ let source_added = request.pty_sources.insert(pane_id);
+ let became_pending = !self.pending.swap(true, Ordering::AcqRel);
+ became_pending || source_added
+ }
+
+ pub(crate) fn has_generic_or_terminal_title(&self) -> bool {
+ let request = self
+ .request
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ request.generic || !request.terminal_title_sources.is_empty()
+ }
+
+ /// Checks pending PTY origins without allocating a source snapshot.
+ /// Keep the predicate narrow because producers share this lock.
+ pub(crate) fn has_pty_source_matching(
+ &self,
+ mut predicate: impl FnMut(PaneId) -> bool,
+ ) -> bool {
+ self.request
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .pty_sources
+ .iter()
+ .copied()
+ .any(&mut predicate)
}
/// Coalesces terminal-title changes separately from ordinary PTY damage so
@@ -54,8 +81,9 @@ impl RenderSignal {
.request
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
- request.terminal_title_sources.insert(pane_id);
- !self.pending.swap(true, Ordering::AcqRel)
+ let source_added = request.terminal_title_sources.insert(pane_id);
+ let became_pending = !self.pending.swap(true, Ordering::AcqRel);
+ became_pending || source_added
}
pub(crate) fn pending_terminal_title_sources(&self) -> HashSet<PaneId> {
@@ -88,7 +116,7 @@ mod tests {
assert!(signal.request_pty(first));
assert!(!signal.request_pty(first));
- assert!(!signal.request_pty(second));
+ assert!(signal.request_pty(second));
let request = signal.take();
assert!(!request.generic);
@@ -98,6 +126,16 @@ mod tests {
}
#[test]
+ fn terminal_title_source_wakes_pending_pty_work() {
+ let signal = RenderSignal::new();
+ let pane_id = PaneId::from_raw(10);
+
+ assert!(signal.request_pty(pane_id));
+ assert!(signal.request_terminal_title(pane_id));
+ assert!(!signal.request_terminal_title(pane_id));
+ }
+
+ #[test]
fn coalesces_terminal_title_sources_without_making_them_pty_damage() {
let signal = RenderSignal::new();
let pane_id = PaneId::from_raw(10);
@@ -120,7 +158,7 @@ mod tests {
let pane_id = PaneId::from_raw(10);
signal.request_generic();
- assert!(!signal.request_pty(pane_id));
+ assert!(signal.request_pty(pane_id));
let request = signal.take();
assert!(request.generic);
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 4231b073..ae0d7b20 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -698,8 +698,18 @@ impl HeadlessServer {
self.stream_host_mouse_capture_mode();
self.stream_host_keyboard_enhancement_flags();
- // 7. Render virtually and stream frames.
- if needs_render && self.app.can_render_now(now) {
+ // 7. Render virtually and stream frames. Hidden-only PTY work keeps a
+ // bounded classification cadence without delaying presentation work
+ // that joins the same coalesced request.
+ let render_cadence_due = self.app.can_render_now(now);
+ if needs_render
+ && (render_cadence_due
+ || (self.app.can_present_now(now)
+ && self.has_pending_presentation_work(
+ needs_full_render,
+ needs_graphics_render,
+ )))
+ {
crate::render_prof::event("render.attempt");
let render_request = self.app.render_dirty.take();
let pty_dirty = !render_request.pty_sources.is_empty();
@@ -744,11 +754,12 @@ impl HeadlessServer {
PtyRenderState::Hidden
};
let mut deferred_graphics = false;
- let rendered_retained = match retained_render_plan(RetainedRenderInput {
+ let render_plan = retained_render_plan(RetainedRenderInput {
needs_full_render,
needs_graphics_render,
pty,
- }) {
+ });
+ let rendered_retained = match render_plan {
RetainedRenderPlan::Full => false,
RetainedRenderPlan::Graphics => {
match self.render_retained_graphics_update_and_stream() {
@@ -774,7 +785,8 @@ impl HeadlessServer {
crate::render_prof::event("full_render.invoke");
self.render_and_stream();
}
- self.app.last_render_at = Some(now);
+ self.app
+ .record_render_attempt(now, render_plan != RetainedRenderPlan::HiddenPty);
needs_render = false;
needs_full_render = false;
needs_graphics_render = false;
@@ -4069,10 +4081,32 @@ impl HeadlessServer {
}
}
- fn pty_sources_visible_to_any_render_target(
+ fn has_pending_presentation_work(
&self,
- sources: &HashSet<crate::layout::PaneId>,
+ needs_full_render: bool,
+ needs_graphics_render: bool,
) -> bool {
+ if needs_full_render
+ || needs_graphics_render
+ || self.app.render_dirty.has_generic_or_terminal_title()
+ {
+ return true;
+ }
+
+ let (has_app_target, direct_terminal_targets) = self.pty_render_targets();
+ if !has_app_target && direct_terminal_targets.is_empty() {
+ return false;
+ }
+ self.app.render_dirty.has_pty_source_matching(|pane_id| {
+ self.pty_source_visible_to_render_targets(
+ pane_id,
+ has_app_target,
+ &direct_terminal_targets,
+ )
+ })
+ }
+
+ fn pty_render_targets(&self) -> (bool, HashSet<&str>) {
let mut has_app_target = false;
let mut direct_terminal_targets = HashSet::new();
for client in self
@@ -4091,15 +4125,35 @@ impl HeadlessServer {
ClientConnectionMode::App => {}
}
}
+ (has_app_target, direct_terminal_targets)
+ }
+
+ fn pty_source_visible_to_render_targets(
+ &self,
+ pane_id: crate::layout::PaneId,
+ has_app_target: bool,
+ direct_terminal_targets: &HashSet<&str>,
+ ) -> bool {
+ let terminal_id = self.terminal_id_for_pane(pane_id);
+ (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id)))
+ || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str()))
+ }
+
+ fn pty_sources_visible_to_any_render_target(
+ &self,
+ sources: &HashSet<crate::layout::PaneId>,
+ ) -> bool {
+ let (has_app_target, direct_terminal_targets) = self.pty_render_targets();
if !has_app_target && direct_terminal_targets.is_empty() {
return false;
}
- sources.iter().any(|&pane_id| {
- let terminal_id = self.terminal_id_for_pane(pane_id);
- (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id)))
- || terminal_id
- .is_none_or(|source| direct_terminal_targets.contains(source.as_str()))
+ sources.iter().copied().any(|pane_id| {
+ self.pty_source_visible_to_render_targets(
+ pane_id,
+ has_app_target,
+ &direct_terminal_targets,
+ )
})
}
@@ -9627,6 +9681,17 @@ next_tab = ""
}
#[test]
+ fn visible_source_wakes_pending_hidden_work() {
+ let (server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]);
+ let visible_pane = server.app.state.workspaces[0].tabs[0].root_pane;
+
+ assert!(server.app.render_dirty.request_pty(background_pane));
+ assert!(!server.has_pending_presentation_work(false, false));
+ assert!(server.app.render_dirty.request_pty(visible_pane));
+ assert!(server.has_pending_presentation_work(false, false));
+ }
+
+ #[test]
fn inactive_tab_pty_source_is_hidden_until_tab_focus() {
let (server, background_pane) = hidden_pty_visibility_test_server(&[]);
let sources = HashSet::from([background_pane]);