diff --git a/runner/src/session.rs b/runner/src/session.rs index f9eb8da..3b9e02f 100644 --- a/runner/src/session.rs +++ b/runner/src/session.rs @@ -315,6 +315,8 @@ impl TerminalSession { let raw = &buf[..n]; // 1. Sanitize output (strip OSC 52 clipboard, etc.) + // Returns Borrowed(raw) in the common case (no OSC 52), + // avoiding any allocation. let sanitized = vt_parser::sanitize_output(raw); // 2. Feed to VT state @@ -327,8 +329,12 @@ impl TerminalSession { // 3. Mark snapshot dirty — recomputed lazily on next AttachSession. self.snapshot_dirty.store(true, Ordering::Release); - // 4. Broadcast sanitized bytes to subscribers - let bytes = Bytes::copy_from_slice(&sanitized); + // 4. Broadcast sanitized bytes to subscribers. + // For Borrowed (no OSC-52): into_owned() copies once into Vec, then + // Bytes takes ownership — same cost as before but we avoided the + // first allocation in sanitize_output. + // For Owned (OSC-52 stripped): Bytes takes the existing Vec, no copy. + let bytes = Bytes::from(sanitized.into_owned()); // Ignore send errors — no receivers is fine. let _ = self.broadcast_tx.send(bytes); } diff --git a/runner/src/vt_parser.rs b/runner/src/vt_parser.rs index a243c7f..e6743c0 100644 --- a/runner/src/vt_parser.rs +++ b/runner/src/vt_parser.rs @@ -3,6 +3,7 @@ //! Maintains a virtual screen buffer so that reconnecting clients can receive //! a snapshot of the current terminal state without replaying the full history. +use std::borrow::Cow; use std::collections::VecDeque; use std::io::Write as _; @@ -104,7 +105,8 @@ impl Row { pub struct ScreenBuffer { cols: usize, rows: usize, - grid: Vec, + /// Active screen rows stored in a deque so scroll_up/scroll_down are O(1). + grid: VecDeque, scrollback: VecDeque, scrollback_limit: usize, } @@ -130,24 +132,24 @@ impl ScreenBuffer { } /// Scroll the viewport up by one line: the top line goes into scrollback, - /// the bottom line is cleared. + /// the bottom line is cleared. O(1) thanks to `VecDeque`. fn scroll_up(&mut self, count: usize) { for _ in 0..count { - let top = self.grid.remove(0); + let top = self.grid.pop_front().unwrap_or_else(|| Row::new(self.cols)); if self.scrollback.len() >= self.scrollback_limit { self.scrollback.pop_front(); } self.scrollback.push_back(top); - self.grid.push(Row::new(self.cols)); + self.grid.push_back(Row::new(self.cols)); } } /// Scroll the viewport down by one line: the bottom line is discarded, - /// a blank line is inserted at the top. + /// a blank line is inserted at the top. O(1) thanks to `VecDeque`. fn scroll_down(&mut self, count: usize) { for _ in 0..count { - self.grid.pop(); - self.grid.insert(0, Row::new(self.cols)); + self.grid.pop_back(); + self.grid.push_front(Row::new(self.cols)); } } @@ -208,9 +210,9 @@ impl ScreenBuffer { fn insert_lines(&mut self, row: usize, count: usize) { let count = count.min(self.rows - row); for _ in 0..count { - // Remove last line, insert blank at row + // Remove last line, insert blank at row. if row < self.rows { - self.grid.pop(); + self.grid.pop_back(); self.grid.insert(row, Row::new(self.cols)); } } @@ -221,7 +223,7 @@ impl ScreenBuffer { for _ in 0..count { if row < self.grid.len() { self.grid.remove(row); - self.grid.push(Row::new(self.cols)); + self.grid.push_back(Row::new(self.cols)); } } } @@ -246,16 +248,16 @@ impl ScreenBuffer { } fn resize(&mut self, cols: usize, rows: usize) { - // Adjust columns on existing rows + // Adjust columns on existing rows. for row in &mut self.grid { row.resize(cols); } - // Adjust number of rows + // Adjust number of rows. while self.grid.len() < rows { - self.grid.push(Row::new(cols)); + self.grid.push_back(Row::new(cols)); } while self.grid.len() > rows { - self.grid.pop(); + self.grid.pop_back(); } self.cols = cols; self.rows = rows; @@ -811,7 +813,21 @@ impl vte::Perform for VtPerformer<'_> { /// /// Currently strips OSC 52 (clipboard write) sequences. The OSC 52 format is: /// `ESC ] 52 ; ST` where ST is `ESC \` or `BEL (0x07)`. -pub fn sanitize_output(data: &[u8]) -> Vec { +/// +/// Returns `Cow::Borrowed(data)` when nothing is removed (the common case), +/// avoiding any allocation. Returns `Cow::Owned` only when an OSC 52 sequence +/// is found and stripped. +pub fn sanitize_output(data: &[u8]) -> Cow<'_, [u8]> { + // Fast path: scan for ESC ] without allocating. + let has_osc52 = data + .windows(4) + .any(|w| w[0] == 0x1b && w[1] == b']' && w[2] == b'5' && w[3] == b'2'); + + if !has_osc52 { + return Cow::Borrowed(data); + } + + // Slow path: at least one OSC 52 sequence present — build a filtered copy. let mut out = Vec::with_capacity(data.len()); let mut i = 0; @@ -829,7 +845,7 @@ pub fn sanitize_output(data: &[u8]) -> Vec { i += 1; } - out + Cow::Owned(out) } /// Check if data starting at an OSC introducer is OSC 52. @@ -1122,21 +1138,32 @@ mod tests { fn test_sanitize_output_strips_osc52() { let input = b"before\x1b]52;c;SGVsbG8=\x07after"; let out = sanitize_output(input); - assert_eq!(out, b"beforeafter"); + assert_eq!(out.as_ref(), b"beforeafter" as &[u8]); } #[test] fn test_sanitize_output_strips_osc52_st() { let input = b"before\x1b]52;c;SGVsbG8=\x1b\\after"; let out = sanitize_output(input); - assert_eq!(out, b"beforeafter"); + assert_eq!(out.as_ref(), b"beforeafter" as &[u8]); } #[test] fn test_sanitize_output_preserves_other_osc() { let input = b"before\x1b]0;title\x07after"; let out = sanitize_output(input); - assert_eq!(out, input.to_vec()); + // No OSC 52 — should return Borrowed (no allocation). + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + assert_eq!(out.as_ref(), input as &[u8]); + } + + #[test] + fn test_sanitize_output_no_alloc_for_clean_input() { + let input = b"Hello, World!\r\n"; + let out = sanitize_output(input); + // No OSC sequences at all — fast path, Borrowed. + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + assert_eq!(out.as_ref(), input as &[u8]); } #[test]