diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-24 20:59:48 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-24 20:59:48 +0300 |
| commit | c411883ec639c9893ed9c33021c890485e91727b (patch) | |
| tree | 797bb6efdb3a9425e3d8c31953f62452bd564cb1 /src/server/headless/tests | |
| parent | 11a7ab4ae21ab9427bcb44b3be01f62a4ab58c47 (diff) | |
feat!: replace the custom graphics api with optimized native kitty rendering (#4561)
* feat!: replace the custom graphics api with optimized native kitty rendering
* fix: enable safe kitty optimizations automatically
---------
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/server/headless/tests')
| -rw-r--r-- | src/server/headless/tests/mod.rs | 24 | ||||
| -rw-r--r-- | src/server/headless/tests/native_graphics.rs | 1411 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 1133 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_move.rs | 1 |
4 files changed, 1415 insertions, 1154 deletions
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 84031ce4..34258dd3 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1,9 +1,10 @@ use super::*; -#[path = "pane_graphics.rs"] -mod pane_graphics_tests; +mod native_graphics; #[path = "pane_move.rs"] mod pane_move_tests; +#[path = "pane_graphics.rs"] +mod retained_graphics_tests; #[path = "surface_delta.rs"] mod surface_delta_tests; #[path = "surface_interest.rs"] @@ -102,6 +103,7 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ client_socket_path: socket_path, client_socket_identity, clients: HashMap::new(), + native_graphics: Default::default(), #[cfg(unix)] next_client_id: 1, foreground_client_id: None, @@ -245,7 +247,6 @@ fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> }, respond_to, response_write_complete: None, - stream_active: None, }); let response: api::schema::SuccessResponse = serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); @@ -303,7 +304,6 @@ fn headless_api_request_drains_all_pending_internal_events_before_reading_state( }, respond_to, response_write_complete: None, - stream_active: None, }) ); let response = response_rx @@ -2100,7 +2100,6 @@ async fn client_local_navigation_does_not_emit_global_focus_transitions() { }, respond_to, response_write_complete: None, - stream_active: None, }, ); server.app.sync_focus_events(); @@ -2191,7 +2190,6 @@ async fn client_local_navigation_emits_pane_focused_only_when_that_client_moves( }, respond_to, response_write_complete: None, - stream_active: None, }, ); let response = response_rx.recv().expect("navigation response"); @@ -2323,7 +2321,6 @@ async fn repeated_layout_action_reapplies_controller_geometry() { }, respond_to, response_write_complete: None, - stream_active: None, }, )); @@ -2368,7 +2365,6 @@ async fn public_close_reapplies_controller_geometry() { }, respond_to, response_write_complete: None, - stream_active: None, }) ); @@ -2572,7 +2568,6 @@ async fn public_background_tab_create_preserves_client_locations() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert_eq!( @@ -2620,7 +2615,6 @@ async fn public_workspace_focus_preserves_each_clients_remembered_tabs() { }, respond_to, response_write_complete: None, - stream_active: None, }); let first_location = server.clients[&41].shell_location.as_ref().unwrap(); @@ -2701,7 +2695,6 @@ async fn public_agent_focus_replaces_a_diverged_client_shell_projection() { }, respond_to, response_write_complete: None, - stream_active: None, }); let response: crate::api::schema::SuccessResponse = serde_json::from_str(&response_rx.recv().expect("agent focus response")).unwrap(); @@ -2776,7 +2769,6 @@ async fn public_api_focus_replaces_every_client_shell_projection() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert_eq!(server.app.state.active, Some(1)); server.render_and_stream(); @@ -3393,7 +3385,6 @@ async fn worktree_discovery_does_not_block_client_typing() { }, respond_to, response_write_complete: None, - stream_active: None, }); entered .recv_timeout(Duration::from_secs(5)) @@ -6997,7 +6988,6 @@ fn notification_show_api_forwards_one_semantic_client_notification() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7060,7 +7050,6 @@ fn notification_show_api_preserves_colon_in_forwarded_title() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7106,7 +7095,6 @@ fn notification_show_api_validates_empty_title_before_disabled_delivery() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7137,7 +7125,6 @@ fn notification_show_api_reports_no_foreground_client() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); @@ -7188,7 +7175,6 @@ fn notification_show_api_includes_sound_in_semantic_event() { }, respond_to, response_write_complete: None, - stream_active: None, }) ); @@ -7250,7 +7236,6 @@ fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema: }, respond_to, response_write_complete: None, - stream_active: None, }); let response = response_rx .recv_timeout(Duration::from_millis(100)) @@ -7585,7 +7570,6 @@ fn stale_api_agent_report_does_not_forward_done_sound() { }, respond_to, response_write_complete: None, - stream_active: None, }); assert!(changed); diff --git a/src/server/headless/tests/native_graphics.rs b/src/server/headless/tests/native_graphics.rs new file mode 100644 index 00000000..bde7cf08 --- /dev/null +++ b/src/server/headless/tests/native_graphics.rs @@ -0,0 +1,1411 @@ +//! Native export integration tests for file delivery, retirement, and fallback. +#![cfg(unix)] + +use super::*; +use crate::kitty_graphics::surface::{DeliveryCache, NATIVE_SLOT_BIT, NATIVE_TRANSFER_BIT}; +use crate::protocol::{ + SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsPlacement, + SurfaceGraphicsScene, SurfaceGraphicsSource, SurfaceGraphicsTarget, +}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +fn native_started(server: &mut HeadlessServer, client: u64, transfer: u64, image: u32) -> bool { + server.native_started(client, transfer, image) +} + +fn native_result( + server: &mut HeadlessServer, + client: u64, + transfer: u64, + image: u32, + success: bool, +) -> bool { + server + .native_result(client, transfer, image, success) + .unwrap_or(false) +} + +fn commit_scene(server: &mut HeadlessServer, client: u64, scene: &SurfaceGraphicsScene) { + let inline_assets = scene + .assets + .iter() + .map(|asset| asset.key.clone()) + .collect::<Vec<_>>(); + server + .native_graphics + .commit_scene(client, scene, &inline_assets); +} + +fn scene() -> SurfaceGraphicsScene { + scene_for(1, 123) +} + +fn scene_for(image_id: u32, data_fingerprint: u64) -> SurfaceGraphicsScene { + let key = SurfaceGraphicsAssetKey { + source: SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "native-test".into(), + }, + image_id, + }, + image_width: 1, + image_height: 1, + format: SurfaceGraphicsFormat::Rgba, + data_len: 4, + data_fingerprint, + }; + SurfaceGraphicsScene { + assets: vec![SurfaceGraphicsAsset { + key: key.clone(), + data: vec![1, 2, 3, 255], + }], + placements: vec![SurfaceGraphicsPlacement { + asset: key, + logical_placement_id: 1, + x: 2, + y: 3, + cols: 1, + rows: 1, + source_x: 0, + source_y: 0, + source_width: 1, + source_height: 1, + x_offset: 0, + y_offset: 0, + z: 0, + scrollback_offset: 0, + }], + retained_assets: Vec::new(), + } +} + +fn add_client(server: &mut HeadlessServer, id: u64) -> (Receiver<Vec<u8>>, Receiver<Vec<u8>>) { + let (writer, control, render) = test_client_writer(); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + id, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.mode = ClientConnectionMode::ClientShell; + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(id, client); + server.foreground_client_id = Some(id); + (control, render) +} + +fn prepare_and_commit(server: &mut HeadlessServer, id: u64) -> (PathBuf, u64, u32) { + let mut desired = scene(); + let (pending, message) = server + .native_graphics + .prepare( + id, + &server.client_shell_boot_id, + &mut desired, + &DeliveryCache::default(), + ) + .expect("eligible native export"); + let ServerMessage::GraphicsFile { + path, + transfer_id, + image_id, + expected_len, + leading, + control, + surface_asset, + } = message + else { + panic!("expected file upload"); + }; + assert_ne!(transfer_id & NATIVE_TRANSFER_BIT, 0); + assert_eq!(expected_len, 4); + assert!(leading.is_empty()); + assert_eq!(control, format!("a=t,f=32,s=1,v=1,i={image_id},q=0")); + assert_eq!(surface_asset.as_ref(), Some(&desired.placements[0].asset)); + assert!(desired.assets.is_empty()); + assert_eq!(std::fs::read(&path).unwrap(), [1, 2, 3, 255]); + commit_scene(server, id, &desired); + server.native_graphics.commit(id, pending); + (PathBuf::from(path), transfer_id, image_id) +} + +fn prepare_scene_and_commit( + server: &mut HeadlessServer, + id: u64, + mut desired: SurfaceGraphicsScene, +) -> (u64, u32) { + let (pending, message) = server + .native_graphics + .prepare( + id, + &server.client_shell_boot_id, + &mut desired, + &DeliveryCache::default(), + ) + .expect("eligible native export"); + let ServerMessage::GraphicsFile { + transfer_id, + image_id, + .. + } = message + else { + panic!("expected file upload"); + }; + commit_scene(server, id, &desired); + server.native_graphics.commit(id, pending); + (transfer_id, image_id) +} + +fn ack_native(server: &mut HeadlessServer, id: u64, transfer: u64, image: u32) { + assert!(native_started(server, id, transfer, image)); + native_result(server, id, transfer, image, true); + assert!(!server.native_graphics.is_pending(id)); +} + +fn assert_retirement(control: &Receiver<Vec<u8>>, transfer: u64, image: u32) { + assert!(matches!( + read_server_message(control.recv_timeout(Duration::from_millis(100)).expect("control retirement")), + ServerMessage::GraphicsTransmissionRetired { transfer_id, image_id } + if transfer_id == transfer && image_id == image + )); +} + +fn assert_native_disabled(server: &mut HeadlessServer, id: u64) { + let mut desired = scene(); + assert!(server + .native_graphics + .prepare( + id, + &server.client_shell_boot_id, + &mut desired, + &DeliveryCache::default(), + ) + .is_none()); + assert_eq!(desired.assets[0].data, [1, 2, 3, 255]); + assert!(server.clients[&id].direct_graphics); +} + +#[test] +fn native_tokens_require_started_and_old_api_tokens_are_ignored() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (path, token, image) = prepare_and_commit(&mut server, 7); + + assert!(!native_result(&mut server, 7, token, image, true)); + assert!(server.native_graphics.is_pending(7)); + assert!(path.exists()); + assert!(!native_started( + &mut server, + 7, + token & !NATIVE_TRANSFER_BIT, + image + )); + assert!(!native_result( + &mut server, + 7, + token & !NATIVE_TRANSFER_BIT, + image, + true, + )); + assert!(native_started(&mut server, 7, token, image)); + assert!(!native_result(&mut server, 7, token, image, true)); + assert!(!server.native_graphics.is_pending(7)); + assert!(!path.exists()); +} + +#[test] +fn native_image_banks_advance_only_on_started_ack_and_are_scoped_and_pruned() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (_other_control, _other_render) = add_client(&mut server, 8); + let base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &scene().placements[0].asset, + ); + + // Unknown sources start in bank 1, avoiding an inline image at the base ID. + let (first_transfer, first_image) = prepare_scene_and_commit(&mut server, 7, scene()); + assert_eq!(first_image, base ^ NATIVE_SLOT_BIT); + // A success before Started is not an ACK and neither completes nor advances. + assert!(!native_result( + &mut server, + 7, + first_transfer, + first_image, + true + )); + assert!(server.native_graphics.is_pending(7)); + ack_native(&mut server, 7, first_transfer, first_image); + + let (second_transfer, second_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 124)); + assert_eq!(second_image, base); + // A late ACK for the preceding transfer cannot advance the pending revision. + assert!(!native_result( + &mut server, + 7, + first_transfer, + first_image, + true + )); + assert!(server.native_graphics.is_pending(7)); + ack_native(&mut server, 7, second_transfer, second_image); + let (third_transfer, third_image) = prepare_scene_and_commit(&mut server, 7, scene_for(1, 125)); + assert_eq!(third_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, third_transfer, third_image); + + // Selection is independent per client. + let (other_transfer, other_image) = prepare_scene_and_commit(&mut server, 8, scene_for(1, 126)); + assert_eq!(other_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 8, other_transfer, other_image); + + // A different logical source also starts in bank 1. Retention keeps source + // 1's bank history even though it has no placement in this scene. + let mut source_two = scene_for(2, 127); + source_two + .retained_assets + .push(scene_for(1, 127).placements[0].asset.clone()); + let source_two_base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &source_two.placements[0].asset, + ); + let (source_two_transfer, source_two_image) = + prepare_scene_and_commit(&mut server, 7, source_two); + assert_eq!(source_two_image, source_two_base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, source_two_transfer, source_two_image); + let (retained_transfer, retained_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 128)); + assert_eq!(retained_image, base); + ack_native(&mut server, 7, retained_transfer, retained_image); + + // Move source 1 back to bank 1, then omit it entirely. Returning after an + // unrelated upload starts at bank 1 rather than retaining stale history. + let (before_prune_transfer, before_prune_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 129)); + assert_eq!(before_prune_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, before_prune_transfer, before_prune_image); + let (pruning_transfer, pruning_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(2, 130)); + assert_eq!(pruning_image, source_two_base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, pruning_transfer, pruning_image); + let (restored_transfer, restored_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 131)); + assert_eq!(restored_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, restored_transfer, restored_image); + + // Reusing a disconnected client ID must not inherit its source-bank history. + server.remove_client(7); + let (_control, _render) = add_client(&mut server, 7); + let (_reconnected_transfer, reconnected_image) = + prepare_scene_and_commit(&mut server, 7, scene_for(1, 132)); + assert_eq!(reconnected_image, base ^ NATIVE_SLOT_BIT); +} + +#[test] +fn inline_commit_updates_bank_only_for_a_new_asset_revision() { + // Replaying inline bytes for the exact ACKed key still addresses the native + // bank retained by the client, so it must not be recorded as bank 0. + let mut exact = test_headless_server(); + let (_control, _render) = add_client(&mut exact, 7); + let base = crate::kitty_graphics::surface::native_host_image_id( + &exact.client_shell_boot_id, + &scene().placements[0].asset, + ); + let (first_transfer, first_image) = prepare_scene_and_commit(&mut exact, 7, scene()); + assert_eq!(first_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut exact, 7, first_transfer, first_image); + commit_scene(&mut exact, 7, &scene()); + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut exact, 7, scene_for(1, 124)); + assert_eq!(next_image, base); + + // A new inline revision has no native key mapping on the client and is + // uploaded at the logical base ID. The next native revision must therefore + // stage into bank 1 rather than collide with that visible base image. + let mut revised = test_headless_server(); + let (_control, _render) = add_client(&mut revised, 7); + let (first_transfer, first_image) = prepare_scene_and_commit(&mut revised, 7, scene()); + ack_native(&mut revised, 7, first_transfer, first_image); + commit_scene(&mut revised, 7, &scene_for(1, 124)); + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut revised, 7, scene_for(1, 125)); + assert_eq!(next_image, base ^ NATIVE_SLOT_BIT); +} + +#[test] +fn rejected_omitted_scene_does_not_prune_visible_bank_history() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &scene().placements[0].asset, + ); + let (first_transfer, first_image) = prepare_scene_and_commit(&mut server, 7, scene()); + assert_eq!(first_image, base ^ NATIVE_SLOT_BIT); + ack_native(&mut server, 7, first_transfer, first_image); + + // Preparing an omitted-source scene is speculative. Model the full render + // path rejecting it because its render lane is already occupied. + let writer = ClientWriter::test_paused(); + server.clients.get_mut(&7).unwrap().writer = Some(writer.clone()); + writer.render.try_send(vec![1]).unwrap(); + let mut omitted = SurfaceGraphicsScene::default(); + assert!(server + .prepare_native_scene( + 7, + &mut omitted, + &mut DeliveryCache::default(), + &mut Default::default(), + ) + .is_none()); + assert!(matches!( + writer.render.try_send(vec![2]), + Err(std::sync::mpsc::TrySendError::Full(_)) + )); + + // The rejected omission did not change what is visible. Stage opposite the + // still-visible bank 1 when the source returns. + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut server, 7, scene_for(1, 124)); + assert_eq!(next_image, base); +} + +#[test] +fn unqueued_native_prepare_does_not_advance_image_bank() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let base = crate::kitty_graphics::surface::native_host_image_id( + &server.client_shell_boot_id, + &scene_for(3, 200).placements[0].asset, + ); + let mut dropped_scene = scene_for(3, 200); + let (dropped, dropped_message) = server + .native_graphics + .prepare( + 7, + &server.client_shell_boot_id, + &mut dropped_scene, + &DeliveryCache::default(), + ) + .unwrap(); + let ServerMessage::GraphicsFile { + transfer_id: dropped_transfer, + image_id: dropped_image, + .. + } = dropped_message + else { + panic!("expected file upload"); + }; + assert_eq!(dropped_image, base ^ NATIVE_SLOT_BIT); + drop(dropped); // Model serialization/send failure before commit. + + let (transfer, image) = prepare_scene_and_commit(&mut server, 7, scene_for(3, 201)); + assert_eq!(image, dropped_image); + assert!(!native_result( + &mut server, + 7, + dropped_transfer, + dropped_image, + true + )); + assert!(server.native_graphics.is_pending(7)); + ack_native(&mut server, 7, transfer, image); + let (_next_transfer, next_image) = prepare_scene_and_commit(&mut server, 7, scene_for(3, 202)); + assert_eq!(next_image, base); +} + +#[test] +fn native_timeout_uses_control_lane_and_falls_back_inline() { + let mut server = test_headless_server(); + let (control, render) = add_client(&mut server, 7); + let (path, token, image) = prepare_and_commit(&mut server, 7); + // Occupy the render lane: retirement must not wait for file/render backpressure. + server.clients[&7] + .writer + .as_ref() + .unwrap() + .render + .send_ordered(vec![99]) + .unwrap(); + assert!(server.expire_native_graphics(Instant::now() + Duration::from_secs(60))); + assert_retirement(&control, token, image); + assert_eq!( + render.recv_timeout(Duration::from_millis(100)).unwrap(), + vec![99] + ); + assert!(render.try_recv().is_err()); + assert!(!path.exists()); + assert!(!server.native_graphics.is_pending(7)); + assert!(!server.clients[&7].shell_graphics_delivery.has_pending()); + assert_eq!(server.clients[&7].deferred_render(), DeferredRender::Full); + assert_native_disabled(&mut server, 7); + assert!(!native_result(&mut server, 7, token, image, true)); +} + +#[test] +fn native_rejection_falls_back_without_disabling_other_client() { + let mut server = test_headless_server(); + let (control, render) = add_client(&mut server, 7); + let (_other_control, _other_render) = add_client(&mut server, 8); + let (path, token, image) = prepare_and_commit(&mut server, 7); + let (other_path, other_token, other_image) = prepare_and_commit(&mut server, 8); + assert!(native_result(&mut server, 7, token, image, false)); + assert_retirement(&control, token, image); + assert!(render.try_recv().is_err()); + assert!(!path.exists()); + assert_native_disabled(&mut server, 7); + assert!(other_path.exists()); + assert!(server.native_graphics.is_pending(8)); + native_started(&mut server, 8, other_token, other_image); + native_result(&mut server, 8, other_token, other_image, true); + assert!(!other_path.exists()); + assert!(!server.native_graphics.is_pending(8)); + assert!(server.clients[&8].direct_graphics); +} + +#[test] +fn disconnect_unlinks_only_that_clients_native_export() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (_other_control, _other_render) = add_client(&mut server, 8); + let (path, token, image) = prepare_and_commit(&mut server, 7); + let (other_path, other_token, other_image) = prepare_and_commit(&mut server, 8); + server.remove_client(7); + assert!(!path.exists()); + assert!(other_path.exists()); + assert!(!server.native_graphics.is_pending(7)); + assert!(server.native_graphics.is_pending(8)); + assert!(!native_result(&mut server, 7, token, image, true)); + native_started(&mut server, 8, other_token, other_image); + native_result(&mut server, 8, other_token, other_image, true); + assert!(!other_path.exists()); + assert!(!server.native_graphics.is_pending(8)); + assert!(server.clients[&8].direct_graphics); +} + +#[test] +fn pending_hold_accepts_pixel_revision_but_not_geometry_or_source_change() { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (_path, _token, _image) = prepare_and_commit(&mut server, 7); + let original = scene(); + assert!(server.native_graphics.can_hold(7, &original)); + let mut next = original.clone(); + next.placements[0].asset.data_fingerprint += 1; + next.assets[0].key.data_fingerprint += 1; + next.assets[0].data[0] += 1; + assert!(server.native_graphics.can_hold(7, &next)); + assert_eq!( + server.native_graphics.hold(7).unwrap().0.placements, + original.placements + ); + next.placements[0].cols += 1; + assert!(!server.native_graphics.can_hold(7, &next)); + next = original.clone(); + next.placements[0].x += 1; + assert!(!server.native_graphics.can_hold(7, &next)); + next = original.clone(); + next.placements[0].asset.image_width += 1; + assert!(!server.native_graphics.can_hold(7, &next)); + next = original.clone(); + next.placements[0].asset.source = SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { + pane_id: "another-pane".into(), + }, + image_id: 1, + }; + assert!(!server.native_graphics.can_hold(7, &next)); + assert!(!server + .native_graphics + .can_hold(7, &SurfaceGraphicsScene::default())); + next = original; + next.retained_assets.push(next.placements[0].asset.clone()); + assert!(!server.native_graphics.can_hold(7, &next)); +} + +fn drain_native_render_messages(writer: &ClientWriter) -> Vec<ServerMessage> { + let mut messages = Vec::new(); + for batch in writer.test_drain() { + let len = batch.len() as u64; + let mut cursor = std::io::Cursor::new(batch); + while cursor.position() < len { + messages.push( + protocol::read_message(&mut cursor, protocol::MAX_GRAPHICS_FRAME_SIZE) + .expect("decode ordered scene/file transaction"), + ); + } + } + messages +} + +fn write_chunked_test_image( + server: &mut HeadlessServer, + pane: crate::layout::PaneId, + image_id: u32, + side: u32, + pixels: &[u8], +) { + use base64::Engine as _; + + let encoded = base64::engine::general_purpose::STANDARD.encode(pixels); + let chunks = encoded.as_bytes().chunks(4096); + let count = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let more = u8::from(index + 1 < count); + let mut command = if index == 0 { + format!( + "\x1b_Ga=T,f=32,t=d,i={image_id},p={image_id},s={side},v={side},c=1,r=1,q=2,m={more};" + ) + .into_bytes() + } else { + format!("\x1b_Gm={more};").into_bytes() + }; + command.extend_from_slice(chunk); + command.extend_from_slice(b"\x1b\\"); + write_shared_test_pane(server, pane, &command); + } +} + +#[tokio::test] +async fn delta_encoded_inline_commit_updates_next_native_bank() { + let (mut server, _control, _render, pane) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.render_state.enable_surface_delta(true); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + + server.render_and_stream(); + let first = drain_native_render_messages(&writer); + let (first_transfer, first_image) = first + .iter() + .find_map(|message| match message { + ServerMessage::GraphicsFile { + transfer_id, + image_id, + .. + } => Some((*transfer_id, *image_id)), + _ => None, + }) + .expect("first native upload"); + assert!(native_started(&mut server, 1, first_transfer, first_image)); + native_result(&mut server, 1, first_transfer, first_image, true); + + // Model a client without direct-file capability for this replacement. + // Delta encoding + // wraps its surface in EndpointControl, but the queued asset still commits + // bank 0 bookkeeping. + server.clients.get_mut(&1).unwrap().direct_graphics = false; + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;AP8A/w==\x1b\\", + ); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane]))); + let inline = drain_native_render_messages(&writer); + assert!(inline.iter().any(|message| matches!( + message, + ServerMessage::EndpointControl { kind, .. } + if kind == protocol::surface_delta::MESSAGE_KIND + ))); + assert!(!inline + .iter() + .any(|message| matches!(message, ServerMessage::GraphicsFile { .. }))); + + server.clients.get_mut(&1).unwrap().direct_graphics = true; + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;AAD//w==\x1b\\", + ); + server.render_and_stream(); + let next = drain_native_render_messages(&writer); + assert!(next.iter().any(|message| matches!( + message, + ServerMessage::EndpointControl { kind, .. } + if kind == protocol::surface_delta::MESSAGE_KIND + ))); + let next_image = next + .iter() + .find_map(|message| match message { + ServerMessage::GraphicsFile { image_id, .. } => Some(*image_id), + _ => None, + }) + .expect("native upload after inline delta"); + assert_eq!(next_image, first_image, "must stage opposite inline bank 0"); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn oversized_native_scene_queues_file_and_continues_stripped_assets_after_ack() { + let (mut server, _control, _render, pane) = retained_test_server_with_control(b""); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + + const SIDE: u32 = 128; + let pixels = vec![0x7f; SIDE as usize * SIDE as usize * 4]; + for image_id in [7, 8, 9] { + write_chunked_test_image(&mut server, pane, image_id, SIDE, &pixels); + } + + // The limit is deliberately above one 64 KiB asset plus metadata but below + // the two inline assets left after selecting one native upload. + server.render_and_stream_with_test_graphics_limit(100_000); + let first = drain_native_render_messages(&writer); + let first_surface = first + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("trimmed metadata scene"); + assert_eq!(first_surface.graphics.placements.len(), 3); + assert_eq!(first_surface.graphics.assets.len(), 1); + let first_inline = first_surface.graphics.assets[0].key.clone(); + let (transfer, image, first_native) = first + .iter() + .find_map(|message| match message { + ServerMessage::GraphicsFile { + transfer_id, + image_id, + surface_asset: Some(asset), + .. + } => Some((*transfer_id, *image_id, asset.clone())), + _ => None, + }) + .expect("selected native asset survives oversized recovery"); + assert_ne!(first_inline.source, first_native.source); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + assert!(native_started(&mut server, 1, transfer, image)); + native_result(&mut server, 1, transfer, image, true); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + server.render_and_stream_with_test_graphics_limit(100_000); + let continuation = drain_native_render_messages(&writer); + let continued_asset = continuation.iter().find_map(|message| match message { + ServerMessage::GraphicsFile { + surface_asset: Some(asset), + .. + } => Some(asset), + ServerMessage::PaneSurface(surface) => { + surface.graphics.assets.first().map(|asset| &asset.key) + } + _ => None, + }); + let continued_asset = continued_asset.expect("stripped asset delivered on continuation"); + assert_ne!(continued_asset.source, first_inline.source); + assert_ne!(continued_asset.source, first_native.source); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn oversized_inline_trims_largest_and_sends_fitting_asset_without_retry_spin() { + let (mut server, _control, _render, pane) = retained_test_server_with_control(b""); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = false; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + + for (image_id, side) in [(7, 128u32), (8, 16u32)] { + let pixels = vec![image_id as u8; side as usize * side as usize * 4]; + write_chunked_test_image(&mut server, pane, image_id, side, &pixels); + } + + // Probe the exact payload size of this surface with only the small asset. + // Resetting the server-side delivery/baseline makes the measured render + // observational only; the next call exercises the production recovery path. + server.render_and_stream(); + let mut measured_surface = drain_native_render_messages(&writer) + .into_iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("measurement surface"); + assert_eq!(measured_surface.graphics.assets.len(), 2); + measured_surface + .graphics + .assets + .retain(|asset| asset.key.image_width == 16); + let exact_small_limit = HeadlessServer::frame_server_message_with_max( + &ServerMessage::PaneSurface(measured_surface), + crate::protocol::MAX_GRAPHICS_FRAME_SIZE, + ) + .unwrap() + .len() + .saturating_sub(4); + let client = server.clients.get_mut(&1).unwrap(); + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.shell_graphics_delivery = DeliveryCache::default(); + client.clear_deferred_render(); + + // Exactly metadata plus the 1 KiB image fits; adding the 64 KiB image does not. + server.render_and_stream_with_test_graphics_limit(exact_small_limit); + let first = drain_native_render_messages(&writer); + let first_surface = first + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("surface with fitting inline asset"); + assert_eq!(first_surface.graphics.assets.len(), 1); + assert_eq!(first_surface.graphics.assets[0].key.image_width, 16); + assert!(!first + .iter() + .any(|message| matches!(message, ServerMessage::GraphicsFile { .. }))); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + // The remaining large asset cannot fit even alone. It remains absent from + // delivery state, but the identical impossible frame is not rescheduled forever. + server.render_and_stream_with_test_graphics_limit(exact_small_limit); + let second = drain_native_render_messages(&writer); + let second_surface = second + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("metadata-only bounded fallback"); + assert!(second_surface.graphics.assets.is_empty()); + assert!(server.clients[&1].shell_graphics_delivery.has_pending()); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn quiet_native_producer_geometry_retirement_schedules_full_inline_recovery() { + for retained in [false, true] { + let (mut server, _control, _render, pane) = retained_test_server_with_control( + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", + ); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + client.direct_graphics = true; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + server.app.render_dirty.take(); + server.render_and_stream(); + + // The writer has already drained, so no future writer progress can wake + // the server after geometry cancellation. The native ACK is still pending. + let initial = drain_native_render_messages(&writer); + let metadata_index = initial + .iter() + .position(|message| matches!(message, ServerMessage::PaneSurface(_))) + .expect("native metadata scene"); + let file_index = initial + .iter() + .position(|message| matches!(message, ServerMessage::GraphicsFile { .. })) + .expect("native file upload"); + assert!(metadata_index < file_index); + let ServerMessage::PaneSurface(metadata) = &initial[metadata_index] else { + unreachable!() + }; + assert!(metadata.graphics.assets.is_empty()); + assert_eq!(metadata.graphics.placements.len(), 1); + assert_eq!(metadata.graphics.placements[0].cols, 1); + let ServerMessage::GraphicsFile { + transfer_id, + image_id, + path, + .. + } = &initial[file_index] + else { + unreachable!() + }; + let (token, image, native_path) = (*transfer_id, *image_id, PathBuf::from(path)); + assert!(native_path.exists()); + assert!(server.native_graphics.is_pending(1)); + assert!(writer.test_drain().is_empty()); + native_started(&mut server, 1, token, image); + + // A single producer update moves/resizes the placement, then goes quiet. + // Re-upload the same pixels to replace placement p=3 with two columns. + write_shared_test_pane( + &mut server, + pane, + b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=2,r=1,q=2;/wAA/w==\x1b\\", + ); + server.app.render_dirty.take(); // model the main loop consuming this PTY wake + assert!(!server.app.render_dirty.is_pending()); + if retained { + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane]))); + } else { + server.render_and_stream(); + } + assert!(!server.native_graphics.is_pending(1), "retained={retained}"); + assert!(!native_path.exists()); + assert!( + server.app.render_dirty.is_pending(), + "geometry retirement must independently wake the quiet server; retained={retained}" + ); + assert!(server.app.render_dirty.has_immediate_work()); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + let retired = drain_native_render_messages(&writer); + assert!(retired.iter().any(|message| matches!(message, + ServerMessage::GraphicsTransmissionRetired { transfer_id, image_id } + if *transfer_id == token && *image_id == image + ))); + assert!(!retired.iter().any(|message| matches!( + message, + ServerMessage::PaneSurface(_) | ServerMessage::GraphicsFile { .. } + ))); + + // A stale success must not restore native residency or consume the full + // recovery wake. No producer bytes or writer events occur after this ACK. + assert!(!native_result(&mut server, 1, token, image, true)); + assert!(!server.native_graphics.is_pending(1)); + let request = server.app.render_dirty.take(); + assert!( + request.generic, + "generic full-render request must survive late ACK; retained={retained}" + ); + server.render_and_stream(); + let recovered = drain_native_render_messages(&writer); + assert!(!recovered + .iter() + .any(|message| matches!(message, ServerMessage::GraphicsFile { .. }))); + let surface = recovered + .iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect( + "next scheduled full render delivers inline fallback without new producer activity", + ); + assert_eq!(surface.graphics.placements.len(), 1); + assert_eq!(surface.graphics.placements[0].cols, 2); + assert_eq!(surface.graphics.assets.len(), 1); + assert_eq!(surface.graphics.assets[0].data, [255, 0, 0, 255]); + assert!(server.clients[&1].direct_graphics); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + shutdown_test_runtimes(&mut server); + } +} + +#[tokio::test] +#[ignore = "manual native-file retained-render scaling profile; one image, 1/15 populated panes"] +async fn native_file_render_scale_profile() { + use base64::Engine as _; + use ratatui::layout::Direction; + + const WARMUP: usize = 5; + const SAMPLES: usize = 35; + const IMAGE_WIDTH: u32 = 800; + const IMAGE_HEIGHT: u32 = 480; + for count in [1, 15] { + for (native, source_retention) in [(false, false), (true, false), (true, true)] { + let (mut server, _control, _render, root) = + retained_test_server_with_control(b"populated root terminal\r\n"); + if source_retention { + // Test runtimes normally leave file transmission disabled. Enable + // it and the snapshot callback only for this source-producing pane. + server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, root) + .unwrap() + .test_enable_kitty_source_forwarding(); + } + let mut panes = vec![root]; + for index in 1..count { + let workspace = &mut server.app.state.workspaces[0]; + workspace.tabs[0].layout.focus_pane(panes[(index - 1) / 2]); + let pane = workspace.test_split(if index % 2 == 0 { + Direction::Vertical + } else { + Direction::Horizontal + }); + workspace.insert_test_runtime( + pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 160, + 48, + b"populated terminal\r\nsecond row\r\nthird row\r\n", + ), + ); + panes.push(pane); + } + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.terminal_size = (160, 48); + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + // Compare actual capability fallback with automatic native delivery. + client.direct_graphics = native; + client.pixel_mouse = true; + server.app.state.kitty_graphics_enabled = true; + server.render_and_stream(); + let baseline = drain_native_render_messages(&writer); + assert!(baseline + .iter() + .any(|message| matches!(message, ServerMessage::PaneSurface(_)))); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + let sources = panes.iter().copied().collect::<HashSet<_>>(); + + // Deterministic noisy RGB, opaque alpha. Only the first pixel changes + // each submission; no timing includes producer/base64 construction. + let mut rgba = vec![0u8; IMAGE_WIDTH as usize * IMAGE_HEIGHT as usize * 4]; + let mut noise = 0x1234_5678u32; + for pixel in rgba.chunks_exact_mut(4) { + noise = noise.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + pixel[..3].copy_from_slice(&noise.to_le_bytes()[..3]); + pixel[3] = 255; + } + // Private /var/tmp native-store generation owns the producer path: + // bounded to one image and removed even if an assertion unwinds. + let producer_store = crate::pane_graphics_files::FileStore::native_sources(); + let producer = source_retention + .then(|| producer_store.export(&rgba).expect("private producer file")); + let mut previous_source_path: Option<PathBuf> = None; + let mut times = Vec::with_capacity(SAMPLES); + for sample in 0..WARMUP + SAMPLES { + assert!(!server.native_graphics.is_pending(1)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + assert!(writer.test_drain().is_empty()); + rgba[..3].copy_from_slice(&(sample as u32 + 1).to_le_bytes()[..3]); + let mut payload = Vec::new(); + payload.extend_from_slice(b"\x1b[H"); + if let Some(producer) = &producer { + std::fs::write(producer.path(), &rgba).unwrap(); + let encoded_path = base64::engine::general_purpose::STANDARD + .encode(producer.path().as_os_str().as_encoded_bytes()); + payload.extend_from_slice(format!( + "\x1b_Ga=T,f=32,t=f,i=7,p=3,s={IMAGE_WIDTH},v={IMAGE_HEIGHT},c=1,r=1,q=2;{encoded_path}\x1b\\" + ).as_bytes()); + } else { + let encoded = base64::engine::general_purpose::STANDARD.encode(&rgba); + let chunks = encoded.as_bytes().chunks(4096); + let chunk_count = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let more = u8::from(index + 1 < chunk_count); + if index == 0 { + payload.extend_from_slice(format!( + "\x1b_Ga=T,f=32,t=d,i=7,p=3,s={IMAGE_WIDTH},v={IMAGE_HEIGHT},c=1,r=1,q=2,m={more};" + ).as_bytes()); + } else { + payload.extend_from_slice(format!("\x1b_Gm={more};").as_bytes()); + } + payload.extend_from_slice(chunk); + payload.extend_from_slice(b"\x1b\\"); + } + } + for pane in &panes { + write_shared_test_pane( + &mut server, + *pane, + format!("\x1b[Hframe {sample:03}").as_bytes(), + ); + } + write_shared_test_pane(&mut server, root, &payload); + let retained_source_path = if source_retention { + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, root) + .unwrap(); + let placements = + runtime.kitty_image_placements_with_data_filter(|descriptor| { + assert!(descriptor.source_file, + "source profile requires working CoW retention, not decoded fallback"); + false + }); + assert_eq!(placements.len(), 1); + assert!(placements[0].data.is_empty()); + let source = placements[0].source_file.as_ref().expect("retained source"); + let path = source.path().to_owned(); + if let Some(previous) = previous_source_path.take() { + assert_ne!(previous, path); + assert!(!previous.exists(), "replacement releases previous source"); + } + Some(path) + } else { + None + }; + server.app.render_dirty.take(); + + let started = Instant::now(); + let retained = server.render_retained_pane_surface_and_stream(&sources); + let elapsed = started.elapsed(); + assert!( + retained, + "profile must not silently fall back to full rendering" + ); + if sample >= WARMUP { + times.push(elapsed); + } + + // Validate outside timing. Decoded exports close on ACK; source + // snapshots remain owned by the image until its replacement. + let messages = drain_native_render_messages(&writer); + let surfaces = messages + .iter() + .filter_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(surfaces.len(), 1); + let graphics = &surfaces[0].graphics; + assert_eq!(graphics.placements.len(), 1); + assert_eq!(graphics.placements[0].asset.image_width, IMAGE_WIDTH); + assert_eq!(graphics.placements[0].asset.image_height, IMAGE_HEIGHT); + let files = messages + .iter() + .filter_map(|message| match message { + ServerMessage::GraphicsFile { + path, + expected_len, + transfer_id, + image_id, + .. + } => Some((path, *expected_len, *transfer_id, *image_id)), + _ => None, + }) + .collect::<Vec<_>>(); + if native { + assert!(graphics.assets.is_empty()); + assert_eq!(files.len(), 1); + let (path, expected_len, token, image) = files[0]; + assert_eq!(expected_len, rgba.len() as u64); + assert_eq!(std::fs::read(path).unwrap(), rgba); + assert_ne!(token & NATIVE_TRANSFER_BIT, 0); + native_started(&mut server, 1, token, image); + native_result(&mut server, 1, token, image, true); + if source_retention { + assert_eq!( + retained_source_path.as_deref(), + Some(std::path::Path::new(path)) + ); + assert!( + std::path::Path::new(path).exists(), + "ACK must preserve image backing" + ); + previous_source_path = Some(PathBuf::from(path)); + } else { + assert!(!std::path::Path::new(path).exists()); + } + } else { + assert!(files.is_empty()); + assert_eq!(graphics.assets.len(), 1); + assert_eq!(graphics.assets[0].data, rgba); + } + assert!(!server.native_graphics.is_pending(1)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + } + times.sort_unstable(); + println!( + "native_file_render_scale retained=true native_files={native} source_retention={source_retention} viewport=160x48 populated_panes={count} changing_images=1 image=800x480 warmup={WARMUP} samples={SAMPLES} median_us={} p95_us={}", + times[SAMPLES / 2].as_micros(), times[SAMPLES * 95 / 100].as_micros(), + ); + shutdown_test_runtimes(&mut server); + drop(server); + if let Some(path) = previous_source_path { + // Aborted compression tasks release their terminal reference on poll. + for _ in 0..100 { + if !path.exists() { + break; + } + tokio::task::yield_now().await; + } + assert!(!path.exists(), "terminal shutdown releases final source"); + } + } + } +} + +fn source_backed_scene( + source: &Arc<crate::pane_graphics_files::OwnedExport>, +) -> ( + SurfaceGraphicsScene, + crate::kitty_graphics::surface::SourceFiles, +) { + let mut desired = scene(); + let key = &mut desired.assets[0].key; + key.image_width = (source.len() / 4) as u32; + key.data_len = source.len() as u64; + key.data_fingerprint = source.fingerprint(); + desired.placements[0].asset = key.clone(); + desired.placements[0].source_width = key.image_width; + let sources = std::collections::HashMap::from([(key.clone(), Arc::clone(source))]); + desired.assets[0].data.clear(); + (desired, sources) +} + +fn assert_source_upload_and_ack( + source: &Arc<crate::pane_graphics_files::OwnedExport>, + pixels: &[u8], +) { + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + let (mut desired, mut sources) = source_backed_scene(source); + let mut delivery = DeliveryCache::default(); + let (pending, message) = server + .prepare_native_scene(7, &mut desired, &mut delivery, &mut sources) + .expect("source-backed native upload"); + assert!(desired.assets.is_empty(), "metadata must not carry pixels"); + assert_eq!(desired.placements.len(), 1); + assert!(sources.is_empty()); + let ServerMessage::GraphicsFile { + path, + transfer_id, + image_id, + expected_len, + surface_asset, + leading, + control, + } = message + else { + panic!("expected path-only upload"); + }; + assert_eq!( + PathBuf::from(&path), + source.path(), + "reuse source, not decoded export" + ); + assert_eq!(expected_len, pixels.len() as u64); + assert_eq!(surface_asset.as_ref(), Some(&desired.placements[0].asset)); + assert!(leading.is_empty()); + assert_eq!( + control, + format!("a=t,f=32,s={},v=1,i={image_id},q=0", pixels.len() / 4) + ); + assert_ne!(transfer_id & NATIVE_TRANSFER_BIT, 0); + assert_eq!(std::fs::read(&path).unwrap(), pixels); + commit_scene(&mut server, 7, &desired); + server.native_graphics.commit(7, pending); + native_started(&mut server, 7, transfer_id, image_id); + native_result(&mut server, 7, transfer_id, image_id, true); + assert!(!server.native_graphics.is_pending(7)); + // The producer image's Arc still owns the backing after the transfer ACK. + assert_eq!(std::fs::read(&path).unwrap(), pixels); + assert_eq!(source.copy_rgba().unwrap(), pixels); +} + +#[test] +fn source_backed_native_upload_reuses_path_and_ack_preserves_image_backing() { + let store = crate::pane_graphics_files::FileStore::native_sources(); + let pixels = [1, 2, 3, 255]; + let source = Arc::new(store.export(&pixels).unwrap()); + let path = source.path().to_owned(); + assert_source_upload_and_ack(&source, &pixels); + drop(source); + assert!(!path.exists(), "final image owner releases the backing"); +} + +#[test] +fn source_backed_client_without_direct_support_materializes_exact_pixels() { + let store = crate::pane_graphics_files::FileStore::native_sources(); + let pixels = [1, 2, 3, 255, 200, 100, 50, 0]; + let source = Arc::new(store.export(&pixels).unwrap()); + let (mut desired, mut sources) = source_backed_scene(&source); + let expected_placements = desired.placements.clone(); + let mut server = test_headless_server(); + let (_control, _render) = add_client(&mut server, 7); + server.clients.get_mut(&7).unwrap().direct_graphics = false; + assert!(server + .prepare_native_scene(7, &mut desired, &mut DeliveryCache::default(), &mut sources) + .is_none()); + assert!(sources.is_empty()); + assert_eq!(desired.assets.len(), 1); + assert_eq!(desired.assets[0].data, pixels); + assert_eq!(desired.placements, expected_placements); + assert!(!server.native_graphics.is_pending(7)); +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshotted_source_survives_producer_unlink_and_native_ack() { + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt as _; + + let store = crate::pane_graphics_files::FileStore::native_sources(); + let producer_path = store.source_directory().unwrap().join("producer.rgba"); + let pixels = vec![73; 4096]; + let mut producer = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&producer_path) + .unwrap(); + std::io::Write::write_all(&mut producer, &pixels).unwrap(); + let snapshot = store.snapshot(i64::from(producer.as_raw_fd()), pixels.len()); + std::fs::remove_file(&producer_path).unwrap(); + drop(producer); + let source = match snapshot { + Ok(source) => Arc::new(source), + Err(error) => { + assert!( + error.kind() == std::io::ErrorKind::Unsupported + || matches!( + error.raw_os_error(), + Some( + libc::EOPNOTSUPP + | libc::ENOTTY + | libc::EXDEV + | libc::EINVAL + | libc::ENOSYS + ) + ), + "unexpected snapshot failure: {error}" + ); + eprintln!("native snapshot unsupported on test filesystem: {error}"); + return; + } + }; + assert!(!producer_path.exists()); + let path = source.path().to_owned(); + assert_source_upload_and_ack(&source, &pixels); + drop(source); + assert!(!path.exists()); +} + +#[tokio::test] +async fn failed_source_read_retries_after_identical_suppression_without_producer_activity() { + let (mut server, _control, _render, _pane) = + retained_test_server_with_control(b"quiet source producer"); + let writer = ClientWriter::test_paused(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer.clone()); + client.mode = ClientConnectionMode::ClientShell; + client.render_state = + crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); + server.clients.get_mut(&1).unwrap().direct_graphics = false; + server.render_and_stream(); + let mut surface = drain_native_render_messages(&writer) + .into_iter() + .find_map(|message| match message { + ServerMessage::PaneSurface(surface) => Some(surface), + _ => None, + }) + .expect("initial quiet surface"); + + // Inject the collected sidecar at the scheduler boundary, avoiding global + // environment changes and a filesystem-dependent CoW requirement. + let store = crate::pane_graphics_files::FileStore::native_sources(); + let pixels = [1, 2, 3, 255]; + let source = Arc::new(store.export(&pixels).unwrap()); + std::fs::write(source.path(), []).unwrap(); + let mut delivery = DeliveryCache::default(); + for attempt in 0..2 { + let (mut graphics, mut sources) = source_backed_scene(&source); + assert!(server + .prepare_native_scene(1, &mut graphics, &mut delivery, &mut sources) + .is_none()); + assert!(graphics.assets.is_empty()); + surface.graphics = graphics; + let client = server.clients.get_mut(&1).unwrap(); + let prepared = client.render_state.prepare_pane_surface(surface.clone()); + if attempt == 0 { + client + .render_state + .commit_sent_frame(prepared.expect("first failed scene")); + } else { + assert!( + prepared.is_none(), + "repeated read failure produces identical metadata" + ); + // Exactly the full-render identical-frame suppression branch. + client.clear_deferred_render(); + } + } + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + server.app.render_dirty.take(); + + // The timer is one-shot: an inactive client does not receive repeated wakes + // unless a real materialization attempt fails and explicitly re-arms it. + let tick = Instant::now() + Duration::from_secs(2); + assert!(server.expire_native_graphics(tick)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + server.clients.get_mut(&1).unwrap().clear_deferred_render(); + assert!(!server.expire_native_graphics(tick + Duration::from_secs(3600))); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + + let (mut failed_graphics, mut failed_sources) = source_backed_scene(&source); + assert!(server + .prepare_native_scene(1, &mut failed_graphics, &mut delivery, &mut failed_sources) + .is_none()); + assert!(failed_graphics.assets.is_empty()); + let rearmed_tick = tick + Duration::from_secs(7200); + assert!(server.expire_native_graphics(rearmed_tick)); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::Full); + + // No PTY bytes, writer progress, or native ACK occurs after recovery. + std::fs::write(source.path(), pixels).unwrap(); + let (mut graphics, mut sources) = source_backed_scene(&source); + assert!(server + .prepare_native_scene(1, &mut graphics, &mut delivery, &mut sources) + .is_none()); + assert_eq!(graphics.assets[0].data, pixels); + surface.graphics = graphics; + let client = server.clients.get_mut(&1).unwrap(); + let prepared = client + .render_state + .prepare_pane_surface(surface) + .expect("recovered pixels must not be suppressed as identical"); + let ServerMessage::PaneSurface(recovered) = prepared.message() else { + panic!("expected recovered surface"); + }; + assert_eq!(recovered.graphics.assets[0].data, pixels); + client.render_state.commit_sent_frame(prepared); + client.clear_deferred_render(); + assert!( + !server.expire_native_graphics(rearmed_tick + Duration::from_secs(2)), + "successful materialization leaves the one-shot retry timer disarmed" + ); + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); + shutdown_test_runtimes(&mut server); +} diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 3e36a8fc..190eaa80 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -5,93 +5,6 @@ fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Durati } #[tokio::test] -async fn client_shell_surface_sends_complete_placements_and_each_live_asset_once() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell graphics"); - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - - server.render_and_stream(); - let first = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(first) = first else { - panic!("expected client shell pane surface"); - }; - assert_eq!(first.graphics.placements.len(), 1); - assert_eq!(first.graphics.assets.len(), 1); - assert_eq!(first.graphics.assets[0].data, vec![1, 2, 3, 4]); - assert!(matches!( - first.graphics.placements[0].asset.source, - crate::protocol::SurfaceGraphicsSource::PaneLayer { .. } - )); - - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let second = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(second) = second else { - panic!("expected replacement client shell pane surface"); - }; - assert_eq!(second.graphics.placements, first.graphics.placements); - assert!(second.graphics.assets.is_empty()); - - // A post-commit typed surface.set(true) resets only this viewer's delivery cache, so the - // already selected target receives the asset again without relying on graphics that may have - // arrived during the frozen source frame. - assert!(server.set_client_shell_surface_active(1, true).is_some()); - server.render_and_stream(); - let replay = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(replay) = replay else { - panic!("expected post-commit graphics replay"); - }; - assert_eq!(replay.graphics.placements, first.graphics.placements); - assert_eq!(replay.graphics.assets.len(), 1); - assert_eq!(replay.graphics.assets[0].data, vec![1, 2, 3, 4]); -} - -#[tokio::test] -async fn client_shell_asset_delivery_is_bounded_to_the_current_live_scene() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell graphics"); - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - - server.render_and_stream(); - let _first = receive_render(&client_rx, Duration::from_millis(100)); - server.app.pane_graphics.slots.clear(); - server.app.pane_graphics.mark_changed(); - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let removed = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(removed) = removed else { - panic!("expected removed client shell scene"); - }; - assert!(removed.graphics.placements.is_empty()); - - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3, 4]); - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let restored = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(restored) = restored else { - panic!("expected restored client shell scene"); - }; - assert_eq!(restored.graphics.assets.len(), 1); - assert_eq!(restored.graphics.assets[0].data, vec![1, 2, 3, 4]); -} - -#[tokio::test] async fn first_kitty_image_updates_retained_surface_without_full_redraw() { let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control(b"text before image"); @@ -409,87 +322,6 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { assert!(surface.graphics.assets.is_empty()); } -#[tokio::test] -async fn full_client_shell_render_lane_does_not_commit_graphics_delivery() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell graphics"); - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - set_graphics_layer(&mut server, pane_id, vec![5, 6, 7, 8]); - - fill_render_lane(&server); - server.render_and_stream(); - let _older = receive_render(&client_rx, Duration::from_millis(100)); - server.render_and_stream(); - let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); - let ServerMessage::PaneSurface(surface) = message else { - panic!("expected client shell pane surface"); - }; - assert_eq!(surface.graphics.assets.len(), 1); - assert_eq!(surface.graphics.assets[0].data, vec![5, 6, 7, 8]); -} - -fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key { - (pane_id, api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()) -} - -fn active_gate() -> std::sync::Arc<std::sync::atomic::AtomicBool> { - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) -} - -fn set_graphics_layer(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, data: Vec<u8>) { - set_named_graphics_layer( - server, - pane_id, - api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID, - data, - 0, - ); -} - -fn set_named_graphics_layer( - server: &mut HeadlessServer, - pane_id: crate::layout::PaneId, - layer_id: &str, - data: Vec<u8>, - z_index: i32, -) { - let key = (pane_id, layer_id.into()); - let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap(); - let layer = crate::app::pane_graphics::Layer::inline( - api::schema::PaneGraphicsFormat::Png, - 1, - 1, - data, - Default::default(), - z_index, - ); - server.app.pane_graphics.slots.insert( - key, - crate::app::pane_graphics::Slot::test(host_image_id, Some(layer)), - ); -} - -fn set_stream_owner(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, owner: &str) { - let key = graphics_key(pane_id); - if let Some(slot) = server.app.pane_graphics.slots.get_mut(&key) { - slot.stream_owner = Some(owner.into()); - slot.stream_active = Some(active_gate()); - } else { - let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap(); - let mut slot = crate::app::pane_graphics::Slot::test(host_image_id, None); - slot.stream_owner = Some(owner.into()); - slot.stream_active = Some(active_gate()); - server.app.pane_graphics.slots.insert(key, slot); - } -} - fn fill_render_lane(server: &HeadlessServer) { let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("dummy frame"); @@ -500,105 +332,6 @@ fn fill_render_lane(server: &HeadlessServer) { .test_fill_render(queued); } -fn stream_set_message( - id: &str, - pane_id: &str, - owner: &str, - data: Vec<u8>, -) -> (api::ApiRequestMessage, std::sync::mpsc::Receiver<String>) { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - ( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.into(), - method: api::schema::Method::PaneGraphicsStreamSet( - api::schema::PaneGraphicsSetParams { - pane_id: pane_id.into(), - layer_id: None, - z_index: 0, - owner: owner.into(), - format: api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data: Some(data), - data_base64: String::new(), - placement: api::schema::PaneGraphicsPlacementParams::default(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - response_rx, - ) -} - -#[cfg(unix)] -fn sparse_direct_frame( - server: &HeadlessServer, - name: &str, - image_width: u32, - image_height: u32, -) -> String { - use std::os::unix::fs::OpenOptionsExt as _; - - let path = server - .app - .pane_graphics_files - .source_directory() - .unwrap() - .join(name); - let file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .unwrap(); - file.set_len(u64::from(image_width) * u64::from(image_height) * 4) - .unwrap(); - path.to_string_lossy().into_owned() -} - -#[cfg(unix)] -fn direct_stream_message( - id: &str, - pane_id: &str, - owner: &str, - path: String, - image_width: u32, - image_height: u32, - sequence: u64, -) -> (api::ApiRequestMessage, std::sync::mpsc::Receiver<String>) { - let (respond_to, response_rx) = std::sync::mpsc::channel(); - ( - api::ApiRequestMessage { - request: api::schema::Request { - id: id.into(), - method: api::schema::Method::PaneGraphicsStreamDirect( - api::schema::PaneGraphicsDirectParams { - pane_id: pane_id.into(), - layer_id: None, - z_index: 0, - owner: owner.into(), - image_width, - image_height, - format: api::schema::PaneGraphicsFormat::Rgba, - path, - sequence, - revision: 1, - placement: Default::default(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }, - response_rx, - ) -} - #[tokio::test] async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { let (mut server, _client_rx, _pane_id) = @@ -614,7 +347,6 @@ async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { }; client.host_mouse_capture_active = None; client.host_sgr_pixels_active = None; - server.app.direct_graphics_available = false; server.stream_host_mouse_capture_mode(); assert!(matches!( @@ -625,868 +357,3 @@ async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { } )); } - -#[tokio::test] -async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { - let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa"); - set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); - - assert!(!server - .app - .pane_graphics - .retain_live_panes(&server.app.state)); - assert!(server - .app - .pane_graphics - .slots - .contains_key(&graphics_key(pane_id))); - - server.app.state.workspaces.clear(); - assert!(server - .app - .pane_graphics - .retain_live_panes(&server.app.state)); - assert!(server.app.pane_graphics.slots.is_empty()); -} - -#[test] -fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() { - let mut server = test_headless_server(); - server.app.state.kitty_graphics_enabled = true; - let workspace = crate::workspace::Workspace::test_new("gated"); - let pane_id = workspace.tabs[0].root_pane; - let public = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - let active = active_gate(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - - server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "open-gated".into(), - method: api::schema::Method::PaneGraphicsStreamOpen( - api::schema::PaneGraphicsStreamParams { - pane_id: public.clone(), - layer_id: None, - z_index: 0, - owner: "worker-1".into(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: Some(active.clone()), - }); - assert!( - serde_json::from_str::<api::schema::SuccessResponse>(&response_rx.recv().unwrap()).is_ok() - ); - let (frame, frame_response) = - stream_set_message("gated-frame", &public, "worker-1", vec![1, 2, 3]); - assert_eq!( - server.handle_api_request_with_render_impact(frame), - RenderImpact::Graphics - ); - assert!(frame_response.recv().is_ok()); - assert!(active.load(std::sync::atomic::Ordering::Acquire)); - active.store(false, std::sync::atomic::Ordering::Release); - let (delayed, delayed_response) = - stream_set_message("delayed-frame", &public, "worker-1", vec![4, 5, 6]); - assert_eq!( - server.handle_api_request_with_render_impact(delayed), - RenderImpact::None - ); - let error: api::schema::ErrorResponse = - serde_json::from_str(&delayed_response.recv().unwrap()).unwrap(); - assert_eq!(error.error.code, "stream_closed"); - assert!(server - .app - .pane_graphics - .slots - .remove(&graphics_key(pane_id)) - .is_some()); - assert!(!active.load(std::sync::atomic::Ordering::Acquire)); -} - -#[test] -fn stream_set_has_graphics_only_render_impact() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("graphics"); - let pane_id = workspace.tabs[0].root_pane; - let public_pane_id = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.kitty_graphics_enabled = true; - set_stream_owner(&mut server, pane_id, "owner-a"); - - let (request, response_rx) = - stream_set_message("wrong-owner", &public_pane_id, "owner-b", vec![1, 2, 3]); - assert_eq!( - server.handle_api_request_with_render_impact(request), - RenderImpact::None - ); - assert!(serde_json::from_str::<api::schema::ErrorResponse>( - &response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap() - ) - .is_ok()); - - let (request, response_rx) = - stream_set_message("stream-frame", &public_pane_id, "owner-a", vec![1, 2, 3]); - assert_eq!( - server.handle_api_request_with_render_impact(request), - RenderImpact::Graphics - ); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap() - ) - .is_ok()); - - server - .app - .event_tx - .try_send(AppEvent::UpdateReady { - version: "9.9.9".into(), - install_command: "herdr update".into(), - }) - .unwrap(); - let (request, _response_rx) = stream_set_message( - "stream-frame-with-internal-event", - &public_pane_id, - "owner-a", - vec![4, 5, 6], - ); - assert_eq!( - server.handle_api_request_with_render_impact(request), - RenderImpact::Full - ); - - server.app.pane_graphics.clear(); - let (respond_to, _response_rx) = std::sync::mpsc::channel(); - let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage { - request: api::schema::Request { - id: "direct-frame".into(), - method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams { - pane_id: public_pane_id, - layer_id: None, - z_index: 0, - owner: String::new(), - format: api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data: Some(vec![1, 2, 3]), - data_base64: String::new(), - placement: api::schema::PaneGraphicsPlacementParams::default(), - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - assert_eq!(impact, RenderImpact::Full); -} - -#[test] -fn rejected_or_stale_requests_do_not_schedule_rendering() { - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("graphics"); - let pane_id = workspace.tabs[0].root_pane; - let public_pane_id = format!("{}:p1", workspace.id); - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - server.app.state.selected = 0; - server.app.state.kitty_graphics_enabled = false; - - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: api::schema::Request { - id: "disabled-set".into(), - method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams { - pane_id: public_pane_id.clone(), - layer_id: None, - z_index: 0, - owner: String::new(), - format: api::schema::PaneGraphicsFormat::Png, - image_width: 1, - image_height: 1, - data: Some(vec![1, 2, 3]), - data_base64: String::new(), - placement: api::schema::PaneGraphicsPlacementParams::default(), - }), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - assert!(!changed); - let response = response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap(); - assert_eq!( - serde_json::from_str::<api::schema::ErrorResponse>(&response) - .unwrap() - .error - .code, - "feature_disabled" - ); - - server.app.state.kitty_graphics_enabled = true; - set_stream_owner(&mut server, pane_id, "current-owner"); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage { - request: api::schema::Request { - id: "stale-close".into(), - method: api::schema::Method::PaneGraphicsStreamClose( - api::schema::PaneGraphicsStreamParams { - pane_id: public_pane_id, - layer_id: None, - z_index: 0, - owner: "stale-owner".into(), - }, - ), - }, - respond_to, - response_write_complete: None, - stream_active: None, - }); - assert_eq!(impact, RenderImpact::None); - assert_eq!( - server - .app - .pane_graphics - .slots - .get(&graphics_key(pane_id)) - .and_then(|slot| slot.stream_owner.as_deref()), - Some("current-owner") - ); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx - .recv_timeout(Duration::from_millis(100)) - .unwrap() - ) - .is_ok()); -} - -#[cfg(unix)] -#[tokio::test] -async fn direct_graphics_availability_follows_foreground_client_with_background_clients() { - let (mut server, _client_rx, _pane_id) = retained_test_server(b"direct eligibility"); - let foreground = server.clients.get_mut(&1).unwrap(); - foreground.direct_graphics = true; - foreground.pixel_mouse = true; - - let (background_writer, _background_control_rx, _background_render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - 2, - RenderEncoding::SemanticFrame, - Some(background_writer), - ), - ); - - server.sync_foreground_client_state(); - assert!(server.direct_graphics_available()); - assert!(server.app.direct_graphics_available); - - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - assert!(!server.direct_graphics_available()); - assert!(!server.app.direct_graphics_available); -} - -#[cfg(unix)] -#[test] -fn direct_graphics_routing_prefers_the_target_stream_owner() { - let (mut server, first_key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - add_direct_client(&mut server, 8); - server.clients.get_mut(&8).unwrap().last_activity = 2; - let (transfer_id, image_id) = direct_ids(&server, &first_key); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert_eq!(response_rx.recv().unwrap(), "ack"); - - let second_key = (first_key.0, "second".into()); - let second_layer = crate::app::pane_graphics::Layer { - format: crate::api::schema::PaneGraphicsFormat::Rgba, - image_width: 1, - image_height: 1, - backing: crate::app::pane_graphics::Backing::Resident { - len: 4, - client_id: 8, - }, - data_fingerprint: 2, - render: Default::default(), - z_index: 0, - }; - server.app.pane_graphics.slots.insert( - second_key.clone(), - crate::app::pane_graphics::Slot::test((1 << 31) | 901, Some(second_layer)), - ); - - assert_eq!(server.direct_graphics_client_for_key(&first_key), Some(7)); - assert_eq!(server.direct_graphics_client_for_key(&second_key), Some(8)); - assert_eq!( - server.direct_graphics_client_for_key(&(first_key.0, "new".into())), - Some(8) - ); -} - -#[cfg(unix)] -#[test] -fn resident_direct_stream_survives_non_direct_client_becoming_foreground() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - server.foreground_client_id = Some(7); - server.sync_foreground_client_state(); - let (transfer_id, image_id) = direct_ids(&server, &key); - assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert_eq!(response_rx.recv().unwrap(), "ack"); - - let (writer, _control_rx, _render_rx) = test_client_writer(); - server.clients.insert( - 8, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize::default(), - 2, - RenderEncoding::SemanticFrame, - Some(writer), - ), - ); - server.foreground_client_id = Some(8); - server.sync_foreground_client_state(); - - assert!(server.direct_graphics_available()); - assert!(server.app.direct_graphics_available); - assert_eq!( - server.app.pane_graphics.slots[&key] - .layer - .as_ref() - .and_then(crate::app::pane_graphics::Layer::resident_client), - Some(7) - ); - - server.remove_client_and_resize_if_needed(7); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(!server.direct_graphics_available()); - assert!(!server.app.direct_graphics_available); -} - -#[cfg(unix)] -#[tokio::test] -async fn client_shell_direct_graphics_uploads_without_server_authored_coordinates() { - let (mut server, _control_rx, client_rx, pane_id) = - retained_test_server_with_control(b"client shell direct"); - server.app.state.kitty_graphics_enabled = true; - let client = server.clients.get_mut(&1).unwrap(); - client.mode = ClientConnectionMode::ClientShell; - client.render_state = - crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); - client.cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - client.direct_graphics = true; - client.pixel_mouse = true; - server.app.direct_graphics_available = true; - let (background_writer, _background_control_rx, background_render_rx) = test_client_writer(); - server.clients.insert( - 2, - ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 2, - RenderEncoding::SemanticFrame, - Some(background_writer), - ), - ); - set_stream_owner(&mut server, pane_id, "browser"); - let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); - let path = sparse_direct_frame(&server, "client-shell-direct.rgba", 1, 1); - let (message, response_rx) = - direct_stream_message("shell-direct", &public_pane_id, "browser", path, 1, 1, 1); - - assert_eq!( - server.handle_pane_graphics_stream_frame(message), - RenderImpact::None - ); - let (transfer_id, image_id, asset) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("client shell direct upload"), - ) { - ServerMessage::GraphicsFile { - transfer_id, - image_id, - leading, - control, - surface_asset: Some(asset), - .. - } => { - assert!(leading.is_empty()); - assert!(control.starts_with("a=t,"), "{control}"); - assert!(!control.contains("\u{1b}["), "{control}"); - (transfer_id, image_id, asset) - } - other => panic!("expected client shell graphics file, got {other:?}"), - }; - assert!(background_render_rx.try_recv().is_err()); - assert_eq!( - image_id, - crate::kitty_graphics::surface::host_image_id(&server.client_shell_boot_id, &asset) - ); - let (pending, _) = crate::server::client_shell_graphics::collect( - &server.app, - &[], - &[], - None, - Some(crate::ui::TabSurfaceTarget { - workspace_index: 0, - tab_index: 0, - }), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - &crate::kitty_graphics::surface::DeliveryCache::default(), - 1, - ); - assert_eq!(pending.retained_assets, vec![asset.clone()]); - - server.start_direct_graphics_response(1, transfer_id, image_id); - assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() - ) - .is_ok()); - - server.foreground_client_id = Some(2); - server.sync_foreground_client_state(); - let next_path = sparse_direct_frame(&server, "client-shell-direct-next.rgba", 1, 1); - let (next_message, next_response_rx) = direct_stream_message( - "shell-direct-next", - &public_pane_id, - "browser", - next_path, - 1, - 1, - 2, - ); - assert_eq!( - server.handle_pane_graphics_stream_frame(next_message), - RenderImpact::None - ); - let (next_transfer_id, next_image_id, next_asset) = match read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("sticky owner direct upload"), - ) { - ServerMessage::GraphicsFile { - transfer_id, - image_id, - surface_asset: Some(asset), - .. - } => (transfer_id, image_id, asset), - other => panic!("expected sticky owner graphics file, got {other:?}"), - }; - assert!(background_render_rx.try_recv().is_err()); - server.start_direct_graphics_response(1, next_transfer_id, next_image_id); - assert!(server.complete_direct_graphics(1, next_transfer_id, next_image_id, true)); - assert!(serde_json::from_str::<api::schema::SuccessResponse>( - &next_response_rx - .recv_timeout(Duration::from_secs(1)) - .unwrap() - ) - .is_ok()); - assert!(server.app.direct_graphics_available); - - server.clients.get_mut(&1).unwrap().request_repaint(); - server.render_and_stream(); - let surface = read_server_message( - client_rx - .recv_timeout(Duration::from_secs(1)) - .expect("resident client shell scene"), - ); - let ServerMessage::PaneSurface(surface) = surface else { - panic!("expected client shell pane surface"); - }; - assert_eq!(surface.graphics.placements.len(), 1); - assert!(surface.graphics.assets.is_empty()); - assert_eq!(surface.graphics.placements[0].asset, next_asset); - assert_eq!(surface.graphics.retained_assets, vec![next_asset.clone()]); - - let background_surface = read_server_message( - background_render_rx - .recv_timeout(Duration::from_secs(1)) - .expect("non-owning client shell scene"), - ); - let ServerMessage::PaneSurface(background_surface) = background_surface else { - panic!("expected non-owning client shell pane surface"); - }; - assert!(background_surface.graphics.assets.is_empty()); - assert!(background_surface.graphics.placements.is_empty()); - assert!(background_surface.graphics.retained_assets.is_empty()); - - let (hidden, _) = crate::server::client_shell_graphics::collect( - &server.app, - &[], - &[], - None, - Some(crate::ui::TabSurfaceTarget { - workspace_index: 0, - tab_index: 0, - }), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - &crate::kitty_graphics::surface::DeliveryCache::default(), - 1, - ); - assert!(hidden.placements.is_empty()); - assert_eq!(hidden.retained_assets, vec![next_asset]); -} - -#[cfg(unix)] -fn direct_gate_server( - data: &[u8], -) -> ( - HeadlessServer, - crate::app::pane_graphics::Key, - std::sync::mpsc::Receiver<String>, -) { - direct_gate_server_with_file(data.len(), Some(data)) -} - -#[cfg(unix)] -fn direct_gate_server_with_file( - len: usize, - data: Option<&[u8]>, -) -> ( - HeadlessServer, - crate::app::pane_graphics::Key, - std::sync::mpsc::Receiver<String>, -) { - use std::io::Write as _; - use std::os::unix::fs::OpenOptionsExt as _; - let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("direct-gate"); - let pane_id = workspace.tabs[0].root_pane; - server.app.state.workspaces = vec![workspace]; - server.app.state.active = Some(0); - let key = graphics_key(pane_id); - let path = server - .app - .pane_graphics_files - .source_directory() - .unwrap() - .join("gate-frame"); - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - .unwrap(); - if let Some(data) = data { - file.write_all(data).unwrap(); - } else { - file.set_len(len as u64).unwrap(); - } - drop(file); - let lease = server.app.pane_graphics_files.lease(&path, len).unwrap(); - let (respond_to, response_rx) = std::sync::mpsc::channel(); - let layer = - crate::app::pane_graphics::Layer::direct(1, 1, lease.clone(), Default::default(), 0); - let mut slot = crate::app::pane_graphics::Slot::test((1 << 31) | 900, Some(layer)); - slot.stream_owner = Some("owner".into()); - slot.stream_active = Some(active_gate()); - slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { - transfer_id: lease.fingerprint(), - image_id: (1 << 31) | 900, - client_id: 7, - deadline: std::time::Instant::now() + Duration::from_secs(1), - written: true, - success_response: "ack".into(), - respond_to, - }); - server.app.pane_graphics.slots.insert(key.clone(), slot); - (server, key, response_rx) -} - -#[cfg(unix)] -fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) { - let slot = &server.app.pane_graphics.slots[key]; - let gate = slot.direct_gate.as_ref().unwrap(); - (gate.transfer_id, gate.image_id) -} - -#[cfg(unix)] -fn add_direct_client(server: &mut HeadlessServer, client_id: u64) { - let (writer, control_rx, render_rx) = test_client_writer(); - std::mem::forget((control_rx, render_rx)); - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 1, - RenderEncoding::SemanticFrame, - Some(writer), - ); - client.direct_graphics = true; - client.pixel_mouse = true; - server.clients.insert(client_id, client); -} - -#[cfg(unix)] -#[test] -fn terminal_response_deadline_starts_only_after_client_flush() { - let (mut server, key, _response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap(); - let gate = slot.direct_gate.as_mut().unwrap(); - gate.written = false; - let (transfer_id, image_id) = (gate.transfer_id, slot.host_image_id); - assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert!(!server.start_direct_graphics_response(7, transfer_id, image_id)); - let gate = server.app.pane_graphics.slots[&key] - .direct_gate - .as_ref() - .unwrap(); - assert!(gate.written && gate.deadline > std::time::Instant::now()); -} - -#[cfg(unix)] -#[test] -fn outer_timeout_covers_both_direct_phases_and_cancellation_blocks_late_results() { - assert!( - crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT - > crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT - + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT - ); - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap(); - slot.stream_active - .as_ref() - .unwrap() - .store(false, std::sync::atomic::Ordering::Release); - let (transfer_id, image_id) = ( - slot.direct_gate.as_ref().unwrap().transfer_id, - slot.host_image_id, - ); - - assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert!(response_rx.try_recv().is_err()); - assert!(server.app.pane_graphics.slots[&key] - .layer - .as_ref() - .unwrap() - .direct_lease() - .is_some()); -} - -#[cfg(unix)] -#[test] -fn matching_terminal_ok_releases_producer_and_acknowledges() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (transfer_id, image_id) = direct_ids(&server, &key); - - assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); - - assert_eq!(response_rx.recv().unwrap(), "ack"); - let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); - assert!(layer.terminal_only()); - assert!(layer.direct_lease().is_none()); -} - -#[cfg(unix)] -#[test] -fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() { - for error in [ - std::sync::mpsc::TrySendError::Full(Vec::new()), - std::sync::mpsc::TrySendError::Disconnected(Vec::new()), - ] { - let should_ack = matches!(error, std::sync::mpsc::TrySendError::Full(_)); - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - let gate = server - .app - .pane_graphics - .slots - .get_mut(&key) - .and_then(|slot| slot.direct_gate.take()) - .unwrap(); - let result = server.handle_unwritten_direct_failure( - &key, - gate.success_response, - gate.respond_to, - error, - ); - let inline = server - .app - .pane_graphics - .slots - .get(&key) - .and_then(|slot| slot.layer.as_ref()?.inline_data()) - .is_some(); - assert_eq!( - ( - result, - response_rx.try_recv().ok().as_deref() == Some("ack"), - inline, - server.clients[&7].direct_graphics, - ), - (should_ack, should_ack, should_ack, true) - ); - } -} - -#[cfg(unix)] -#[test] -fn eligibility_loss_cancels_the_queued_direct_upload() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (writer, control_rx, _render_rx) = test_client_writer(); - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 1, - RenderEncoding::SemanticFrame, - Some(writer), - ); - client.direct_graphics = true; - client.pixel_mouse = true; - server.clients.insert(7, client); - let (transfer_id, image_id) = direct_ids(&server, &key); - - server.retire_all_direct_graphics(); - - assert!(matches!( - read_server_message( - control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("direct retirement") - ), - ServerMessage::GraphicsTransmissionRetired { - transfer_id: retired_transfer, - image_id: retired_image, - } if retired_transfer == transfer_id && retired_image == image_id - )); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(response_rx.recv().is_err()); -} - -#[cfg(unix)] -#[test] -fn client_loss_retires_only_its_direct_stream() { - let (mut pending, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - pending.retire_direct_graphics_for_client(8); - assert!(pending.app.pane_graphics.slots.contains_key(&key)); - pending.retire_direct_graphics_for_client(7); - assert!(!pending.app.pane_graphics.slots.contains_key(&key)); - assert!(response_rx.recv().is_err()); - - let (mut resident, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let slot = resident.app.pane_graphics.slots.get(&key).unwrap(); - assert!(resident.complete_direct_graphics( - 7, - slot.direct_gate.as_ref().unwrap().transfer_id, - slot.host_image_id, - true, - )); - assert_eq!(response_rx.recv().unwrap(), "ack"); - resident.retire_direct_graphics_for_client(8); - assert!(resident.app.pane_graphics.slots.contains_key(&key)); - resident.retire_direct_graphics_for_client(7); - assert!(!resident.app.pane_graphics.slots.contains_key(&key)); -} - -#[cfg(unix)] -#[test] -fn pane_removal_cancels_the_pending_client_upload() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (writer, control_rx, _render_rx) = test_client_writer(); - let mut client = ClientConnection::new( - (80, 24), - crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }, - 1, - RenderEncoding::SemanticFrame, - Some(writer), - ); - client.direct_graphics = true; - client.pixel_mouse = true; - server.clients.insert(7, client); - let (transfer_id, image_id) = direct_ids(&server, &key); - server.app.state.workspaces.clear(); - - assert!(server.retain_live_pane_graphics()); - - assert!(matches!( - read_server_message( - control_rx - .recv_timeout(Duration::from_millis(100)) - .expect("pane removal retirement") - ), - ServerMessage::GraphicsTransmissionRetired { - transfer_id: retired_transfer, - image_id: retired_image, - } if retired_transfer == transfer_id && retired_image == image_id - )); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(response_rx.recv().is_err()); -} - -#[cfg(unix)] -#[test] -fn pane_removal_and_shutdown_drop_direct_without_ack() { - let setups: [fn(&mut HeadlessServer); 2] = [ - |server| server.app.state.workspaces.clear(), - |server| server.shutting_down = true, - ]; - for setup in setups { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - let (transfer_id, image_id) = direct_ids(&server, &key); - setup(&mut server); - assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); - assert!(response_rx.recv().is_err()); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - } -} - -#[cfg(unix)] -#[test] -fn timeout_retires_stream_without_producer_ack() { - let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); - add_direct_client(&mut server, 7); - server - .app - .pane_graphics - .slots - .get_mut(&key) - .unwrap() - .direct_gate - .as_mut() - .unwrap() - .deadline = std::time::Instant::now() - Duration::from_millis(1); - - assert!(server.expire_direct_graphics(std::time::Instant::now())); - - assert!(response_rx.recv().is_err()); - assert!(!server.app.pane_graphics.slots.contains_key(&key)); - assert!(!server.clients[&7].direct_graphics); - assert!(server.clients[&7].pixel_mouse); -} diff --git a/src/server/headless/tests/pane_move.rs b/src/server/headless/tests/pane_move.rs index 789b144f..99b39c8b 100644 --- a/src/server/headless/tests/pane_move.rs +++ b/src/server/headless/tests/pane_move.rs @@ -29,7 +29,6 @@ fn public_move( }, respond_to, response_write_complete: None, - stream_active: None, }); let response = response_rx.recv().expect("pane move response"); match serde_json::from_str::<SuccessResponse>(&response) { |
