fix(recording): bound incremental transcript replay

Prevent long-running TUI output without line feeds from growing an
unbounded pending line and repeatedly rebuilding it on the output path.

Keep the line and cursor across chunks, split oversized transcript lines,
bound CSI expansion, and preserve echo filtering across forced segments.
Add focused regressions for these bounds and raw recording preservation.
This commit is contained in:
mengshouer committed 2026-10-05 19:46:00 +08:00
1 parent 15922a5605
commit 59b4c7ae31
4 files changed
+288 -63

No files matched your search

-1
View File
@@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::mem;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{self, SyncSender, TrySendError};
+48 -38
View File
@@ -118,31 +118,34 @@ fn context_records(
#[cfg(test)]
fn strip_terminal_control_sequences(text: &str) -> String {
let replayed = replay_terminal_output(text, "", 0);
let mut pending = Vec::new();
let mut cursor = 0;
let replayed = replay_terminal_output(text, &mut pending, &mut cursor);
let mut out = String::with_capacity(text.len());
for line in replayed.lines {
out.push_str(&line);
out.push('\n');
}
out.push_str(&replayed.tail);
out.extend(pending);
out
}
struct TerminalReplayResult {
lines: Vec<String>,
tail: String,
cursor: usize,
// Echo matching must stop at the first artificial line boundary.
first_forced_split: Option<usize>,
}
fn replay_terminal_output(
text: &str,
initial_line: &str,
initial_cursor: usize,
line: &mut Vec<char>,
cursor: &mut usize,
) -> TerminalReplayResult {
let bytes = text.as_bytes();
let mut line = initial_line.chars().collect::<Vec<_>>();
let mut cursor = initial_cursor.min(line.len());
let mut lines = Vec::new();
let mut replayed = TerminalReplayResult {
lines: Vec::new(),
first_forced_split: None,
};
let mut i = 0;
while i < bytes.len() {
@@ -163,9 +166,9 @@ fn replay_terminal_output(
apply_csi_sequence(
&text[params_start..i - 1],
b,
&mut line,
&mut cursor,
&mut lines,
line,
cursor,
&mut replayed.lines,
);
break;
}
@@ -201,28 +204,26 @@ fn replay_terminal_output(
}
}
b'\r' => {
cursor = 0;
*cursor = 0;
i += 1;
}
b'\n' => {
lines.push(line.iter().collect());
line.clear();
cursor = 0;
replayed.lines.push(take_replay_line(line, cursor));
i += 1;
}
b'\t' => {
write_terminal_char(&mut line, &mut cursor, '\t');
write_terminal_char(line, cursor, '\t', &mut replayed);
i += 1;
}
b'\x08' => {
cursor = cursor.saturating_sub(1);
*cursor = cursor.saturating_sub(1);
i += 1;
}
b if b.is_ascii_control() => {
i += 1;
}
b if b.is_ascii() => {
write_terminal_char(&mut line, &mut cursor, b as char);
write_terminal_char(line, cursor, b as char, &mut replayed);
i += 1;
}
_ => {
@@ -233,24 +234,36 @@ fn replay_terminal_output(
let Some(ch) = text[i..].chars().next() else {
break;
};
write_terminal_char(&mut line, &mut cursor, ch);
write_terminal_char(line, cursor, ch, &mut replayed);
i += ch.len_utf8();
}
}
}
TerminalReplayResult {
lines,
tail: line.iter().collect(),
cursor,
}
replayed
}
const MAX_REPLAY_CURSOR_COLUMNS: usize = 4096;
// Transcript segments are bounded even when a TUI never sends a newline.
const MAX_REPLAY_LINE_CHARS: usize = 16 * 1024;
fn write_terminal_char(line: &mut Vec<char>, cursor: &mut usize, ch: char) {
while *cursor > line.len() {
line.push(' ');
fn take_replay_line(line: &mut Vec<char>, cursor: &mut usize) -> String {
*cursor = 0;
line.drain(..).collect()
}
fn write_terminal_char(
line: &mut Vec<char>,
cursor: &mut usize,
ch: char,
replayed: &mut TerminalReplayResult,
) {
if *cursor >= MAX_REPLAY_LINE_CHARS {
replayed.first_forced_split.get_or_insert(replayed.lines.len());
replayed.lines.push(take_replay_line(line, cursor));
}
if *cursor > line.len() {
line.resize(*cursor, ' ');
}
if *cursor == line.len() {
@@ -270,12 +283,9 @@ fn apply_csi_sequence(
) {
match final_byte {
b'B' | b'E' => {
let count = csi_param(params, 0, 1).max(1);
for _ in 0..count {
lines.push(line.iter().collect());
line.clear();
}
*cursor = 0;
// Empty transcript records are discarded; do not expand cursor
// movement into a parameter-sized collection of blank rows.
lines.push(take_replay_line(line, cursor));
}
b'C' => {
let count = csi_param(params, 0, 1);
@@ -316,10 +326,10 @@ fn apply_csi_sequence(
}
}
b'@' => {
let count = csi_param(params, 0, 1);
for _ in 0..count {
line.insert((*cursor).min(line.len()), ' ');
}
let count = csi_param(params, 0, 1)
.min(MAX_REPLAY_LINE_CHARS.saturating_sub(line.len()));
let start = (*cursor).min(line.len());
line.splice(start..start, std::iter::repeat_n(' ', count));
}
_ => {}
}
+19 -20
View File
@@ -2,7 +2,7 @@ struct TranscriptState {
records: VecDeque<TranscriptRecord>,
record_bytes: usize,
memory_limit_bytes: usize,
output_buffer: String,
output_buffer: Vec<char>,
output_cursor: usize,
submitted_line_echo: Option<String>,
next_line_id: u64,
@@ -14,7 +14,7 @@ impl TranscriptState {
records: VecDeque::new(),
record_bytes: 0,
memory_limit_bytes,
output_buffer: String::new(),
output_buffer: Vec::new(),
output_cursor: 0,
submitted_line_echo: None,
next_line_id: 1,
@@ -50,14 +50,23 @@ impl TranscriptState {
return Vec::new();
}
let replayed = replay_terminal_output(data, &self.output_buffer, self.output_cursor);
self.output_buffer = replayed.tail;
self.output_cursor = replayed.cursor;
let replayed = replay_terminal_output(
data,
&mut self.output_buffer,
&mut self.output_cursor,
);
let first_forced_split = replayed.first_forced_split;
replayed
.lines
.into_iter()
.filter_map(|line| self.append_output_line(line))
.enumerate()
.filter_map(|(index, line)| {
if Some(index) == first_forced_split {
// A segment ending in the command is not proof of an echo.
self.submitted_line_echo = None;
}
self.append_output_line(line)
})
.collect()
}
@@ -93,11 +102,8 @@ impl TranscriptState {
fn flush_output_lines(&mut self, flush_partial: bool) -> Vec<TranscriptRecord> {
let mut flushed = Vec::new();
if flush_partial && !self.output_buffer.is_empty() {
let tail = mem::take(&mut self.output_buffer)
.trim_end_matches('\r')
.to_string();
self.output_cursor = 0;
if flush_partial {
let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor);
if let Some(record) = self.append_output_line(tail) {
flushed.push(record);
}
@@ -137,14 +143,7 @@ impl TranscriptState {
}
fn flush_output_before_command(&mut self) -> Vec<TranscriptRecord> {
if self.output_buffer.is_empty() {
return Vec::new();
}
let tail = mem::take(&mut self.output_buffer)
.trim_end_matches('\r')
.to_string();
self.output_cursor = 0;
let tail = take_replay_line(&mut self.output_buffer, &mut self.output_cursor);
if looks_like_prompt_tail(&tail) {
return Vec::new();
+221 -4
View File
@@ -120,6 +120,220 @@ mod tests {
assert!(!cleaned.contains("CoderKangMode"));
}
#[test]
fn segments_long_output_in_one_chunk_without_losing_unicode() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let output = "你🙂".repeat(super::MAX_REPLAY_LINE_CHARS + 3);
let mut records = transcript.write_output(&output);
assert_eq!(records.len(), 2);
assert!(records.iter().all(|record| {
record.data.chars().count() == super::MAX_REPLAY_LINE_CHARS
}));
records.extend(transcript.finish());
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, output);
}
#[test]
fn segments_long_output_across_chunks_with_bounded_pending_line() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let chunk = "x".repeat(257);
let mut records = Vec::new();
for _ in 0..256 {
records.extend(transcript.write_output(&chunk));
assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS);
}
records.extend(transcript.finish());
assert!(records.iter().all(|record| {
record.data.chars().count() <= super::MAX_REPLAY_LINE_CHARS
}));
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, chunk.repeat(256));
}
#[test]
fn keeps_full_pending_line_editable_until_it_needs_another_segment() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let full_line = "x".repeat(super::MAX_REPLAY_LINE_CHARS);
assert!(transcript.write_output(&full_line).is_empty());
assert!(transcript.write_output("\rZ").is_empty());
let edited = transcript.write_output("\n");
assert_eq!(edited.len(), 1);
assert_eq!(edited[0].data, format!("Z{}", &full_line[1..]));
assert!(transcript.write_output(&full_line).is_empty());
let segments = transcript.write_output("!");
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].data, full_line);
assert_eq!(transcript.finish()[0].data, "!");
}
#[test]
fn preserves_split_output_ending_with_a_pending_command() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.record_command("pwd".to_string());
let output = format!("{}pwdTAIL", "x".repeat(super::MAX_REPLAY_LINE_CHARS - 3));
let records = transcript.write_output(&format!("{output}\n"));
assert_eq!(records.len(), 2);
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, output);
}
#[test]
fn filters_complete_command_echo_before_later_forced_splits() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.record_command("pwd".to_string());
let output = format!("{}tail", "x".repeat(super::MAX_REPLAY_LINE_CHARS));
let records = transcript.write_output(&format!("pwd\n{output}\n"));
assert_eq!(records.len(), 2);
let restored: String = records.iter().map(|record| record.data.as_str()).collect();
assert_eq!(restored, output);
}
#[test]
fn reuses_pending_line_storage_across_chunks_and_flushes() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.output_buffer.reserve(128);
let allocation = transcript.output_buffer.as_ptr();
let capacity = transcript.output_buffer.capacity();
for _ in 0..64 {
assert!(transcript.write_output("x").is_empty());
assert_eq!(transcript.output_buffer.as_ptr(), allocation);
}
assert_eq!(transcript.write_output("\n")[0].data, "x".repeat(64));
assert_eq!(transcript.output_buffer.capacity(), capacity);
transcript.write_output("next");
assert_eq!(transcript.output_buffer.as_ptr(), allocation);
}
#[test]
fn preserves_cursor_position_between_output_chunks() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output("abc");
transcript.write_output("\x1b[8G");
let records = transcript.write_output("X\n");
assert_eq!(records.len(), 1);
assert_eq!(records[0].data, "abc X");
}
#[test]
fn resets_cursor_when_flushing_an_empty_pending_line() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output("\x1b[8G");
assert!(transcript.finish().is_empty());
assert_eq!(transcript.write_output("first\n")[0].data, "first");
transcript.write_output("\x1b[8G");
transcript.snapshot_records();
assert_eq!(transcript.write_output("second\n")[0].data, "second");
transcript.write_output("\x1b[8G");
transcript.record_command("echo third".to_string());
assert_eq!(transcript.write_output("third\n")[0].data, "third");
}
#[test]
fn bounds_csi_insert_expansion_without_losing_existing_text() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output("tail");
let insert = format!("\r\x1b[{}@", usize::MAX);
assert!(transcript.write_output(&insert).is_empty());
assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS);
assert!(transcript.write_output(&insert).is_empty());
assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_LINE_CHARS);
let records = transcript.write_output("ok\nnext\n");
assert_eq!(records.len(), 2);
assert!(records[0].data.starts_with("ok"));
assert!(records[0].data.ends_with("tail"));
assert_eq!(records[0].data.chars().count(), super::MAX_REPLAY_LINE_CHARS);
assert_eq!(records[1].data, "next");
}
#[test]
fn bounds_csi_vertical_movement_without_expanding_empty_rows() {
for final_byte in ['B', 'E'] {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let output = format!("before\x1b[{}{final_byte}after\n", usize::MAX);
let records = transcript.write_output(&output);
assert_eq!(records.len(), 2);
assert_eq!(records[0].data, "before");
assert_eq!(records[1].data, "after");
assert!(transcript.output_buffer.is_empty());
}
}
#[test]
fn bounds_csi_horizontal_movement_and_deletion() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
transcript.write_output(&format!("\x1b[{}C", usize::MAX));
transcript.write_output("X");
assert_eq!(transcript.output_buffer.len(), super::MAX_REPLAY_CURSOR_COLUMNS + 1);
let records = transcript.write_output(&format!(
"\x1b[{}D\x1b[{}Pdone\n", usize::MAX, usize::MAX
));
assert_eq!(records.len(), 1);
assert_eq!(records[0].data, "done");
}
#[test]
fn bounds_repeated_tui_redraws_without_linefeeds() {
let mut transcript = super::TranscriptState::new(super::DEFAULT_MEMORY_LIMIT_BYTES);
let text = "x".repeat(200);
let chunk = format!("\x1b[1;1H\x1b[32m{text}\x1b[0m");
let mut recorded_chars = 0;
for _ in 0..2048 {
for record in transcript.write_output(&chunk) {
assert!(record.data.len() <= super::MAX_REPLAY_LINE_CHARS);
recorded_chars += record.data.len();
}
assert!(transcript.output_buffer.len() <= super::MAX_REPLAY_LINE_CHARS);
}
for record in transcript.finish() {
recorded_chars += record.data.len();
}
assert_eq!(recorded_chars, text.len() * 2048);
}
#[test]
fn searches_output_after_long_unterminated_text() {
let manager = RecordingManager::new();
manager.write_output("s1", &"x".repeat(super::MAX_REPLAY_LINE_CHARS * 3));
manager.write_output("s1", "tail-sentinel\n");
let result = manager.search_history(super::TerminalHistorySearchRequest {
session_id: "s1".to_string(),
query: "tail-sentinel".to_string(),
case_sensitive: true,
regex: false,
whole_word: false,
limit: None,
context_before: None,
context_after: None,
max_lines: None,
}).unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.results[0].preview, "tail-sentinel");
}
#[test]
fn consumes_matching_echo_prefix() {
let mut prefix = "ps -ef".to_string();
@@ -571,13 +785,16 @@ mod tests {
let path = manager
.start_with_profile(session_id, context, profile, None)
.unwrap();
manager.write_raw_output(session_id, b"\x1b[31mERROR\x1b[0m\n");
manager.write_output(session_id, "\x1b[31mERROR\x1b[0m\n");
let output = format!(
"\x1b[31mERROR\x1b[0m\n{}",
"x".repeat(super::MAX_REPLAY_LINE_CHARS * 2 + 3),
);
manager.write_raw_output(session_id, output.as_bytes());
manager.write_output(session_id, &output);
manager.stop(session_id).unwrap();
let raw = fs::read(&path).unwrap();
assert!(raw.windows(5).any(|window| window == b"ERROR"));
assert!(raw.contains(&0x1b));
assert_eq!(raw, output.as_bytes());
let transcript_path = unique_path("raw-transcript");
manager