diff options
Diffstat (limited to 'src/server/headless')
| -rw-r--r-- | src/server/headless/pane_graphics.rs | 535 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 725 |
2 files changed, 1097 insertions, 163 deletions
diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index ed28eab0..d027f79b 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -1,5 +1,3 @@ -use tracing::warn; - use super::{HeadlessServer, RenderImpact}; use crate::api; use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE}; @@ -12,31 +10,16 @@ pub(super) enum RetainedGraphicsOutcome { Fallback, } -pub(super) fn frame_pane_graphics_for_client(bytes: Vec<u8>) -> Vec<u8> { +pub(super) fn frame_pane_graphics(bytes: Vec<u8>) -> Vec<u8> { if bytes.is_empty() { return bytes; } - let mut framed = Vec::with_capacity(bytes.len() + 4); - framed.extend_from_slice(b"\x1b7"); - framed.extend_from_slice(&bytes); - framed.extend_from_slice(b"\x1b8"); - framed + [b"\x1b7".as_slice(), &bytes, b"\x1b8"].concat() } impl HeadlessServer { pub(super) fn pane_graphics_runtime_active(&self) -> bool { - !self.app.state.pane_graphics_layers.is_empty() - || !self.app.state.pane_graphics_streams.is_empty() - } - - pub(super) fn cancel_inactive_pane_graphics_streams(&self) { - api::cancel_inactive_pane_graphics_streams(|owner| { - self.app - .state - .pane_graphics_streams - .values() - .any(|active_owner| active_owner == owner) - }); + !self.app.pane_graphics.slots.is_empty() } pub(super) fn handle_pane_graphics_stream_frame( @@ -49,12 +32,164 @@ impl HeadlessServer { } let internal_changed = self.drain_all_internal_events_with_forwarding(); + let direct = match &msg.request.method { + api::schema::Method::PaneGraphicsStreamDirect(params) => Some(params.clone()), + _ => None, + }; + let direct_key = direct.as_ref().and_then(|params| { + self.app.parse_pane_id(¶ms.pane_id).map(|(_, pane_id)| { + ( + pane_id, + params + .layer_id + .clone() + .unwrap_or_else(|| api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()), + ) + }) + }); + let direct_client = self + .direct_graphics_available() + .then_some(self.foreground_client_id) + .flatten(); + let gate_busy = self + .app + .pane_graphics + .slots + .values() + .any(|slot| slot.direct_gate.is_some()); + self.app.direct_graphics_available = direct_client.is_some() && !gate_busy; let response = self .app .handle_api_request_after_internal_events_drained(msg.request); + self.app.direct_graphics_available = self.direct_graphics_available(); let succeeded = serde_json::from_str::<api::schema::SuccessResponse>(&response).is_ok(); - let _ = msg.respond_to.send(response); + if succeeded { + if let (Some(params), Some(client_id)) = (direct, direct_client) { + let direct_frame = direct_key.as_ref().and_then(|key| { + self.app.pane_graphics.slots.get(key).and_then(|slot| { + let active_owner = slot.stream_owner.as_deref() == Some(¶ms.owner) + && slot.stream_is_active(); + active_owner.then_some(())?; + slot.layer + .as_ref() + .and_then(crate::app::pane_graphics::Layer::direct_lease) + .map(|lease| { + ( + slot.host_image_id, + lease.path().to_string_lossy().into_owned(), + lease.len() as u64, + lease.fingerprint(), + ) + }) + }) + }); + 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 Some(command) = command else { + if self.install_inline_fallback(&key) { + if msg.respond_to.send(response).is_err() { + self.retire_direct_gate(&key); + } + return if internal_changed { + RenderImpact::Full + } else { + RenderImpact::Graphics + }; + } + self.retire_direct_gate(&key); + return if internal_changed { + RenderImpact::Full + } else { + RenderImpact::Graphics + }; + }; + let message = ServerMessage::GraphicsFile { + path, + expected_len, + image_id, + transfer_id, + leading: command.leading, + control: command.control, + }; + let send = + Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE) + .map_err(|_| std::sync::mpsc::TrySendError::Full(Vec::new())) + .and_then(|framed| { + let Some(writer) = self + .clients + .get(&client_id) + .and_then(|client| client.writer.as_ref()) + else { + return Err(std::sync::mpsc::TrySendError::Disconnected( + framed, + )); + }; + writer.render.send_ordered(framed) + }); + match send { + Ok(()) => { + if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) { + slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { + transfer_id, + client_id, + deadline: std::time::Instant::now() + + crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT, + written: false, + success_response: response, + 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 { + RenderImpact::None + }; + } + Err(error) => { + self.handle_unwritten_direct_failure( + &key, + response, + msg.respond_to, + error, + ); + return RenderImpact::Graphics; + } + } + } + } + } + + let response_failed = msg.respond_to.send(response).is_err(); + if succeeded && response_failed { + if let Some(key) = direct_key { + self.retire_direct_gate(&key); + } + } if internal_changed { RenderImpact::Full } else if succeeded { @@ -64,6 +199,276 @@ impl HeadlessServer { } } + fn install_inline_fallback(&mut self, key: &crate::app::pane_graphics::Key) -> bool { + let len = self + .app + .pane_graphics + .slots + .get(key) + .and_then(|slot| slot.layer.as_ref()) + .and_then(crate::app::pane_graphics::Layer::direct_lease) + .map(crate::pane_graphics_files::Lease::len); + let Some(len) = len else { + return false; + }; + if !self.app.pane_graphics.can_store_inline(key, len) { + return false; + } + let data = self + .app + .pane_graphics + .slots + .get(key) + .and_then(|slot| slot.layer.as_ref()) + .and_then(crate::app::pane_graphics::Layer::direct_lease) + .and_then(|lease| lease.copy_rgba().ok()); + let Some(data) = data else { + return false; + }; + let slot = self + .app + .pane_graphics + .slots + .get_mut(key) + .expect("matched slot"); + if !slot.stream_is_active() { + return false; + } + let layer = slot.layer.take().expect("direct layer"); + slot.layer = Some(crate::app::pane_graphics::Layer::inline( + layer.format, + layer.image_width, + layer.image_height, + data, + layer.render, + layer.z_index, + )); + self.app.pane_graphics.mark_changed(); + true + } + + pub(super) fn handle_unwritten_direct_failure( + &mut self, + key: &crate::app::pane_graphics::Key, + response: String, + respond_to: std::sync::mpsc::Sender<String>, + error: std::sync::mpsc::TrySendError<Vec<u8>>, + ) -> bool { + if matches!(error, std::sync::mpsc::TrySendError::Full(_)) + && self.install_inline_fallback(key) + && respond_to.send(response).is_ok() + { + return true; + } + self.retire_direct_gate(key); + false + } + + pub(super) fn retire_direct_gate(&mut self, key: &crate::app::pane_graphics::Key) { + if self.app.pane_graphics.slots.remove(key).is_some() { + self.app.pane_graphics.mark_changed(); + } + } + + pub(super) fn start_direct_graphics_response( + &mut self, + client_id: u64, + transfer_id: u64, + image_id: u32, + ) -> bool { + if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| { + (slot.host_image_id == image_id && slot.stream_is_active()) + .then_some(slot.direct_gate.as_mut()) + .flatten() + .filter(|gate| gate.client_id == client_id && gate.transfer_id == transfer_id) + }) { + gate.written = true; + gate.deadline = + std::time::Instant::now() + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT; + } + false + } + + pub(super) fn complete_direct_graphics( + &mut self, + client_id: u64, + transfer_id: u64, + image_id: u32, + success: bool, + ) -> bool { + if self.shutting_down { + self.retire_all_direct_graphics(); + return false; + } + let key = self.app.pane_graphics.slots.iter().find_map(|(key, slot)| { + slot.direct_gate + .as_ref() + .is_some_and(|gate| { + slot.stream_is_active() + && gate.client_id == client_id + && gate.transfer_id == transfer_id + && slot.host_image_id == image_id + && (!success || gate.written) + }) + .then(|| key.clone()) + }); + let Some(key) = key else { + return false; + }; + let pane_is_live = self + .app + .state + .workspaces + .iter() + .any(|workspace| workspace.pane_state(key.0).is_some()); + if !pane_is_live { + self.retire_direct_gate(&key); + return false; + } + if success { + let (gate, host_image_id) = { + let slot = self + .app + .pane_graphics + .slots + .get_mut(&key) + .expect("matched slot"); + if !slot.stream_is_active() { + return false; + } + if let Some(layer) = slot.layer.as_mut() { + layer.mark_resident(client_id); + } + ( + slot.direct_gate.take().expect("matched gate"), + slot.host_image_id, + ) + }; + if let (Some(client), Some(layer)) = ( + self.clients.get_mut(&client_id), + self.app + .pane_graphics + .slots + .get(&key) + .and_then(|slot| slot.layer.as_ref()), + ) { + client + .graphics_cache + .trust_pane_layer(&key, host_image_id, layer); + } + if gate.respond_to.send(gate.success_response).is_err() { + self.retire_direct_gate(&key); + return true; + } + return true; + } + + 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) { + self.retire_direct_gate(&key); + self.retire_all_direct_graphics(); + return true; + } + let gate = self + .app + .pane_graphics + .slots + .get_mut(&key) + .filter(|slot| slot.stream_is_active()) + .and_then(|slot| slot.direct_gate.take()); + let Some(gate) = gate else { + self.retire_direct_gate(&key); + return true; + }; + if gate.respond_to.send(gate.success_response).is_err() { + self.retire_direct_gate(&key); + return true; + } + self.retire_all_direct_graphics(); + true + } + + pub(super) fn expire_direct_graphics(&mut self, now: std::time::Instant) -> bool { + let expired = self + .app + .pane_graphics + .slots + .iter() + .filter_map(|(key, slot)| { + slot.direct_gate + .as_ref() + .filter(|gate| gate.deadline <= now) + .map(|gate| (key.clone(), gate.client_id)) + }) + .collect::<Vec<_>>(); + for (key, client_id) in &expired { + if let Some(slot) = self.app.pane_graphics.slots.get(key) { + if let Some(gate) = &slot.direct_gate { + self.send_to_client( + *client_id, + ServerMessage::GraphicsTransmissionRetired { + transfer_id: gate.transfer_id, + image_id: slot.host_image_id, + }, + ); + } + } + if let Some(client) = self.clients.get_mut(client_id) { + client.direct_graphics = false; + } + self.retire_direct_gate(key); + } + if !expired.is_empty() { + self.app.direct_graphics_available = false; + self.retire_all_direct_graphics(); + } + !expired.is_empty() + } + + pub(super) fn retire_all_direct_graphics(&mut self) { + let keys = self + .app + .pane_graphics + .slots + .iter() + .filter(|(_, slot)| { + slot.layer + .as_ref() + .is_some_and(crate::app::pane_graphics::Layer::terminal_only) + }) + .map(|(key, _)| key.clone()) + .collect::<Vec<_>>(); + for key in keys { + self.retire_direct_gate(&key); + } + } + + pub(super) fn retire_direct_graphics_for_client(&mut self, client_id: u64) { + let keys = self + .app + .pane_graphics + .slots + .iter() + .filter_map(|(key, slot)| { + let layer = slot.layer.as_ref()?; + let owned = layer.resident_client() == Some(client_id) + || (layer.direct_lease().is_some() + && slot + .direct_gate + .as_ref() + .is_some_and(|gate| gate.client_id == client_id)); + (layer.terminal_only() && owned).then(|| key.clone()) + }) + .collect::<Vec<_>>(); + for key in keys { + self.retire_direct_gate(&key); + } + } + pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome { crate::render_prof::event("retained_graphics.attempt"); if self.app.full_redraw_pending { @@ -117,74 +522,62 @@ impl HeadlessServer { let mut next_graphics_cache = client.graphics_cache.clone(); let encode_started = crate::render_prof::timer(); - let bytes = - frame_pane_graphics_for_client(crate::kitty_graphics::encode_local_pane_graphics( - &self.app.state, - &self.app.terminal_runtimes, - self.app.state.view.tab_surface(), - cell_size, - &mut next_graphics_cache, - )); + let encoded = crate::kitty_graphics::encode_local_pane_graphics( + &self.app.state, + &self.app.pane_graphics, + &self.app.terminal_runtimes, + self.app.state.view.tab_surface(), + cell_size, + Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET), + &mut next_graphics_cache, + ); crate::render_prof::duration_since("retained_graphics.graphics_encode", encode_started); - if bytes.len() > MAX_GRAPHICS_FRAME_SIZE { - warn!( - client_id, - graphics_bytes = bytes.len(), - max = MAX_GRAPHICS_FRAME_SIZE, - "dropping oversized retained graphics payload" - ); + prepared.push((client_id, encoded, next_graphics_cache)); + } + + let mut broken_clients = Vec::new(); + for (client_id, encoded, next_graphics_cache) in prepared { + let Some(client) = self.clients.get_mut(&client_id) else { continue; - } - let serialized = if bytes.is_empty() { + }; + let serialized = if encoded.bytes.is_empty() { None } else { - let serialize_started = crate::render_prof::timer(); - let framed = match Self::frame_server_message_with_max( - &ServerMessage::Graphics { bytes }, + match Self::frame_server_message_with_max( + &ServerMessage::Graphics { + bytes: frame_pane_graphics(encoded.bytes), + }, MAX_GRAPHICS_FRAME_SIZE, ) { - Ok(framed) => framed, - Err(err) => { - warn!(client_id, err = %err, "failed to serialize retained graphics"); + Ok(serialized) => Some(serialized), + Err(_) => { + crate::render_prof::event("retained_graphics_fallback.oversized"); return RetainedGraphicsOutcome::Fallback; } - }; - crate::render_prof::duration_since( - "retained_graphics.serialize", - serialize_started, - ); - Some(framed) - }; - prepared.push((client_id, serialized, next_graphics_cache)); - } - - let mut broken_clients = Vec::new(); - for (client_id, serialized, next_graphics_cache) in prepared { - let Some(client) = self.clients.get_mut(&client_id) else { - continue; + } }; - let Some(serialized) = serialized else { - client.graphics_cache = next_graphics_cache; - client.clear_deferred_render(); - continue; + let result = match (serialized, client.writer.as_ref()) { + (None, _) => Ok(()), + (Some(bytes), Some(writer)) => writer.render.try_send(bytes), + (Some(bytes), None) => Err(std::sync::mpsc::TrySendError::Disconnected(bytes)), }; - let Some(writer) = client.writer.as_ref().cloned() else { - broken_clients.push(client_id); - continue; - }; - match writer.render.try_send(serialized) { + match result { Ok(()) => { client.graphics_cache = next_graphics_cache; - client.clear_deferred_render(); + if encoded.incomplete { + client.defer_full_render(); + deferred = true; + } else { + client.clear_deferred_render(); + } crate::render_prof::event("retained_graphics.sent"); } Err(std::sync::mpsc::TrySendError::Full(_)) => { - client.defer_pane_graphics_render(); + client.defer_full_render(); deferred = true; - crate::render_prof::event("retained_graphics.deferred"); } Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - broken_clients.push(client_id); + broken_clients.push(client_id) } } } diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 1eba9816..f9bd0a01 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -1,12 +1,52 @@ use super::*; -#[test] -fn frames_preserve_cursor_without_changing_ordinary_messages() { - assert_eq!(frame_pane_graphics_for_client(Vec::new()), Vec::<u8>::new()); - assert_eq!( - frame_pane_graphics_for_client(b"graphics".to_vec()), - b"\x1b7graphics\x1b8" - ); +fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Duration) -> Vec<u8> { + receiver.recv_timeout(timeout).unwrap() +} + +#[tokio::test] +async fn cold_redraw_advances_one_bounded_layer_after_each_send() { + let (mut server, client_rx, pane_id) = retained_test_server(b"cold redraw"); + server.app.state.kitty_graphics_enabled = true; + server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + const LAYERS: usize = 8; + for index in 0..LAYERS { + set_named_graphics_layer( + &mut server, + pane_id, + &format!("layer-{index:02}"), + vec![index as u8; 1024 * 1024], + index as i32, + ); + } + + fill_render_lane(&server); + server.render_and_stream(); + assert!(server.clients[&1].graphics_cache.is_empty()); + let _older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + for expected in 1..=LAYERS { + server.render_and_stream(); + let bytes = client_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(bytes.len() <= MAX_GRAPHICS_FRAME_SIZE + 4); + let frame = read_server_frame(bytes); + assert_eq!( + frame + .graphics + .windows(4) + .filter(|part| *part == b"a=t,") + .count(), + 1 + ); + assert_eq!( + server.clients[&1].graphics_cache.test_image_count(), + expected + ); + } + assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None); } fn enable_graphics_and_render( @@ -19,39 +59,72 @@ fn enable_graphics_and_render( height_px: 20, }; server.render_and_stream(); - read_server_frame( - client_rx - .recv_timeout(Duration::from_millis(100)) - .expect("initial frame"), - ) + read_server_frame(receive_render(client_rx, Duration::from_millis(100))) +} + +fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key { + (pane_id, api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()) +} + +fn active_gate() -> std::sync::Arc<std::sync::atomic::AtomicBool> { + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) } fn set_graphics_layer(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, data: Vec<u8>) { - server.app.state.pane_graphics_layers.insert( + set_named_graphics_layer( + server, pane_id, - crate::app::state::PaneGraphicsLayer::new( - api::schema::PaneGraphicsFormat::Png, - 1, - 1, - data, - api::schema::PaneGraphicsPlacementParams::default(), - ), + api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID, + data, + 0, ); } +fn set_named_graphics_layer( + server: &mut HeadlessServer, + pane_id: crate::layout::PaneId, + layer_id: &str, + data: Vec<u8>, + z_index: i32, +) { + let key = (pane_id, layer_id.into()); + let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap(); + let layer = crate::app::pane_graphics::Layer::inline( + api::schema::PaneGraphicsFormat::Png, + 1, + 1, + data, + Default::default(), + z_index, + ); + server.app.pane_graphics.slots.insert( + key, + crate::app::pane_graphics::Slot::test(host_image_id, Some(layer)), + ); +} + +fn set_stream_owner(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, owner: &str) { + let key = graphics_key(pane_id); + if let Some(slot) = server.app.pane_graphics.slots.get_mut(&key) { + slot.stream_owner = Some(owner.into()); + slot.stream_active = Some(active_gate()); + } else { + let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap(); + let mut slot = crate::app::pane_graphics::Slot::test(host_image_id, None); + slot.stream_owner = Some(owner.into()); + slot.stream_active = Some(active_gate()); + server.app.pane_graphics.slots.insert(key, slot); + } +} + fn fill_render_lane(server: &HeadlessServer) { let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("dummy frame"); - server - .clients - .get(&1) - .unwrap() + server.clients[&1] .writer .as_ref() .unwrap() - .render - .try_send(queued) - .expect("pre-fill render lane"); + .test_fill_render(queued); } fn stream_set_message( @@ -68,6 +141,8 @@ fn stream_set_message( method: api::schema::Method::PaneGraphicsStreamSet( api::schema::PaneGraphicsSetParams { pane_id: pane_id.into(), + layer_id: None, + z_index: 0, owner: owner.into(), format: api::schema::PaneGraphicsFormat::Png, image_width: 1, @@ -80,12 +155,115 @@ fn stream_set_message( }, 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) = + retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer); + client.direct_graphics = false; + client.pixel_mouse = true; + client.host_mouse_capture_active = None; + client.host_sgr_pixels_active = None; + server.app.direct_graphics_available = false; + + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false + } + )); + + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: true + } + )); +} + +#[tokio::test] +async fn pixel_input_metadata_cannot_resize_authoritative_client_state() { + let (mut server, _client_rx, pane_id) = + retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); + set_graphics_layer(&mut server, pane_id, vec![1]); + let client = server.clients.get_mut(&1).unwrap(); + client.pixel_mouse = true; + client.host_sgr_pixels_active = Some(true); + server.foreground_client_id = None; + assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { + client_id: 1, + data: b"\x1b[<0;500;300M".to_vec(), + geometry: crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), + })); + server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }; + for (geometry, data) in [ + ( + crate::input::mouse::HostGeometry::new(100, 30, 1_000, 600).unwrap(), + b"\x1b[<0;500;300M".as_slice(), + ), + ( + crate::input::mouse::HostGeometry::new(80, 24, 960, 480).unwrap(), + b"\x1b[<0;500;300M", + ), + ( + crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(), + b"\x1b[<0;0;1M", + ), + ] { + assert!(!server.handle_server_event(ServerEvent::ClientInputPixels { + client_id: 1, + data: data.to_vec(), + geometry, + })); + } + assert_eq!(server.clients[&1].terminal_size, (80, 24)); + assert_eq!( + (server.effective_size, server.foreground_client_id), + ((80, 24), None) + ); +} + +#[test] +fn direct_eligibility_is_installed_with_the_client_connection() { + let mut server = test_headless_server(); + let (writer, _control_rx, _render_rx) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 10, + cell_height_px: 20, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: None, + direct_attach_requested: false, + direct_graphics: true, + writer, + })); + + let client = server.clients.get(&7).expect("connected client"); + assert!(client.direct_graphics); + assert_eq!(server.foreground_client_id, Some(7)); + assert!(server.app.direct_graphics_available); +} + +#[tokio::test] async fn focus_repaint_preserves_uploaded_graphics() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); let (client_2_writer, _client_2_control_rx, client_2_rx) = test_client_writer(); @@ -109,11 +287,8 @@ async fn focus_repaint_preserves_uploaded_graphics() { let initial_graphics = String::from_utf8_lossy(&initial.graphics); assert!(initial_graphics.contains("a=t")); assert!(initial_graphics.contains("a=p")); - let client_2_initial = read_server_frame( - client_2_rx - .recv_timeout(Duration::from_millis(100)) - .expect("second client initial frame"), - ); + let client_2_initial = + read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); assert!(String::from_utf8_lossy(&client_2_initial.graphics).contains("a=t")); assert!(server.handle_server_event(ServerEvent::ClientInput { @@ -123,12 +298,68 @@ async fn focus_repaint_preserves_uploaded_graphics() { assert_eq!(server.foreground_client_id, Some(2)); server.render_and_stream(); - let focused = read_server_frame( - client_2_rx - .recv_timeout(Duration::from_millis(100)) - .expect("focus redraw"), + let focused = read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100))); + let focused_graphics = String::from_utf8_lossy(&focused.graphics); + assert!(focused_graphics.contains("a=p")); + assert!(!focused_graphics.contains("a=t")); +} + +#[tokio::test] +async fn resize_replays_placement_without_retransmitting_or_closing_stream() { + let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); + set_stream_owner(&mut server, pane_id, "owner-resize"); + let initial = enable_graphics_and_render(&mut server, &client_rx); + assert!(String::from_utf8_lossy(&initial.graphics).contains("a=t")); + + for (cols, rows, cell_width_px, cell_height_px) in + [(100, 30, 10, 20), (100, 30, 12, 24), (100, 30, 12, 24)] + { + assert!(server.handle_server_event(ServerEvent::ClientResize { + client_id: 1, + cols, + rows, + cell_width_px, + cell_height_px, + })); + server.render_and_stream(); + let frame = read_server_frame(receive_render(&client_rx, Duration::from_millis(100))); + let graphics = String::from_utf8_lossy(&frame.graphics); + assert!(!graphics.contains("a=t")); + assert!(graphics.contains("a=p")); + } + assert_eq!( + server + .app + .pane_graphics + .slots + .get(&graphics_key(pane_id)) + .and_then(|slot| slot.stream_owner.as_deref()), + Some("owner-resize") ); - assert!(focused.graphics.is_empty()); +} + +#[tokio::test] +async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() { + let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa"); + set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]); + + assert!(!server + .app + .pane_graphics + .retain_live_panes(&server.app.state)); + assert!(server + .app + .pane_graphics + .slots + .contains_key(&graphics_key(pane_id))); + + server.app.state.workspaces.clear(); + assert!(server + .app + .pane_graphics + .retain_live_panes(&server.app.state)); + assert!(server.app.pane_graphics.slots.is_empty()); } #[tokio::test] @@ -164,58 +395,25 @@ async fn retained_update_sends_only_graphics_message() { } #[tokio::test] -async fn retained_update_defers_on_full_render_lane() { +async fn retained_graphics_stays_ordered_after_an_older_render() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); let _ = enable_graphics_and_render(&mut server, &client_rx); fill_render_lane(&server); set_graphics_layer(&mut server, pane_id, vec![4, 5, 6]); - + let older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!( server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Deferred + RetainedGraphicsOutcome::Sent ); - let client = server.clients.get(&1).unwrap(); - assert_eq!(client.deferred_render(), DeferredRender::Graphics); + let graphics = client_rx.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(matches!( - read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + read_server_message(older), ServerMessage::ReloadSoundConfig )); - assert_eq!( - server.handle_server_event_with_render_impact(ServerEvent::ClientWriterDrained { - client_id: 1 - }), - RenderImpact::Graphics - ); -} - -#[tokio::test] -async fn retained_update_does_not_downgrade_pending_full_render() { - let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); - let _ = enable_graphics_and_render(&mut server, &client_rx); - fill_render_lane(&server); - let client = server.clients.get_mut(&1).unwrap(); - client.request_repaint(); - server.render_and_stream(); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - - set_graphics_layer(&mut server, pane_id, vec![7, 8, 9]); - assert_eq!( - server.render_retained_graphics_update_and_stream(), - RetainedGraphicsOutcome::Deferred - ); - assert_eq!( - server.clients.get(&1).unwrap().deferred_render(), - DeferredRender::Full - ); - assert_eq!( - server.handle_server_event_with_render_impact(ServerEvent::ClientWriterDrained { - client_id: 1 - }), - RenderImpact::Full - ); + assert!(matches!( + read_server_message(graphics), + ServerMessage::Graphics { .. } + )); } #[tokio::test] @@ -247,6 +445,64 @@ async fn retained_update_falls_back_for_mixed_app_geometry() { } #[test] +fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() { + let mut server = test_headless_server(); + server.app.state.kitty_graphics_enabled = true; + let workspace = crate::workspace::Workspace::test_new("gated"); + let pane_id = workspace.tabs[0].root_pane; + let public = format!("{}:p1", workspace.id); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + let active = active_gate(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "open-gated".into(), + method: api::schema::Method::PaneGraphicsStreamOpen( + api::schema::PaneGraphicsStreamParams { + pane_id: public.clone(), + layer_id: None, + z_index: 0, + owner: "worker-1".into(), + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: Some(active.clone()), + }); + assert!( + serde_json::from_str::<api::schema::SuccessResponse>(&response_rx.recv().unwrap()).is_ok() + ); + let (frame, frame_response) = + stream_set_message("gated-frame", &public, "worker-1", vec![1, 2, 3]); + assert_eq!( + server.handle_api_request_with_render_impact(frame), + RenderImpact::Graphics + ); + assert!(frame_response.recv().is_ok()); + assert!(active.load(std::sync::atomic::Ordering::Acquire)); + active.store(false, std::sync::atomic::Ordering::Release); + let (delayed, delayed_response) = + stream_set_message("delayed-frame", &public, "worker-1", vec![4, 5, 6]); + assert_eq!( + server.handle_api_request_with_render_impact(delayed), + RenderImpact::None + ); + let error: api::schema::ErrorResponse = + serde_json::from_str(&delayed_response.recv().unwrap()).unwrap(); + assert_eq!(error.error.code, "stream_closed"); + assert!(server + .app + .pane_graphics + .slots + .remove(&graphics_key(pane_id)) + .is_some()); + assert!(!active.load(std::sync::atomic::Ordering::Acquire)); +} + +#[test] fn stream_set_has_graphics_only_render_impact() { let mut server = test_headless_server(); let workspace = crate::workspace::Workspace::test_new("graphics"); @@ -256,11 +512,7 @@ fn stream_set_has_graphics_only_render_impact() { server.app.state.active = Some(0); server.app.state.selected = 0; server.app.state.kitty_graphics_enabled = true; - server - .app - .state - .pane_graphics_streams - .insert(pane_id, "owner-a".into()); + set_stream_owner(&mut server, pane_id, "owner-a"); let (request, response_rx) = stream_set_message("wrong-owner", &public_pane_id, "owner-b", vec![1, 2, 3]); @@ -307,13 +559,15 @@ fn stream_set_has_graphics_only_render_impact() { RenderImpact::Full ); - server.app.state.pane_graphics_streams.clear(); + server.app.pane_graphics.clear(); let (respond_to, _response_rx) = std::sync::mpsc::channel(); let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage { request: api::schema::Request { id: "direct-frame".into(), method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams { pane_id: public_pane_id, + layer_id: None, + z_index: 0, owner: String::new(), format: api::schema::PaneGraphicsFormat::Png, image_width: 1, @@ -325,6 +579,7 @@ fn stream_set_has_graphics_only_render_impact() { }, respond_to, response_write_complete: None, + stream_active: None, }); assert_eq!(impact, RenderImpact::Full); } @@ -345,6 +600,8 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { id: "disabled-set".into(), method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams { pane_id: public_pane_id.clone(), + layer_id: None, + z_index: 0, owner: String::new(), format: api::schema::PaneGraphicsFormat::Png, image_width: 1, @@ -356,6 +613,7 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { }, respond_to, response_write_complete: None, + stream_active: None, }); assert!(!changed); let response = response_rx @@ -370,11 +628,7 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { ); server.app.state.kitty_graphics_enabled = true; - server - .app - .state - .pane_graphics_streams - .insert(pane_id, "current-owner".into()); + set_stream_owner(&mut server, pane_id, "current-owner"); let (respond_to, response_rx) = std::sync::mpsc::channel(); let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage { request: api::schema::Request { @@ -382,17 +636,25 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { method: api::schema::Method::PaneGraphicsStreamClose( api::schema::PaneGraphicsStreamParams { pane_id: public_pane_id, + layer_id: None, + z_index: 0, owner: "stale-owner".into(), }, ), }, respond_to, response_write_complete: None, + stream_active: None, }); assert_eq!(impact, RenderImpact::None); assert_eq!( - server.app.state.pane_graphics_streams.get(&pane_id), - Some(&"current-owner".to_string()) + server + .app + .pane_graphics + .slots + .get(&graphics_key(pane_id)) + .and_then(|slot| slot.stream_owner.as_deref()), + Some("current-owner") ); assert!(serde_json::from_str::<api::schema::SuccessResponse>( &response_rx @@ -401,3 +663,282 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() { ) .is_ok()); } + +#[cfg(unix)] +fn direct_gate_server( + data: &[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(); + let workspace = crate::workspace::Workspace::test_new("direct-gate"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + let key = graphics_key(pane_id); + let path = server + .app + .pane_graphics_files + .source_directory() + .unwrap() + .join("gate-frame"); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .unwrap(); + file.write_all(data).unwrap(); + drop(file); + let lease = server + .app + .pane_graphics_files + .lease(&path, data.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); + let mut slot = crate::app::pane_graphics::Slot::test((1 << 31) | 900, Some(layer)); + slot.stream_owner = Some("owner".into()); + slot.stream_active = Some(active_gate()); + slot.direct_gate = Some(crate::app::pane_graphics::DirectGate { + transfer_id: lease.fingerprint(), + client_id: 7, + deadline: std::time::Instant::now() + Duration::from_secs(1), + written: true, + success_response: "ack".into(), + respond_to, + }); + server.app.pane_graphics.slots.insert(key.clone(), slot); + (server, key, response_rx) +} + +#[cfg(unix)] +fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) { + let slot = &server.app.pane_graphics.slots[key]; + ( + slot.direct_gate.as_ref().unwrap().transfer_id, + slot.host_image_id, + ) +} + +#[cfg(unix)] +fn add_direct_client(server: &mut HeadlessServer, client_id: u64) { + let (writer, control_rx, render_rx) = test_client_writer(); + std::mem::forget((control_rx, render_rx)); + let mut client = ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize { + width_px: 10, + height_px: 20, + }, + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(writer), + ); + client.direct_graphics = true; + client.pixel_mouse = true; + server.clients.insert(client_id, client); +} + +#[cfg(unix)] +#[test] +fn terminal_response_deadline_starts_only_after_client_flush() { + let (mut server, key, _response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap(); + let gate = slot.direct_gate.as_mut().unwrap(); + gate.written = false; + let (transfer_id, image_id) = (gate.transfer_id, slot.host_image_id); + assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); + assert!(!server.start_direct_graphics_response(7, transfer_id, image_id)); + let gate = server.app.pane_graphics.slots[&key] + .direct_gate + .as_ref() + .unwrap(); + assert!(gate.written && gate.deadline > std::time::Instant::now()); +} + +#[cfg(unix)] +#[test] +fn outer_timeout_covers_both_direct_phases_and_cancellation_blocks_late_results() { + assert!( + crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT + > crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT + + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT + ); + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap(); + slot.stream_active + .as_ref() + .unwrap() + .store(false, std::sync::atomic::Ordering::Release); + let (transfer_id, image_id) = ( + slot.direct_gate.as_ref().unwrap().transfer_id, + slot.host_image_id, + ); + + assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); + assert!(response_rx.try_recv().is_err()); + assert!(server.app.pane_graphics.slots[&key] + .layer + .as_ref() + .unwrap() + .direct_lease() + .is_some()); +} + +#[cfg(unix)] +#[test] +fn matching_terminal_ok_releases_producer_and_acknowledges() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (transfer_id, image_id) = direct_ids(&server, &key); + + assert!(server.complete_direct_graphics(7, transfer_id, image_id, true)); + + assert_eq!(response_rx.recv().unwrap(), "ack"); + let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); + assert!(layer.terminal_only()); + assert!(layer.direct_lease().is_none()); +} + +#[cfg(unix)] +#[test] +fn explicit_terminal_error_acks_only_after_owned_inline_fallback() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + add_direct_client(&mut server, 7); + let (transfer_id, image_id) = direct_ids(&server, &key); + let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); + server + .clients + .get_mut(&7) + .unwrap() + .graphics_cache + .trust_pane_layer(&key, image_id, layer); + assert!(server.complete_direct_graphics(7, transfer_id, image_id, false)); + + let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap(); + assert_eq!( + ( + response_rx.recv().unwrap(), + layer.inline_data(), + server.clients[&7].direct_graphics, + server.clients[&7].pixel_mouse, + ), + ("ack".into(), Some([1, 2, 3, 4].as_slice()), false, true) + ); + assert!(server.clients[&7].graphics_cache.is_empty()); +} + +#[cfg(unix)] +#[test] +fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() { + for error in [ + std::sync::mpsc::TrySendError::Full(Vec::new()), + std::sync::mpsc::TrySendError::Disconnected(Vec::new()), + ] { + let should_ack = matches!(error, std::sync::mpsc::TrySendError::Full(_)); + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + add_direct_client(&mut server, 7); + let gate = server + .app + .pane_graphics + .slots + .get_mut(&key) + .and_then(|slot| slot.direct_gate.take()) + .unwrap(); + let result = server.handle_unwritten_direct_failure( + &key, + gate.success_response, + gate.respond_to, + error, + ); + let inline = server + .app + .pane_graphics + .slots + .get(&key) + .and_then(|slot| slot.layer.as_ref()?.inline_data()) + .is_some(); + assert_eq!( + ( + result, + response_rx.try_recv().ok().as_deref() == Some("ack"), + inline, + server.clients[&7].direct_graphics, + ), + (should_ack, should_ack, should_ack, true) + ); + } +} + +#[cfg(unix)] +#[test] +fn client_loss_retires_only_its_direct_stream() { + let (mut pending, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + pending.retire_direct_graphics_for_client(8); + assert!(pending.app.pane_graphics.slots.contains_key(&key)); + pending.retire_direct_graphics_for_client(7); + assert!(!pending.app.pane_graphics.slots.contains_key(&key)); + assert!(response_rx.recv().is_err()); + + let (mut resident, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let slot = resident.app.pane_graphics.slots.get(&key).unwrap(); + assert!(resident.complete_direct_graphics( + 7, + slot.direct_gate.as_ref().unwrap().transfer_id, + slot.host_image_id, + true, + )); + assert_eq!(response_rx.recv().unwrap(), "ack"); + resident.retire_direct_graphics_for_client(8); + assert!(resident.app.pane_graphics.slots.contains_key(&key)); + resident.retire_direct_graphics_for_client(7); + assert!(!resident.app.pane_graphics.slots.contains_key(&key)); +} + +#[cfg(unix)] +#[test] +fn pane_removal_and_shutdown_drop_direct_without_ack() { + let setups: [fn(&mut HeadlessServer); 2] = [ + |server| server.app.state.workspaces.clear(), + |server| server.shutting_down = true, + ]; + for setup in setups { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + let (transfer_id, image_id) = direct_ids(&server, &key); + setup(&mut server); + assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true)); + assert!(response_rx.recv().is_err()); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + } +} + +#[cfg(unix)] +#[test] +fn timeout_retires_stream_without_producer_ack() { + let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]); + add_direct_client(&mut server, 7); + server + .app + .pane_graphics + .slots + .get_mut(&key) + .unwrap() + .direct_gate + .as_mut() + .unwrap() + .deadline = std::time::Instant::now() - Duration::from_millis(1); + + assert!(server.expire_direct_graphics(std::time::Instant::now())); + + assert!(response_rx.recv().is_err()); + assert!(!server.app.pane_graphics.slots.contains_key(&key)); + assert!(!server.clients[&7].direct_graphics); + assert!(server.clients[&7].pixel_mouse); +} |
