From 24012601f25c4f60dd33fb25c18cc9beaaa1e022 Mon Sep 17 00:00:00 2001 From: irving ou Date: Thu, 4 Jun 2026 15:03:40 -0400 Subject: [PATCH] fix(terminal-streamer): correct TRP live-shadow sizing and large-line panic Two TRP-only shadow-streaming bugs: 1. Size header was emitted with width/height swapped and non-standard row/col keys (read columns from bytes[2..4] instead of [0..2]). Read columns from [0..2], rows from [2..4], and emit a standard asciicast v2 {width,height} header so clients size the terminal correctly. 2. AsyncReadChannel::poll_read copied an entire decoded line into the caller's ReadBuf without bounds, panicking when a line exceeded the buffer (e.g. a full-screen htop redraw > 8 KiB). Copy only what fits and buffer the remainder across reads. --- crates/terminal-streamer/src/asciinema.rs | 6 ++- crates/terminal-streamer/src/trp_decoder.rs | 43 +++++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/terminal-streamer/src/asciinema.rs b/crates/terminal-streamer/src/asciinema.rs index 1eb954b82..7516ee41b 100644 --- a/crates/terminal-streamer/src/asciinema.rs +++ b/crates/terminal-streamer/src/asciinema.rs @@ -24,9 +24,11 @@ pub(crate) enum AsciinemaEvent { impl AsciinemaHeader { pub(crate) fn to_json(&self) -> String { + // Emit the standard asciicast v2 header (width = columns, height = rows) so any cast + // client (asciinema-player, the Avalonia replayer, …) sizes the terminal correctly. format!( - r#"{{"version": {}, "row": {}, "col": {}}}"#, - self.version, self.row, self.col + r#"{{"version": {}, "width": {}, "height": {}}}"#, + self.version, self.col, self.row ) } } diff --git a/crates/terminal-streamer/src/trp_decoder.rs b/crates/terminal-streamer/src/trp_decoder.rs index ebfd8c1b7..5daa76b1e 100644 --- a/crates/terminal-streamer/src/trp_decoder.rs +++ b/crates/terminal-streamer/src/trp_decoder.rs @@ -23,20 +23,48 @@ pub fn decode_stream( struct AsyncReadChannel { receiver: tokio::sync::mpsc::Receiver>, + // A single decoded message can be larger than the caller's read buffer (e.g. a full-screen + // redraw becomes one big cast line). Hold the unread remainder across poll_read calls. + leftover: Vec, + leftover_pos: usize, } impl AsyncReadChannel { fn new(receiver: tokio::sync::mpsc::Receiver>) -> Self { - Self { receiver } + Self { + receiver, + leftover: Vec::new(), + leftover_pos: 0, + } } } impl AsyncRead for AsyncReadChannel { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - let res = Pin::new(&mut self.receiver).poll_recv(cx); - match res { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let this = self.get_mut(); + + // Drain any leftover from a previous oversized message before pulling a new one. + if this.leftover_pos < this.leftover.len() { + let n = std::cmp::min(buf.remaining(), this.leftover.len() - this.leftover_pos); + buf.put_slice(&this.leftover[this.leftover_pos..this.leftover_pos + n]); + this.leftover_pos += n; + if this.leftover_pos >= this.leftover.len() { + this.leftover.clear(); + this.leftover_pos = 0; + } + return Poll::Ready(Ok(())); + } + + match Pin::new(&mut this.receiver).poll_recv(cx) { Poll::Ready(Some(Ok(data))) => { - buf.put_slice(data.as_bytes()); + // Only copy what fits; buffer the rest so we never overflow the read buffer. + let bytes = data.as_bytes(); + let n = std::cmp::min(buf.remaining(), bytes.len()); + buf.put_slice(&bytes[..n]); + if n < bytes.len() { + this.leftover = bytes[n..].to_vec(); + this.leftover_pos = 0; + } Poll::Ready(Ok(())) } Poll::Ready(Some(Err(e))) => Poll::Ready(Err(std::io::Error::other(e))), @@ -120,8 +148,9 @@ async fn parse_trp_stream( 2 => { // Terminal size change if before_setup_cache.is_some() { - header.row = u16::from_le_bytes(event_payload[0..2].try_into()?); - header.col = u16::from_le_bytes(event_payload[2..4].try_into()?); + // Size-change payload is little-endian [columns, rows]. + header.col = u16::from_le_bytes(event_payload[0..2].try_into()?); + header.row = u16::from_le_bytes(event_payload[2..4].try_into()?); } else { let event = AsciinemaEvent::Resize { width: header.col,