feat: metric recording ('all-smi record') and TUI replay ('view --replay') (#197)
* feat: add metric recording (record) and TUI replay (view --replay)
Add the `record` subcommand that captures live metrics to an NDJSON
stream and extends `view` with `--replay <file>` so operators can
step, seek, and scrub through a past incident in the exact same TUI
they would have seen live.
Both sides share the snapshot subcommand's JSON serializer through a
new `write_frame_json` helper; compression (`.zst`, `.gz`) is
auto-detected by extension and rotation is bounded by
`--max-size`/`--max-files`. Replay-mode keybindings (SPACE, `]`/`[`,
`+`/`-`, `j`/`k`, `g`, `L`) are routed with strict mode precedence so
the filter-edit mode from #186 keeps its keys.
Closes #187
* fix: clean SIGTERM shutdown, replay arrow keys, index fast-path off-by-one
Three issues caught during review of issue #187 (record + replay):
1. SIGTERM/SIGINT during `all-smi record` was calling the unconditional
`std::process::exit(0)` handlers from `run_command`, which raced the
recorder's cooperative shutdown and left the output file at zero
bytes. Skip the global signal handlers when the subcommand is
`Record` so `record::install_signal_handlers` can flush the encoder
and call `RotatingWriter::finish()` cleanly. Directly covers the
acceptance criterion "SIGTERM during recording closes cleanly with
a complete final JSON line."
2. `handle_up_arrow` / `handle_down_arrow` / `handle_page_up` /
`handle_page_down` decided local-vs-remote mode from
`args.hosts.is_some() || args.hostfile.is_some()`. In `view --replay`
neither flag is set, so arrow keys fell into the local-process-list
scroll branch even though the UI renders tabs + GPU columns from the
recorded data. Include `args.replay.is_some()` in the remote
classification so scrolling matches what the user sees.
3. `Replayer::rewind_and_seek_to` set `next_disk_seq = nearest_seq`
after skipping past a sparse index frame. Index frames are written
AFTER their matching data frame (`record::write_data_frame`), so
the first data frame read post-skip has absolute sequence
`nearest_seq + 1`. The old code off-by-one-ed every subsequent
frame's reported sequence number, corrupting the REPLAY status-bar
"frame N / M" display after an index fast-path seek. Added a
regression test and a `#[cfg(test)] index_points_seen()` accessor
so the test can assert the fast-path actually fired.
* fix(record): harden writer + replay against hostile inputs
Address security/performance findings on the record+replay pipeline
(PR #197 follow-up, issue #187):
Writer (src/record/writer.rs):
- Open new segments with `O_NOFOLLOW` + `create_new(true)` + mode 0o600
on Unix and `share_mode(0)` on Windows via new `open_secure` helper,
mirroring the atomic-snapshot hardening from #185. Blocks the
co-tenant symlink preplant attack (`/tmp/all-smi-record.ndjson ->
/etc/shadow`).
- During rollover, refuse to unlink or rename onto a pre-planted
symlink at the rotated path (symlink_metadata check before
remove_file).
Replay (src/record/replay.rs):
- Cap per-line length at 16 MiB (MAX_LINE_BYTES) so a 1 KiB `.zst`
cannot decompress to a 10 GiB line; oversized lines are drained,
logged, and skipped while the rest of the file stays playable.
- Apply the cap to the index-fast-path skip-ahead loop too via
`skip_one_line_bounded`.
- Call `window_log_max(27)` on the zstd decoder to cap declared
frame-window memory at 128 MiB.
- Validate index frames at ingest: reject implausible `seq` (≥
u64::MAX/2 or 0), non-monotonic `seq`, index frames that point
past observed data (seq ≥ next_disk_seq), and index frames before
any data frame (line == 0). Drop offending entries without
aborting playback.
- Replace `self.next_disk_seq += 1` with checked_add that surfaces
a new `ReplayError::SeqOverflow` on wrap, eliminating the debug
panic / release silent-wrap on u64 overflow.
- Cap header `hosts[]` at MAX_HEADER_HOSTS (1024) at ingest; truncate
and warn on overflow.
- Introduce a per-tick scan budget (SCAN_BUDGET_PER_TICK = 1024) so
a file with millions of malformed lines cannot monopolise the
async driver tick; the budget-exhaust path yields via Ok(false)
and preserves pause/seek state for the next call.
- Switch back-cache from `Vec` with `remove(0)` (O(N)) to `VecDeque`
with `pop_front()` (O(1)).
Driver (src/view/data_collection/replay_collector.rs):
- Tighten speed-multiplier guard: clamp to [0.05, 16.0] and fall
back to 1.0 on NaN/+inf/-inf before `Duration::from_secs_f32`,
which panics on those inputs.
- Replace `debug_assert_eq!` on cursor retreat with a soft tracing
warning so an unexpected cursor position cannot abort the replay
session.
Runner (src/view/runner.rs):
- Defensive belt-and-suspenders cap on `initial_state.tabs` length
using the replayer's public MAX_HEADER_HOSTS constant.
Regression tests added:
- Record writer symlink refusal (Unix-only), 0o600 mode, existing-file
refusal, rollover symlink refusal.
- Oversized-line skip without OOM.
- Zstd decoder window cap (file with window_log=28 rejected).
- Index frame with seq=u64::MAX rejected at ingest.
- Header hosts list truncated at 1024.
- next_disk_seq overflow → clean `SeqOverflow` error, no panic.
* docs+test: finalize record/replay PR documentation and test coverage
- README "Recording & Replay" section now documents all four security
invariants: O_NOFOLLOW symlink refusal on the writer, 16 MiB per-line
cap and 128 MiB zstd window ceiling on the reader, and 1024-entry
hosts-list cap.
- Changelog entry for v0.21.0 updated to include record + view --replay,
interactive filter bar, and alert history panel.
- Help pane (h key) conditionally shows replay keybindings when replay
mode is active (state.replay.is_some()), matching the status-bar hint.
- New unit tests:
replay.rs: replayer_scan_budget_prevents_runaway_loop (F6) — verifies
SCAN_BUDGET_PER_TICK stops the read loop from spinning on an all-
garbage file and still reaches the next valid frame.
app_state.rs: cycle_speed_up_advances_ladder,
cycle_speed_down_retreats_ladder, cycle_speed_nan_input_does_not_panic
— cover the speed-ladder stepping and the NaN-safe fallback. J
Jeongkyu Shin committed
36ee46a68ff45dcd821ad0cf0af2ab9794f96836
Parent: 8e3452a
Committed by GitHub <noreply@github.com>
on 4/20/2026, 2:24:36 PM