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
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
30 changes: 30 additions & 0 deletions src/app/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
52 changes: 45 additions & 7 deletions src/render_signal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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> {
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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);
Expand Down
89 changes: 77 additions & 12 deletions src/server/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
})
}

Expand Down Expand Up @@ -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(&[]);
Expand Down
Loading