diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index ac55d0be69..c4bab869ff 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -23,6 +23,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- Foreground typing no longer waits behind render cadence consumed by output from panes in hidden tabs. (#2890) - The Windows ARM64 installer now waits for x64 emulation to release the verified executable before activating the downloaded release. (#2916) - On Unix, Ctrl-click URL openers are now reaped after they exit, preventing defunct child processes from accumulating on long-running servers. (#2903) - Herdr no longer sends the full OSC 4 palette query burst under WSL, preventing reply fragments from leaking into the shell through ConPTY. (#2440) diff --git a/src/app/mod.rs b/src/app/mod.rs index 2095b99efd..501f39f607 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, + /// Last attempt that could update a connected presentation surface. + pub(crate) last_presentation_at: Option, pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc, pub(crate) render_dirty: Arc, @@ -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 d70237274f..8367a22350 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; @@ -657,6 +673,20 @@ mod tests { use crate::app::state; 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 2f6d9cb827..f4b4391db5 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 { @@ -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); @@ -97,6 +125,16 @@ mod tests { assert!(!signal.is_pending()); } + #[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(); @@ -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 4231b073cf..ae0d7b201b 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, + 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, + ) -> 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, + ) }) } @@ -9626,6 +9680,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(&[]);