aboutsummaryrefslogtreecommitdiff
path: root/src/server/client_transport.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-09 02:04:39 +0300
committerGitHub <noreply@github.com>2026-08-09 02:04:39 +0300
commit1777e9bba32b953ed1ad203b4a16d01105539000 (patch)
tree22096973812c9baba822453d8470bdcfe64fc2a6 /src/server/client_transport.rs
parent36a8d36a7258e08b40c01bf521b1f4006525f143 (diff)
feat(graphics): add direct pane frame streaming (#2523)
* feat(graphics): add direct pane frame streaming * feat(graphics): expose pane visibility * test(graphics): preserve legacy terminal image output * fix(graphics): harden streaming lifecycle * fix(input): preserve mouse releases outside panes
Diffstat (limited to 'src/server/client_transport.rs')
-rw-r--r--src/server/client_transport.rs381
1 files changed, 290 insertions, 91 deletions
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 2675b4c3..b5023b97 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -41,6 +41,8 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
/// Maximum structured input events accepted in one client message.
const MAX_INPUT_EVENT_BATCH: usize = 4096;
+/// Maximum encoded mouse report accepted with pixel geometry.
+const MAX_PIXEL_MOUSE_PAYLOAD: usize = 128;
/// Channels owned by the server side of a client writer thread.
#[derive(Clone, Debug)]
@@ -51,115 +53,99 @@ pub(crate) struct ClientWriter {
pub(crate) render: ClientRenderWriter,
}
-#[cfg(test)]
impl ClientWriter {
+ pub(crate) fn replace_with_cleanup(&self, data: Vec<u8>) {
+ self.render.queue.replace_with_cleanup(data);
+ }
+
+ #[cfg(test)]
+ pub(crate) fn test_fill_render(&self, data: Vec<u8>) {
+ self.render.try_send(data).unwrap();
+ }
+
+ #[cfg(test)]
+ pub(crate) fn test_close(&self) {
+ self.render.queue.close_writer();
+ }
+
+ #[cfg(test)]
pub(crate) fn test_channel(
control: std::sync::mpsc::Sender<Vec<u8>>,
render: std::sync::mpsc::SyncSender<Vec<u8>>,
) -> Self {
- Self {
- control: ClientControlWriter {
- target: ClientControlTarget::Channel(control),
- },
- render: ClientRenderWriter {
- target: ClientRenderTarget::Channel(render),
- },
- }
+ let queue = ClientWriterQueue::new();
+ let drain = queue.clone();
+ let control_writer = ClientControlWriter::queue(queue.clone());
+ let mut render_writer = ClientRenderWriter::queue(queue);
+ render_writer.test_render = Some(render.clone());
+ let writer = Self {
+ control: control_writer,
+ render: render_writer,
+ };
+ std::thread::spawn(move || {
+ while let Some(item) = drain.recv() {
+ let sent = match item {
+ ClientWriteItem::Control(data) => control.send(data).is_ok(),
+ ClientWriteItem::Render(data) => render.send(data).is_ok(),
+ };
+ if !sent {
+ break;
+ }
+ }
+ drain.close_writer();
+ });
+ writer
}
}
#[derive(Debug)]
pub(crate) struct ClientControlWriter {
- target: ClientControlTarget,
-}
-
-#[derive(Debug)]
-enum ClientControlTarget {
- Queue(Arc<ClientWriterQueue>),
+ queue: Arc<ClientWriterQueue>,
#[cfg(test)]
- Channel(std::sync::mpsc::Sender<Vec<u8>>),
+ test_render: Option<std::sync::mpsc::SyncSender<Vec<u8>>>,
}
#[derive(Debug)]
pub(crate) struct ClientRenderWriter {
- target: ClientRenderTarget,
-}
-
-#[derive(Debug)]
-enum ClientRenderTarget {
- Queue(Arc<ClientWriterQueue>),
+ queue: Arc<ClientWriterQueue>,
#[cfg(test)]
- Channel(std::sync::mpsc::SyncSender<Vec<u8>>),
+ test_render: Option<std::sync::mpsc::SyncSender<Vec<u8>>>,
}
-impl Clone for ClientControlWriter {
- fn clone(&self) -> Self {
- match &self.target {
- ClientControlTarget::Queue(queue) => {
- queue.add_sender();
+macro_rules! writer_handle {
+ ($type:ty) => {
+ impl Clone for $type {
+ fn clone(&self) -> Self {
+ self.queue.add_sender();
Self {
- target: ClientControlTarget::Queue(queue.clone()),
+ queue: self.queue.clone(),
+ #[cfg(test)]
+ test_render: self.test_render.clone(),
}
}
- #[cfg(test)]
- ClientControlTarget::Channel(sender) => Self {
- target: ClientControlTarget::Channel(sender.clone()),
- },
}
- }
-}
-
-impl Drop for ClientControlWriter {
- fn drop(&mut self) {
- match &self.target {
- ClientControlTarget::Queue(queue) => queue.remove_sender(),
- #[cfg(test)]
- ClientControlTarget::Channel(_) => {}
+ impl Drop for $type {
+ fn drop(&mut self) {
+ self.queue.remove_sender();
+ }
}
- }
+ };
}
+writer_handle!(ClientControlWriter);
+writer_handle!(ClientRenderWriter);
impl ClientControlWriter {
fn queue(queue: Arc<ClientWriterQueue>) -> Self {
queue.add_sender();
Self {
- target: ClientControlTarget::Queue(queue),
- }
- }
-
- pub(crate) fn send(&self, data: Vec<u8>) -> Result<(), SendError<Vec<u8>>> {
- match &self.target {
- ClientControlTarget::Queue(queue) => queue.send_control(data),
- #[cfg(test)]
- ClientControlTarget::Channel(sender) => sender.send(data),
- }
- }
-}
-
-impl Clone for ClientRenderWriter {
- fn clone(&self) -> Self {
- match &self.target {
- ClientRenderTarget::Queue(queue) => {
- queue.add_sender();
- Self {
- target: ClientRenderTarget::Queue(queue.clone()),
- }
- }
+ queue,
#[cfg(test)]
- ClientRenderTarget::Channel(sender) => Self {
- target: ClientRenderTarget::Channel(sender.clone()),
- },
+ test_render: None,
}
}
-}
-impl Drop for ClientRenderWriter {
- fn drop(&mut self) {
- match &self.target {
- ClientRenderTarget::Queue(queue) => queue.remove_sender(),
- #[cfg(test)]
- ClientRenderTarget::Channel(_) => {}
- }
+ pub(crate) fn send(&self, data: Vec<u8>) -> Result<(), SendError<Vec<u8>>> {
+ self.queue.send_control(data)
}
}
@@ -167,16 +153,22 @@ impl ClientRenderWriter {
fn queue(queue: Arc<ClientWriterQueue>) -> Self {
queue.add_sender();
Self {
- target: ClientRenderTarget::Queue(queue),
+ queue,
+ #[cfg(test)]
+ test_render: None,
}
}
pub(crate) fn try_send(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
- match &self.target {
- ClientRenderTarget::Queue(queue) => queue.try_send_render(data),
- #[cfg(test)]
- ClientRenderTarget::Channel(sender) => sender.try_send(data),
+ #[cfg(test)]
+ if let Some(sender) = &self.test_render {
+ return sender.try_send(data);
}
+ self.queue.try_send_render(data)
+ }
+
+ pub(crate) fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
+ self.queue.send_ordered(data)
}
}
@@ -189,6 +181,7 @@ struct ClientWriterQueue {
#[derive(Debug, Default)]
struct ClientWriterQueueState {
control: VecDeque<Vec<u8>>,
+ ordered: VecDeque<Vec<u8>>,
render: Option<Vec<u8>>,
senders: usize,
writer_alive: bool,
@@ -245,16 +238,45 @@ impl ClientWriterQueue {
Ok(())
}
+ fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
+ let mut state = self.lock_state();
+ if !state.writer_alive {
+ return Err(TrySendError::Disconnected(data));
+ }
+ if !state.ordered.is_empty() {
+ return Err(TrySendError::Full(data));
+ }
+ if let Some(older) = state.render.take() {
+ state.ordered.push_back(older);
+ }
+ state.ordered.push_back(data);
+ self.ready.notify_one();
+ Ok(())
+ }
+
+ fn replace_with_cleanup(&self, data: Vec<u8>) {
+ let mut state = self.lock_state();
+ state.render = None;
+ state.ordered.clear();
+ if state.writer_alive {
+ state.control.push_back(data);
+ self.ready.notify_one();
+ }
+ }
+
fn recv(&self) -> Option<ClientWriteItem> {
let mut state = self.lock_state();
loop {
if let Some(data) = state.control.pop_front() {
return Some(ClientWriteItem::Control(data));
}
- if let Some(data) = state.render.take() {
+ if let Some(data) = state.ordered.pop_front() {
self.ready.notify_one();
return Some(ClientWriteItem::Render(data));
}
+ if let Some(data) = state.render.take() {
+ return Some(ClientWriteItem::Render(data));
+ }
if state.senders == 0 {
return None;
}
@@ -268,6 +290,8 @@ impl ClientWriterQueue {
fn close_writer(&self) {
let mut state = self.lock_state();
state.writer_alive = false;
+ state.render = None;
+ state.ordered.clear();
self.ready.notify_all();
}
@@ -291,10 +315,29 @@ pub(crate) enum ServerEvent {
render_encoding: RenderEncoding,
keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
direct_attach_requested: bool,
+ direct_graphics: bool,
writer: ClientWriter,
},
/// A client sent an input message.
ClientInput { client_id: u64, data: Vec<u8> },
+ /// A client reported the one armed Kitty regular-file response.
+ GraphicsTransmissionResult {
+ client_id: u64,
+ transfer_id: u64,
+ image_id: u32,
+ success: bool,
+ },
+ GraphicsTransmissionStarted {
+ client_id: u64,
+ transfer_id: u64,
+ image_id: u32,
+ },
+ /// One confirmed SGR pixel report with client read-time geometry.
+ ClientInputPixels {
+ client_id: u64,
+ data: Vec<u8>,
+ geometry: crate::input::mouse::HostGeometry,
+ },
/// A client sent structured input events.
ClientInputEvents {
client_id: u64,
@@ -515,6 +558,7 @@ pub(crate) fn handle_client_handshake(
render_encoding,
keybindings,
direct_attach_requested,
+ direct_graphics,
) = match hello {
ClientMessage::Hello {
version,
@@ -564,6 +608,7 @@ pub(crate) fn handle_client_handshake(
requested_encoding,
keybindings,
launch_mode == ClientLaunchMode::TerminalAttach,
+ launch_mode == ClientLaunchMode::AppDirectGraphics,
)
}
_ => {
@@ -618,6 +663,7 @@ pub(crate) fn handle_client_handshake(
render_encoding,
keybindings,
direct_attach_requested,
+ direct_graphics,
writer,
});
@@ -633,19 +679,17 @@ fn client_writer_loop(
server_event_tx: mpsc::Sender<ServerEvent>,
) {
while let Some(item) = writer_queue.recv() {
- match item {
- ClientWriteItem::Control(data) => {
- if !write_framed_bytes(&mut stream, &data) {
- break;
- }
- }
+ let written = match item {
+ ClientWriteItem::Control(data) => write_framed_bytes(&mut stream, &data),
ClientWriteItem::Render(data) => {
let _ =
server_event_tx.blocking_send(ServerEvent::ClientWriterDrained { client_id });
- if !write_framed_bytes(&mut stream, &data) {
- break;
- }
+ write_framed_bytes(&mut stream, &data)
}
+ };
+ if !written {
+ let _ = server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
}
}
writer_queue.close_writer();
@@ -728,6 +772,47 @@ fn client_read_loop(
ServerEvent::ClientInput { client_id, data }
}
}
+ ClientMessage::InputPixels {
+ data,
+ cols,
+ rows,
+ width_px,
+ height_px,
+ } => {
+ let Some(geometry) =
+ crate::input::mouse::HostGeometry::new(cols, rows, width_px, height_px)
+ else {
+ warn!(
+ client_id,
+ cols,
+ rows,
+ width_px,
+ height_px,
+ "invalid pixel mouse geometry from client, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ };
+ if data.len() > MAX_PIXEL_MOUSE_PAYLOAD
+ || crate::input::mouse::parse_report(&data).is_none()
+ {
+ warn!(
+ client_id,
+ size = data.len(),
+ max = MAX_PIXEL_MOUSE_PAYLOAD,
+ "invalid pixel mouse report from client, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ ServerEvent::ClientInputPixels {
+ client_id,
+ data,
+ geometry,
+ }
+ }
ClientMessage::InputEvents { events } => match input_event_limit(&events) {
InputEventLimit::WithinLimits => {
ServerEvent::ClientInputEvents { client_id, events }
@@ -777,6 +862,24 @@ fn client_read_loop(
takeover,
}
}
+ ClientMessage::GraphicsTransmissionResult {
+ transfer_id,
+ image_id,
+ success,
+ } => ServerEvent::GraphicsTransmissionResult {
+ client_id,
+ transfer_id,
+ image_id,
+ success,
+ },
+ ClientMessage::GraphicsTransmissionStarted {
+ transfer_id,
+ image_id,
+ } => ServerEvent::GraphicsTransmissionStarted {
+ client_id,
+ transfer_id,
+ image_id,
+ },
ClientMessage::ClipboardImage { extension, data } => {
if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
warn!(
@@ -948,6 +1051,30 @@ mod tests {
}
#[test]
+ fn ordered_direct_follows_older_render_and_stays_bounded() {
+ let (writer, queue) = test_queue_writer();
+ writer.render.try_send(b"old".to_vec()).unwrap();
+ writer.render.send_ordered(b"direct".to_vec()).unwrap();
+ assert!(matches!(
+ writer.render.send_ordered(b"second".to_vec()),
+ Err(TrySendError::Full(_))
+ ));
+ writer.render.try_send(b"new".to_vec()).unwrap();
+
+ for expected in [b"old".as_slice(), b"direct", b"new"] {
+ assert_eq!(
+ queue.recv(),
+ Some(ClientWriteItem::Render(expected.to_vec()))
+ );
+ }
+ queue.close_writer();
+ assert!(matches!(
+ writer.render.send_ordered(b"closed".to_vec()),
+ Err(TrySendError::Disconnected(_))
+ ));
+ }
+
+ #[test]
fn client_writer_prioritizes_control_and_reports_render_drain() {
let (mut client_stream, server_stream, _path) = local_stream_pair("client-writer-priority");
let (writer, queue) = test_queue_writer();
@@ -1203,6 +1330,7 @@ new_tab = "ctrl+notakey"
render_encoding,
keybindings,
direct_attach_requested,
+ direct_graphics,
writer,
} => {
assert_eq!(client_id, 42);
@@ -1211,6 +1339,7 @@ new_tab = "ctrl+notakey"
assert_eq!(render_encoding, RenderEncoding::TerminalAnsi);
assert!(keybindings.is_none());
assert!(!direct_attach_requested);
+ assert!(!direct_graphics);
drop(writer);
}
other => panic!("expected ClientConnected, got {other:?}"),
@@ -1395,6 +1524,76 @@ new_tab = "ctrl+notakey"
}
#[test]
+ fn client_read_loop_disconnects_invalid_pixel_mouse_geometry() {
+ let (mut client_stream, server_stream, _path) =
+ local_stream_pair("client-read-invalid-pixel-geometry");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let read_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
+ });
+
+ protocol::write_message(
+ &mut client_stream,
+ &ClientMessage::InputPixels {
+ data: b"\x1b[<35;1;1M".to_vec(),
+ cols: 0,
+ rows: 24,
+ width_px: 800,
+ height_px: 480,
+ },
+ )
+ .expect("write invalid pixel geometry");
+
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "invalid pixel geometry disconnect"),
+ ServerEvent::ClientDisconnected { client_id: 7 }
+ ));
+ drop(client_stream);
+ should_quit.store(true, Ordering::Release);
+ handle
+ .join()
+ .expect("read thread join")
+ .expect("read thread result");
+ }
+
+ #[test]
+ fn client_read_loop_disconnects_invalid_pixel_mouse_report() {
+ let (mut client_stream, server_stream, _path) =
+ local_stream_pair("client-read-invalid-pixel-report");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let read_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
+ });
+
+ protocol::write_message(
+ &mut client_stream,
+ &ClientMessage::InputPixels {
+ data: vec![b'x'; MAX_PIXEL_MOUSE_PAYLOAD + 1],
+ cols: 80,
+ rows: 24,
+ width_px: 800,
+ height_px: 480,
+ },
+ )
+ .expect("write invalid pixel report");
+
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "invalid pixel report disconnect"),
+ ServerEvent::ClientDisconnected { client_id: 7 }
+ ));
+ drop(client_stream);
+ should_quit.store(true, Ordering::Release);
+ handle
+ .join()
+ .expect("read thread join")
+ .expect("read thread result");
+ }
+
+ #[test]
fn client_read_loop_disconnects_marker_wrapped_invalid_utf8() {
let (mut client_stream, server_stream, _path) =
local_stream_pair("client-read-invalid-utf8-paste");