mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
fix(graphics): support high-dpi direct file frames (#2680)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user