diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-28 23:29:11 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-28 23:29:11 +0300 |
| commit | e27a4ea427c9ed6248c0ee80570e94ed7b1928bd (patch) | |
| tree | 0cae21a2b3939293be129aaaecb23641f68bab54 /src/server | |
| parent | e16d7d8c07a20f5ee0b4111808680bbcfd7df9ac (diff) | |
perf: skip rendering hidden pty updates
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 352 | ||||
| -rw-r--r-- | src/server/notifications.rs | 2 |
2 files changed, 337 insertions, 17 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index 7444f10d..899dbf9b 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -14,7 +14,7 @@ //! - Handles stale socket cleanup, explicit server stop, minimum terminal size, //! and pane spawn failure during restore -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -145,6 +145,42 @@ impl RenderImpact { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PtyRenderState { + Clean, + Hidden, + Visible, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RetainedRenderInput { + needs_full_render: bool, + needs_graphics_render: bool, + pty: PtyRenderState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RetainedRenderPlan { + Full, + Graphics, + Pty, + HiddenPty, +} + +fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { + if input.needs_full_render { + RetainedRenderPlan::Full + } else if input.needs_graphics_render && input.pty != PtyRenderState::Visible { + RetainedRenderPlan::Graphics + } else { + match input.pty { + PtyRenderState::Visible => RetainedRenderPlan::Pty, + PtyRenderState::Hidden => RetainedRenderPlan::HiddenPty, + PtyRenderState::Clean => RetainedRenderPlan::Full, + } + } +} + fn record_render_impact(source: &'static str, impact: RenderImpact) { let event = match (source, impact) { ("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests", @@ -511,10 +547,10 @@ impl HeadlessServer { continue; } - // 1. Check render_dirty flag from PTY reader tasks. - if self.app.render_dirty.load(Ordering::Acquire) { + // 1. Check the coalesced render signal from PTY readers and generic runtime work. + if self.app.render_dirty.is_pending() { needs_render = true; - crate::render_prof::event("render.request.pty_dirty"); + crate::render_prof::event("render.request.signal"); } let terminal_title_changed = self.app.sync_terminal_titles(); if terminal_title_changed && self.app.terminal_title_sidebar_configured() { @@ -618,28 +654,54 @@ impl HeadlessServer { // 7. Render virtually and stream frames. if needs_render && self.app.can_render_now(now) { crate::render_prof::event("render.attempt"); - let pty_dirty = self.app.render_dirty.swap(false, Ordering::AcqRel); + let render_request = self.app.render_dirty.take(); + let pty_dirty = !render_request.pty_sources.is_empty(); if pty_dirty { crate::render_prof::event("render.attempt.pty_dirty"); + crate::render_prof::counter( + "render.attempt.pty_sources", + render_request.pty_sources.len() as u64, + ); + } + if render_request.generic { + needs_full_render = true; + crate::render_prof::event("full_render_cause.generic_dirty"); } if needs_full_render { crate::render_prof::event("retained_gate.needs_full_render"); } else if !pty_dirty { crate::render_prof::event("retained_gate.not_pty_dirty"); } - let mut deferred_graphics = false; - let rendered_retained = if needs_graphics_render && !needs_full_render && !pty_dirty + let pty = if !pty_dirty { + PtyRenderState::Clean + } else if self.pty_sources_visible_to_any_render_target(&render_request.pty_sources) { - match self.render_retained_graphics_update_and_stream() { - RetainedGraphicsOutcome::Sent => true, - RetainedGraphicsOutcome::Deferred => { - deferred_graphics = true; - false + PtyRenderState::Visible + } else { + PtyRenderState::Hidden + }; + let mut deferred_graphics = false; + let rendered_retained = match retained_render_plan(RetainedRenderInput { + needs_full_render, + needs_graphics_render, + pty, + }) { + RetainedRenderPlan::Full => false, + RetainedRenderPlan::Graphics => { + match self.render_retained_graphics_update_and_stream() { + RetainedGraphicsOutcome::Sent => true, + RetainedGraphicsOutcome::Deferred => { + deferred_graphics = true; + false + } + RetainedGraphicsOutcome::Fallback => false, } - RetainedGraphicsOutcome::Fallback => false, } - } else { - pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream() + RetainedRenderPlan::Pty => self.render_retained_pty_update_and_stream(), + RetainedRenderPlan::HiddenPty => { + crate::render_prof::event("render.skipped.hidden_sources"); + true + } }; if deferred_graphics { needs_render = false; @@ -737,7 +799,7 @@ impl HeadlessServer { } } LoopEvent::RenderRequested => { - if self.app.render_dirty.load(Ordering::Acquire) { + if self.app.render_dirty.is_pending() { needs_render = true; } } @@ -3396,6 +3458,85 @@ impl HeadlessServer { } } + fn pty_sources_visible_to_any_render_target( + &self, + sources: &HashSet<crate::layout::PaneId>, + ) -> bool { + let mut has_app_target = false; + let mut direct_terminal_targets = HashSet::new(); + for client in self + .clients + .values() + .filter(|client| client.writer.is_some()) + { + match &client.mode { + ClientConnectionMode::App if client.is_full_app_client() => { + has_app_target = true; + } + ClientConnectionMode::TerminalAttach { terminal_id } + | ClientConnectionMode::TerminalObserve { terminal_id } => { + direct_terminal_targets.insert(terminal_id.as_str()); + } + ClientConnectionMode::App => {} + } + } + 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())) + }) + } + + fn terminal_id_for_pane( + &self, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalId> { + if let Some(popup) = self + .app + .state + .popup_pane + .as_ref() + .filter(|popup| popup.pane_id == pane_id) + { + return Some(&popup.terminal_id); + } + self.app + .find_pane(pane_id) + .map(|(_, pane)| &pane.attached_terminal_id) + } + + fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { + if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == pane_id) + { + return true; + } + let Some(workspace) = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + else { + return false; + }; + let Some(tab) = workspace.active_tab() else { + return false; + }; + if !tab.panes.contains_key(&pane_id) { + return false; + } + !tab.zoomed || tab.layout.focused() == pane_id + } + fn render_retained_pty_update_and_stream(&mut self) -> bool { crate::render_prof::event("retained.attempt"); let retained_started = crate::render_prof::timer(); @@ -4478,6 +4619,42 @@ mod tests { #[path = "pane_graphics.rs"] mod pane_graphics_tests; + #[test] + fn retained_render_plan_covers_each_render_path() { + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: true, + needs_graphics_render: true, + pty: PtyRenderState::Hidden, + }), + RetainedRenderPlan::Full + ); + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: true, + pty: PtyRenderState::Hidden, + }), + RetainedRenderPlan::Graphics + ); + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: false, + pty: PtyRenderState::Visible, + }), + RetainedRenderPlan::Pty + ); + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: false, + pty: PtyRenderState::Hidden, + }), + RetainedRenderPlan::HiddenPty + ); + } + fn test_headless_server() -> HeadlessServer { test_headless_server_with_event_hub(api::EventHub::default()) } @@ -4827,6 +5004,38 @@ mod tests { (server, client_rx, pane_id) } + fn hidden_pty_visibility_test_server( + client_sizes: &[(u16, u16)], + ) -> (HeadlessServer, crate::layout::PaneId) { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + for (index, &terminal_size) in client_sizes.iter().enumerate() { + let client_id = index as u64 + 1; + let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + client_id, + ClientConnection::new( + terminal_size, + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + client_id, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + } + + (server, background_pane) + } + fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { assert_eq!( (actual.width, actual.height), @@ -8076,6 +8285,117 @@ next_tab = "" ); } + #[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]); + assert!(!server.pty_sources_visible_to_any_render_target(&sources)); + + let (mut server, background_pane) = + hidden_pty_visibility_test_server(&[(120, 40), (44, 20)]); + let sources = HashSet::from([background_pane]); + assert!(!server.pty_sources_visible_to_any_render_target(&sources)); + + server.app.state.workspaces[0].switch_tab(1); + assert!(server.pty_sources_visible_to_any_render_target(&sources)); + } + + #[tokio::test] + async fn hidden_pty_output_appears_after_switching_to_its_tab() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let background_tab = workspace.test_add_tab(Some("background")); + let background_pane = workspace.tabs[background_tab].root_pane; + workspace.insert_test_runtime( + background_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"before"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, _client_control_rx, client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + server.render_and_stream(); + let _initial_frame = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial frame"); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, background_pane) + .expect("background runtime"); + runtime.test_process_pty_bytes(b"\rhidden-update"); + assert!(server.app.render_dirty.request_pty(background_pane)); + let request = server.app.render_dirty.take(); + let pty = if server.pty_sources_visible_to_any_render_target(&request.pty_sources) { + PtyRenderState::Visible + } else { + PtyRenderState::Hidden + }; + assert_eq!( + retained_render_plan(RetainedRenderInput { + needs_full_render: false, + needs_graphics_render: false, + pty, + }), + RetainedRenderPlan::HiddenPty + ); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + server.app.state.workspaces[0].switch_tab(background_tab); + server.render_and_stream(); + let visible_frame = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("frame after tab switch"), + ); + assert!(frame_text(&visible_frame).contains("hidden-update")); + } + + #[test] + fn direct_terminal_observer_keeps_hidden_pty_source_renderable() { + let (mut server, background_pane) = hidden_pty_visibility_test_server(&[]); + let terminal_id = server.app.state.workspaces[0] + .terminal_id(background_pane) + .expect("background terminal id") + .to_string(); + let (client_tx, _client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new_with_mode( + ClientConnectionMode::TerminalObserve { terminal_id }, + None, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + false, + Some(client_tx), + ), + ); + + assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane]))); + } + #[tokio::test] async fn retained_pty_update_streams_dirty_row_from_last_frame() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 79ff63ca..b78cb6df 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -124,7 +124,7 @@ mod tests { &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + std::sync::Arc::new(crate::render_signal::RenderSignal::new()), ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); |
