fix(tui): add MAX_WRAP_INPUT_LEN guard to prevent UI freeze on oversized lines (closes #26)

- compute_line_entry/compute_visual_height: double guard (raw + post-format)
- Skip detect_level on oversized raw input to avoid O(n) JSON parsing
- Add post-format guard for JSON lines that expand beyond 10MB
- progressive_reader: add post-format guard to compute_line_visual_height
- Add truncate_to_columns helper using existing wrap_line_chars
- Fix misleading docstring on MAX_WRAP_INPUT_LEN constant
- Add 6 regression tests covering all guard paths
This commit is contained in:
dailz
2026-06-11 13:15:11 +08:00
parent a43ef673b0
commit e99861c76d
3 changed files with 135 additions and 2 deletions

View File

@@ -223,6 +223,9 @@ pub fn compute_line_visual_height(
}
if json_format {
let formatted = format_json_line(line_text);
if formatted.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
compute_text_visual_height(&formatted, terminal_width)
} else {
compute_text_visual_height(line_text, terminal_width)

View File

@@ -1,5 +1,6 @@
/// Maximum input length for wrap/format operations (10 MB).
/// Lines exceeding this are returned as-is to avoid pathological cases.
/// Callers should check against this constant before invoking `wrap_line_chars`
/// to avoid pathological cases on oversized lines.
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
/// Split a line into chunks of exactly `width` display columns.

View File

@@ -6,7 +6,7 @@ use log_viewer_core::io::progressive_reader::{
IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height,
spawn_indexer,
};
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars};
use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN};
use log_viewer_core::types::LogLevel;
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
@@ -201,12 +201,33 @@ impl App {
/// Compute a single line's viewport entry (wrapped rows + level + height).
fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
let raw = self.get_line(line).unwrap_or_default();
// Guard 1: oversized raw input — skip detect_level and JSON formatting
// to avoid O(n) parsing overhead on huge lines.
if raw.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&raw, width)],
level: None,
visual_height: 1,
};
}
let level = log_viewer_core::parser::level::detect_level(&raw);
let display_text = if self.json_format {
format_json_line(&raw)
} else {
raw
};
// Guard 2: JSON pretty-printing may expand a line beyond the limit.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&display_text, width)],
level,
visual_height: 1,
};
}
let mut wrapped = Vec::new();
for sub_line in display_text.split('\n') {
wrapped.extend(wrap_line_chars(sub_line, width));
@@ -222,11 +243,23 @@ impl App {
/// Compute visual height for a single line without storing it.
fn compute_visual_height(&self, line: usize, width: usize) -> usize {
let raw = self.get_line(line).unwrap_or_default();
// Guard 1: oversized raw input.
if raw.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let display_text = if self.json_format {
format_json_line(&raw)
} else {
raw
};
// Guard 2: post-format expansion.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let mut height = 0;
for sub_line in display_text.split('\n') {
height += wrap_line_chars(sub_line, width).len();
@@ -1154,6 +1187,13 @@ impl App {
}
}
fn truncate_to_columns(s: &str, max_cols: usize) -> String {
if max_cols == 0 || s.is_empty() {
return String::new();
}
wrap_line_chars(s, max_cols).into_iter().next().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3419,4 +3459,93 @@ plain text line
});
assert!(result.is_ok());
}
#[test]
fn test_compute_line_entry_oversized_raw_guard() {
let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1);
let path = make_temp_file(&format!("short\n{}\n", long_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert!(app.is_loaded());
let entry = app.compute_line_entry(1, 80);
assert_eq!(entry.visual_height, 1, "oversized line should have height 1");
assert!(entry.level.is_none(), "oversized raw line should skip detect_level");
assert_eq!(entry.wrapped_rows.len(), 1);
assert!(
entry.wrapped_rows[0].len() <= 80,
"preview should be bounded to width"
);
let short_entry = app.compute_line_entry(0, 80);
assert_eq!(short_entry.visual_height, 1);
assert!(short_entry.level.is_none(), "'short' has no log level");
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_compute_visual_height_oversized_raw_guard() {
let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100);
let path = make_temp_file(&format!("hi\n{}\n", long_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
assert!(app.is_loaded());
assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1");
assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1");
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_compute_line_entry_post_format_guard() {
// Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN
// but whose pretty-printed form (with added spaces around ':') exceeds it.
let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11);
let json_line = format!(r#"{{"msg":"{}"}}"#, inner);
assert!(
json_line.len() < MAX_WRAP_INPUT_LEN,
"raw JSON should be under limit: got {}",
json_line.len()
);
let path = make_temp_file(&format!("{}\n", json_line));
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
load_file_ready(&mut app, &path);
app.json_format = true;
let entry = app.compute_line_entry(0, 80);
assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1");
assert_eq!(entry.wrapped_rows.len(), 1);
});
cleanup(&path);
assert!(result.is_ok());
}
#[test]
fn test_truncate_to_columns_basic() {
assert_eq!(truncate_to_columns("hello world", 5), "hello");
assert_eq!(truncate_to_columns("hi", 80), "hi");
assert_eq!(truncate_to_columns("", 80), "");
assert_eq!(truncate_to_columns("abc", 0), "");
}
#[test]
fn test_truncate_to_columns_cjk() {
// Each CJK char is 2 columns wide
assert_eq!(truncate_to_columns("你好世界", 3), "");
assert_eq!(truncate_to_columns("你好世界", 4), "你好");
}
#[test]
fn test_truncate_to_columns_tab() {
// Tab expands to 4 spaces
assert_eq!(truncate_to_columns("a\tb", 3), "a");
}
}