aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/api/herdr-api.schema.json8
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx7
-rw-r--r--src/api/schema/panes.rs1
-rw-r--r--src/api/schema/response.rs2
-rw-r--r--src/app/api/pane_graphics.rs86
-rw-r--r--src/client/direct_graphics.rs112
-rw-r--r--src/client/mod.rs2
-rw-r--r--src/ghostty/mod.rs37
-rw-r--r--src/kitty_graphics.rs69
-rw-r--r--src/server/headless/pane_graphics.rs42
-rw-r--r--src/server/headless/tests/pane_graphics.rs350
11 files changed, 616 insertions, 100 deletions
diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json
index 674dac8d..f9642ffa 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 6431dcb9..17502cee 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 c8d89c48..6435a422 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 ae5a32e0..da2813a6 100644
--- a/src/api/schema/response.rs
+++ b/src/api/schema/response.rs
@@ -177,6 +177,8 @@ pub enum ResponseResult {
file_frame_formats: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_max_bytes: Option<usize>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ file_frame_direct_max_bytes: Option<usize>,
/// 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 f5f5f2b7..4fc1deec 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");
}
@@ -1039,6 +1053,24 @@ mod tests {
}
#[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,
owner: &str,
@@ -1147,6 +1179,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::<SuccessResponse>(&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() {
let (mut app, pane_id) = app();
app.direct_graphics_available = true;
diff --git a/src/client/direct_graphics.rs b/src/client/direct_graphics.rs
index 1bf10041..32bce913 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::<usize>().ok().filter(|value| *value > 0),
+ "v" => height = value.parse::<usize>().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 e19c47ca..cba735fa 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 4dead632..cea7c283 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 726e1d8b..12141173 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<DirectFileCommand> {
- 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 d027f79b..151b26ee 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 f9bd0a01..bd6e3cd7 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<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: 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) =
@@ -665,6 +729,252 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
}
#[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::<api::schema::SuccessResponse>(
+ &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::<api::schema::SuccessResponse>(
+ &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],
) -> (
@@ -672,6 +982,18 @@ fn direct_gate_server(
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();
@@ -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);
@@ -837,6 +1159,22 @@ fn explicit_terminal_error_acks_only_after_owned_inline_fallback() {
#[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() {
for error in [
std::sync::mpsc::TrySendError::Full(Vec::new()),