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.
Replace tx.send() with tx.try_send() in file watcher notify callback
to avoid blocking the notify thread when the bounded channel (cap 100)
is full. Events are silently dropped instead of blocking, preventing
shutdown delays and event loss during consumer stalls.
Closes#7
Background threads (spawn_indexer, spawn_visual_height_rebuild) previously
held mmap during entire file scan, risking SIGBUS if file was truncated
externally. Now uses BufReader streaming scan with mmap created only
after scan completes, plus stat validation.
Changes:
- spawn_indexer: replace mmap scan with BufReader fill_buf/consume loop,
create mmap post-scan with fd stat validation
- spawn_visual_height_rebuild: replace mmap/FileReader with sequential
BufReader scan, discard results on line count mismatch
- FileReader::open/reload/update_for_append: add stat-after-mmap check
- LineIndex: make fields pub(crate) for direct construction from scan loop
- Add 3 regression tests for truncation scenarios
spawn_indexer builds LineIndex from mmap snapshot but IndexCache::save()
re-opened the file to compute the hash. If the file changed between those
two steps, the cached index would be stored under the wrong hash.
- Add IndexCache::save_with_hash() that computes hash from in-memory data
- Add compute_data_hash() public function (same algorithm as compute_file_hash)
- Update spawn_indexer, FileReader::save_cache, and test callers