From 59b4c7ae31eba62a32bf889bbe9a85a3cff32b80 Mon Sep 17 00:00:00 2001 From: mengshouer Date: Mon, 5 Oct 2026 19:46:00 +0800 Subject: [PATCH] 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. --- src-tauri/src/core/recording/mod.rs | 1 - src-tauri/src/core/recording/search.rs | 86 ++++--- .../src/core/recording/session_capture.rs | 39 ++- src-tauri/src/core/recording/tests.rs | 225 +++++++++++++++++- 4 files changed, 288 insertions(+), 63 deletions(-) diff --git a/src-tauri/src/core/recording/mod.rs b/src-tauri/src/core/recording/mod.rs index 8f408b00..884a0168 100644 --- a/src-tauri/src/core/recording/mod.rs +++ b/src-tauri/src/core/recording/mod.rs @@ -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}; diff --git a/src-tauri/src/core/recording/search.rs b/src-tauri/src/core/recording/search.rs index 756b3803..a6850218 100644 --- a/src-tauri/src/core/recording/search.rs +++ b/src-tauri/src/core/recording/search.rs @@ -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, - tail: String, - cursor: usize, + // Echo matching must stop at the first artificial line boundary. + first_forced_split: Option, } fn replay_terminal_output( text: &str, - initial_line: &str, - initial_cursor: usize, + line: &mut Vec, + cursor: &mut usize, ) -> TerminalReplayResult { let bytes = text.as_bytes(); - let mut line = initial_line.chars().collect::>(); - 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, cursor: &mut usize, ch: char) { - while *cursor > line.len() { - line.push(' '); +fn take_replay_line(line: &mut Vec, cursor: &mut usize) -> String { + *cursor = 0; + line.drain(..).collect() +} + +fn write_terminal_char( + line: &mut Vec, + 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)); } _ => {} } diff --git a/src-tauri/src/core/recording/session_capture.rs b/src-tauri/src/core/recording/session_capture.rs index 136165d4..0bab3858 100644 --- a/src-tauri/src/core/recording/session_capture.rs +++ b/src-tauri/src/core/recording/session_capture.rs @@ -2,7 +2,7 @@ struct TranscriptState { records: VecDeque, record_bytes: usize, memory_limit_bytes: usize, - output_buffer: String, + output_buffer: Vec, output_cursor: usize, submitted_line_echo: Option, 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 { 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 { - 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(); diff --git a/src-tauri/src/core/recording/tests.rs b/src-tauri/src/core/recording/tests.rs index 2fc910d3..e0ec0399 100644 --- a/src-tauri/src/core/recording/tests.rs +++ b/src-tauri/src/core/recording/tests.rs @@ -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