fix(graphics): support high-dpi direct file frames (#2680)

This commit is contained in:
Can Celik
2026-08-13 17:21:26 +03:00
committed by GitHub
parent 952729ee03
commit b7dca4bb2a
11 changed files with 616 additions and 100 deletions
+8
View File
@@ -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",
@@ -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.
+1
View File
@@ -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;
+2
View File
@@ -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,
+79 -7
View File
@@ -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::<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() {
+78 -34
View File
@@ -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}");
}
}
+1 -1
View File
@@ -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()
+37
View File
@@ -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();
+42 -27
View File
@@ -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(
+17 -25
View File
@@ -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
+344 -6
View File
@@ -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) =
@@ -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::<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],
@@ -671,6 +981,18 @@ fn direct_gate_server(
HeadlessServer,
crate::app::pane_graphics::Key,
std::sync::mpsc::Receiver<String>,
) {
direct_gate_server_with_file(data.len(), Some(data))
}
#[cfg(unix)]
fn direct_gate_server_with_file(
len: usize,
data: Option<&[u8]>,
) -> (
HeadlessServer,
crate::app::pane_graphics::Key,
std::sync::mpsc::Receiver<String>,
) {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
@@ -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() {