diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 674dac8da5..f9642ffa0d 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -9295,6 +9295,14 @@ "description": "Accepts damage metadata while still consuming a complete canonical file.", "type": "boolean" }, + "file_frame_direct_max_bytes": { + "format": "uint", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, "file_frame_directory": { "type": [ "string", diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 6431dcb9f3..17502cee39 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -221,6 +221,13 @@ acknowledging source reuse. Monolithic `--no-session` mode advertises neither fast file transport nor exact pixel mouse and remains on owned inline fallback. Direct files are always complete canonical `width * height * 4` RGBA frames. +`file_frame_max_bytes` is the limit that remains eligible for owned inline fallback. +Primary-layer RGBA files may use the larger `file_frame_direct_max_bytes` limit when +`file_frame_transport` is available. Frames above the fallback limit are acknowledged +only when the terminal accepts the direct transfer; rejection closes the stream. If a +frame cannot use owned inline fallback while its pane is temporarily hidden or cannot be +placed during a redraw, Herdr uploads the image without displaying it and replays its +placement when the pane becomes visible again. `file_frame_damage: true` means Herdr accepts optional damage metadata for producer-side canonical-ring efficiency; it still copies or presents the full file. Resize and full redraw replay placements without retransmitting pixels. diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index c8d89c48ec..6435a422e3 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; pub(crate) const PANE_GRAPHICS_SET_MAX_BYTES: usize = 512 * 1024; pub(crate) const PANE_GRAPHICS_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES: usize = 400 * 1024 * 1024; pub(crate) const PANE_GRAPHICS_MAX_LAYERS_PER_PANE: usize = 16; pub(crate) const PANE_GRAPHICS_MAX_LAYERS_TOTAL: usize = 64; pub(crate) const PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL: usize = 64 * 1024 * 1024; diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index ae5a32e00f..da2813a6c3 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -177,6 +177,8 @@ pub enum ResponseResult { file_frame_formats: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] file_frame_max_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + file_frame_direct_max_bytes: Option, /// Accepts damage metadata while still consuming a complete canonical file. #[serde(default)] file_frame_damage: bool, diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs index f5f5f2b7f0..4fc1deec0b 100644 --- a/src/app/api/pane_graphics.rs +++ b/src/app/api/pane_graphics.rs @@ -2,8 +2,8 @@ use base64::Engine; use crate::api::schema::{ PaneGraphicsClearParams, PaneGraphicsSetParams, PaneGraphicsStreamParams, ResponseResult, - PANE_GRAPHICS_MAX_LAYERS_PER_PANE, PANE_GRAPHICS_PRIMARY_LAYER_ID, PANE_GRAPHICS_SET_MAX_BYTES, - PANE_GRAPHICS_STREAM_MAX_BYTES, + PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES, PANE_GRAPHICS_MAX_LAYERS_PER_PANE, + PANE_GRAPHICS_PRIMARY_LAYER_ID, PANE_GRAPHICS_SET_MAX_BYTES, PANE_GRAPHICS_STREAM_MAX_BYTES, }; use crate::app::pane_graphics::{Key as PaneGraphicsKey, Layer, Slot}; use crate::app::App; @@ -65,6 +65,7 @@ impl App { Vec::new() }, file_frame_max_bytes: direct.then_some(PANE_GRAPHICS_STREAM_MAX_BYTES), + file_frame_direct_max_bytes: direct.then_some(PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES), // Damage metadata never changes the complete canonical frame contract. file_frame_damage: true, max_layers_per_pane: PANE_GRAPHICS_MAX_LAYERS_PER_PANE, @@ -318,9 +319,18 @@ impl App { ) { return encode_error(id, "invalid_image", "direct frames require rgba or bgra"); } + let primary = key.1 == PANE_GRAPHICS_PRIMARY_LAYER_ID; + let direct = self.direct_graphics_available + && primary + && params.format == crate::api::schema::PaneGraphicsFormat::Rgba; + let max_bytes = if direct { + PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES + } else { + PANE_GRAPHICS_STREAM_MAX_BYTES + }; let expected_len = match expected_len(params.format, params.image_width, params.image_height) { - Ok(Some(len)) if len <= PANE_GRAPHICS_STREAM_MAX_BYTES => len, + Ok(Some(len)) if len <= max_bytes => len, _ => return encode_error(id, "invalid_image", "invalid direct RGBA dimensions"), }; let lease = match self @@ -330,10 +340,6 @@ impl App { Ok(lease) => lease, Err(err) => return encode_error(id, "invalid_frame_file", err.to_string()), }; - let primary = key.1 == PANE_GRAPHICS_PRIMARY_LAYER_ID; - let direct = self.direct_graphics_available - && primary - && params.format == crate::api::schema::PaneGraphicsFormat::Rgba; if !direct && !self.pane_graphics.can_store_inline(&key, expected_len) { return encode_error( id, @@ -699,6 +705,14 @@ mod tests { value["result"]["file_frame_formats"], serde_json::json!(["rgba", "bgra"]) ); + assert_eq!( + value["result"]["file_frame_max_bytes"], + PANE_GRAPHICS_STREAM_MAX_BYTES + ); + assert_eq!( + value["result"]["file_frame_direct_max_bytes"], + PANE_GRAPHICS_DIRECT_FILE_MAX_BYTES + ); assert_eq!(value["result"]["file_frame_damage"], true); assert_eq!(value["result"]["file_frame_transport"], "direct-kitty"); } @@ -1038,6 +1052,24 @@ mod tests { path.to_string_lossy().into_owned() } + #[cfg(unix)] + fn sparse_direct_file(app: &App, name: &str, len: usize) -> String { + use std::os::unix::fs::OpenOptionsExt as _; + let path = 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(len as u64).unwrap(); + path.to_string_lossy().into_owned() + } + #[cfg(unix)] fn direct_params( pane_id: String, @@ -1145,6 +1177,46 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn direct_primary_rgba_accepts_fullscreen_retina_frame() { + let (mut app, pane_id) = app(); + app.direct_graphics_available = true; + app.handle_pane_graphics_stream_open( + "open".into(), + PaneGraphicsStreamParams { + pane_id: pane_id.clone(), + layer_id: None, + z_index: 0, + owner: "owner".into(), + }, + ); + let (image_width, image_height) = (3456, 2234); + let len = image_width * image_height * 4; + let path = sparse_direct_file(&app, "retina-frame", len as usize); + let response = app.handle_pane_graphics_stream_direct( + "frame".into(), + crate::api::schema::PaneGraphicsDirectParams { + image_width, + image_height, + ..direct_params(pane_id, "owner", path) + }, + ); + + assert!(serde_json::from_str::(&response).is_ok()); + assert!(app + .pane_graphics + .slots + .values() + .next() + .unwrap() + .layer + .as_ref() + .unwrap() + .direct_lease() + .is_some()); + } + #[cfg(unix)] #[test] fn bgra_and_secondary_file_frames_are_canonical_owned_rgba() { diff --git a/src/client/direct_graphics.rs b/src/client/direct_graphics.rs index 1bf10041f1..32bce91373 100644 --- a/src/client/direct_graphics.rs +++ b/src/client/direct_graphics.rs @@ -212,42 +212,47 @@ fn matching_response_controls(bytes: &[u8], expected: u32) -> bool { matched } -pub(super) fn valid_control(control: &str, image_id: u32) -> bool { +pub(super) fn valid_control(control: &str, image_id: u32, expected_len: usize) -> bool { if control.len() > 1024 || control.contains([';', '\x1b']) { return false; } - let mut action = false; + let mut seen = 0_u32; + let mut action = None; let mut format = false; let mut image = false; let mut quiet = false; - let mut cursor = false; + let mut width = None; + let mut height = None; + let mut placement = [false; 5]; + let mut has_placement_controls = false; for field in control.split(',') { let Some((key, value)) = field.split_once('=') else { return false; }; - if key == "t" - || !matches!( - key, - "a" | "f" - | "s" - | "v" - | "i" - | "p" - | "c" - | "r" - | "z" - | "C" - | "q" - | "x" - | "y" - | "w" - | "h" - | "X" - | "Y" - ) - { + let key_bit = match key { + "a" => 1 << 0, + "f" => 1 << 1, + "s" => 1 << 2, + "v" => 1 << 3, + "i" => 1 << 4, + "p" => 1 << 5, + "c" => 1 << 6, + "r" => 1 << 7, + "z" => 1 << 8, + "C" => 1 << 9, + "q" => 1 << 10, + "x" => 1 << 11, + "y" => 1 << 12, + "w" => 1 << 13, + "h" => 1 << 14, + "X" => 1 << 15, + "Y" => 1 << 16, + _ => return false, + }; + if seen & key_bit != 0 { return false; } + seen |= key_bit; let numeric = value .strip_prefix('-') .unwrap_or(value) @@ -258,15 +263,47 @@ pub(super) fn valid_control(control: &str, image_id: u32) -> bool { return false; } match key { - "a" => action = value == "T", + "a" if matches!(value, "T" | "t") => action = Some(value), + "a" => return false, "f" => format = value == "32", + "s" => width = value.parse::().ok().filter(|value| *value > 0), + "v" => height = value.parse::().ok().filter(|value| *value > 0), "i" => image = value.parse() == Ok(image_id), "q" => quiet = value == "0", - "C" => cursor = value == "1", + "p" => { + placement[0] = true; + has_placement_controls = true; + } + "c" => { + placement[1] = true; + has_placement_controls = true; + } + "r" => { + placement[2] = true; + has_placement_controls = true; + } + "z" => { + placement[3] = true; + has_placement_controls = true; + } + "C" => { + placement[4] = value == "1"; + has_placement_controls = true; + } + "x" | "y" | "w" | "h" | "X" | "Y" => has_placement_controls = true, _ => {} } } - action && format && image && quiet && cursor + let dimensions_match = width + .zip(height) + .and_then(|(width, height)| width.checked_mul(height)?.checked_mul(4)) + == Some(expected_len); + let profile_matches = match action { + Some("T") => placement.into_iter().all(|present| present), + Some("t") => !has_placement_controls, + _ => false, + }; + format && image && quiet && dimensions_match && profile_matches } #[cfg(test)] @@ -313,18 +350,25 @@ mod tests { } #[test] - fn validated_control_is_one_owned_rgba_transmit_and_display() { + fn validated_control_accepts_only_owned_rgba_direct_profiles() { assert!(valid_control( "a=T,f=32,s=10,v=20,i=42,p=7,c=5,r=6,z=-1,C=1,q=0,x=2", - 42 + 42, + 800, )); + assert!(valid_control("a=t,f=32,s=10,v=20,i=42,q=0", 42, 800,)); for invalid in [ - "a=T,f=24,i=42,C=1,q=0", - "a=T,f=32,i=41,C=1,q=0", - "a=T,t=f,f=32,i=42,C=1,q=0", - "a=p,f=32,i=42,C=1,q=0", + "a=T,f=24,s=10,v=20,i=42,p=7,c=5,r=6,z=-1,C=1,q=0", + "a=T,f=32,s=10,v=20,i=41,p=7,c=5,r=6,z=-1,C=1,q=0", + "a=T,t=f,f=32,s=10,v=20,i=42,p=7,c=5,r=6,z=-1,C=1,q=0", + "a=p,f=32,s=10,v=20,i=42,q=0", + "a=t,f=32,s=10,v=20,i=42,C=1,q=0", + "a=t,f=32,s=10,v=20,i=42,p=7,q=0", + "a=t,f=32,s=10,v=19,i=42,q=0", + "a=t,f=32,s=10,i=42,q=0", + "a=t,f=32,s=10,s=10,v=20,i=42,q=0", ] { - assert!(!valid_control(invalid, 42), "{invalid}"); + assert!(!valid_control(invalid, 42, 800), "{invalid}"); } } diff --git a/src/client/mod.rs b/src/client/mod.rs index e19c47caee..cba735fa21 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1721,8 +1721,8 @@ async fn run_client_loop( len, ) .is_ok() + && direct_graphics::valid_control(&control, image_id, len) }) - && direct_graphics::valid_control(&control, image_id) && state .direct_graphics_response .lock() diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 4dead632c9..cea7c28378 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -3488,6 +3488,43 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[cfg(unix)] + #[test] + fn kitty_graphics_file_upload_can_be_placed_later() { + let dir = std::env::temp_dir().join(format!( + "herdr-kitty-file-upload-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("pixel.rgba"); + std::fs::write(&path, [255, 0, 0, 255]).unwrap(); + + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); + terminal.resize(10, 5, 8, 16).unwrap(); + let mut upload = Vec::new(); + crate::kitty_graphics::encode_kitty_regular_file( + &mut upload, + &[], + "a=t,f=32,s=1,v=1,i=10,q=0", + path.to_str().unwrap(), + ); + terminal.write(&upload); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + + terminal.write(b"\x1b_Ga=p,i=10,p=5,c=10,r=5,C=1,q=2\x1b\\"); + let placements = terminal.kitty_image_placements().unwrap(); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].image_id, 10); + assert_eq!(placements[0].placement_id, 5); + assert_eq!(placements[0].image_width, 1); + assert_eq!(placements[0].image_height, 1); + assert_eq!(placements[0].format, KittyImageFormat::Rgba); + assert_eq!(placements[0].data, [255, 0, 0, 255]); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn kitty_graphics_unicode_placeholder_placement_is_queryable() { let mut terminal = Terminal::new(10, 5, 0).unwrap(); diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 726e1d8b97..1214117367 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -1180,38 +1180,53 @@ pub(crate) fn prepare_direct_file( graphics: &crate::app::pane_graphics::Runtime, surface: crate::ui::TabSurfaceView<'_>, cell_size: HostCellSize, - cache: &mut HostGraphicsCache, + allow_placement: bool, + cache: &HostGraphicsCache, key: &crate::app::pane_graphics::Key, ) -> Option { - if app.mode != Mode::Terminal || !cell_size.is_known() || app.active.is_none() { - return None; - } - let info = surface.pane_infos.iter().find(|info| info.id == key.0)?; let slot = graphics.slots.get(key)?; let layer = slot.layer.as_ref()?; layer.direct_lease()?; - let placement = pane_graphics_host_placement( - info, - &key.1, - slot.host_image_id, - cell_size, - layer, - &cache.images, - false, - ); - let (command, clipped, format_code, placement_id) = - direct_file_command(&placement, slot.host_image_id)?; - cache - .images - .insert(slot.host_image_id, image_signature(&placement, format_code)); - cache.placements.insert( - (slot.host_image_id, placement_id), - placement_signature(clipped, layer.z_index, 0), - ); - cache - .sources - .insert(placement.source_key, slot.host_image_id); - Some(command) + + let info = allow_placement + .then(|| surface.pane_infos.iter().find(|info| info.id == key.0)) + .flatten() + .filter(|_| app.mode == Mode::Terminal && cell_size.is_known() && app.active.is_some()); + if let Some(command) = info + .map(|info| { + pane_graphics_host_placement( + info, + &key.1, + slot.host_image_id, + cell_size, + layer, + &cache.images, + false, + ) + }) + .and_then(|placement| direct_file_command(&placement, slot.host_image_id)) + .map(|(command, _, _, _)| command) + { + return Some(command); + } + + let inline_fallback_available = layer.data_len() + <= crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + && graphics.can_store_inline(key, layer.data_len()); + (!inline_fallback_available).then(|| direct_file_upload_command(layer, slot.host_image_id)) +} + +fn direct_file_upload_command( + layer: &crate::app::pane_graphics::Layer, + host_image_id: u32, +) -> DirectFileCommand { + DirectFileCommand { + leading: Vec::new(), + control: format!( + "a=t,f=32,s={},v={},i={host_image_id},q=0", + layer.image_width, layer.image_height + ), + } } fn direct_file_command( diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index d027f79b87..151b26eee0 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -87,26 +87,17 @@ impl HeadlessServer { if let (Some(key), Some((image_id, path, expected_len, transfer_id))) = (direct_key.clone(), direct_frame) { - let mut next_cache = self - .clients - .get(&client_id) - .map(|client| client.graphics_cache.clone()) - .unwrap_or_default(); - let command = (!internal_changed) - .then(|| { - crate::kitty_graphics::prepare_direct_file( - &self.app.state, - &self.app.pane_graphics, - self.app.state.view.tab_surface(), - self.clients - .get(&client_id) - .map(|client| client.cell_size) - .unwrap_or_default(), - &mut next_cache, - &key, - ) - }) - .flatten(); + let command = self.clients.get(&client_id).and_then(|client| { + crate::kitty_graphics::prepare_direct_file( + &self.app.state, + &self.app.pane_graphics, + self.app.state.view.tab_surface(), + client.cell_size, + !internal_changed, + &client.graphics_cache, + &key, + ) + }); let Some(command) = command else { if self.install_inline_fallback(&key) { if msg.respond_to.send(response).is_err() { @@ -161,9 +152,6 @@ impl HeadlessServer { respond_to: msg.respond_to, }); } - if let Some(client) = self.clients.get_mut(&client_id) { - client.graphics_cache = next_cache; - } return if internal_changed { RenderImpact::Full } else { @@ -211,7 +199,9 @@ impl HeadlessServer { let Some(len) = len else { return false; }; - if !self.app.pane_graphics.can_store_inline(key, len) { + if len > crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + || !self.app.pane_graphics.can_store_inline(key, len) + { return false; } let data = self @@ -365,7 +355,6 @@ impl HeadlessServer { if let Some(client) = self.clients.get_mut(&client_id) { client.direct_graphics = false; - client.graphics_cache.forget_pane_layer(&key, image_id); } self.app.direct_graphics_available = false; if !self.install_inline_fallback(&key) { @@ -373,6 +362,9 @@ impl HeadlessServer { self.retire_all_direct_graphics(); return true; } + if let Some(client) = self.clients.get_mut(&client_id) { + client.graphics_cache.forget_pane_layer(&key, image_id); + } let gate = self .app .pane_graphics diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index f9bd0a0187..bd6e3cd7c6 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -161,6 +161,70 @@ fn stream_set_message( ) } +#[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, +) -> (api::ApiRequestMessage, std::sync::mpsc::Receiver) { + 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: 1, + revision: 1, + placement: Default::default(), + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }, + response_rx, + ) +} + #[tokio::test] async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport() { let (mut server, _client_rx, pane_id) = @@ -664,6 +728,252 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { .is_ok()); } +#[cfg(unix)] +#[tokio::test] +async fn hidden_large_direct_frame_uploads_then_replays_placement_without_closing_stream() { + let (mut server, client_rx, _) = retained_test_server(b"active"); + enable_graphics_and_render(&mut server, &client_rx); + let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); + let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; + let pane_number = server.app.state.workspaces[0] + .public_pane_number(pane_id) + .unwrap(); + let public_pane_id = crate::workspace::public_pane_id_for_number( + &server.app.state.workspaces[0].id, + pane_number, + ); + server.clients.get_mut(&1).unwrap().direct_graphics = true; + server.app.direct_graphics_available = true; + set_stream_owner(&mut server, pane_id, "browser"); + + let image_width = 2_048; + let image_height = 2_049; + let expected_len = u64::from(image_width) * u64::from(image_height) * 4; + assert!(expected_len > api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES as u64); + let path = sparse_direct_frame( + &server, + "hidden-large-frame.rgba", + image_width, + image_height, + ); + let (message, response_rx) = direct_stream_message( + "hidden-frame", + &public_pane_id, + "browser", + path, + image_width, + image_height, + ); + + assert_eq!( + server.handle_pane_graphics_stream_frame(message), + RenderImpact::None + ); + let (transfer_id, image_id, control, leading) = match read_server_message( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("hidden direct upload"), + ) { + ServerMessage::GraphicsFile { + transfer_id, + image_id, + control, + leading, + expected_len: sent_len, + .. + } => { + assert_eq!(sent_len, expected_len); + (transfer_id, image_id, control, leading) + } + other => panic!("expected graphics file, got {other:?}"), + }; + assert!(leading.is_empty()); + assert!(control.starts_with("a=t,"), "{control}"); + assert!(!control.contains("p="), "{control}"); + assert!(response_rx.try_recv().is_err()); + + server.app.state.workspaces[0].switch_tab(background_tab); + server.render_and_stream(); + let frame = read_server_frame( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("frame while upload is pending"), + ); + assert!(!frame.graphics.windows(4).any(|bytes| bytes == b"a=p,")); + + server.app.state.workspaces[0].switch_tab(0); + server.render_and_stream(); + let _hidden_again = read_server_frame( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("frame after hiding pending upload"), + ); + 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::( + &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() + ) + .is_ok()); + let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; + assert!(slot.stream_is_active()); + assert!(slot.layer.as_ref().unwrap().terminal_only()); + + server.app.state.workspaces[0].switch_tab(background_tab); + server.render_and_stream(); + let frame = read_server_frame( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("placement replay after tab switch"), + ); + let graphics = String::from_utf8_lossy(&frame.graphics); + assert!(graphics.contains("a=p,"), "{graphics:?}"); + assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); + assert!(!graphics.contains("a=t,"), "{graphics:?}"); + + let next_path = sparse_direct_frame( + &server, + "visible-next-frame.rgba", + image_width, + image_height, + ); + let (message, next_response_rx) = direct_stream_message( + "visible-frame", + &public_pane_id, + "browser", + next_path, + image_width, + image_height, + ); + assert_eq!( + server.handle_pane_graphics_stream_frame(message), + RenderImpact::None + ); + match read_server_message( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("next visible direct frame"), + ) { + ServerMessage::GraphicsFile { control, .. } => { + assert!(control.starts_with("a=T,"), "{control}"); + } + other => panic!("expected graphics file, got {other:?}"), + } + assert!(next_response_rx.try_recv().is_err()); + assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); +} + +#[cfg(unix)] +#[tokio::test] +async fn hidden_small_direct_frame_preserves_owned_inline_fallback() { + let (mut server, client_rx, _) = retained_test_server(b"active"); + enable_graphics_and_render(&mut server, &client_rx); + let background_tab = server.app.state.workspaces[0].test_add_tab(Some("browser")); + let pane_id = server.app.state.workspaces[0].tabs[background_tab].root_pane; + let pane_number = server.app.state.workspaces[0] + .public_pane_number(pane_id) + .unwrap(); + let public_pane_id = crate::workspace::public_pane_id_for_number( + &server.app.state.workspaces[0].id, + pane_number, + ); + server.clients.get_mut(&1).unwrap().direct_graphics = true; + server.app.direct_graphics_available = true; + set_stream_owner(&mut server, pane_id, "browser"); + + let path = sparse_direct_frame(&server, "hidden-small-frame.rgba", 1, 1); + let (message, response_rx) = + direct_stream_message("hidden-small", &public_pane_id, "browser", path, 1, 1); + assert_eq!( + server.handle_pane_graphics_stream_frame(message), + RenderImpact::Graphics + ); + assert!(serde_json::from_str::( + &response_rx.recv_timeout(Duration::from_secs(1)).unwrap() + ) + .is_ok()); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + let slot = &server.app.pane_graphics.slots[&graphics_key(pane_id)]; + assert!(slot.stream_is_active()); + assert_eq!( + slot.layer.as_ref().unwrap().inline_data(), + Some([0; 4].as_slice()) + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn direct_frame_during_internal_redraw_uploads_without_placement() { + let (mut server, client_rx, pane_id) = retained_test_server(b"active"); + enable_graphics_and_render(&mut server, &client_rx); + let pane_number = server.app.state.workspaces[0] + .public_pane_number(pane_id) + .unwrap(); + let public_pane_id = crate::workspace::public_pane_id_for_number( + &server.app.state.workspaces[0].id, + pane_number, + ); + server.clients.get_mut(&1).unwrap().direct_graphics = true; + server.app.direct_graphics_available = true; + set_stream_owner(&mut server, pane_id, "browser"); + server + .app + .event_tx + .try_send(AppEvent::UpdateReady { + version: "9.9.9".into(), + install_command: "herdr update".into(), + }) + .unwrap(); + + let image_width = 2_048; + let image_height = 2_049; + let path = sparse_direct_frame(&server, "redraw-frame.rgba", image_width, image_height); + let (message, response_rx) = direct_stream_message( + "redraw", + &public_pane_id, + "browser", + path, + image_width, + image_height, + ); + assert_eq!( + server.handle_pane_graphics_stream_frame(message), + RenderImpact::Full + ); + let (transfer_id, image_id) = match read_server_message( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("direct upload during redraw"), + ) { + ServerMessage::GraphicsFile { + control, + leading, + transfer_id, + image_id, + .. + } => { + assert!(leading.is_empty()); + assert!(control.starts_with("a=t,"), "{control}"); + (transfer_id, image_id) + } + other => panic!("expected graphics file, got {other:?}"), + }; + server.start_direct_graphics_response(1, transfer_id, image_id); + assert!(server.complete_direct_graphics(1, transfer_id, image_id, true)); + assert!(response_rx.recv_timeout(Duration::from_secs(1)).is_ok()); + assert!(server.app.pane_graphics.slots[&graphics_key(pane_id)].stream_is_active()); + + server.render_and_stream(); + let frame = read_server_frame( + client_rx + .recv_timeout(Duration::from_secs(1)) + .expect("placement after redraw upload acknowledgement"), + ); + let graphics = String::from_utf8_lossy(&frame.graphics); + assert!(graphics.contains("a=p,"), "{graphics:?}"); + assert!(graphics.contains(&format!("i={image_id}")), "{graphics:?}"); + assert!(!graphics.contains("a=t,"), "{graphics:?}"); +} + #[cfg(unix)] fn direct_gate_server( data: &[u8], @@ -671,6 +981,18 @@ fn direct_gate_server( HeadlessServer, crate::app::pane_graphics::Key, std::sync::mpsc::Receiver, +) { + 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, ) { use std::io::Write as _; use std::os::unix::fs::OpenOptionsExt as _; @@ -692,13 +1014,13 @@ fn direct_gate_server( .mode(0o600) .open(&path) .unwrap(); - file.write_all(data).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, data.len()) - .unwrap(); + 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); @@ -835,6 +1157,22 @@ fn explicit_terminal_error_acks_only_after_owned_inline_fallback() { assert!(server.clients[&7].graphics_cache.is_empty()); } +#[cfg(unix)] +#[test] +fn large_direct_terminal_error_closes_without_acknowledging_or_copying() { + let len = crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES + 4; + let (mut server, key, response_rx) = direct_gate_server_with_file(len, None); + add_direct_client(&mut server, 7); + let (transfer_id, image_id) = direct_ids(&server, &key); + + assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(matches!( + response_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Disconnected) + )); +} + #[cfg(unix)] #[test] fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() {