aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless/tests/native_graphics.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/headless/tests/native_graphics.rs')
-rw-r--r--src/server/headless/tests/native_graphics.rs1411
1 files changed, 1411 insertions, 0 deletions
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);
+}