diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-21 18:00:59 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-21 18:00:59 +0300 |
| commit | bc410c4261f12377a2eb7ea08f4112a1a9574a77 (patch) | |
| tree | 99fef6ff2406c9be4147e63b0145f89c612261ea /src | |
| parent | b1c9833b828d12e7e0196c5607ae63372998e25a (diff) | |
perf: reduce headless server render overhead
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 2 | ||||
| -rw-r--r-- | src/app/runtime.rs | 13 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 13 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 63 | ||||
| -rw-r--r-- | src/persist/restore.rs | 1 | ||||
| -rw-r--r-- | src/server/headless.rs | 112 | ||||
| -rw-r--r-- | src/workspace.rs | 17 |
7 files changed, 173 insertions, 48 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index a598100a..a3094ed8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -25,6 +25,8 @@ use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); pub(crate) const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); +pub(crate) const HEADLESS_ANIMATION_INTERVAL: Duration = Duration::from_millis(128); +pub(crate) const HEADLESS_ANIMATION_TICK_STEP: u32 = 8; const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index b6ad806b..739a3670 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use crossterm::terminal; @@ -171,9 +171,16 @@ impl App { } pub(crate) fn sync_animation_timer(&mut self, now: Instant) { + self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL); + } + + pub(crate) fn sync_headless_animation_timer(&mut self, now: Instant) { + self.sync_animation_timer_with_interval(now, crate::app::HEADLESS_ANIMATION_INTERVAL); + } + + fn sync_animation_timer_with_interval(&mut self, now: Instant, interval: Duration) { if self.agent_panel_has_animation() { - self.next_animation_tick - .get_or_insert(now + ANIMATION_INTERVAL); + self.next_animation_tick.get_or_insert(now + interval); } else { self.next_animation_tick = None; } diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index e236b36f..75e600b8 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1121,10 +1121,17 @@ impl<'a> RowCellIter<'a> { } pub fn graphemes(&self) -> Result<Vec<u32>, Error> { + let mut out = Vec::new(); + self.graphemes_into(&mut out)?; + Ok(out) + } + + pub fn graphemes_into(&self, out: &mut Vec<u32>) -> Result<(), Error> { let len = self.grapheme_len()? as usize; - let mut out = vec![0u32; len]; + out.clear(); + out.resize(len, 0); if len == 0 { - return Ok(out); + return Ok(()); } // SAFETY: out buffer is allocated for the grapheme count returned by the API. unsafe { @@ -1135,7 +1142,7 @@ impl<'a> RowCellIter<'a> { ) .into_result()?; } - Ok(out) + Ok(()) } } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 28b4186e..de4781b4 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -569,6 +569,8 @@ impl GhosttyPaneTerminal { Ok(rows) => rows, Err(_) => return, }; + let mut grapheme_scratch = Vec::new(); + let mut symbol_scratch = String::new(); let mut y = 0u16; while y < area.height && rows.next() { let mut cells = match rows.populate_cells(&mut row_cells) { @@ -579,11 +581,22 @@ impl GhosttyPaneTerminal { while x < area.width && cells.next() { let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow); let style = ghostty_cell_style(&cells, default_fg, default_bg); - let symbol = ghostty_buffer_symbol(&cells, wide) - .unwrap_or_else(|_| ghostty_blank_symbol_for_width(wide).to_string()); + let symbol = match ghostty_buffer_symbol_into( + &cells, + wide, + &mut grapheme_scratch, + &mut symbol_scratch, + ) { + Ok(symbol) => symbol, + Err(_) => { + symbol_scratch.clear(); + symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide)); + symbol_scratch.as_str() + } + }; let cell = &mut buf[(area.x + x, area.y + y)]; cell.reset(); - cell.set_symbol(&symbol); + cell.set_symbol(symbol); cell.set_style(style); x += 1; } @@ -739,6 +752,7 @@ pub(super) fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) -> } } +#[cfg(test)] pub(super) fn ghostty_normalize_buffer_symbol( symbol: &str, wide: crate::ghostty::CellWide, @@ -760,18 +774,47 @@ pub(super) fn ghostty_normalize_buffer_symbol( ghostty_blank_symbol_for_width(wide).to_string() } -fn ghostty_buffer_symbol( +fn ghostty_buffer_symbol_into<'a>( cells: &crate::ghostty::RowCellIter<'_>, wide: crate::ghostty::CellWide, -) -> Result<String, crate::ghostty::Error> { - let symbol = match wide { - crate::ghostty::CellWide::SpacerTail => String::new(), - crate::ghostty::CellWide::SpacerHead => " ".to_string(), + grapheme_scratch: &mut Vec<u32>, + symbol_scratch: &'a mut String, +) -> Result<&'a str, crate::ghostty::Error> { + symbol_scratch.clear(); + match wide { + crate::ghostty::CellWide::SpacerTail => {} + crate::ghostty::CellWide::SpacerHead => symbol_scratch.push(' '), crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => { - ghostty_cell_symbol(cells)? + cells.graphemes_into(grapheme_scratch)?; + if grapheme_scratch.is_empty() { + symbol_scratch.push(' '); + } else { + for &codepoint in grapheme_scratch.iter() { + if let Some(ch) = char::from_u32(codepoint) { + symbol_scratch.push(ch); + } + } + if symbol_scratch.is_empty() { + symbol_scratch.push(' '); + } + } } + } + + let expected_width = match wide { + crate::ghostty::CellWide::Wide => 2, + crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => 1, + crate::ghostty::CellWide::SpacerTail => 0, }; - Ok(ghostty_normalize_buffer_symbol(&symbol, wide)) + let actual_width = symbol_scratch.width(); + if actual_width != expected_width + && !(wide == crate::ghostty::CellWide::Narrow && actual_width == 2) + { + symbol_scratch.clear(); + symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide)); + } + + Ok(symbol_scratch.as_str()) } fn ghostty_reset_cell( diff --git a/src/persist/restore.rs b/src/persist/restore.rs index eeef0af1..3eb5a4a3 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -83,6 +83,7 @@ fn restore_workspace( .unwrap_or_else(crate::workspace::generate_workspace_id), custom_name: snap.custom_name.clone(), identity_cwd: snap.identity_cwd.clone(), + cached_git_branch: None, cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number, diff --git a/src/server/headless.rs b/src/server/headless.rs index d120d21b..d08a0ad0 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -162,6 +162,8 @@ struct ClientConnection { host_terminal_theme: crate::terminal_theme::TerminalTheme, /// Monotonic activity stamp used to choose the fallback foreground client. last_activity: u64, + /// Last frame sent to this client. Used to skip identical frame sends. + last_frame: Option<FrameData>, /// Channel for sending framed ServerMessage data to the client writer thread. writer: Option<std::sync::mpsc::Sender<Vec<u8>>>, } @@ -465,7 +467,7 @@ impl HeadlessServer { needs_render = true; } - self.app.sync_animation_timer(now); + self.app.sync_headless_animation_timer(now); // 7. Render virtually and stream frames. if needs_render && self.app.can_render_now(now) { @@ -1005,6 +1007,7 @@ impl HeadlessServer { terminal_size: (cols, rows), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), last_activity, + last_frame: None, writer: Some(writer), }, ); @@ -1281,22 +1284,20 @@ impl HeadlessServer { /// frames to all connected clients. fn render_and_stream(&mut self) { let foreground_client_id = self.foreground_client_id; - let mut render_targets: Vec<(u64, (u16, u16), std::sync::mpsc::Sender<Vec<u8>>, bool)> = - self.clients - .iter() - .filter_map(|(&client_id, client)| { - client.writer.as_ref().map(|writer| { - ( - client_id, - client.terminal_size, - writer.clone(), - foreground_client_id == Some(client_id), - ) - }) - }) - .collect(); + let mut render_targets: Vec<(u64, (u16, u16), bool)> = self + .clients + .iter() + .filter(|(_, client)| client.writer.is_some()) + .map(|(&client_id, client)| { + ( + client_id, + client.terminal_size, + foreground_client_id == Some(client_id), + ) + }) + .collect(); - render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id)); + render_targets.sort_by_key(|(client_id, _, is_foreground)| (*is_foreground, *client_id)); if render_targets.is_empty() { let (cols, rows) = self.effective_size; @@ -1310,10 +1311,23 @@ impl HeadlessServer { } let mut broken_clients: Vec<u64> = Vec::new(); - for (client_id, (cols, rows), writer, is_foreground) in render_targets { + for (client_id, (cols, rows), is_foreground) in render_targets { let area = Rect::new(0, 0, cols, rows); let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground); - let message = ServerMessage::Frame(FrameData::from_ratatui_buffer(&buffer, cursor)); + let frame = FrameData::from_ratatui_buffer(&buffer, cursor); + + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + continue; + }; + + if client.last_frame.as_ref() == Some(&frame) { + continue; + } + + let message = ServerMessage::Frame(frame.clone()); let serialized = match Self::frame_server_message(&message) { Ok(framed) => framed, Err(err) => { @@ -1326,7 +1340,10 @@ impl HeadlessServer { if writer.send(serialized).is_err() { debug!(client_id, "client writer channel closed, marking as broken"); broken_clients.push(client_id); + continue; } + + client.last_frame = Some(frame); } if !broken_clients.is_empty() { @@ -1349,7 +1366,7 @@ impl HeadlessServer { fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool { let mut changed = false; - self.app.sync_animation_timer(now); + self.app.sync_headless_animation_timer(now); // No resize polling needed — server has no terminal. // Client resize messages drive size changes instead. @@ -1379,8 +1396,12 @@ impl HeadlessServer { .next_animation_tick .is_some_and(|deadline| now >= deadline) { - self.app.state.spinner_tick = self.app.state.spinner_tick.wrapping_add(1); - self.app.next_animation_tick = Some(now + app::ANIMATION_INTERVAL); + self.app.state.spinner_tick = self + .app + .state + .spinner_tick + .wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP); + self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL); changed = true; } @@ -1412,7 +1433,7 @@ impl HeadlessServer { self.app.save_session_now(); } - self.app.sync_animation_timer(now); + self.app.sync_headless_animation_timer(now); changed } @@ -1832,7 +1853,7 @@ mod tests { let app = crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default()); let dir = std::env::temp_dir().join(format!( - "herdr-headless-test-{}-{}", + "hh-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1942,7 +1963,7 @@ mod tests { #[test] fn prepare_socket_path_removes_stale_socket() { let dir = std::env::temp_dir().join(format!( - "herdr-test-stale-{}-{}", + "hs-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1969,7 +1990,7 @@ mod tests { #[test] fn prepare_socket_path_rejects_live_socket() { let dir = std::env::temp_dir().join(format!( - "herdr-test-live-{}-{}", + "hl-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -2062,6 +2083,7 @@ mod tests { }), }, last_activity: 1, + last_frame: None, writer: None, }, ); @@ -2082,6 +2104,7 @@ mod tests { }), }, last_activity: 2, + last_frame: None, writer: None, }, ); @@ -2128,6 +2151,7 @@ mod tests { terminal_size: (120, 40), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), last_activity: 1, + last_frame: None, writer: Some(desktop_tx), }, ); @@ -2137,6 +2161,7 @@ mod tests { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), last_activity: 2, + last_frame: None, writer: Some(phone_tx), }, ); @@ -2154,6 +2179,41 @@ mod tests { } #[test] + fn render_and_stream_skips_identical_frame_sends() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, client_rx) = std::sync::mpsc::channel(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + last_activity: 1, + last_frame: None, + writer: 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 first = client_rx.recv_timeout(Duration::from_millis(100)); + assert!(first.is_ok(), "expected first frame to be sent"); + + server.render_and_stream(); + assert!( + client_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "identical frame should not be sent twice" + ); + } + + #[test] fn clipboard_write_targets_foreground_client_only() { let mut server = test_headless_server(); let (background_tx, background_rx) = std::sync::mpsc::channel(); @@ -2165,6 +2225,7 @@ mod tests { terminal_size: (120, 40), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), last_activity: 1, + last_frame: None, writer: Some(background_tx), }, ); @@ -2174,6 +2235,7 @@ mod tests { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), last_activity: 2, + last_frame: None, writer: Some(foreground_tx), }, ); diff --git a/src/workspace.rs b/src/workspace.rs index b80e5773..6d0d2488 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -44,6 +44,8 @@ pub struct Workspace { pub custom_name: Option<String>, /// Fallback workspace identity source for tests, old snapshots, or missing runtimes. pub identity_cwd: PathBuf, + /// Cached current git branch for the workspace repo. + pub(crate) cached_git_branch: Option<String>, /// Cached ahead/behind counts for the workspace repo's current branch upstream. pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, /// Stable-ish public pane numbers within this workspace. @@ -98,7 +100,8 @@ impl Workspace { Ok(Self { id: generate_workspace_id(), custom_name: None, - identity_cwd: initial_cwd, + identity_cwd: initial_cwd.clone(), + cached_git_branch: git_branch(&initial_cwd), cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number: 2, @@ -318,8 +321,7 @@ impl Workspace { } pub fn branch(&self) -> Option<String> { - self.resolved_identity_cwd() - .and_then(|cwd| git_branch(&cwd)) + self.cached_git_branch.clone() } pub fn git_ahead_behind(&self) -> Option<(usize, usize)> { @@ -327,9 +329,9 @@ impl Workspace { } pub fn refresh_git_ahead_behind(&mut self) { - self.cached_git_ahead_behind = self - .resolved_identity_cwd() - .and_then(|cwd| git_ahead_behind(&cwd)); + let cwd = self.resolved_identity_cwd(); + self.cached_git_branch = cwd.as_deref().and_then(git_branch); + self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind); } pub fn focused_runtime(&self) -> Option<&PaneRuntime> { @@ -447,7 +449,8 @@ impl Workspace { Self { id: generate_workspace_id(), custom_name: Some(name.to_string()), - identity_cwd, + identity_cwd: identity_cwd.clone(), + cached_git_branch: git_branch(&identity_cwd), cached_git_ahead_behind: None, public_pane_numbers, next_public_pane_number: 2, |
