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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user