summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-18 20:54:35 +0200
committerPaul Buetow <paul@buetow.org>2026-03-18 20:54:35 +0200
commitcd554b0af706b5f62b4e1bfde04091052b4aac61 (patch)
treee6d02f1c2a1da27da17386e8832c2d4a3e699cdf
parentb421b2232351049277ee4ad5b31367bb2b6779bb (diff)
cleanup
-rw-r--r--README.md38
-rw-r--r--docs/libbpfgo-upgrade-plan.md154
-rw-r--r--docs/parquet-recording-perf-baseline.md113
-rw-r--r--docs/tui-dashboard-table-sorting-plan.md336
-rw-r--r--docs/tui-flamegraph-behavior.md46
-rw-r--r--docs/tui-flamegraph-plan.md450
-rw-r--r--docs/tui-global-filter-architecture.md160
-rwxr-xr-xflamegraph.testbin12360000 -> 0 bytes
-rw-r--r--internal/c/generated_tracepoints.c46
-rw-r--r--internal/event/event.go2
-rw-r--r--internal/event/pair.go23
-rw-r--r--internal/file/file.go9
-rw-r--r--internal/flags/flags.go26
-rw-r--r--internal/flamegraph/iordata.go40
-rw-r--r--internal/flamegraph/iordata_test.go4
-rw-r--r--internal/flamegraph/livetrie.go18
-rw-r--r--internal/flamegraph/trie.go9
-rw-r--r--internal/generate/codegen.go9
-rw-r--r--internal/generate/typesgo.go2
-rw-r--r--internal/globalfilter/filter.go20
-rw-r--r--internal/parquet/schema.go16
-rw-r--r--internal/probemanager/grouping.go14
-rw-r--r--internal/probemanager/manager.go5
-rw-r--r--internal/statsengine/bench_test.go4
-rw-r--r--internal/statsengine/filerank.go11
-rw-r--r--internal/statsengine/process.go25
-rw-r--r--internal/statsengine/syscall.go25
-rw-r--r--internal/statsengine/syscall_test.go12
-rw-r--r--internal/tui/dashboard/bubbles.go19
-rw-r--r--internal/tui/dashboard/files.go10
-rw-r--r--internal/tui/dashboard/icicle.go15
-rw-r--r--internal/tui/dashboard/treemap.go27
-rw-r--r--internal/tui/flamegraph/model.go20
-rw-r--r--internal/tui/flamegraph/renderer.go19
-rw-r--r--internal/tui/pidpicker/proclist.go19
-rw-r--r--internal/types/generated_types.go18
36 files changed, 301 insertions, 1463 deletions
diff --git a/README.md b/README.md
index 07a8f8a..58d0a47 100644
--- a/README.md
+++ b/README.md
@@ -109,19 +109,36 @@ The default stack order is `comm,path,tracepoint` (bottom to top).
## Recording Modes
-`ior` has three distinct output flows. They are intentionally different:
+`ior` has four distinct output flows. They are intentionally different:
| Mode | How to use it | What it writes | Filter behavior |
| --- | --- | --- | --- |
| TUI dashboard | default startup | nothing continuously; data stays in memory unless you export | current TUI/global filters drive what you see |
| TUI CSV snapshot export | press `e` in the dashboard | one `ior-stream-<timestamp>.csv` snapshot of the current filtered stream view | exports only the currently filtered in-memory rows |
+| Headless `.ior.zst` export | start with `-flamegraph -name <name>` | one aggregated native trace artifact written at shutdown | no TUI filter stack; this is the native trace/integration workflow |
| Parquet recording | press `R` in the TUI, or start with `-parquet <file>` | a streaming Parquet file of traced syscall rows | TUI mode records rows that pass the active TUI filter; headless `-parquet` records all traced rows |
Important distinction:
+- `.ior.zst` output is an aggregated native artifact, not a row-by-row event log.
- CSV export is a point-in-time snapshot of the ring buffer.
- Parquet recording is a streaming capture from start to stop.
-- The ring buffer is capped, so CSV export is not a replacement for Parquet recording.
+- The ring buffer is capped, so CSV export is not a replacement for Parquet recording or `.ior.zst` output.
+
+### Headless Native `.ior.zst` Output
+
+Use `-flamegraph` when you want the native `ior` trace artifact instead of a streaming row log:
+
+```shell
+sudo ./ior -flamegraph -name trace-run -duration 60
+```
+
+Native `.ior.zst` behavior:
+
+- writes one `*.ior.zst` file when the run ends
+- stores aggregated counters for repeated syscall/path/process combinations
+- is intended for `ior`'s native flamegraph and integration-style workflows
+- does not preserve one output row per traced syscall
### TUI Parquet Recording
@@ -155,6 +172,23 @@ Headless Parquet mode behavior:
Use headless mode when you want a full recording, and TUI mode when you want interactive filtering plus optional start/stop recording from the dashboard.
+### Choosing Between `.ior.zst` and Parquet
+
+Both formats are useful, but they solve different problems:
+
+| Question | Native `.ior.zst` | Parquet |
+| --- | --- | --- |
+| Data shape | aggregated counters | one row per traced syscall |
+| Write pattern | collect in memory, write one compressed artifact at the end | stream rows continuously while recording |
+| Best for | `ior`-native trace artifacts, flamegraph workflows, integration assertions | offline analysis in other tools, long captures, preserving per-event detail |
+| Relative write cost | usually lower because repeated events are folded together before file write | usually higher because each traced row is serialized |
+| Detail retained | loses original row order and per-event granularity | keeps per-event timing and syscall fields |
+
+Rule of thumb:
+
+- choose `.ior.zst` when you want the native `ior` artifact and do not need every traced syscall row preserved
+- choose Parquet when you want a full event stream for downstream analysis outside `ior`
+
## TUI Navigation
The TUI interface provides an in‑screen help panel (toggle with **H**) that lists all available keys. Use this help screen to discover navigation shortcuts.
diff --git a/docs/libbpfgo-upgrade-plan.md b/docs/libbpfgo-upgrade-plan.md
deleted file mode 100644
index 1969015..0000000
--- a/docs/libbpfgo-upgrade-plan.md
+++ /dev/null
@@ -1,154 +0,0 @@
-# libbpfgo Upgrade Plan
-
-## Goal
-
-Upgrade `ior` from `github.com/aquasecurity/libbpfgo v0.6.0-libbpf-1.3.0...`
-to the latest tagged upstream release `v0.9.2-libbpf-1.5.1`, and align the
-repo's Go module, local static-link toolchain checkout, build instructions, and
-runtime validation on that same tag.
-
-## Current State
-
-- `go.mod` / `go.sum` now pin
- `github.com/aquasecurity/libbpfgo v0.9.2-libbpf-1.5.1`
-- `Magefile.go` defaults to the sibling checkout at `../libbpfgo` (local path:
- `/home/paul/git/libbpfgo`) and emits rebuild guidance if static
- artifacts are missing
-- The local checkout is currently ahead of the latest tag:
- `v0.9.2-libbpf-1.5.1-23-g9a319d2`
-- `README.md`, `AGENTS.md`, and `integrationtests/README.md` now pin the tag,
- sync the `libbpf` submodule, and document the rebuild or validation workflow
-- Integration coverage now passes again after restoring the legacy
- `-flamegraph` / `-name` compatibility path used by the harness to collect
- `.ior.zst` artifacts
-
-## Upgrade Target
-
-- Upstream tag: `v0.9.2-libbpf-1.5.1`
-- Local checkout to use for static headers/archive:
- `/home/paul/git/libbpfgo`
-- Repo-relative default checkout path used by `Magefile.go`: `../libbpfgo`
-- Override path for local experiments: `LIBBPFGO=/absolute/path/to/libbpfgo`
-- Do not target `libbpfgo` `main` as part of this upgrade unless a tagged
- release blocker is found
-
-## Pinned Source of Truth
-
-- `go.mod` / `go.sum` pin `github.com/aquasecurity/libbpfgo
- v0.9.2-libbpf-1.5.1`
-- `README.md`, `AGENTS.md`, and `integrationtests/README.md` document the same
- checkout, tag, validation commands, and `make libbpfgo-static` workflow
-- `Magefile.go` fails with explicit rebuild guidance when the local
- `libbpfgo` checkout is missing the static artifacts that `ior` expects
-- `internal/ior.go` preserves the legacy `-flamegraph` / `-name` trace-output
- path required by the integration harness while leaving TUI and `-plain`
- behavior unchanged
-
-## Breaking-Change Watchpoints
-
-- `v0.8.0-libbpf-1.5` includes a `BPFProg` API alignment change
-- `v0.9.1-libbpf-1.5.1` changes `AttachUprobe` /
- `AttachURetprobe` signatures
-- `libbpf` minimum version moves from `1.3.x` to `1.5.1`
-- Static builds require `git submodule update --init --recursive` in the local
- `libbpfgo` checkout before `make libbpfgo-static`
-
-`ior` appears to use a narrow subset of APIs:
-
-- module loading (`NewModuleFromFile`, `NewModuleFromBuffer`, `BPFLoadObject`)
-- maps (`GetMap`, `SetMaxEntries`, `InitGlobalVariable`)
-- ringbuf (`InitRingBuf`)
-- program lookup and tracepoint attach (`GetProgram`, `AttachTracepoint`)
-
-The direct API-break risk is therefore expected to be low, but compile/runtime
-validation is still required.
-
-## Implementation Workstreams
-
-1. Align the version source of truth
- - Pin `go.mod` / `go.sum` to `v0.9.2-libbpf-1.5.1`
- - Align the local checkout instructions in `README.md`
- - Align `AGENTS.md` and `Magefile.go` guidance with the same tag and rebuild flow
- - Ensure the local checkout is reset to the exact tag and rebuilt
-
-2. Rebuild the local static toolchain
- - In `/home/paul/git/libbpfgo`:
- - `git checkout v0.9.2-libbpf-1.5.1`
- - `git submodule update --init --recursive`
- - `make libbpfgo-static`
-
-3. Compile and fix `ior`
- - Rebuild `ior` against the upgraded wrapper and static `libbpf`
- - Fix any compile/API regressions in:
- - `internal/ior.go`
- - `internal/bpfsetup.go`
- - `internal/bpfembed.go`
- - any `probemanager` adapter code if signatures changed
-
-4. Validate behavior
- - Run `env GOTOOLCHAIN=auto mage world`
- - Run root-required `env GOTOOLCHAIN=auto mage integrationTest`
- - Specifically verify:
- - embedded `ior.bpf.o` loading still works
- - tracepoint attach/detach still works
- - ring buffer event ingestion still works
- - static build/link flags still work with the rebuilt local checkout
-
-5. Finalize docs and rollback guidance
- - Document the exact `libbpfgo` tag and rebuild commands
- - Mention the local checkout path used by `Magefile.go`
- - Add troubleshooting notes for submodule sync / static rebuild failures
- - Record the rollback target: `go.mod` pseudo-version
- `v0.6.0-libbpf-1.3.0.20240111220235-90dbffffbdab` plus local checkout
- commit `90dbffffbdab`
-
-## Validation Result
-
-- `env GOTOOLCHAIN=auto mage world` passed after the pinning commit
- `f28dab3`
-- `env GOTOOLCHAIN=auto mage integrationTest` passed after compatibility fix
- commit `28338f4`
-- The embedded-object path is covered by
- `env GOTOOLCHAIN=auto TEST_NAME=TestLoadBPFModuleUsesEmbeddedObjectByDefault mage testWithName`
-
-## Troubleshooting
-
-- Missing `bpf/bpf.h` or `libbpf` symbols usually means the sibling checkout is
- not at `v0.9.2-libbpf-1.5.1` or was not rebuilt after a `git checkout`.
-- Raw `go test` can still fail for packages that import `libbpfgo` because it
- does not inherit the `CGO_CFLAGS`, `CGO_LDFLAGS`, and `LIBBPFGO` values that
- `Magefile.go` sets up. Use Mage targets for validated flows.
-- If integration tests fail immediately with unknown `-flamegraph` /
- `-name` flags, rebuild `ior` from a checkout that includes commit `28338f4`.
-
-## Rollback
-
-If the tagged release proves insufficient, revert the `ior` side to
-`github.com/aquasecurity/libbpfgo
-v0.6.0-libbpf-1.3.0.20240111220235-90dbffffbdab`, reset the sibling checkout,
-and rebuild:
-
-```bash
-git -C /home/paul/git/libbpfgo checkout 90dbffffbdab
-git -C /home/paul/git/libbpfgo submodule update --init --recursive
-make -C /home/paul/git/libbpfgo libbpfgo-static
-```
-
-## Validation Commands
-
-- `GOTOOLCHAIN=auto mage test`
-- `GOTOOLCHAIN=auto mage world`
-- `GOTOOLCHAIN=auto mage integrationTest`
-
-## References
-
-- Repo files:
- - `go.mod`
- - `README.md`
- - `AGENTS.md`
- - `Magefile.go`
- - `internal/ior.go`
- - `internal/bpfsetup.go`
- - `internal/bpfembed.go`
-- Local toolchain checkout:
- - `/home/paul/git/libbpfgo`
diff --git a/docs/parquet-recording-perf-baseline.md b/docs/parquet-recording-perf-baseline.md
deleted file mode 100644
index e1731a7..0000000
--- a/docs/parquet-recording-perf-baseline.md
+++ /dev/null
@@ -1,113 +0,0 @@
-# Parquet Recording Performance Baseline
-
-Captured on 2026-03-13 from the benchmark task using the current Parquet recording implementation.
-
-## Reproduction
-
-Run the pipeline benchmark profiler:
-
-```bash
-env GOTOOLCHAIN=auto mage benchProf
-```
-
-This writes timestamped pipeline profiles under `bench-profiles/`. The baseline captured for this run was:
-
-- `bench-profiles/pipeline-20260313-054719-cpu.prof`
-- `bench-profiles/pipeline-20260313-054719-mem.prof`
-- `bench-profiles/pipeline-20260313-054719-block.prof`
-
-Useful follow-up commands:
-
-```bash
-env GOTOOLCHAIN=auto go tool pprof -top bench-profiles/pipeline-20260313-054719-cpu.prof
-env GOTOOLCHAIN=auto go tool pprof -top -sample_index=alloc_space bench-profiles/pipeline-20260313-054719-mem.prof
-env GOTOOLCHAIN=auto go tool pprof -top -sample_index=inuse_space bench-profiles/pipeline-20260313-054719-mem.prof
-env GOTOOLCHAIN=auto go tool pprof -top bench-profiles/pipeline-20260313-054719-block.prof
-```
-
-## Baseline Numbers
-
-`mage benchProf` recorded the parquet-specific pipeline benchmarks at:
-
-- `BenchmarkPipelineHeadlessParquetCapture`: `14.20 ms/op`, `2000 pairs/op`, `347159 B/op`, `7212 allocs/op`
-- `BenchmarkPipelineTUIParquetRecording`: `19.13 ms/op`, `2000 pairs/op`, `994016 B/op`, `19873 allocs/op`
-
-Interpretation:
-
-- The TUI recording path is about 35% slower than the headless parquet path for the same synthetic stream.
-- The TUI recording path allocates about 2.9x more memory per operation because it also exercises the stats engine, ring buffer, live trie, and stream fanout path.
-
-## CPU Findings
-
-Top CPU samples were still dominated by the core event-loop path rather than parquet serialization itself:
-
-- `(*eventLoop).processRawEvent` and `(*eventLoop).tracepointExited` were the heaviest cumulative runtime buckets.
-- `file.NewFdWithPid` and `os.Readlink` remained a large cumulative cost in exit handling and fd/path materialization.
-- Channel scheduling (`runtime.chansend`, `runtime.chanrecv`, `runtime.selectgo`) stayed visible, especially in the TUI fanout path.
-- Parquet-specific work was present but secondary: `parquet.(*Recorder).runSession`, `parquet.(*Writer).Close`, parquet-go column flushing, and Zstd compression showed up as meaningful but not dominant contributors.
-
-## Allocation Findings
-
-Allocation-space profile highlights:
-
-- `benchmarkPipelineMix` still accounted for the single largest allocation bucket because it rebuilds the synthetic raw-event stream for each benchmark run.
-- `os.Readlink`, `file.(*FdFile).Dup`, and `file.NewFdWithPid` remained major allocators in the traced event path.
-- TUI-only structures added measurable cost:
- - `tui/eventstream.NewRingBuffer`
- - `parquet.newRecordingSession`
- - `benchmarkPipelineTUIParquet`
-- Parquet writer lifecycle allocations were visible but bounded:
- - parquet-go column buffers
- - Zstd encoder initialization
- - recorder session queue allocation
-
-Retained in-use memory was modest and dominated by parquet-go writer buffers and Zstd encoder state during flush/close:
-
-- `parquet-go/internal/memory.newSlice`
-- parquet column buffer construction
-- Zstd encoder initialization blocks
-
-## Contention Findings
-
-The block profile did not show a recorder lock hotspot. It was dominated by channel waits:
-
-- `runtime.chanrecv2`: about 65.8% of blocked time
-- `runtime.chanrecv1`: about 31.8% of blocked time
-
-Most blocked time came from long-lived background workers waiting on channels, especially comm resolver workers. That means the current parquet path does not yet show a major mutex-contention bottleneck; the bigger costs are work done per event and the extra TUI fanout/allocation load.
-
-## Optimization Targets
-
-These are the highest-value targets for the follow-up optimization task:
-
-- Reduce fd/path resolution overhead in the event loop, especially `Readlink`-driven work in `file.NewFdWithPid`.
-- Lower TUI recording allocations by reusing stream fanout buffers and reducing ring-buffer/session setup churn.
-- Revisit recorder/session and parquet writer setup costs if recordings are started frequently in short sessions.
-- Only optimize parquet compression or flush behavior after confirming they dominate a focused headless profile; they are not currently the primary cost center.
-
-## Verified Follow-up Win
-
-After profiling, the first optimization pass removed the extra TUI `streamEvents` channel hop and pushed directly into the mutex-protected ring buffer.
-
-Re-run command:
-
-```bash
-env GOTOOLCHAIN=auto mage benchProf
-```
-
-Optimized pipeline artifacts:
-
-- `bench-profiles/pipeline-20260313-055321-cpu.prof`
-- `bench-profiles/pipeline-20260313-055321-mem.prof`
-- `bench-profiles/pipeline-20260313-055321-block.prof`
-
-Benchmark comparison for the changed path:
-
-| Benchmark | Before | After | Change |
-| --- | --- | --- | --- |
-| `BenchmarkPipelineTUIParquetRecording` | `19.13 ms/op`, `994016 B/op`, `19873 allocs/op` | `16.51 ms/op`, `992334 B/op`, `19866 allocs/op` | about `13.7%` faster with a small allocation reduction |
-
-Notes:
-
-- `BenchmarkPipelineHeadlessParquetCapture` also moved between runs, but that path was not changed; treat that difference as benchmark noise rather than a verified optimization win.
-- Post-change CPU samples still show the event loop and fd/path resolution dominating overall cost, so the next optimization pass should stay focused on those areas instead of tuning parquet compression first.
diff --git a/docs/tui-dashboard-table-sorting-plan.md b/docs/tui-dashboard-table-sorting-plan.md
deleted file mode 100644
index 0d4586e..0000000
--- a/docs/tui-dashboard-table-sorting-plan.md
+++ /dev/null
@@ -1,336 +0,0 @@
-# TUI Dashboard Table Sorting Plan
-
-## Overview
-
-Add column-driven sorting to the dashboard table views for:
-
-- `3:Syscalls`
-- `4:Files`
-- `5:Processes`
-
-This is a **table-view-only** feature. Bubble, treemap, and icicle modes keep
-their existing ordering rules.
-
-The task wording says "sort by any row", but the current dashboard already
-tracks both a selected row and a selected column. This plan therefore treats
-`s` as **sort by the currently selected column/cell**.
-
-Pressing `s`:
-
-1. on a new selected column enables that column's sort order
-2. again on the same selected column clears the custom sort and restores the
- tab's current default ordering
-
-## Current Behavior
-
-The dashboard already has the key pieces needed for this feature:
-
-- `internal/tui/dashboard/model.go`
- - stores row selection and selected column for Syscalls, Files, and Processes
- - routes table navigation with `left/right` and `h/l`
-- `internal/tui/dashboard/syscalls.go`
- - renders the syscall table from `snap.Syscalls()`
-- `internal/tui/dashboard/files.go`
- - renders both the file table and the grouped-directory table
-- `internal/tui/dashboard/processes.go`
- - renders the process table
-
-The current default ordering comes from the snapshot producers:
-
-- Syscalls: `Count desc`, then `Name asc`
-- Files: `Accesses desc`, then `Path asc`
-- Grouped directories: `Accesses desc`, then `Directory asc`
-- Processes: `Syscalls desc`, then `Bytes desc`, then `PID asc`
-
-That ordering should remain the baseline whenever no custom sort is active.
-
-## Design Goals
-
-- `s` sorts by the selected column in table mode.
-- `s` on the same selected column toggles back to the default ranking.
-- `Enter` continues to act on the row currently visible on screen after sorting.
-- Sorting stays in the dashboard layer; `statsengine` snapshot semantics do not
- change.
-- Selection remains anchored to the same logical entity when sorting changes.
-- Width changes do not corrupt sort state for the Syscalls tab.
-
-## UX Rules
-
-- `s` is active only for sortable dashboard tables:
- - Syscalls table mode
- - Files table mode
- - Files directory-grouped table mode
- - Processes table mode
-- `s` does nothing in:
- - Overview
- - Latency+Gaps
- - Stream
- - Flame
- - bubble/treemap/icicle modes
-- Table footer hints should add `s:sort`.
-- The footer should also show the active sort, for example:
- - `sort: default`
- - `sort: p95 desc`
- - `sort: Path asc`
-- Expanded help should mention `s` so the feature is discoverable.
-
-## State Model
-
-Add dashboard-local sort state per table shape.
-
-Example shape:
-
-```go
-type tableSortState[K comparable] struct {
- active bool
- key K
-}
-```
-
-Recommended fields on `dashboard.Model`:
-
-- `syscallsSort`
-- `filesSort`
-- `filesDirSort`
-- `processesSort`
-
-`Files` needs **two** sort states because the tab has two different table
-schemas:
-
-- file rows
-- grouped directory rows
-
-Those states should persist independently when `d` toggles between files and
-directories.
-
-## Logical Sort Keys
-
-Do **not** store the raw selected column index as the sort identifier.
-
-The Syscalls table changes shape by width:
-
-- narrow layout: `Syscall Count Rate/s Avg p95 p99 Bytes Errors`
-- wide layout: `Syscall Count Rate/s Avg Min Max p50 p95 p99 Bytes Errors`
-
-If sort state stored only a column index, resizing from narrow to wide would
-turn "sort by p95" into "sort by Min". The sort state must therefore use a
-stable logical key enum, and map the current visible column index to that enum
-at keypress time.
-
-Recommended enums:
-
-- `syscallSortKey`
-- `fileSortKey`
-- `fileDirSortKey`
-- `processSortKey`
-
-## Column Ordering Rules
-
-Use a fixed natural direction per logical column. This avoids inventing a
-three-state cycle and matches the task requirement of "sort" plus "toggle back".
-
-### Syscalls
-
-- `Syscall`: `Name asc`
-- `Count`: `Count desc`
-- `Rate/s`: `RatePerSec desc`
-- `Avg`: `LatencyMeanNs desc`
-- `Min`: `LatencyMinNs desc`
-- `Max`: `LatencyMaxNs desc`
-- `p50`: `LatencyP50Ns desc`
-- `p95`: `LatencyP95Ns desc`
-- `p99`: `LatencyP99Ns desc`
-- `Bytes`: `Bytes desc`
-- `Errors`: `Errors desc`
-
-### Files
-
-- `Accesses`: `Accesses desc`
-- `Read`: `BytesRead desc`
-- `Write`: `BytesWritten desc`
-- `Avg Latency`: `AvgLatencyNs desc`
-- `Max Latency`: `MaxLatencyNs desc`
-- `Path`: `Path asc`
-
-### Grouped Directories
-
-- `Accesses`: `Accesses desc`
-- `Read`: `BytesRead desc`
-- `Write`: `BytesWritten desc`
-- `Avg Latency`: `AvgLatencyNs desc`
-- `Max Latency`: `MaxLatencyNs desc`
-- `Files`: `FileCount desc`
-- `Directory`: `Dir asc`
-
-### Processes
-
-- `PID`: `PID asc`
-- `Comm`: `Comm asc`
-- `Syscalls`: `Syscalls desc`
-- `Rate/s`: `RatePerSec desc`
-- `Total Bytes`: `Bytes desc`
-- `Avg Latency`: `AvgLatencyNs desc`
-
-## Comparator Rules
-
-For deterministic output, custom comparators should fall back to the existing
-default ranking for that row type.
-
-Examples:
-
-- `p95 desc`, then syscall default order
-- `Path asc`, then file default order
-- `Comm asc`, then process default order
-
-This keeps ties stable and makes the "toggle back to default" behavior
-predictable.
-
-## Selection Anchoring
-
-Changing sort order must not leave the cursor on the same numeric row index if
-that index now points to a different entity.
-
-Before toggling sort:
-
-1. capture the currently selected logical entity key
-2. recompute the sorted rows
-3. restore the selected row to the same entity in the new order
-4. if the entity no longer exists, clamp as today
-
-Recommended identity keys:
-
-- Syscalls: `Name`
-- Files: `Path`
-- Grouped directories: `Dir`
-- Processes: `PID`
-
-This same anchor logic should run on refresh ticks while custom sorting is
-active so the selected item does not drift unpredictably as live stats change.
-
-## Implementation Shape
-
-Keep the sorting logic in `internal/tui/dashboard`, not in `internal/statsengine`.
-
-Reason:
-
-- snapshot order is part of the existing aggregate ranking behavior
-- only the table presentation needs alternate ordering
-- bubble/treemap/icicle already have their own ordering rules
-
-Recommended implementation split:
-
-- `internal/tui/common/keys.go`
- - add a `Sort` binding for `s`
- - include it in dashboard help output
-- `internal/tui/dashboard/model.go`
- - add per-table sort state
- - handle `s`
- - ignore `s` outside sortable table modes
- - preserve selection anchors when sort changes
- - make `selectedSyscallFilter`, `selectedFileFilter`, and
- `selectedProcessSnapshot` read from the same sorted rows used by rendering
-- `internal/tui/dashboard/syscalls.go`
- - add syscall sort key mapping from visible column index
- - add sorted syscall row helper
- - expose active sort label for footer hints
-- `internal/tui/dashboard/files.go`
- - add file and directory sort key helpers
- - keep file and grouped-directory comparators separate
-- `internal/tui/dashboard/processes.go`
- - add process sort key helpers and sorted row helper
-- `internal/tui/dashboard/table.go`
- - extend footer hints/status rendering as needed for the active sort label
-
-## Rendering/Data Consistency
-
-The most important implementation rule is:
-
-**the rendered rows and the row-selection actions must use the exact same sorted
-slice**
-
-Without this, the UI can show one row while `Enter` filters a different row.
-
-The safest approach is to centralize each table's sorted typed rows in helper
-functions and use those helpers in both:
-
-- render paths
-- selected-row action paths
-
-## Files Tab Details
-
-The Files tab needs one extra rule beyond Syscalls and Processes:
-
-- in plain file mode, sorting operates on `[]statsengine.FileSnapshot`
-- in directory-grouped mode, sorting operates on `[]DirSnapshot`
-
-The two modes should not share a single sort key because their columns differ.
-Switching with `d` should preserve:
-
-- last file-table custom sort
-- last directory-table custom sort
-
-## Interaction With Existing Features
-
-- `Enter`
- - still filters the currently selected visible row
-- `d`
- - only changes Files table shape; custom sort state persists per mode
-- `v`
- - custom sort state persists, but only applies when returning to table mode
-- `b`
- - unaffected; bubble/treemap ordering remains metric-driven
-- terminal resize
- - sort state persists because it stores logical keys, not raw indices
-- trace restart / filter apply
- - sort state should remain as view state
-
-## Testing Plan
-
-Add focused tests in `internal/tui/dashboard` and `internal/tui/common`.
-
-### Model behavior
-
-- `s` on Syscalls enables a column sort.
-- `s` on the same Syscalls column restores default sorting.
-- `s` on Processes does nothing in non-table modes.
-- `s` on Files uses file-mode sort state when `filesDirGrouped == false`.
-- `s` on Files uses directory-mode sort state when `filesDirGrouped == true`.
-- changing sort preserves the selected entity instead of only the row index.
-
-### Width-sensitive syscall behavior
-
-- sorting by `p95` in narrow mode survives a resize into wide mode
-- sorting by `Syscall` or `Count` maps correctly in both layouts
-
-### Selection action consistency
-
-- `selectedSyscallFilter()` uses sorted syscall rows
-- `selectedFileFilter()` uses sorted file or directory rows
-- `selectedProcessSnapshot()` uses sorted process rows in table mode
-
-### Help/footer rendering
-
-- expanded help includes `s`
-- table footer includes `s:sort`
-- active sort label is visible in the table footer
-
-### Negative cases
-
-- `s` does nothing on Overview / Stream / Flame / Latency+Gaps
-- `s` does nothing for bubble / treemap / icicle views
-
-## Recommended Delivery Order
-
-1. add key binding and sort state plumbing in `dashboard.Model`
-2. implement sorted typed-row helpers per tab
-3. switch render paths and selected-row actions to the shared helpers
-4. add footer/help output
-5. add regression tests for sort toggling, width changes, and selected-row
- action consistency
-
-## Non-Goals
-
-- no change to snapshot generation order in `statsengine`
-- no sortable Overview or Latency+Gaps tables
-- no ascending/descending toggle cycle beyond "custom sort" vs "default"
-- no behavior change for bubble/treemap/icicle ordering
diff --git a/docs/tui-flamegraph-behavior.md b/docs/tui-flamegraph-behavior.md
deleted file mode 100644
index cc9bb5d..0000000
--- a/docs/tui-flamegraph-behavior.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# TUI Flamegraph Expected Behavior
-
-This document records the expected interaction and layout behavior for the TUI
-flamegraph. It is intended as a stable reference for regressions and for tests
-under `internal/tui/flamegraph/` and `internal/tui/dashboard/`.
-
-## Interaction
-
-- `space` toggles pause. `p` does not pause the flamegraph and remains reserved
- for the global PID picker at the top-level TUI.
-- `enter` and left-click zoom into the selected or clicked frame.
-- Clicking an ancestor frame in the zoom lineage re-roots the view to that
- ancestor.
-- `u`, `backspace`, and `esc` undo one zoom step.
-- Direct clicks into a deep descendant create a single undo step back to the
- previous zoom root, not an implicit stack of every skipped ancestor.
-- While paused, navigation and zoom must continue to work against the frozen
- snapshot.
-
-## Layout
-
-- The selected frame must not render with underline or a horizontal highlight
- line across the bar.
-- The current zoom root must span the full flamegraph width.
-- The children of the current zoom root must be normalized to the full viewport
- width, even when the zoom root has self time or exclusive weight.
-- Zooming from any direction must produce the same full-width result for the
- newly selected zoom root.
-- The zoom lineage rows shown above the zoomed subtree provide context, but they
- must not steal horizontal space from the zoomed subtree.
-
-## Rendering
-
-- Rendering the dashboard view must not mutate persistent flamegraph state.
-- Redundant same-size viewport updates must be no-ops.
-- In paused mode, repeated renders must not reintroduce stale frame geometry or
- leave artifacts from a previous layout on screen.
-
-## Regression Coverage
-
-These expectations are covered by tests in:
-
-- `internal/tui/flamegraph/renderer_test.go`
-- `internal/tui/flamegraph/model_test.go`
-- `internal/tui/flamegraph/stress_test.go`
-- `internal/tui/dashboard/model_test.go`
diff --git a/docs/tui-fl