CI / ci (windows-latest) (push) Has been cancelled
Pure formatting pass - no semantic changes. Mostly long-line reflow,
import statement reordering (alphabetical), and trailing-newline fixes
that had been accumulating in the working tree.
Affected files:
* crates/bench/src/{data_gen,main,mmap_reader,pread_reader,report,runner}.rs
* crates/bench/src/suites/{concurrent,growth,jump,memory,render,rotation,startup}.rs
* crates/core/src/io/{file_reader,index_cache,read_cache}.rs
* crates/core/src/{types.rs, watcher/file_watcher.rs}
CI / ci (windows-latest) (push) Has been cancelled
* ci.yml: trigger on pushes/PRs to master instead of main. The repo's
default branch is master, so the previous config never fired and CI
silently passed on every commit.
* Add README.md (English) and README-zh.md (Chinese), the bilingual
project documentation referenced by AGENTS.md.
* crates/*/target/ - per-crate build output (root /target/ was already
covered, but workspace members' own target/ dirs were leaking through)
* .omo/ .sisyphus/ - local agent tooling state, not project content
* benchmark-report.md - output of cargo run -p log-viewer-bench,
regenerated per run so should not be tracked
* line_index.rs: replace manual `x % BLOCK_SIZE == 0` with
`.is_multiple_of(BLOCK_SIZE)` (Rust 1.81+).
* line_index.rs: gate three pub(crate) accessor methods
(`sampled_offsets`, `total_lines`, `has_trailing_newline`) behind
`#[cfg(test)]`. They are only consumed by tests in file_reader.rs;
marking them test-only removes them from production builds entirely,
eliminating the dead_code warning without suppressing it.
* json.rs: drop redundant `Some(... .ok()?)` wrapper in
parse_json_object_with_duplicates. `.ok()` already returns Option,
so wrapping it in Some and unwrapping with ? was a no-op.
No behavior change. Unblocks `cargo clippy --workspace -- -D warnings`.
Three intertwined fixes for the no-VHI scroll path that together eliminate
the 'switching to a new entry scrolls it to viewport top' behavior observed
during Loading state, Loading->Ready transition window, and Tab-toggle
VHI rebuild window.
Cursor/viewport decoupling (the user-visible bug):
* scroll_down_line / scroll_up_line no-VHI branches now advance only the
cursor (cursor_line, cursor_sub_offset), then call ensure_cursor_visible_no_vhi
to adjust the viewport minimally if needed. Previously they locked
v_offset = cursor_line, forcing the cursor's entry to the top of the
viewport on every keypress.
* New helpers: advance_visual_pos, retreat_visual_pos (bounded visual-row
walks using compute_visual_height), ensure_cursor_visible_no_vhi (matches
the VHI-path semantics - top-align when cursor is above viewport,
bottom-align with minimal scroll when below, no movement when within).
* Same pattern applied to scroll_down/up_half_page and scroll_down/up_page.
* scroll_to_bottom sets cursor_sub_offset to the last visual row of the
last line in no-VHI state (was 0).
* ensure_viewport_cache hidden anchors removed: the params-change recenter
and the Loading post-check that did v_offset = cursor_line are replaced
with the visibility helper.
* 4 transition sites (Tab handler, ensure_visual_height_index, reload,
poll VHI complete) now clamp cursor_sub_offset against the current
line height instead of blindly zeroing.
Width calculation correctness (root cause of the residual j/k bug):
* gutter_width() pub(crate) method extracted from ui.rs's inline formula,
shared between renderer and App to prevent drift.
* get_content_width() now returns effective text-wrap width (area minus
gutter), matching the renderer's actual_content_width. Previously it
returned raw area width, causing compute_visual_height to under-count
wrap rows for lines that wrapped only after gutter subtraction.
* Free function gutter_width_for(total_lines, is_loading) added so
handle_file_appended and reload_ready_reader can recompute width
after line-count changes without conflicting borrows on self.
* handle_file_appended: width now captured AFTER update_for_append
(was before), so digit-boundary crossings (9->10, 99->100) don't
leave stale gutter widths.
ui.rs render_content now calls app.gutter_width() instead of inlining
the formula (DRY).
Tests: 7 existing tests that locked in the old buggy v_offset = cursor_line
coupling have been rewritten to assert the new correct behavior. New
property tests verify wrap_line_count matches wrap_line_chars across
CJK/emoji/tab/combining-mark/control-char inputs.
Four changes that compound to a 2-5x speedup on VHI rebuild for large
files, plus eliminating one full wasted VHI pass on initial open.
* spawn_indexer no longer computes VHI inline (progressive_reader.rs).
The inline VHI used a hardcoded width=80, was invalidated immediately
on Loading->Ready transition (gutter digit-boundary), and triggered
a second full rebuild. Now returns visual_height_index: None and lets
the UI trigger one rebuild with the correct post-layout width.
* spawn_visual_height_rebuild rewritten as a single linear pass over
mmap bytes using memchr::memchr (SIMD) to find newlines, replacing
the byte-by-byte BufRead::read_until scan. Cancel check throttled
to every 1MB instead of per-line.
* wrap_line_count(&str, width) -> usize added as a zero-allocation
counterpart to wrap_line_chars. Property tests cover ASCII, CJK,
emoji, combining marks, tabs, zero-width chars, control chars, and
real log lines across many widths - all match wrap_line_chars().len()
exactly. compute_text_visual_height now uses wrap_line_count, removing
10M Vec allocations on a 10M-line file.
* format_json_line returns Cow<'_, str> instead of String. Non-JSON
lines borrow the input (zero allocation); only pretty-printed JSON
objects allocate. Removes another 10M allocations when json_format=true.
Removes now-unused compute_visual_heights (only caller was the deleted
inline VHI path).
Compact instruction file covering workspace layout, the non-obvious
default-members behavior (bare cargo run builds core, not tui), the
12 CoreError variants, the CRITICAL TerminalGuard Drop caveat, per-module
temp-file test helpers, and the benchmark test-file generation requirement.
load_file previously cleared file_watcher before attempting to open the
new file. If open failed, the watcher was lost while loading_state and
file_path still pointed to the old file, breaking change detection.
Refactor to build-then-swap pattern: all fallible work runs on local
variables first, then self is updated only on success. On failure, the
old file's watcher, loading_state and file_path remain intact.
Update test to verify all three fields are preserved on failure.
Closes#32
- Report save failures in settings instead of silently discarding errors
- Save draft first, commit color_config only on success (prevents split-brain)
- Show error message in red on status bar, stay in Settings mode on failure
- Clear error on Esc, entering settings, or any settings edit
- Rewrite truncate_to_columns as dedicated O(prefix) truncator
- Tab: stop before tab if expansion would exceed width
- Fixes pre-existing test_truncate_to_columns_tab failure
Ready state can have VHI=None during async rebuild (Tab toggle, resize,
file append, Loading→Ready transition). Without rebase, v_offset retains
a visual-row value that gets treated as a logical-line offset, causing
viewport jumps and cursor drift.
Changes:
- Add rebase_offset_for_invalidate() to convert v_offset from visual-row
to logical-line + sub-row before VHI invalidation
- Call it at all 6 invalidation sites in Ready state
- Recalibrate v_offset from viewport top when VHI rebuild completes
- Clamp preserved sub_row to new line height after JSON/width changes
Regression tests: 6 new tests covering rebase conversion, Tab toggle,
VHI rebuild recalibration, sub-row clamping, and Loading→Ready transition.
The loading branch of ensure_viewport_cache captured v_offset before the
params_changed block, which could reassign self.v_offset. This caused the
viewport to use a stale offset when loading + width/format changed together.
Remove the stale local variable and read self.v_offset directly, consistent
with the non-loading branch. Add regression test.
Before this fix, level strings with surrounding whitespace (e.g. " WARN ")
were incorrectly parsed as Unknown instead of the matching variant.
This primarily affected the JSON log path where serde preserves the raw
field value including whitespace.
Changes:
- Add s.trim() before case-insensitive matching in FromStr impl
- Store trimmed value in Unknown variant to avoid whitespace noise
- Add unit tests for whitespace-padded known levels
- Add unit tests for Unknown trimming semantics (empty, internal ws)
- Add JSON regression test for level field with surrounding whitespace
serde_json rejects BOM (U+FEFF) prefixed input with 'expected value'
error, causing BOM-prefixed JSON log lines to be silently dropped.
Add strip_bom() helper that strips exactly one leading BOM character,
apply it in detect_json_log() and parse_line().
Closes#20
Replace deterministic .index.tmp path with per-save unique temp files
via tempfile::Builder. Eliminates race condition where background
indexer thread and TUI main thread could collide on the same temp path,
causing data truncation or corrupt cache writes.
Changes:
- Add write_cache_atomically() helper using tempfile::Builder
- Refactor save_with_hash() and save() to use the helper
- Extract encode_cache() to deduplicate serialization logic
- Move tempfile from [dev-dependencies] to [dependencies]
- Add 2 concurrent tests validating no corruption under parallel writes
Fixes#17
Background worker threads used blocking tx.send() on bounded channels.
If the consumer stopped draining, threads hung forever with no way to
reach the cancel check. Drop-issued cancellation was ineffective.
Changes:
- Progress messages: tx.try_send() (discard if full, never blocks loop)
- Terminal messages (Complete/Error): new send_cancelable<T>() helper
using crossbeam select! — sleeps efficiently until send succeeds or
cancel arrives
- Drop cancellation: tx.try_send() — Drop must never block
- spawn_visual_height_rebuild: same fix for its bounded(1) channel
- 5 new tests covering full-channel + cancel scenarios
Previously Err(_) => return in the notify callback silently dropped all
backend errors (inotify exhaustion, fs unmount, permission loss), leaving
the application unaware that file monitoring had stopped working.
Add FileEvent::WatcherError { message: String } variant to propagate
backend errors through the existing bounded channel. The TUI consumer
receives the event without disrupting the UI for transient errors.
Closes#14
- Add early return for len==0 (Ok(&[])) matching std::io semantics
- Add slot.len > 0 guard to cache hit predicate to prevent empty-slot
false matches
- Replace unchecked arithmetic with checked_add/saturating_add for
request_end, block_end, and post-read coverage check
- Fix misleading comment about get(file,0,0) behavior on miss path
- Strengthen clear() to fully reset block_offset and last_access
- Register read_cache module in io/mod.rs
- Add 4 regression tests: zero-len on fresh/populated cache,
zero-len at u64::MAX, overflow error on nonzero read at u64::MAX
When a file does not end with a newline, appending content extends the
last logical line's text and thus its visual height. The incremental
extend path in handle_file_appended only computed heights for newly
created logical lines, missing the old last line whose content changed.
Add VisualHeightIndex::replace_last_line_height() — an O(1) method that
rewrites the final prefix sum entry and total. Called before
extend_from_heights so the correct line is targeted.
Changes:
- progressive_reader.rs: add replace_last_line_height, pub with_params,
7 VHI unit tests
- app.rs: save old_reader_line_count before update, recompute last old
line height in extend path, 2 integration regression tests
lines_read was incorrectly set to max_lines.min(total) (the loop upper bound)
instead of the actual number of successfully read lines. Now tracks lines_read
via get_line(i).is_some() counter and uses max_lines.min(total) as the correct
loop upper bound to handle empty file edge case.
Fixes#43
Reuse the existing count_existing_lines() (reader.lines().count())
instead of a manual read_until loop, eliminating duplicate line-counting
logic in data_gen.rs.
Closes#40
- variants: use direct tuple comparison instead of format! string, add sort() after dedup
- Memory section: sort rows by (test_name, backend, variant) before output
- Extra Metrics section: sort rows by (test_name, variant_label) before output
- add [lib] target to Cargo.toml to enable unit tests
- add regression test: same data in different input order produces identical report
Issue #38: warn_reset_hwm() silently swallowed non-permission I/O errors
from /proc/self/clear_refs (e.g. missing /proc, read-only procfs, kernel
incompatibility). This left users unaware that VmHWM reset failed and
memory peak data could be contaminated across suites.
Changes:
- runner.rs: all errors now produce a warning with specific failure reason;
PermissionDenied retains 'try running as root' hint; AtomicBool warn-once
prevents duplicate output across 7 suite runs
- main.rs: preflight check now uses warn_reset_hwm() instead of the vague
can_reset_vm_hwm(), sharing the same warn-once mechanism
- metrics.rs: remove dead can_reset_vm_hwm() (no callers remaining)
- tests: add hwm_warned_flag_prevents_reentry and warn_reset_hwm_does_not_panic
Introduce Suite enum (runner.rs) replacing stringly-typed suite matching.
BenchConfig.suites is now Option<Vec<Suite>>, making invalid states
unrepresentable. Unknown suite names produce a clear error listing all
valid values.
Fixes: #37
The reader's line_index and file_size were frozen at open time.
After current_line exceeded the initial 150K lines, get_line_impl
returned None for all subsequent reads. With the background thread
appending ~10K lines/sec, ~40% of measured frame latencies were
actually the cost of a None return, not real I/O.
- Add PreadReaderCore::refresh_index(&mut self): seek to start,
rebuild LineIndex, update file_size, invalidate read cache
- Add PreadReaderPlain::refresh_index forwarding method
- Add ReadCache::invalidate to force cache miss after reindex
- Rewrite bench_scroll_during_append: time-based refresh (250ms),
only record latencies for successful reads, assert max_line > initial
- Add regression tests for refresh_index with appended lines
Add BufWriter::with_capacity(64KB) to generate_test_file,
generate_growable_file, and append_lines in data_gen.rs.
Previously each writeln! triggered an individual write syscall,
making 5GB/74M-line benchmark data generation extremely slow.
BufWriter batches writes into 64KB chunks, reducing syscalls
by ~1000x.
Explicit flush()? + drop before subsequent reads ensures data
visibility and propagates flush errors (BufWriter::drop swallows
them).
Closes#35
Replace `static mut OLD_SIGBUS_HANDLER` with AtomicU8 + AtomicPtr to
remove data race UB when concurrent benchmarks call open() from multiple
threads.
Key changes:
- Use `Once::call_once` to guarantee single handler installation
- Publish old handler to atomics BEFORE installing new handler (closes
the handler-active-but-state-unpublished race window)
- Read atomics with Acquire in signal handler (async-signal-safe)
- Align si_addr to page boundary before mmap(MAP_FIXED)
- Add concurrent test: 8 threads open all 5 variants simultaneously
Loading state silently dropped FileEvent::Appended/Truncated via _ => {}.
After Loading→Ready transition the FileReader was based on a stale snapshot.
- Add reload_after_loading flag to defer reload until Ready state
- Extract reload_ready_reader() from handle_file_truncated
- Explicit 3-branch match: Ready handles, Loading sets flag, rest ignores
- Clear flag on IndexerMessage::Error to prevent stale dirty bit
- 4 regression tests covering append/truncate/collapse/error paths
The previous code called std::process::exit(1) on file load failure,
bypassing all terminal restoration (disable_raw_mode, LeaveAlternateScreen,
show_cursor). This left the user's shell in a broken state with no echo
and no visible cursor.
Introduce TerminalGuard with Drop-based cleanup that fires on every exit
path: normal return, ? error propagation, and panic unwind. The guard
also handles partial initialization rollback (e.g. raw mode enabled but
alternate screen fails). Remove all process::exit calls from the guarded
scope.