diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 02:27:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 02:31:12 +0300 |
| commit | cc527d45fe4821cb11c484372a9f25c51c02e078 (patch) | |
| tree | 2e3411b39576b0910eba75e2b5b77c5e4e398e74 /src/server | |
| parent | f7d315d494b6a3d583574012526d15c1ddc82bfe (diff) | |
perf: retain terminal render updates
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 791 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 23 |
2 files changed, 808 insertions, 6 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index b405e887..75195441 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -76,6 +76,64 @@ enum LoopEvent { RenderRequested, } +fn rect_fits_frame(rect: Rect, frame: &FrameData) -> bool { + rect.x.saturating_add(rect.width) <= frame.width + && rect.y.saturating_add(rect.height) <= frame.height +} + +fn apply_terminal_dirty_patch( + frame: &mut FrameData, + area: Rect, + patch: crate::pane::TerminalDirtyPatch, +) -> bool { + if !rect_fits_frame(area, frame) { + return false; + } + let width = usize::from(frame.width); + for (local_y, row_cells) in patch.rows { + if local_y >= area.height || row_cells.len() != usize::from(area.width) { + return false; + } + let frame_y = area.y + local_y; + let start = usize::from(frame_y) * width + usize::from(area.x); + let end = start + usize::from(area.width); + if end > frame.cells.len() { + return false; + } + frame.cells[start..end].clone_from_slice(&row_cells); + } + true +} + +fn dirty_patch_intersects_hyperlinks( + frame: &FrameData, + area: Rect, + patch: &crate::pane::TerminalDirtyPatch, +) -> bool { + if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) { + return false; + } + let width = usize::from(frame.width); + for (local_y, _) in &patch.rows { + if *local_y >= area.height { + return true; + } + let frame_y = area.y + *local_y; + let start = usize::from(frame_y) * width + usize::from(area.x); + let end = start + usize::from(area.width); + if end > frame.cells.len() { + return true; + } + if frame.cells[start..end] + .iter() + .any(|cell| cell.hyperlink.is_some()) + { + return true; + } + } + false +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -293,8 +351,12 @@ impl HeadlessServer { self.app.input_rx = None; let mut needs_render = true; + let mut needs_full_render = true; loop { + crate::render_prof::event("loop.tick"); + crate::render_prof::flush_if_due(); + // If shutdown has been initiated, complete it and exit. if self.shutting_down { self.complete_shutdown()?; @@ -310,17 +372,22 @@ impl HeadlessServer { // 1. Check render_dirty flag from PTY reader tasks. if self.app.render_dirty.load(Ordering::Acquire) { needs_render = true; + crate::render_prof::event("render.request.pty_dirty"); } // 2. Drain a bounded internal-event batch. API handlers perform an // exhaustive forwarding-aware drain before reading pane/runtime state. if self.drain_internal_events_with_forwarding() { needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.internal_events"); } // 3. Drain API requests. if self.drain_api_requests_with_shutdown_check() { needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.api_requests"); } self.app.sync_focus_events(); @@ -332,12 +399,16 @@ impl HeadlessServer { // 5. Drain server events from client threads. if self.drain_server_events() { needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.server_events"); } // 6. Handle scheduled tasks. let now = Instant::now(); if self.handle_scheduled_tasks_headless(now, needs_render) { needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.scheduled_tasks"); } // Handle deferred requests. @@ -345,28 +416,38 @@ impl HeadlessServer { self.app.state.request_complete_onboarding = false; self.app.open_settings_from_onboarding(); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_onboarding"); } if self.app.state.request_new_workspace { self.app.state.request_new_workspace = false; self.app.create_workspace(); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_new_workspace"); } if self.app.state.request_new_tab { self.app.state.request_new_tab = false; self.app.create_tab(); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_new_tab"); } if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() { self.app.open_new_linked_worktree_dialog(ws_idx); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); } if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() { self.app.open_existing_worktree_dialog(ws_idx); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); } if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() { @@ -375,35 +456,47 @@ impl HeadlessServer { self.app.state.mode = app::Mode::Navigate; } needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_workspace_cwd"); } if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() { self.app.open_remove_linked_worktree_confirmation(ws_idx); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_worktree_dialog"); } if self.app.state.request_submit_worktree_create { self.app.state.request_submit_worktree_create = false; self.app.start_worktree_add(); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_worktree_submit"); } if self.app.state.request_submit_worktree_open { self.app.state.request_submit_worktree_open = false; self.app.open_selected_existing_worktree(); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_worktree_submit"); } if self.app.state.request_submit_worktree_remove { self.app.state.request_submit_worktree_remove = false; self.app.start_worktree_remove(); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.deferred_worktree_submit"); } if self.app.state.request_reload_config { self.app.state.request_reload_config = false; self.reload_server_config(true); needs_render = true; + needs_full_render = true; + crate::render_prof::event("full_render_cause.config_reload"); } self.drain_client_config_reload_request(); @@ -413,10 +506,25 @@ impl HeadlessServer { // 7. Render virtually and stream frames. if needs_render && self.app.can_render_now(now) { - self.app.render_dirty.swap(false, Ordering::AcqRel); - self.render_and_stream(); + crate::render_prof::event("render.attempt"); + let pty_dirty = self.app.render_dirty.swap(false, Ordering::AcqRel); + if pty_dirty { + crate::render_prof::event("render.attempt.pty_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 rendered_retained = + pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream(); + if !rendered_retained { + crate::render_prof::event("full_render.invoke"); + self.render_and_stream(); + } self.app.last_render_at = Some(now); needs_render = false; + needs_full_render = false; continue; } @@ -454,16 +562,19 @@ impl HeadlessServer { LoopEvent::Internal(ev) => { if self.handle_internal_event_with_forwarding(ev) { needs_render = true; + needs_full_render = true; } } LoopEvent::Api(msg) => { if self.handle_api_request_with_shutdown_check(msg) { needs_render = true; + needs_full_render = true; } } LoopEvent::ServerEvent(ev) => { if self.handle_server_event(ev) { needs_render = true; + needs_full_render = true; } } LoopEvent::RenderRequested => { @@ -2273,13 +2384,224 @@ impl HeadlessServer { /// Renders the current state to client-sized virtual buffers and streams /// frames to all connected clients. + fn render_retained_pty_update_and_stream(&mut self) -> bool { + crate::render_prof::event("retained.attempt"); + let retained_started = crate::render_prof::timer(); + macro_rules! retained_fallback { + ($reason:literal) => {{ + crate::render_prof::event(concat!("retained_fallback.", $reason)); + crate::render_prof::duration_since("retained.total", retained_started); + return false; + }}; + } + macro_rules! retained_success { + ($reason:literal) => {{ + crate::render_prof::event("retained.success"); + crate::render_prof::event(concat!("retained_success.", $reason)); + crate::render_prof::duration_since("retained.total", retained_started); + return true; + }}; + } + + if !self.retained_pty_update_allowed_by_app_state() { + retained_fallback!("unsafe_app_state"); + } + + let render_targets = render_targets(&self.clients, self.foreground_client_id); + let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] = + render_targets.as_slice() + else { + retained_fallback!("multiple_or_no_target"); + }; + if !matches!(mode, ClientConnectionMode::App) { + retained_fallback!("not_app_client"); + } + let Some(client) = self.clients.get(client_id) else { + retained_fallback!("client_missing"); + }; + if client.render_pending { + retained_fallback!("render_pending"); + } + if self.app.state.kitty_graphics_enabled && !client.graphics_cache.is_empty() { + retained_fallback!("graphics_cache_active"); + } + if client.graphics_surface_reset_pending { + retained_fallback!("graphics_surface_reset"); + } + if self.app.state.kitty_graphics_enabled + && cell_size.is_known() + && crate::kitty_graphics::has_visible_pane_graphics( + &self.app.state, + &self.app.terminal_runtimes, + *cell_size, + ) + { + retained_fallback!("visible_kitty_graphics"); + } + let Some(mut frame) = client.render_state.last_frame().cloned() else { + retained_fallback!("no_last_frame"); + }; + if frame.width != *cols || frame.height != *rows { + retained_fallback!("frame_size_mismatch"); + } + frame.graphics.clear(); + + let Some(ws_idx) = self.app.state.active else { + retained_fallback!("no_active_workspace"); + }; + let pane_infos = self.app.state.view.pane_infos.clone(); + if pane_infos.is_empty() { + retained_fallback!("no_pane_info"); + } + + let mut touched = false; + for info in pane_infos { + if !rect_fits_frame(info.inner_rect, &frame) { + retained_fallback!("pane_rect_outside_frame"); + } + let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + ws_idx, + info.id, + ) else { + retained_fallback!("missing_runtime"); + }; + match runtime.collect_dirty_patch(info.inner_rect.width, info.inner_rect.height) { + crate::pane::TerminalDirtyPatchOutcome::Clean => { + crate::render_prof::event("retained.pane_clean"); + } + crate::pane::TerminalDirtyPatchOutcome::Fallback => { + retained_fallback!("dirty_patch_fallback"); + } + crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => { + crate::render_prof::event("retained.pane_patch"); + crate::render_prof::counter("retained.patch_rows", patch.rows.len() as u64); + if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) { + retained_fallback!("hyperlink_intersection"); + } + if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) { + retained_fallback!("patch_apply_failed"); + } + touched = true; + } + } + } + + let previous_cursor = frame.cursor.clone(); + frame.cursor = crate::server::render_stream::focused_terminal_cursor( + &self.app.state, + &self.app.terminal_runtimes, + ); + let cursor_changed = frame.cursor != previous_cursor; + + if !touched && !cursor_changed { + retained_success!("clean_no_cursor_change"); + } + + let mut broken_clients = Vec::new(); + let sent = self.send_retained_frame_to_client(*client_id, frame, &mut broken_clients); + for broken_client in broken_clients { + self.remove_client_and_resize_if_needed(broken_client); + } + if sent { + retained_success!("sent"); + } + retained_fallback!("send_failed"); + } + + fn retained_pty_update_allowed_by_app_state(&self) -> bool { + self.app.state.mode == app::Mode::Terminal + && self.app.state.selection.is_none() + && self.app.state.copy_mode.is_none() + && self.app.state.context_menu.is_none() + && !self.app.full_redraw_pending + } + + fn send_retained_frame_to_client( + &mut self, + client_id: u64, + frame: FrameData, + broken_clients: &mut Vec<u64>, + ) -> bool { + let Some(client) = self.clients.get_mut(&client_id) else { + crate::render_prof::event("retained_send_fallback.client_missing"); + return false; + }; + let Some(writer) = client.writer.as_ref().cloned() else { + crate::render_prof::event("retained_send_fallback.writer_missing"); + return false; + }; + let prepare_started = crate::render_prof::timer(); + let Some(prepared) = client.render_state.prepare_frame(&frame) else { + client.render_pending = false; + crate::render_prof::event("retained_send.skip_identical"); + crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); + return true; + }; + crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started); + let serialize_started = crate::render_prof::timer(); + let serialized = match Self::frame_server_message(prepared.message()) { + Ok(framed) => { + crate::render_prof::duration_since("retained_send.serialize", serialize_started); + framed + } + Err(protocol::FramingError::Oversized { claimed, max }) => { + warn!( + client_id, + claimed, max, "skipping oversized retained frame for client" + ); + crate::render_prof::event("retained_send_fallback.serialize_oversized"); + crate::render_prof::duration_since("retained_send.serialize", serialize_started); + return false; + } + Err(err) => { + warn!(client_id, err = %err, "failed to serialize retained frame for client"); + broken_clients.push(client_id); + crate::render_prof::event("retained_send_fallback.serialize_error"); + crate::render_prof::duration_since("retained_send.serialize", serialize_started); + return false; + } + }; + crate::render_prof::counter("retained_send.bytes", serialized.len() as u64); + + let send_started = crate::render_prof::timer(); + match writer.render.try_send(serialized) { + Ok(()) => { + client.render_pending = false; + client.render_state.commit_sent_frame(frame, prepared); + crate::render_prof::event("retained_send.sent"); + crate::render_prof::duration_since("retained_send.try_send", send_started); + true + } + Err(std::sync::mpsc::TrySendError::Full(_)) => { + client.render_pending = true; + crate::render_prof::event("retained_send_fallback.queue_full"); + crate::render_prof::duration_since("retained_send.try_send", send_started); + debug!( + client_id, + "render queue full, deferring latest retained frame" + ); + false + } + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + debug!(client_id, "client writer channel closed, marking as broken"); + broken_clients.push(client_id); + crate::render_prof::event("retained_send_fallback.writer_disconnected"); + crate::render_prof::duration_since("retained_send.try_send", send_started); + false + } + } + } + fn render_and_stream(&mut self) { + let full_started = crate::render_prof::timer(); let render_targets = render_targets(&self.clients, self.foreground_client_id); if render_targets.is_empty() { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); let resize_panes = self.app.state.view.pane_infos.is_empty(); + let render_started = crate::render_prof::timer(); let _ = crate::server::render_stream::render_virtual_with_runtime_registry( &mut self.app.state, &self.app.terminal_runtimes, @@ -2287,6 +2609,9 @@ impl HeadlessServer { resize_panes, crate::kitty_graphics::HostCellSize::default(), ); + crate::render_prof::duration_since("full_render.render_virtual", render_started); + self.app.full_redraw_pending = false; + crate::render_prof::duration_since("full_render.total", full_started); debug!( cols, rows, resize_panes, "rendered virtual frame with no attached clients" @@ -2295,11 +2620,13 @@ impl HeadlessServer { } let mut broken_clients: Vec<u64> = Vec::new(); + let mut deferred_frame = false; for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { let area = Rect::new(0, 0, cols, rows); let is_app_client = matches!(mode, ClientConnectionMode::App); let mut frame = match mode { ClientConnectionMode::App => { + let render_started = crate::render_prof::timer(); let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known() { crate::server::render_stream::render_virtual_with_runtime_registry( @@ -2318,11 +2645,27 @@ impl HeadlessServer { crate::kitty_graphics::HostCellSize::default(), ) }; + crate::render_prof::duration_since( + "full_render.render_virtual", + render_started, + ); + let hyperlinks_started = crate::render_prof::timer(); let hyperlinks = crate::server::render_stream::visible_hyperlinks( &self.app.state, &self.app.terminal_runtimes, ); - FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) + crate::render_prof::duration_since( + "full_render.visible_hyperlinks", + hyperlinks_started, + ); + let frame_started = crate::render_prof::timer(); + let frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + cursor, + &hyperlinks, + ); + crate::render_prof::duration_since("full_render.frame_build", frame_started); + frame } ClientConnectionMode::TerminalAttach { terminal_id } => { let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { @@ -2337,10 +2680,27 @@ impl HeadlessServer { broken_clients.push(client_id); continue; }; + let render_started = crate::render_prof::timer(); let (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, area); + crate::render_prof::duration_since( + "full_render.render_terminal_virtual", + render_started, + ); + let hyperlinks_started = crate::render_prof::timer(); let hyperlinks = runtime.visible_hyperlinks(area); - FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) + crate::render_prof::duration_since( + "full_render.visible_hyperlinks", + hyperlinks_started, + ); + let frame_started = crate::render_prof::timer(); + let frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, + cursor, + &hyperlinks, + ); + crate::render_prof::duration_since("full_render.frame_build", frame_started); + frame } }; @@ -2353,6 +2713,7 @@ impl HeadlessServer { if graphics_surface_reset_pending { frame.graphics = next_graphics_cache.clear_bytes(); } + let graphics_started = crate::render_prof::timer(); frame .graphics .extend(crate::kitty_graphics::encode_local_pane_graphics( @@ -2361,11 +2722,13 @@ impl HeadlessServer { cell_size, &mut next_graphics_cache, )); + crate::render_prof::duration_since("full_render.graphics_encode", graphics_started); } else { frame.graphics = next_graphics_cache.clear_bytes(); } let Some(writer) = client.writer.as_ref().cloned() else { + crate::render_prof::event("full_render.writer_missing"); continue; }; @@ -2381,10 +2744,14 @@ impl HeadlessServer { commit_graphics_cache = false; } + let prepare_started = crate::render_prof::timer(); let Some(mut prepared) = client.render_state.prepare_frame(&frame) else { client.render_pending = false; + crate::render_prof::event("full_render.skip_identical"); + crate::render_prof::duration_since("full_render.prepare_frame", prepare_started); continue; }; + crate::render_prof::duration_since("full_render.prepare_frame", prepare_started); let mut frame_to_commit = frame.clone(); let max_frame_size = if frame.graphics.is_empty() { @@ -2392,11 +2759,15 @@ impl HeadlessServer { } else { MAX_GRAPHICS_FRAME_SIZE }; + let serialize_started = crate::render_prof::timer(); let serialized = match Self::frame_server_message_with_max( prepared.message(), max_frame_size, ) { - Ok(framed) => framed, + Ok(framed) => { + crate::render_prof::duration_since("full_render.serialize", serialize_started); + framed + } Err(protocol::FramingError::Oversized { claimed, max }) if !frame.graphics.is_empty() => { @@ -2410,6 +2781,11 @@ impl HeadlessServer { client.render_state.prepare_frame(&text_only_frame) else { client.render_pending = false; + crate::render_prof::event("full_render.skip_identical_text_only"); + crate::render_prof::duration_since( + "full_render.serialize", + serialize_started, + ); continue; }; let framed = match Self::frame_server_message(text_only_prepared.message()) { @@ -2417,12 +2793,18 @@ impl HeadlessServer { Err(err) => { warn!(client_id, err = %err, "failed to serialize text-only frame for client"); broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + crate::render_prof::duration_since( + "full_render.serialize", + serialize_started, + ); continue; } }; prepared = text_only_prepared; frame_to_commit = text_only_frame; commit_graphics_cache = false; + crate::render_prof::duration_since("full_render.serialize", serialize_started); framed } Err(protocol::FramingError::Oversized { claimed, max }) => { @@ -2430,15 +2812,21 @@ impl HeadlessServer { client_id, claimed, max, "skipping oversized frame for client" ); + crate::render_prof::event("full_render.serialize_oversized"); + crate::render_prof::duration_since("full_render.serialize", serialize_started); continue; } Err(err) => { warn!(client_id, err = %err, "failed to serialize frame for client"); broken_clients.push(client_id); + crate::render_prof::event("full_render.serialize_error"); + crate::render_prof::duration_since("full_render.serialize", serialize_started); continue; } }; + crate::render_prof::counter("full_render.bytes", serialized.len() as u64); + let send_started = crate::render_prof::timer(); match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; @@ -2449,15 +2837,22 @@ impl HeadlessServer { client .render_state .commit_sent_frame(frame_to_commit, prepared); + crate::render_prof::event("full_render.sent"); + crate::render_prof::duration_since("full_render.try_send", send_started); } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; + deferred_frame = true; + crate::render_prof::event("full_render.queue_full"); + crate::render_prof::duration_since("full_render.try_send", send_started); debug!(client_id, "render queue full, deferring latest frame"); continue; } Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { debug!(client_id, "client writer channel closed, marking as broken"); broken_clients.push(client_id); + crate::render_prof::event("full_render.writer_disconnected"); + crate::render_prof::duration_since("full_render.try_send", send_started); continue; } } @@ -2470,6 +2865,10 @@ impl HeadlessServer { } let (cols, rows) = self.effective_size; + if !deferred_frame { + self.app.full_redraw_pending = false; + } + crate::render_prof::duration_since("full_render.total", full_started); debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)"); } @@ -3054,6 +3453,71 @@ mod tests { ) } + fn retained_test_server( + initial_screen: &[u8], + ) -> ( + HeadlessServer, + std::sync::mpsc::Receiver<Vec<u8>>, + crate::layout::PaneId, + ) { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let pane_id = workspace.focused_pane_id().expect("focused pane"); + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen), + ); + 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, client_rx, pane_id) + } + + fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) { + assert_eq!( + (actual.width, actual.height), + (expected.width, expected.height) + ); + assert_eq!(actual.cursor, expected.cursor, "cursor mismatch"); + assert_eq!(actual.hyperlinks, expected.hyperlinks, "hyperlink mismatch"); + assert_eq!(actual.graphics, expected.graphics, "graphics mismatch"); + assert_eq!( + actual.cells.len(), + expected.cells.len(), + "cell length mismatch" + ); + for (idx, (actual_cell, expected_cell)) in + actual.cells.iter().zip(expected.cells.iter()).enumerate() + { + assert_eq!( + actual_cell, + expected_cell, + "cell mismatch at index {idx} (x={}, y={})", + idx % usize::from(actual.width), + idx / usize::from(actual.width), + ); + } + } + #[test] fn foreground_client_applies_client_keybindings() { let mut server = test_headless_server(); @@ -5064,6 +5528,323 @@ next_tab = "" ); } + #[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"); + server.render_and_stream(); + let first = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial frame"), + ); + assert!(first.cells.iter().any(|cell| cell.symbol == "a")); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rZ"); + + assert!(server.render_retained_pty_update_and_stream()); + let patched = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained frame"), + ); + assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); + assert_eq!((patched.width, patched.height), (80, 24)); + } + + #[tokio::test] + async fn retained_pty_update_matches_full_render_frame() { + let initial = b"\x1b[6 qleft \xe4\xb8\xad"; + let update = b"\r\x1b[44mZ\x1b[0m"; + let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); + let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); + + retained_server.render_and_stream(); + let _ = retained_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial retained baseline"); + full_server.render_and_stream(); + let _ = full_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial full baseline"); + + retained_server + .app + .state + .runtime_for_pane_in_workspace( + &retained_server.app.terminal_runtimes, + 0, + retained_pane_id, + ) + .expect("retained runtime") + .test_process_pty_bytes(update); + full_server + .app + .state + .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) + .expect("full runtime") + .test_process_pty_bytes(update); + + assert!(retained_server.render_retained_pty_update_and_stream()); + full_server.render_and_stream(); + + let retained_frame = read_server_frame( + retained_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained frame"), + ); + let full_frame = read_server_frame( + full_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full frame"), + ); + assert_frame_data_eq(&retained_frame, &full_frame); + } + + #[tokio::test] + async fn retained_pty_update_streams_cursor_only_change() { + let initial = b"abcd"; + let update = b"\x1b[D"; + let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial); + let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial); + + retained_server.render_and_stream(); + let _ = retained_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial retained baseline"); + full_server.render_and_stream(); + let _ = full_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial full baseline"); + + retained_server + .app + .state + .runtime_for_pane_in_workspace( + &retained_server.app.terminal_runtimes, + 0, + retained_pane_id, + ) + .expect("retained runtime") + .test_process_pty_bytes(update); + full_server + .app + .state + .runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id) + .expect("full runtime") + .test_process_pty_bytes(update); + + assert!(retained_server.render_retained_pty_update_and_stream()); + full_server.render_and_stream(); + + let retained_frame = read_server_frame( + retained_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained cursor frame"), + ); + let full_frame = read_server_frame( + full_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full cursor frame"), + ); + assert_frame_data_eq(&retained_frame, &full_frame); + } + + #[tokio::test] + async fn retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows() { + let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); + server.render_and_stream(); + let _ = 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, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rZ"); + + server.app.state.mode = crate::app::Mode::Navigate; + assert!(!server.render_retained_pty_update_and_stream()); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + server.app.state.mode = crate::app::Mode::Terminal; + assert!(server.render_retained_pty_update_and_stream()); + let patched = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained frame after safe mode"), + ); + assert!(patched.cells.iter().any(|cell| cell.symbol == "Z")); + } + + #[tokio::test] + async fn headless_full_render_clears_full_redraw_pending_for_future_retained_updates() { + let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); + server.app.full_redraw_pending = true; + + server.render_and_stream(); + let _ = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full redraw frame"); + assert!(!server.app.full_redraw_pending); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rZ"); + + assert!(server.render_retained_pty_update_and_stream()); + } + + #[tokio::test] + async fn retained_pty_update_declines_when_patch_would_stale_hyperlinks() { + let (mut server, client_rx, pane_id) = retained_test_server(b"link"); + server.render_and_stream(); + let _ = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial frame"); + let inner_rect = server.app.state.view.pane_infos[0].inner_rect; + let client = server.clients.get_mut(&1).unwrap(); + let mut frame = client.render_state.last_frame().unwrap().clone(); + frame.hyperlinks = vec!["https://example.com".to_owned()]; + let hyperlink_idx = + usize::from(inner_rect.y) * usize::from(frame.width) + usize::from(inner_rect.x); + frame.cells[hyperlink_idx].hyperlink = Some(0); + let prepared = client + .render_state + .prepare_frame(&frame) + .expect("hyperlink frame differs"); + client.render_state.commit_sent_frame(frame, prepared); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rplain"); + + assert!(!server.render_retained_pty_update_and_stream()); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + server.render_and_stream(); + let full = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full frame after hyperlink overwrite"), + ); + assert!( + full.cells.iter().all(|cell| cell.hyperlink.is_none()), + "full render should clear overwritten hyperlink cells" + ); + } + + #[tokio::test] + async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() { + let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); + server.app.state.kitty_graphics_enabled = true; + server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + + server.render_and_stream(); + let _ = 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, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rZ"); + + assert!(server.render_retained_pty_update_and_stream()); + let retained = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("retained frame with kitty enabled"), + ); + assert!(retained.cells.iter().any(|cell| cell.symbol == "Z")); + } + + #[tokio::test] + async fn retained_pty_update_declines_when_graphics_cache_has_content() { + let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); + server.app.state.kitty_graphics_enabled = true; + let client = server.clients.get_mut(&1).unwrap(); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + + server.render_and_stream(); + let _ = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial frame"); + server + .clients + .get_mut(&1) + .unwrap() + .graphics_cache + .test_mark_non_empty(); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rZ"); + + assert!(!server.render_retained_pty_update_and_stream()); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + } + + #[tokio::test] + async fn full_redraw_pending_survives_full_render_queue_full() { + let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); + let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) + .expect("serialize dummy message"); + server + .clients + .get(&1) + .unwrap() + .writer + .as_ref() + .unwrap() + .render + .send(queued) + .expect("pre-fill render queue"); + server.app.full_redraw_pending = true; + + server.render_and_stream(); + + assert!(server.app.full_redraw_pending); + assert!(server.clients.get(&1).unwrap().render_pending); + assert!(matches!( + read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + ServerMessage::ReloadSoundConfig + )); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rZ"); + + assert!(!server.render_retained_pty_update_and_stream()); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + } + #[test] fn client_config_reload_request_refreshes_attached_clients() { let mut server = test_headless_server(); diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 3548f98a..97baf38d 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -45,8 +45,10 @@ impl ClientRenderState { match self { Self::Semantic { last_frame } => { if last_frame.as_ref() == Some(frame) { + crate::render_prof::event("prepare_frame.semantic.skip_current"); return None; } + crate::render_prof::event("prepare_frame.semantic.changed"); Some(PreparedRender { message: ServerMessage::Frame(frame.clone()), encoded: None, @@ -54,10 +56,22 @@ impl ClientRenderState { } Self::TerminalAnsi { blit_encoder, seq } => { if blit_encoder.is_current(frame) { + crate::render_prof::event("prepare_frame.ansi.skip_current"); return None; } let mut encoded = blit_encoder.encode(frame, false); + crate::render_prof::event("prepare_frame.ansi.changed"); + crate::render_prof::counter("prepare_frame.ansi.bytes", encoded.bytes.len() as u64); + if encoded.full { + crate::render_prof::event("prepare_frame.ansi.full"); + } else { + crate::render_prof::event("prepare_frame.ansi.partial"); + } insert_graphics_before_sync_end(&mut encoded.bytes, &frame.graphics); + crate::render_prof::counter( + "prepare_frame.graphics.bytes", + frame.graphics.len() as u64, + ); Some(PreparedRender { message: ServerMessage::Terminal(TerminalFrame { seq: *seq + 1, @@ -72,6 +86,13 @@ impl ClientRenderState { } } + pub(crate) fn last_frame(&self) -> Option<&FrameData> { + match self { + Self::Semantic { last_frame } => last_frame.as_ref(), + Self::TerminalAnsi { blit_encoder, .. } => blit_encoder.last_frame(), + } + } + pub(crate) fn commit_sent_frame(&mut self, frame: FrameData, prepared: PreparedRender) { match (self, prepared.encoded) { (Self::Semantic { last_frame }, None) => *last_frame = Some(frame), @@ -316,7 +337,7 @@ pub(crate) fn visible_hyperlinks( links } -fn focused_terminal_cursor( +pub(crate) fn focused_terminal_cursor( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option<CursorState> { |
