From 440a8bdd1e3a809ea76f7cee82d73266d9da54d7 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Wed, 23 Sep 2026 02:19:59 +0200 Subject: [PATCH] fix: keep synchronized pane frames atomic (#4508) refs #2968 --- src/pane.rs | 4 + src/pane/terminal.rs | 59 +++++++ src/server/client_shell.rs | 115 ++++++++++---- src/server/headless/render.rs | 115 ++++++++++---- src/server/headless/retained_surface.rs | 25 ++- src/server/headless/tests/mod.rs | 195 ++++++++++++++++++++++++ src/server/render_scale_benchmark.rs | 9 +- src/server/render_stream.rs | 12 +- src/terminal/runtime.rs | 4 + src/ui/panes.rs | 10 +- 10 files changed, 476 insertions(+), 72 deletions(-) diff --git a/src/pane.rs b/src/pane.rs index 118dd3a4..9bc35eca 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3248,6 +3248,10 @@ impl PaneRuntime { self.terminal.synchronized_output_active() } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.terminal.synchronized_output_state() + } + pub fn visible_text(&self) -> String { self.terminal.visible_text() } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index c9d0b90a..e2005a8f 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -195,6 +195,7 @@ pub(crate) struct GhosttyPaneCore { #[cfg(test)] pub dirty_collection_hook: Option>, pub terminal: crate::ghostty::Terminal, + synchronized_output_epoch: u64, #[cfg(windows)] recent_fallback: windows_recent_fallback::Cache, pub render_state: crate::ghostty::RenderState, @@ -482,6 +483,10 @@ impl PaneTerminal { self.ghostty.synchronized_output_active() } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.ghostty.synchronized_output_state() + } + pub fn visible_text(&self) -> String { self.ghostty.visible_text() } @@ -1167,6 +1172,7 @@ impl GhosttyPaneTerminal { #[cfg(test)] dirty_collection_hook: None, terminal, + synchronized_output_epoch: 0, #[cfg(windows)] recent_fallback: windows_recent_fallback::Cache::default(), render_state, @@ -1387,6 +1393,10 @@ impl GhosttyPaneTerminal { core.decscusr_tracker.observe(bytes); let in_progress_default_color_event = core.default_color_event_tracker.in_progress_event(); let default_color_events = core.default_color_event_tracker.drain_pending(); + let synchronized_output_before = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); let write_started = crate::render_prof::timer(); self.write_pty_bytes_with_ordered_responses( &mut core, @@ -1420,6 +1430,9 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) .unwrap_or(false); + if synchronized_output != synchronized_output_before { + core.synchronized_output_epoch = core.synchronized_output_epoch.wrapping_add(1); + } // Intermediate synchronized-frame positions must not become settled cursors. if CURSOR_POSITION_SETTLE_ENABLED && !synchronized_output { let cursor_started = crate::render_prof::timer(); @@ -1660,6 +1673,10 @@ impl GhosttyPaneTerminal { cell_height_px: u32, ) -> Vec { if let Ok(mut core) = self.core.lock() { + let synchronized_output_before = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); #[cfg(windows)] windows_recent_fallback::refresh_if_needed(&mut core); let offset_from_bottom = core @@ -1698,6 +1715,13 @@ impl GhosttyPaneTerminal { let _ = core .terminal .resize(cols, rows, cell_width_px, cell_height_px); + let synchronized_output_after = core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false); + if synchronized_output_after != synchronized_output_before { + core.synchronized_output_epoch = core.synchronized_output_epoch.wrapping_add(1); + } let terminal_responses = self.drain_pending_pty_responses(); let bottom_is_blank = ghostty_detection_text(&mut core) @@ -1971,6 +1995,20 @@ impl GhosttyPaneTerminal { .unwrap_or(false) } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.core + .lock() + .map(|core| { + ( + core.terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false), + core.synchronized_output_epoch, + ) + }) + .unwrap_or((true, 0)) + } + pub fn encode_terminal_key( &self, key: crate::input::TerminalKey, @@ -2308,6 +2346,13 @@ impl GhosttyPaneTerminal { let Ok(mut core) = self.core.lock() else { return; }; + if core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false) + { + return; + } let host_theme = core.host_terminal_theme; let initial_default_foreground = core.initial_default_foreground; let initial_default_background = core.initial_default_background; @@ -2425,6 +2470,13 @@ impl GhosttyPaneTerminal { .lock() .ok() .map(|mut core| { + if core + .terminal + .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT) + .unwrap_or(false) + { + return TerminalDirtyPatchOutcome::Fallback; + } #[cfg(test)] if let Some(hook) = core.dirty_collection_hook.take() { hook(); @@ -5947,14 +5999,21 @@ mod tests { let pane_terminal = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); + assert_eq!(pane_terminal.synchronized_output_state(), (false, 0)); + pane_terminal.process_pty_bytes(pane_id, 0, b"ordinary output", &tx); + assert_eq!(pane_terminal.synchronized_output_state(), (false, 0)); + let begin = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026h", &tx); assert!(!begin.request_render); + assert_eq!(pane_terminal.synchronized_output_state(), (true, 1)); let body = pane_terminal.process_pty_bytes(pane_id, 0, b"hello", &tx); assert!(!body.request_render); + assert_eq!(pane_terminal.synchronized_output_state(), (true, 1)); let end = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx); assert!(end.request_render); + assert_eq!(pane_terminal.synchronized_output_state(), (false, 2)); } #[test] diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 554e8167..d542896a 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -276,6 +276,12 @@ pub(super) struct RenderedPaneSurface { pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, } +#[derive(Debug)] +pub(super) enum SurfaceRenderDeferred { + Synchronized, + Changed, +} + pub(super) fn render_pane_surface( app: &mut app::App, target: Option, @@ -285,36 +291,54 @@ pub(super) fn render_pane_surface( cell_size: crate::kitty_graphics::HostCellSize, graphics_delivery: &crate::kitty_graphics::surface::DeliveryCache, client_id: u64, -) -> RenderedPaneSurface { - let content_revisions_before = target - .and_then(|target| { - let workspace = app.state.workspaces.get(target.workspace_index)?; - let tab = workspace.tabs.get(target.tab_index)?; - Some( - tab.layout - .pane_ids() - .into_iter() - .filter_map(|pane_id| { - app.state - .runtime_for_pane_in_workspace( - &app.terminal_runtimes, - target.workspace_index, - pane_id, - ) - .map(|runtime| (pane_id, runtime.content_seq())) - }) - .collect::>(), - ) - }) - .unwrap_or_default(); +) -> Result { + let layout = crate::ui::compute_tab_surface_for( + &app.state, + &app.terminal_runtimes, + target, + area, + resize_panes, + cell_size, + ); + let mut content_revisions_before = std::collections::HashMap::new(); + if let Some(target) = target { + for pane in &layout.pane_infos { + if let Some(runtime) = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + target.workspace_index, + pane.id, + ) { + let (synchronized, epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + let revision = runtime.content_seq(); + content_revisions_before.insert(pane.id, (epoch, revision)); + } + } + } + let popup_revision_before = if show_popup { + app.state + .popup_pane + .as_ref() + .and_then(|popup| app.terminal_runtimes.get(&popup.terminal_id)) + .map(|runtime| { + let (synchronized, epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + Ok(epoch) + }) + .transpose()? + } else { + None + }; let (buffer, cursor, hyperlinks, layout) = crate::server::render_stream::render_tab_surface_virtual( &app.state, &app.terminal_runtimes, - target, + layout, area, - resize_panes, - cell_size, ); let panes = target .map(|target| { @@ -343,7 +367,9 @@ pub(super) fn render_pane_surface( }; let content_revision = runtime.map_or(0, |runtime| { let after = runtime.content_seq(); - if content_revisions_before.get(&pane.id).copied() == Some(after) + if content_revisions_before + .get(&pane.id) + .is_some_and(|&(_, before)| before == after) && after.is_multiple_of(2) { after @@ -422,14 +448,47 @@ pub(super) fn render_pane_surface( graphics_delivery, client_id, ); - RenderedPaneSurface { + if let Some(target) = target { + for (&pane_id, &(epoch, _)) in &content_revisions_before { + if let Some(runtime) = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + target.workspace_index, + pane_id, + ) { + let (synchronized, after_epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + if after_epoch != epoch { + return Err(SurfaceRenderDeferred::Changed); + } + } + } + } + if let Some(before) = popup_revision_before { + if let Some(runtime) = app + .state + .popup_pane + .as_ref() + .and_then(|popup| app.terminal_runtimes.get(&popup.terminal_id)) + { + let (synchronized, after_epoch) = runtime.synchronized_output_state(); + if synchronized { + return Err(SurfaceRenderDeferred::Synchronized); + } + if after_epoch != before { + return Err(SurfaceRenderDeferred::Changed); + } + } + } + Ok(RenderedPaneSurface { frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), panes, splits, popup, graphics, graphics_delivery: next_graphics_delivery, - } + }) } fn render_popup_surface( diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 82564ba3..a3bbedfa 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -441,12 +441,33 @@ impl HeadlessServer { }); if changed { if let Some(target) = self.shell_target_for_client(*client_id) { + let area = Rect::new(0, 0, *cols, *rows); + let layout = crate::ui::compute_tab_surface_for( + &self.app.state, + &self.app.terminal_runtimes, + Some(target), + area, + false, + *cell_size, + ); + if layout.pane_infos.iter().any(|pane| { + self.app + .state + .runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + target.workspace_index, + pane.id, + ) + .is_some_and(|runtime| runtime.synchronized_output_active()) + }) { + continue; + } crate::ui::resize_tab_surface( &self.app.state, &self.app.terminal_runtimes, target.workspace_index, target.tab_index, - Rect::new(0, 0, *cols, *rows), + area, if cell_size.is_known() { *cell_size } else { @@ -473,6 +494,55 @@ impl HeadlessServer { let shell_target = self.shell_target_for_client(client_id); let shell_tab_id = self.shell_tab_id_for_client(client_id); let shell_shows_popup = shell_tab_id.as_deref() == self.popup_owner_tab_id.as_deref(); + let shell_graphics_delivery = self + .clients + .get(&client_id) + .map(|client| client.shell_graphics_delivery.clone()) + .unwrap_or_default(); + let shell_render = if matches!(mode, ClientConnectionMode::ClientShell) + && self + .clients + .get(&client_id) + .is_some_and(|client| client.shell_surface_active) + { + let render_started = crate::render_prof::timer(); + let render_cell_size = if cell_size.is_known() { + cell_size + } else { + crate::kitty_graphics::HostCellSize::default() + }; + let result = render_client_shell_pane_surface( + &mut self.app, + shell_target, + area, + false, + shell_shows_popup, + render_cell_size, + &shell_graphics_delivery, + client_id, + ); + crate::render_prof::duration_since( + "full_render.render_tab_surface_virtual", + render_started, + ); + match result { + Ok(surface) => Some(surface), + Err(reason) => { + if let Some(client) = self.clients.get_mut(&client_id) { + client.render_state.request_recompute(); + } + if matches!( + reason, + crate::server::client_shell::SurfaceRenderDeferred::Changed + ) { + self.app.render_dirty.request_generic(); + } + continue; + } + } + } else { + None + }; let mut shell_projection_revision = 0; if matches!(mode, ClientConnectionMode::ClientShell) { let location = self @@ -585,20 +655,9 @@ impl HeadlessServer { continue; } } - let shell_graphics_delivery = self - .clients - .get(&client_id) - .map(|client| client.shell_graphics_delivery.clone()) - .unwrap_or_default(); let mut surface_parts = None; let frame = match mode { ClientConnectionMode::ClientShell => { - let render_started = crate::render_prof::timer(); - let render_cell_size = if cell_size.is_known() { - cell_size - } else { - crate::kitty_graphics::HostCellSize::default() - }; let crate::server::client_shell::RenderedPaneSurface { frame, panes, @@ -606,20 +665,7 @@ impl HeadlessServer { popup, graphics, graphics_delivery: next_graphics_delivery, - } = render_client_shell_pane_surface( - &mut self.app, - shell_target, - area, - false, - shell_shows_popup, - render_cell_size, - &shell_graphics_delivery, - client_id, - ); - crate::render_prof::duration_since( - "full_render.render_tab_surface_virtual", - render_started, - ); + } = shell_render.expect("active shell surface"); surface_parts = Some((panes, splits, popup, graphics, next_graphics_delivery)); frame } @@ -638,6 +684,13 @@ impl HeadlessServer { broken_clients.push(client_id); continue; }; + let (synchronized, epoch) = runtime.synchronized_output_state(); + if synchronized { + if let Some(client) = self.clients.get_mut(&client_id) { + client.render_state.request_recompute(); + } + continue; + } let render_started = crate::render_prof::timer(); let (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, area); @@ -651,6 +704,16 @@ impl HeadlessServer { "full_render.visible_hyperlinks", hyperlinks_started, ); + let (synchronized, after_epoch) = runtime.synchronized_output_state(); + if synchronized || after_epoch != epoch { + if let Some(client) = self.clients.get_mut(&client_id) { + client.render_state.request_recompute(); + } + if !synchronized { + self.app.render_dirty.request_generic(); + } + continue; + } let frame_started = crate::render_prof::timer(); let frame = FrameData::from_ratatui_buffer_with_hyperlinks( &buffer, diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index 2a637eac..07d33ef5 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -206,6 +206,20 @@ struct RetainedRecipientUpdate { )>, } +fn has_synchronized_pane(app: &app::App, surface: &protocol::PaneSurfaceFrame) -> bool { + surface.panes.iter().any(|pane| { + app.parse_pane_id(&pane.pane_id) + .and_then(|(workspace_index, pane_id)| { + app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) + }) + .is_some_and(|runtime| runtime.synchronized_output_active()) + }) +} + impl HeadlessServer { /// Applies terminal dirty rows to the committed origin-relative pane surface. /// Any presentation or geometry uncertainty falls back to the complete renderer. @@ -281,6 +295,9 @@ impl HeadlessServer { { fallback!("baseline_mismatch"); } + if has_synchronized_pane(&self.app, surface) { + fallback!("synchronized_visible"); + } recipients.push(RetainedRecipient { client_id: *client_id, surface, @@ -346,7 +363,7 @@ impl HeadlessServer { } let mut updates = Vec::with_capacity(recipients.len()); - for recipient in recipients { + for recipient in &recipients { let client_id = recipient.client_id; let surface = recipient.surface; let mut panes = surface.panes.clone(); @@ -459,6 +476,12 @@ impl HeadlessServer { if updates.is_empty() { success!("unchanged"); } + if recipients + .iter() + .any(|recipient| has_synchronized_pane(&self.app, recipient.surface)) + { + fallback!("synchronized_during_patch"); + } let mut sent = 0u64; let mut deferred = 0u64; diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 5f54f4b6..84031ce4 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1262,6 +1262,147 @@ fn recv_pane_surface_patch( } } +#[tokio::test] +async fn unrelated_render_keeps_synchronized_pane_frame_committed() { + let mut server = test_headless_server(); + let pane_id = install_shared_view_test_runtime(&mut server); + let (_control, render) = connect_matching_test_shell(&mut server, 7); + server.render_and_stream(); + let before = recv_pane_surface(&render, "baseline"); + assert!(frame_text(&before.frame).contains("BASE")); + let projection_before = server.clients[&7].shell_projection_revision; + + write_shared_test_pane( + &mut server, + pane_id, + b"\x1b[?2026h\x1b[?1049h\x1b[2J\x1b[HPARTIAL", + ); + server.app.state.workspaces[0].custom_name = Some("renamed during frame".into()); + server.clients.get_mut(&7).unwrap().request_recompute(); + server.render_and_stream(); + assert!(render.try_recv().is_err(), "partial frame was published"); + assert_eq!( + server.clients[&7].shell_projection_revision, + projection_before + ); + + write_shared_test_pane(&mut server, pane_id, b"\rCOMPLETE\x1b[?2026l"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); + server.render_and_stream(); + let after = recv_pane_surface(&render, "completed frame"); + assert!(frame_text(&after.frame).contains("COMPLETE")); + assert!(after.projection_revision > projection_before); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn sibling_retained_output_waits_for_synchronized_pane_to_finish() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("synchronized-split"); + let first = workspace.tabs[0].root_pane; + let second = workspace.test_split(ratatui::layout::Direction::Vertical); + workspace.insert_test_runtime( + first, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"FIRST"), + ); + workspace.insert_test_runtime( + second, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"SECOND"), + ); + 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 (_control, render) = connect_matching_test_shell(&mut server, 7); + server.render_and_stream(); + let _ = recv_pane_surface(&render, "split baseline"); + + write_shared_test_pane(&mut server, first, b"\x1b[?2026h\rPARTIAL"); + write_shared_test_pane(&mut server, second, b"\rUPDATED"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([second]))); + server.render_and_stream(); + assert!( + render.try_recv().is_err(), + "sibling published partial frame" + ); + + write_shared_test_pane(&mut server, first, b"\rCOMPLETE\x1b[?2026l"); + assert!(!server.render_retained_pane_surface_and_stream(&HashSet::from([first]))); + server.render_and_stream(); + let after = recv_pane_surface(&render, "completed split"); + let text = frame_text(&after.frame); + assert!( + text.contains("COMPLETE") && text.contains("UPDATED"), + "{text}" + ); + assert!(!text.contains("PARTIAL")); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn popup_synchronized_output_waits_for_complete_frame() { + let mut server = test_headless_server(); + install_shared_view_test_runtime(&mut server); + let popup_runtime = + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"POPUP_BASE"); + let (_, popup_id) = server.app.install_test_popup_runtime(popup_runtime); + server.popup_owner_tab_id = server.app.public_tab_id(0, 0); + let (_control, render) = connect_matching_test_shell(&mut server, 7); + server.render_and_stream(); + assert!(recv_pane_surface(&render, "popup baseline").popup.is_some()); + + server + .app + .terminal_runtimes + .get(&popup_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2026h\rPOPUP_PARTIAL"); + server.render_and_stream(); + assert!(render.try_recv().is_err(), "partial popup was published"); + + server + .app + .terminal_runtimes + .get(&popup_id) + .unwrap() + .test_process_pty_bytes(b"\rPOPUP_COMPLETE\x1b[?2026l"); + server.render_and_stream(); + let after = recv_pane_surface(&render, "complete popup"); + assert!(after + .popup + .as_ref() + .is_some_and(|popup| frame_text(&popup.frame).contains("POPUP_COMPLETE"))); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn zoom_hidden_synchronized_pane_does_not_block_surface() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("zoomed-sync"); + let hidden = workspace.tabs[0].root_pane; + let visible = workspace.test_split(ratatui::layout::Direction::Vertical); + workspace.tabs[0].zoomed = true; + workspace.insert_test_runtime( + hidden, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"HIDDEN"), + ); + workspace.insert_test_runtime( + visible, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"VISIBLE"), + ); + 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 (_control, render) = connect_matching_test_shell(&mut server, 7); + write_shared_test_pane(&mut server, hidden, b"\x1b[?2026h\rPARTIAL"); + server.render_and_stream(); + let surface = recv_pane_surface(&render, "zoomed visible pane"); + assert!(frame_text(&surface.frame).contains("VISIBLE")); + assert!(!frame_text(&surface.frame).contains("PARTIAL")); + shutdown_test_runtimes(&mut server); +} + #[tokio::test] async fn retained_snapshot_survives_a_writer_waiting_for_the_terminal_core() { let mut server = test_headless_server(); @@ -3830,6 +3971,60 @@ fn with_terminal_session_test_server( rt.shutdown_timeout(Duration::from_millis(100)); } +#[test] +fn terminal_observers_wait_for_synchronized_output_with_or_without_baseline() { + with_terminal_session_test_server(|server, terminal_id, target, _| { + let connect = |server: &mut HeadlessServer, client_id| { + let (writer, _control, render) = test_client_writer(); + assert!(!server.handle_server_event(ServerEvent::ClientConnected { + client_id, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientObserveTerminal { + client_id, + target: target.clone(), + }) + ); + render + }; + let first = connect(server, 7); + server.render_and_stream(); + let _ = first.recv().expect("observer baseline"); + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2026h\rPARTIAL"); + let second = connect(server, 8); + server.render_and_stream(); + assert!(first.try_recv().is_err()); + assert!(second.try_recv().is_err()); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_process_pty_bytes(b"\rCOMPLETE\x1b[?2026l"); + server.render_and_stream(); + for frames in [&first, &second] { + let ServerMessage::Terminal(frame) = + read_server_message(frames.recv().expect("complete observer frame")) + else { + panic!("expected terminal frame"); + }; + assert!(String::from_utf8_lossy(&frame.bytes).contains("COMPLETE")); + } + }); +} + fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) { let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id); } diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index dcafa1a9..f4bb310b 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -95,7 +95,8 @@ impl RenderPipeline { }, &self.graphics_delivery, 1, - ); + ) + .expect("benchmark surface"); let server_elapsed = started.elapsed(); self.graphics_delivery = rendered.graphics_delivery; @@ -469,7 +470,8 @@ fn print_surface_damage_profiles() { HostCellSize::default(), &pipeline.graphics_delivery, 1, - ); + ) + .expect("benchmark surface"); let mut surface = PaneSurfaceFrame { boot_id: "bench-boot".into(), projection_revision: 1, @@ -520,7 +522,8 @@ fn print_surface_reuse_profiles() { HostCellSize::default(), &pipeline.graphics_delivery, 1, - ); + ) + .expect("benchmark surface"); let surface = PaneSurfaceFrame { boot_id: "bench-boot".into(), projection_revision: 1, diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index b0af26d7..db613e96 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -472,19 +472,9 @@ pub(crate) type RenderedTabSurface = ( pub(crate) fn render_tab_surface_virtual( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, - target: Option, + layout: crate::ui::TabSurfaceLayout, area: Rect, - resize_panes: bool, - cell_size: crate::kitty_graphics::HostCellSize, ) -> RenderedTabSurface { - let layout = crate::ui::compute_tab_surface_for( - app_state, - terminal_runtimes, - target, - area, - resize_panes, - cell_size, - ); let surface = crate::ui::TabSurfaceView { target: layout.target, pane_infos: &layout.pane_infos, diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 7d5542a6..3a460312 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -352,6 +352,10 @@ impl TerminalRuntime { self.0.synchronized_output_active() } + pub(crate) fn synchronized_output_state(&self) -> (bool, u64) { + self.0.synchronized_output_state() + } + pub fn visible_text(&self) -> String { self.0.visible_text() } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index be9df8da..adf1f3d3 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -869,17 +869,21 @@ mod tests { let terminal_id = app.workspaces[0].terminal_id(pane_id).unwrap().clone(); app.terminals.get_mut(&terminal_id).unwrap().restore_error = Some("Saved directory is unavailable. Restart to retry.".into()); - let (buffer, cursor, _, _) = crate::server::render_stream::render_tab_surface_virtual( + let runtimes = TerminalRuntimeRegistry::new(); + let area = Rect::new(0, 0, 80, 24); + let layout = crate::ui::compute_tab_surface_for( &app, - &TerminalRuntimeRegistry::new(), + &runtimes, Some(crate::ui::TabSurfaceTarget { workspace_index: 0, tab_index: 0, }), - Rect::new(0, 0, 80, 24), + area, false, Default::default(), ); + let (buffer, cursor, _, _) = + crate::server::render_stream::render_tab_surface_virtual(&app, &runtimes, layout, area); let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); assert!(text.contains("Saved directory is unavailable.")); assert!(cursor.is_none_or(|cursor| !cursor.visible));