Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions runner/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
65 changes: 46 additions & 19 deletions runner/src/vt_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _;

Expand Down Expand Up @@ -104,7 +105,8 @@ impl Row {
pub struct ScreenBuffer {
cols: usize,
rows: usize,
grid: Vec<Row>,
/// Active screen rows stored in a deque so scroll_up/scroll_down are O(1).
grid: VecDeque<Row>,
scrollback: VecDeque<Row>,
Comment thread
kirich1409 marked this conversation as resolved.
scrollback_limit: usize,
}
Expand All @@ -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 {
Comment thread
kirich1409 marked this conversation as resolved.
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));
}
}

Expand Down Expand Up @@ -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));
}
}
Expand All @@ -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));
}
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -811,7 +813,21 @@ impl vte::Perform for VtPerformer<'_> {
///
/// Currently strips OSC 52 (clipboard write) sequences. The OSC 52 format is:
/// `ESC ] 52 ; <params> ST` where ST is `ESC \` or `BEL (0x07)`.
pub fn sanitize_output(data: &[u8]) -> Vec<u8> {
///
/// 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;

Expand All @@ -829,7 +845,7 @@ pub fn sanitize_output(data: &[u8]) -> Vec<u8> {
i += 1;
}

out
Cow::Owned(out)
}

/// Check if data starting at an OSC introducer is OSC 52.
Expand Down Expand Up @@ -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]
Expand Down
Loading