From cd554b0af706b5f62b4e1bfde04091052b4aac61 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 18 Mar 2026 20:54:35 +0200 Subject: cleanup --- README.md | 38 ++- docs/libbpfgo-upgrade-plan.md | 154 ----------- docs/parquet-recording-perf-baseline.md | 113 -------- docs/tui-dashboard-table-sorting-plan.md | 336 ----------------------- docs/tui-flamegraph-behavior.md | 46 ---- docs/tui-flamegraph-plan.md | 450 ------------------------------- docs/tui-global-filter-architecture.md | 160 ----------- flamegraph.test | Bin 12360000 -> 0 bytes internal/c/generated_tracepoints.c | 46 ++-- internal/event/event.go | 2 +- internal/event/pair.go | 23 +- internal/file/file.go | 9 +- internal/flags/flags.go | 26 +- internal/flamegraph/iordata.go | 40 ++- internal/flamegraph/iordata_test.go | 4 +- internal/flamegraph/livetrie.go | 18 +- internal/flamegraph/trie.go | 9 +- internal/generate/codegen.go | 9 +- internal/generate/typesgo.go | 2 +- internal/globalfilter/filter.go | 20 ++ internal/parquet/schema.go | 16 ++ internal/probemanager/grouping.go | 14 +- internal/probemanager/manager.go | 5 +- internal/statsengine/bench_test.go | 4 +- internal/statsengine/filerank.go | 11 +- internal/statsengine/process.go | 25 +- internal/statsengine/syscall.go | 25 +- internal/statsengine/syscall_test.go | 12 +- internal/tui/dashboard/bubbles.go | 19 +- internal/tui/dashboard/files.go | 10 +- internal/tui/dashboard/icicle.go | 15 +- internal/tui/dashboard/treemap.go | 27 +- internal/tui/flamegraph/model.go | 20 +- internal/tui/flamegraph/renderer.go | 19 +- internal/tui/pidpicker/proclist.go | 19 +- internal/types/generated_types.go | 18 +- 36 files changed, 301 insertions(+), 1463 deletions(-) delete mode 100644 docs/libbpfgo-upgrade-plan.md delete mode 100644 docs/parquet-recording-perf-baseline.md delete mode 100644 docs/tui-dashboard-table-sorting-plan.md delete mode 100644 docs/tui-flamegraph-behavior.md delete mode 100644 docs/tui-flamegraph-plan.md delete mode 100644 docs/tui-global-filter-architecture.md delete mode 100755 flamegraph.test 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-.csv` snapshot of the current filtered stream view | exports only the currently filtered in-memory rows | +| Headless `.ior.zst` export | start with `-flamegraph -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 ` | 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-flamegraph-plan.md b/docs/tui-flamegraph-plan.md deleted file mode 100644 index 261f0fb..0000000 --- a/docs/tui-flamegraph-plan.md +++ /dev/null @@ -1,450 +0,0 @@ -# TUI Flamegraph Tab - Full Design Plan - -## Overview - -Add a **7th dashboard tab** (`7:Flame`) that renders a live, interactive flamegraph -directly in the terminal using lipgloss for layout/styling and **Charm Harmonica** -for smooth spring-based animations on both zoom transitions and live data refresh. -The tab consumes data from an embedded `LiveTrie` and -provides interactive flamegraph navigation directly in-terminal. - -## Architecture - -``` -BPF events -> eventLoop.printCb - | - +-> statsengine.Ingest() (existing tabs 1-5) - +-> eventstream.Push() (existing tab 6) - +-> LiveTrie.Ingest() (NEW: tab 7) -``` - -The `LiveTrie` is instantiated in the TUI startup path and published via -`runtimeBindings`, similar to how `SnapshotSource` and `RingBuffer` are already -wired. - -## New Files and Packages - -| File/Package | Purpose | -|---|---| -| `internal/tui/flamegraph/model.go` | Bubble Tea sub-model: state, Update, View | -| `internal/tui/flamegraph/renderer.go` | Converts LiveTrie snapshot -> terminal frame layout, renders with lipgloss | -| `internal/tui/flamegraph/animation.go` | Harmonica spring state for frame width interpolation and zoom transitions | -| `internal/tui/flamegraph/search.go` | Search/highlight: text input bubble, match filtering, highlight styling | -| `internal/tui/flamegraph/zoom.go` | Zoom stack management (zoom into subtree, undo zoom, reset zoom) | -| `internal/tui/flamegraph/controls.go` | Toolbar rendering (status line, field order, keybindings help) | - -## Detailed Design - -### 1. Data Wiring - -**Changes to existing files:** - -- `internal/tui/tui.go` -- Add `SetLiveTrie(*flamegraph.LiveTrie)` to - `TraceRuntimeBindings` interface and `runtimeBindings` struct. The trace starter - publishes the `LiveTrie` the same way it publishes the stats engine. -- `internal/ior.go` / trace setup -- When running in TUI mode, create a `LiveTrie` - alongside the stats engine. In the `eventLoop.printCb`, call - `liveTrie.Ingest(ep)` in addition to existing stats/stream ingestion. Publish via - `bindings.SetLiveTrie(lt)`. -- `internal/tui/dashboard/model.go` -- Add `liveTrie *flamegraph.LiveTrie` field, - `flamegraphModel flamegraphtui.Model` child model. Wire refresh tick to poll - `LiveTrie.Version()`. - -### 2. Tab Integration - -**Changes to `internal/tui/dashboard/tabs.go`:** - -```go -const ( - // ... existing tabs ... - TabFlame // new 7th tab -) - -var allTabs = []Tab{..., TabFlame} -``` - -Tab label: `"Flame"` (short: `"Flm"`). Key binding: `7`. - -**Changes to `internal/tui/dashboard/model.go`:** - -- Add `flamegraphModel` field of type `flamegraphtui.Model` -- In `Update()`, on `refreshTickMsg` or a dedicated `flameTickMsg` (200ms like - stream), poll `LiveTrie.Version()` and push snapshot data into the flamegraph - model -- In `handleKey()`, add `key.Matches(msg, m.keys.Seven)` -> `TabFlame` -- In `renderActiveTab()`, delegate to `flamegraphModel.View(width, height)` -- On `WindowSizeMsg`, propagate dimensions to `flamegraphModel.SetViewport(w, h)` - -- this triggers re-layout of all frames to fit new terminal size - -### 3. Flamegraph TUI Model (`internal/tui/flamegraph/model.go`) - -```go -type Model struct { - // Data - liveTrie *flamegraph.LiveTrie - lastVersion uint64 - snapshot *flamegraph.trieSnapshot // latest parsed snapshot - - // Layout - frames []tuiFrame // current rendered frames - targetFrames []tuiFrame // target frames (for animation lerp) - width, height int - - // Interaction - selectedIdx int // cursor/selected frame index - zoomStack []zoomState // zoom history for undo - zoomRoot *flamegraph.trieSnapshot // current zoom root (nil = full view) - - // Search - searchActive bool - searchInput textinput.Model // from bubbles/textinput - searchQuery string - matchIndices map[int]bool // frame indices matching search - - // Field ordering - fieldPresets [][]string - fieldIndex int - - // Animation - springs []frameSpring // per-frame Harmonica spring state - animTicker bool // whether animation tick is running - - // Flags - paused bool - isDark bool -} - -type tuiFrame struct { - Name string - Col int // column position (0-based, in terminal cells) - Row int // row from bottom - Width int // width in terminal cells - Total uint64 - Percent float64 - Fill lipgloss.Color - Depth int - Path string // full path for zoom identification -} -``` - -### 4. Rendering Strategy (`internal/tui/flamegraph/renderer.go`) - -Terminal flamegraphs use a **cell-based layout** rather than pixel coordinates: - -1. **BuildTerminalLayout(snapshot, width, height, zoomRoot)** converts trie snapshot - to `[]tuiFrame`: - - Width is terminal columns (not 1200px). Each frame width = - `floor(termWidth * (node.total / rootTotal))`. - - Height is terminal rows. Each frame is exactly **1 row tall** (not 16px). - - Rows grow bottom-to-top: root at the bottom, leaves at the top (classic - flamegraph orientation). If tree depth exceeds available rows, only show the - deepest `height-2` levels (toolbar + status take 2 rows). - - Frames narrower than 1 cell are culled (terminal equivalent of `minWidthPx`). - -2. **Frame rendering**: Each frame is a **colored block** of text: - - Use lipgloss background color fill with the existing `frameColor()` warm palette - - Frame text = truncated function/path name that fits within the frame width - - Selected frame gets a distinct border/highlight style (e.g., bold + inverted) - - Search-matched frames get a different highlight color (e.g., red background) - -3. **Compositing**: Use `lipgloss.Place()` or the new lipgloss v2 compositor/canvas - to layer frames at their (col, row) positions. Each row of the flamegraph is - assembled by joining frame cells horizontally with background fill for gaps. - -4. **Auto-resize**: On `WindowSizeMsg`, re-run `BuildTerminalLayout` with new - dimensions. All frame widths and row counts recalculate. Harmonica springs - animate from old positions/widths to new ones. - -### 5. Subtree Highlighting - -When the user selects (navigates to) a frame, the **entire subtree rooted at that -frame** is visually highlighted so the user can see exactly what would be zoomed -into on `enter`. - -**Visual states for any frame:** - -| State | Visual Treatment | -|---|---| -| **Selected frame** | Bold text + bright border/underline + slightly lightened background | -| **Selected subtree** (ancestors + descendants) | Full saturation, normal brightness -- "active" look | -| **Not in subtree** | **Dimmed**: reduced saturation / lower contrast background, muted text | -| **Search match** | Red/magenta background overlay (overrides dim but not selection) | - -Dimming the *non-subtree* frames makes the selected subtree "pop" naturally. - -**Ancestor vs Descendant Distinction:** - -| Relationship | Visual | -|---|---| -| **Selected frame** | Bold, inverted/bright border | -| **Descendants** | Full color, normal weight | -| **Ancestors** | Full color with subtle left-border indicator (breadcrumb trail) | -| **Unrelated** | Dimmed (lower contrast background, gray text) | - -**Subtree membership** computed via `Path` field (the `\x1f`-delimited ancestor -chain). A frame is in the subtree if: -- Its path is a **prefix** of the selected frame's path (ancestor), OR -- The selected frame's path is a **prefix** of its path (descendant), OR -- It **is** the selected frame - -O(n) scan over frames, recomputed each time selection moves. - -**Interaction with search**: Search matches outside subtree shown dimmed in match -color; inside subtree shown bright. Selected frame matching search uses selection -style. - -### 6. Animation with Harmonica (`internal/tui/flamegraph/animation.go`) - -```go -type frameSpring struct { - widthSpring harmonica.Spring - colSpring harmonica.Spring - currentW float64 - currentCol float64 - velocityW float64 - velocityCol float64 -} -``` - -**Two animation scenarios:** - -1. **Data refresh**: When `LiveTrie` version changes and new frame widths differ - from current, set new target widths. On each animation tick (~30fps = - `tea.Tick(33ms)`), call `spring.Update(current, velocity, target)` for each - frame's width and column. Render at interpolated values. Stop animation tick - when all frames reach target within epsilon. - -2. **Zoom transition**: When user zooms into a subtree, the target layout changes - (zoomed subtree expands to fill full width). Springs animate column positions - and widths from pre-zoom to post-zoom. Undo-zoom reverses this. - -**Spring configuration**: `harmonica.NewSpring(harmonica.FPS(30), 6.0, 1.0)` -- -critically damped for snappy transitions without oscillation. - -### 7. Keybindings - -| Key(s) | Action | -|---|---| -| `j` / `down` / `arrow-down` | Move selection to frame below (shallower depth) | -| `k` / `up` / `arrow-up` | Move selection to frame above (deeper / toward leaves) | -| `h` / `left` / `arrow-left` | Move selection to previous sibling at same depth | -| `l` / `right` / `arrow-right` | Move selection to next sibling at same depth | -| `enter` | Zoom into selected frame's subtree | -| `backspace` / `u` | Undo zoom (pop zoom stack) | -| `escape` (when zoomed) | Reset zoom to root | -| `/` | Open search input | -| `escape` (when searching) | Close search, clear highlights | -| `n` | Jump to next search match | -| `N` (shift+n) | Jump to previous search match | -| `p` | Toggle pause | -| `r` | Reset baseline | -| `o` | Cycle field order preset | -| `?` | Toggle flame-specific help overlay | - -Both vim-style (j/k/h/l) and regular cursor keys (arrow keys) are bound to the -same actions via `key.NewBinding(key.WithKeys("j", "down"))`. - -### 8. Search (`internal/tui/flamegraph/search.go`) - -- Uses `bubbles/textinput` for inline search input at the bottom of flame view -- On submit, iterate frames and mark matching indices (case-insensitive substring - match on frame name) -- Matching frames rendered with highlight color; non-matching frames dimmed -- `n`/`N` moves selection to next/previous match -- Show match count in status line (e.g. `3/12 matches`) - -### 8.1 Color Coding (Implemented) - -Flame frames now use semantic colors first, with hash-based fallback for unknown labels: - -| Category | Match rule | Color (RGBA / hex) | -|---|---|---| -| Read I/O | name contains `read`/`pread` | `78,132,201` (`#4E84C9`) | -| Write I/O | name contains `write`/`pwrite` | `222,122,58` (`#DE7A3A`) | -| Metadata I/O | name contains `open`, `close`, `stat`, `rename`, `link` | `196,168,72` (`#C4A848`) | -| Path-oriented nodes | starts with `/`, contains `/`, or `path:` | `88,156,84` (`#589C54`) | -| Process/thread labels | contains `pid` or `tid` | `67,151,149` (`#439795`) | -| Other syscall buckets | starts with `sys_` | `191,99,74` (`#BF634A`) | -| Fallback | anything else | deterministic hash palette | - -This keeps common I/O classes visually stable across refreshes while preserving -distinct colors for uncategorized frames. - -### 9. Zoom (`internal/tui/flamegraph/zoom.go`) - -- `zoomStack []zoomState` where `zoomState` holds the `path` string of the zoomed - node and the previous `selectedIdx` -- On zoom-in: push current state, find subtree node matching selected frame's path, - set as `zoomRoot`, rebuild layout with subtree as root -- On undo: pop stack, restore previous root -- On reset: clear stack, set `zoomRoot = nil` - -### 10. Field Order Cycling - -Preset cycle: -```go -fieldPresets = [][]string{ - {"comm", "path", "tracepoint"}, - {"path", "tracepoint", "comm"}, - {"tracepoint", "comm", "path"}, - {"pid", "path", "tracepoint"}, -} -``` - -Pressing `o` calls `LiveTrie.Reconfigure(nextPreset)` which resets the trie and -starts fresh accumulation. - -### 11. Toolbar / Status Line (`internal/tui/flamegraph/controls.go`) - -Rendered as a single line above the flamegraph area: - -``` -[LIVE] | o:order(comm>path>tp) | /:search | enter:zoom | u:undo | r:reset | p:pause -``` - -When paused: `[PAUSED]` in red. When searching: shows search input and match count. - -Selected frame info line at the bottom: -``` -sys_read (1,234 calls, 45.2%) - /usr/bin/myapp > /dev/sda > sys_enter_read -``` - -### 12. Dependencies to Add - -- `github.com/charmbracelet/harmonica` -- spring animation -- `charm.land/bubbles/v2/textinput` -- search input (already transitive via bubbles v2) - -### 13. Changes to Existing Files (Summary) - -| File | Change | -|---|---| -| `internal/tui/dashboard/tabs.go` | Add `TabFlame`, update `allTabs`, `String()`, `tabLabel()` | -| `internal/tui/dashboard/model.go` | Add `flamegraphModel` field, wire refresh, handle key `7`, render in `renderActiveTab()` | -| `internal/tui/tui.go` | Add `SetLiveTrie()` to bindings interface, propagate to dashboard | -| `internal/tui/common/keys.go` | Add `Seven` key binding for tab 7 | -| `internal/ior.go` | Create `LiveTrie` in TUI mode, wire into eventLoop callback, publish via bindings | -| `internal/flags/flags.go` | Add `-fields` default propagation to TUI mode | -| `go.mod` | Add `github.com/charmbracelet/harmonica` dependency | - -### 14. Risks and Mitigations - -1. **Performance at high event rates**: The `LiveTrie.Ingest()` call adds overhead - to the hot path. Mitigation: TUI render is decoupled via version polling. - -2. **Terminal width too narrow**: Flamegraphs with many shallow frames may not - render meaningfully in 80-column terminals. Mitigation: cull frames below 1 cell, - show "terminal too narrow" message below ~60 columns. - -3. **Animation frame budget**: 30fps animation ticks in a terminal could cause - flicker on slow terminals. Mitigation: only run animation tick when springs are - active, stop when settled. - -4. **Color support**: Not all terminals support 24-bit color. Mitigation: lipgloss - v2 auto-downgrades. The warm flamegraph palette degrades gracefully to 256-color. - ---- - -## Benchmarking & Profiling Plan - -### Goals - -1. Quantify render performance at various terminal sizes and trie depths -2. Measure animation overhead of Harmonica spring ticks at 30fps with N springs -3. Detect regressions via baseline benchmarks running in CI alongside `mage bench` -4. Profile hot paths to identify allocations and CPU bottlenecks - -### Benchmark Suite - -New file: `internal/tui/flamegraph/bench_test.go` - -| Benchmark | What it measures | -|---|---| -| `BenchmarkBuildTerminalLayout` | trieSnapshot -> []tuiFrame at widths 80/120/200/300 and depths 10/50/100 | -| `BenchmarkRenderFrame` | Full View() render at 80x24, 120x40, 200x60 | -| `BenchmarkComputeSubtreeSet` | Subtree membership with 100/1000/5000 frames | -| `BenchmarkSearchHighlight` | Search match computation across N frames | -| `BenchmarkSpringUpdate` | harmonica spring.Update() across 100/500/2000 springs | -| `BenchmarkAnimationTick` | Full tick: update springs + rebuild render output | -| `BenchmarkZoomTransition` | Layout rebuild on zoom-in | -| `BenchmarkLiveTrieIngestAndSnapshot` | End-to-end: ingest N events + snapshot + layout | -| `BenchmarkResizeRelayout` | Layout rebuild at new terminal dimensions | - -### Benchmark Fixtures - -Synthetic trie generators in `internal/tui/flamegraph/testdata_test.go`: - -| Label | Depth | Breadth | Approximate frame count | -|---|---|---|---| -| `small` | 5 | 3 | ~120 | -| `medium` | 10 | 5 | ~2,500 | -| `large` | 15 | 8 | ~10,000+ | -| `deep` | 50 | 2 | ~100 (narrow but deep) | -| `wide` | 3 | 50 | ~5,000 (shallow but very wide) | - -### Performance Targets - -| Operation | Target | Rationale | -|---|---|---| -| `BuildTerminalLayout` (medium, 120-col) | < 500us | Well within one tick interval | -| `View()` full render (medium, 120x40) | < 2ms | 30fps = 33ms budget | -| `ComputeSubtreeSet` (1000 frames) | < 100us | Runs on every selection move | -| Single animation tick (500 springs) | < 1ms | 16ms frame budget headroom | -| `LiveTrie.Ingest` + `SnapshotJSON` | < 200us | Hot path performance | - -### Profiling Integration - -#### Built-in profiling flag - -When `-pprof` is set in TUI mode: -- Write `ior-tui-cpu.prof` during session -- Write `ior-tui-mem.prof` on quit -- Write `ior-tui-trace.out` for first 10 seconds - -#### Mage Targets - -| Target | Command | -|---|---| -| `mage benchFlame` | `go test ./internal/tui/flamegraph/ -bench=. -benchmem -count=5` | -| `mage benchFlameProf` | Same + `-cpuprofile` + `-memprofile` | -| `mage benchFlameCmp` | Compare against saved baseline via `benchstat` | - -### Allocation Targets - -| Hot path | Strategy | -|---|---| -| `BuildTerminalLayout` | Pre-allocate []tuiFrame, reuse across refreshes | -| `View()` render | strings.Builder with pre-estimated capacity, cache styles | -| `computeSubtreeSet` | Reuse map[int]bool (clear + repopulate) | -| Spring updates | Fixed-size []frameSpring, no per-tick allocs | - -Target: **zero allocs** in animation tick, **< 5 allocs/op** in full render. - -### Stress Tests - -New file: `internal/tui/flamegraph/stress_test.go` - -- **TestStressHighEventRate**: 100k events from 10 goroutines + concurrent render -- **TestStressRapidResize**: 100 WindowSizeMsg with random dimensions -- **TestStressZoomDuringRefresh**: Interleaved zoom/undo with data refresh ticks - -All run with `-race`. - -### Profiling Workflow (Manual) - -```bash -# Run TUI with profiling -sudo ior -pprof -pid 1234 - -# Analyze CPU profile -go tool pprof -http=:8080 ior-tui-cpu.prof - -# Analyze allocations -go tool pprof -http=:8080 -alloc_space ior-tui-mem.prof - -# Analyze execution trace -go tool trace ior-tui-trace.out - -# Benchmark-specific profiling -mage benchFlameProf -go tool pprof -http=:8080 flame-cpu.prof -``` diff --git a/docs/tui-global-filter-architecture.md b/docs/tui-global-filter-architecture.md deleted file mode 100644 index 386ef75..0000000 --- a/docs/tui-global-filter-architecture.md +++ /dev/null @@ -1,160 +0,0 @@ -# TUI Global Filter Architecture - -## Overview - -Add one global filter flow for the TUI that is accessible from any dashboard -screen/tab and applies consistently across: - -- Flame -- Overview -- Syscalls -- Files -- Processes -- Latency+Gaps -- Stream - -The filter UI should reuse the current stream filter concepts, but the filter -state must move to the top-level TUI model so there is a single source of truth. - -## Goals - -- One shared filter modal opened from anywhere in the dashboard. -- One shared filter state owned by the top-level TUI model. -- Aggregate dashboards must only reflect matching live events. -- The stream tab must preserve its existing ring buffer across filter changes. -- Existing stream rows must be re-filtered locally after a filter change. -- String filters must remain substring-based. File path matching is explicitly a - partial substring match, not exact-only. - -## Supported Filter Fields - -The global filter supports the fields currently exposed by the stream filter -workflow, plus the existing runtime PID/TID controls: - -- `syscall` -- `comm` -- `file/path` -- `pid` -- `tid` -- `fd` -- `latency` -- `gap` -- `bytes` -- `retval` -- `errors only` - -## Matching Semantics - -- String fields use case-insensitive substring matching. -- `file/path` uses the same case-insensitive substring matching as the other - string fields. -- Numeric fields use the existing comparison operators (`=`, `!=`, `>`, `>=`, - `<`, `<=`). -- `errors only` keeps only events with negative return values / error-marked - events. - -## Architecture - -``` -BPF events -> eventLoop / print callback - | - +-> global runtime matcher - | - +-> statsengine.Ingest() (filtered live aggregates) - +-> liveTrie.Ingest() (filtered flamegraph) - +-> eventstream.Push() (filtered new stream rows) - -TUI state - top-level model - | - +-> owns shared global filter state - +-> owns global filter modal lifecycle - +-> restarts tracing when filter changes - +-> preserves current screen/tab - +-> asks Stream to re-filter buffered rows in place -``` - -## Runtime Behavior - -Applying a new global filter does all of the following: - -1. Preserve the current screen/tab. -2. Stop the active trace runtime. -3. Reset aggregate dashboard state and flamegraph baseline. -4. Restart tracing with the new global filter. -5. Keep the stream ring buffer contents intact. -6. Re-filter existing buffered stream rows locally so the stream updates - immediately. - -This means aggregate tabs only show post-change matching data, while Stream can -still show matching historical rows from before the restart. - -## Ownership and Data Flow - -### Top-level TUI model - -The top-level TUI model owns: - -- active global filter state -- global filter modal visibility -- filter apply/cancel/clear behavior -- trace restart lifecycle -- publication of filter state to child models that need local re-filtering - -### Stream model - -The stream model no longer owns the primary filter system. It must: - -- accept the shared global filter -- re-filter its retained `allEvents` slice on demand -- preserve the ring buffer across filter changes -- keep regex search as a separate feature -- drop the stream-local add/undo filter stack - -### Runtime / trace startup - -The TUI trace context currently carries only PID/TID. It must be expanded to -carry the full global filter payload. The trace startup path then uses that -payload to construct a runtime matcher before forwarding events into: - -- stats engine -- flamegraph live trie -- new stream events - -## Key Implementation Areas - -- `internal/tui/tui.go` - - own shared filter state - - open modal globally - - restart trace on apply - - preserve current screen/tab -- `internal/tui/dashboard/model.go` - - route global shortcut access cleanly across tabs - - expose active filter summary in dashboard rendering/help -- `internal/tui/eventstream/*` - - refactor modal for reuse - - keep stream history - - re-filter buffered rows in place - - remove stream-local filter stack behavior -- `internal/ior.go` - - plumb full filter payload through trace startup - - apply runtime matcher before aggregate/flame/live stream ingestion - -## UX Rules - -- `f` opens the global filter modal from any dashboard tab. -- `Enter` in the modal applies the filter. -- `Esc` closes the modal without applying. -- clear action resets to the unfiltered state. -- active filter summary is visible in dashboard status/help areas. -- stream regex search (`/`, `?`, `n`, `N`) remains separate from filtering. - -## Testing Requirements - -- context round-trip of the full global filter payload -- runtime matcher coverage for all supported fields -- stream ring buffer retention across filter changes -- local re-filtering of buffered stream rows -- file path substring matching coverage -- aggregate dashboards only reflecting matching live events after restart -- help/status rendering updates for the shared filter workflow diff --git a/flamegraph.test b/flamegraph.test deleted file mode 100755 index 2c68b6c..0000000 Binary files a/flamegraph.test and /dev/null differ diff --git a/internal/c/generated_tracepoints.c b/internal/c/generated_tracepoints.c index 06f8c39..b4d4e0f 100644 --- a/internal/c/generated_tracepoints.c +++ b/internal/c/generated_tracepoints.c @@ -1,7 +1,7 @@ // Code generated - don't change manually! -/// Ignoring sys_enter_accept sys_exit_accept as possibly not file I/O related /// Ignoring sys_enter_accept4 sys_exit_accept4 as possibly not file I/O related +/// Ignoring sys_enter_accept sys_exit_accept as possibly not file I/O related /// Ignoring sys_enter_acct sys_exit_acct as possibly not file I/O related /// Ignoring sys_enter_add_key sys_exit_add_key as possibly not file I/O related /// Ignoring sys_enter_adjtimex sys_exit_adjtimex as possibly not file I/O related @@ -17,20 +17,20 @@ /// Ignoring sys_enter_clock_gettime sys_exit_clock_gettime as possibly not file I/O related /// Ignoring sys_enter_clock_nanosleep sys_exit_clock_nanosleep as possibly not file I/O related /// Ignoring sys_enter_clock_settime sys_exit_clock_settime as possibly not file I/O related -/// Ignoring sys_enter_clone sys_exit_clone as possibly not file I/O related /// Ignoring sys_enter_clone3 sys_exit_clone3 as possibly not file I/O related +/// Ignoring sys_enter_clone sys_exit_clone as possibly not file I/O related /// Ignoring sys_enter_connect sys_exit_connect as possibly not file I/O related /// Ignoring sys_enter_delete_module sys_exit_delete_module as possibly not file I/O related -/// Ignoring sys_enter_epoll_create sys_exit_epoll_create as possibly not file I/O related /// Ignoring sys_enter_epoll_create1 sys_exit_epoll_create1 as possibly not file I/O related +/// Ignoring sys_enter_epoll_create sys_exit_epoll_create as possibly not file I/O related /// Ignoring sys_enter_epoll_ctl sys_exit_epoll_ctl as possibly not file I/O related -/// Ignoring sys_enter_epoll_pwait sys_exit_epoll_pwait as possibly not file I/O related /// Ignoring sys_enter_epoll_pwait2 sys_exit_epoll_pwait2 as possibly not file I/O related +/// Ignoring sys_enter_epoll_pwait sys_exit_epoll_pwait as possibly not file I/O related /// Ignoring sys_enter_epoll_wait sys_exit_epoll_wait as possibly not file I/O related -/// Ignoring sys_enter_eventfd sys_exit_eventfd as possibly not file I/O related /// Ignoring sys_enter_eventfd2 sys_exit_eventfd2 as possibly not file I/O related -/// Ignoring sys_enter_execve sys_exit_execve as possibly not file I/O related +/// Ignoring sys_enter_eventfd sys_exit_eventfd as possibly not file I/O related /// Ignoring sys_enter_execveat sys_exit_execveat as possibly not file I/O related +/// Ignoring sys_enter_execve sys_exit_execve as possibly not file I/O related /// Ignoring sys_enter_exit sys_exit_exit as possibly not file I/O related /// Ignoring sys_enter_exit_group sys_exit_exit_group as possibly not file I/O related /// Ignoring sys_enter_fanotify_init sys_exit_fanotify_init as possibly not file I/O related @@ -42,14 +42,13 @@ /// Ignoring sys_enter_futex_wait sys_exit_futex_wait as possibly not file I/O related /// Ignoring sys_enter_futex_waitv sys_exit_futex_waitv as possibly not file I/O related /// Ignoring sys_enter_futex_wake sys_exit_futex_wake as possibly not file I/O related -/// Ignoring sys_enter_get_mempolicy sys_exit_get_mempolicy as possibly not file I/O related -/// Ignoring sys_enter_get_robust_list sys_exit_get_robust_list as possibly not file I/O related /// Ignoring sys_enter_getcpu sys_exit_getcpu as possibly not file I/O related /// Ignoring sys_enter_getegid sys_exit_getegid as possibly not file I/O related /// Ignoring sys_enter_geteuid sys_exit_geteuid as possibly not file I/O related /// Ignoring sys_enter_getgid sys_exit_getgid as possibly not file I/O related /// Ignoring sys_enter_getgroups sys_exit_getgroups as possibly not file I/O related /// Ignoring sys_enter_getitimer sys_exit_getitimer as possibly not file I/O related +/// Ignoring sys_enter_get_mempolicy sys_exit_get_mempolicy as possibly not file I/O related /// Ignoring sys_enter_getpeername sys_exit_getpeername as possibly not file I/O related /// Ignoring sys_enter_getpgid sys_exit_getpgid as possibly not file I/O related /// Ignoring sys_enter_getpgrp sys_exit_getpgrp as possibly not file I/O related @@ -60,6 +59,7 @@ /// Ignoring sys_enter_getresgid sys_exit_getresgid as possibly not file I/O related /// Ignoring sys_enter_getresuid sys_exit_getresuid as possibly not file I/O related /// Ignoring sys_enter_getrlimit sys_exit_getrlimit as possibly not file I/O related +/// Ignoring sys_enter_get_robust_list sys_exit_get_robust_list as possibly not file I/O related /// Ignoring sys_enter_getrusage sys_exit_getrusage as possibly not file I/O related /// Ignoring sys_enter_getsid sys_exit_getsid as possibly not file I/O related /// Ignoring sys_enter_getsockname sys_exit_getsockname as possibly not file I/O related @@ -69,8 +69,8 @@ /// Ignoring sys_enter_getuid sys_exit_getuid as possibly not file I/O related /// Ignoring sys_enter_init_module sys_exit_init_module as possibly not file I/O related /// Ignoring sys_enter_inotify_add_watch sys_exit_inotify_add_watch as possibly not file I/O related -/// Ignoring sys_enter_inotify_init sys_exit_inotify_init as possibly not file I/O related /// Ignoring sys_enter_inotify_init1 sys_exit_inotify_init1 as possibly not file I/O related +/// Ignoring sys_enter_inotify_init sys_exit_inotify_init as possibly not file I/O related /// Ignoring sys_enter_inotify_rm_watch sys_exit_inotify_rm_watch as possibly not file I/O related /// Ignoring sys_enter_ioperm sys_exit_ioperm as possibly not file I/O related /// Ignoring sys_enter_iopl sys_exit_iopl as possibly not file I/O related @@ -97,11 +97,11 @@ /// Ignoring sys_enter_memfd_secret sys_exit_memfd_secret as possibly not file I/O related /// Ignoring sys_enter_migrate_pages sys_exit_migrate_pages as possibly not file I/O related /// Ignoring sys_enter_mincore sys_exit_mincore as possibly not file I/O related -/// Ignoring sys_enter_mknod sys_exit_mknod as possibly not file I/O related /// Ignoring sys_enter_mknodat sys_exit_mknodat as possibly not file I/O related -/// Ignoring sys_enter_mlock sys_exit_mlock as possibly not file I/O related +/// Ignoring sys_enter_mknod sys_exit_mknod as possibly not file I/O related /// Ignoring sys_enter_mlock2 sys_exit_mlock2 as possibly not file I/O related /// Ignoring sys_enter_mlockall sys_exit_mlockall as possibly not file I/O related +/// Ignoring sys_enter_mlock sys_exit_mlock as possibly not file I/O related /// Ignoring sys_enter_modify_ldt sys_exit_modify_ldt as possibly not file I/O related /// Ignoring sys_enter_mount sys_exit_mount as possibly not file I/O related /// Ignoring sys_enter_move_mount sys_exit_move_mount as possibly not file I/O related @@ -119,8 +119,8 @@ /// Ignoring sys_enter_msgget sys_exit_msgget as possibly not file I/O related /// Ignoring sys_enter_msgrcv sys_exit_msgrcv as possibly not file I/O related /// Ignoring sys_enter_msgsnd sys_exit_msgsnd as possibly not file I/O related -/// Ignoring sys_enter_munlock sys_exit_munlock as possibly not file I/O related /// Ignoring sys_enter_munlockall sys_exit_munlockall as possibly not file I/O related +/// Ignoring sys_enter_munlock sys_exit_munlock as possibly not file I/O related /// Ignoring sys_enter_munmap sys_exit_munmap as possibly not file I/O related /// Ignoring sys_enter_nanosleep sys_exit_nanosleep as possibly not file I/O related /// Ignoring sys_enter_newuname sys_exit_newuname as possibly not file I/O related @@ -129,8 +129,8 @@ /// Ignoring sys_enter_personality sys_exit_personality as possibly not file I/O related /// Ignoring sys_enter_pidfd_open sys_exit_pidfd_open as possibly not file I/O related /// Ignoring sys_enter_pidfd_send_signal sys_exit_pidfd_send_signal as possibly not file I/O related -/// Ignoring sys_enter_pipe sys_exit_pipe as possibly not file I/O related /// Ignoring sys_enter_pipe2 sys_exit_pipe2 as possibly not file I/O related +/// Ignoring sys_enter_pipe sys_exit_pipe as possibly not file I/O related /// Ignoring sys_enter_pivot_root sys_exit_pivot_root as possibly not file I/O related /// Ignoring sys_enter_pkey_alloc sys_exit_pkey_alloc as possibly not file I/O related /// Ignoring sys_enter_pkey_free sys_exit_pkey_free as possibly not file I/O related @@ -162,11 +162,11 @@ /// Ignoring sys_enter_rt_sigsuspend sys_exit_rt_sigsuspend as possibly not file I/O related /// Ignoring sys_enter_rt_sigtimedwait sys_exit_rt_sigtimedwait as possibly not file I/O related /// Ignoring sys_enter_rt_tgsigqueueinfo sys_exit_rt_tgsigqueueinfo as possibly not file I/O related -/// Ignoring sys_enter_sched_get_priority_max sys_exit_sched_get_priority_max as possibly not file I/O related -/// Ignoring sys_enter_sched_get_priority_min sys_exit_sched_get_priority_min as possibly not file I/O related /// Ignoring sys_enter_sched_getaffinity sys_exit_sched_getaffinity as possibly not file I/O related /// Ignoring sys_enter_sched_getattr sys_exit_sched_getattr as possibly not file I/O related /// Ignoring sys_enter_sched_getparam sys_exit_sched_getparam as possibly not file I/O related +/// Ignoring sys_enter_sched_get_priority_max sys_exit_sched_get_priority_max as possibly not file I/O related +/// Ignoring sys_enter_sched_get_priority_min sys_exit_sched_get_priority_min as possibly not file I/O related /// Ignoring sys_enter_sched_getscheduler sys_exit_sched_getscheduler as possibly not file I/O related /// Ignoring sys_enter_sched_rr_get_interval sys_exit_sched_rr_get_interval as possibly not file I/O related /// Ignoring sys_enter_sched_setaffinity sys_exit_sched_setaffinity as possibly not file I/O related @@ -184,10 +184,6 @@ /// Ignoring sys_enter_sendmmsg sys_exit_sendmmsg as possibly not file I/O related /// Ignoring sys_enter_sendmsg sys_exit_sendmsg as possibly not file I/O related /// Ignoring sys_enter_sendto sys_exit_sendto as possibly not file I/O related -/// Ignoring sys_enter_set_mempolicy sys_exit_set_mempolicy as possibly not file I/O related -/// Ignoring sys_enter_set_mempolicy_home_node sys_exit_set_mempolicy_home_node as possibly not file I/O related -/// Ignoring sys_enter_set_robust_list sys_exit_set_robust_list as possibly not file I/O related -/// Ignoring sys_enter_set_tid_address sys_exit_set_tid_address as possibly not file I/O related /// Ignoring sys_enter_setdomainname sys_exit_setdomainname as possibly not file I/O related /// Ignoring sys_enter_setfsgid sys_exit_setfsgid as possibly not file I/O related /// Ignoring sys_enter_setfsuid sys_exit_setfsuid as possibly not file I/O related @@ -195,6 +191,8 @@ /// Ignoring sys_enter_setgroups sys_exit_setgroups as possibly not file I/O related /// Ignoring sys_enter_sethostname sys_exit_sethostname as possibly not file I/O related /// Ignoring sys_enter_setitimer sys_exit_setitimer as possibly not file I/O related +/// Ignoring sys_enter_set_mempolicy sys_exit_set_mempolicy as possibly not file I/O related +/// Ignoring sys_enter_set_mempolicy_home_node sys_exit_set_mempolicy_home_node as possibly not file I/O related /// Ignoring sys_enter_setns sys_exit_setns as possibly not file I/O related /// Ignoring sys_enter_setpgid sys_exit_setpgid as possibly not file I/O related /// Ignoring sys_enter_setpriority sys_exit_setpriority as possibly not file I/O related @@ -203,8 +201,10 @@ /// Ignoring sys_enter_setresuid sys_exit_setresuid as possibly not file I/O related /// Ignoring sys_enter_setreuid sys_exit_setreuid as possibly not file I/O related /// Ignoring sys_enter_setrlimit sys_exit_setrlimit as possibly not file I/O related +/// Ignoring sys_enter_set_robust_list sys_exit_set_robust_list as possibly not file I/O related /// Ignoring sys_enter_setsid sys_exit_setsid as possibly not file I/O related /// Ignoring sys_enter_setsockopt sys_exit_setsockopt as possibly not file I/O related +/// Ignoring sys_enter_set_tid_address sys_exit_set_tid_address as possibly not file I/O related /// Ignoring sys_enter_settimeofday sys_exit_settimeofday as possibly not file I/O related /// Ignoring sys_enter_setuid sys_exit_setuid as possibly not file I/O related /// Ignoring sys_enter_shmat sys_exit_shmat as possibly not file I/O related @@ -213,8 +213,8 @@ /// Ignoring sys_enter_shmget sys_exit_shmget as possibly not file I/O related /// Ignoring sys_enter_shutdown sys_exit_shutdown as possibly not file I/O related /// Ignoring sys_enter_sigaltstack sys_exit_sigaltstack as possibly not file I/O related -/// Ignoring sys_enter_signalfd sys_exit_signalfd as possibly not file I/O related /// Ignoring sys_enter_signalfd4 sys_exit_signalfd4 as possibly not file I/O related +/// Ignoring sys_enter_signalfd sys_exit_signalfd as possibly not file I/O related /// Ignoring sys_enter_socket sys_exit_socket as possibly not file I/O related /// Ignoring sys_enter_socketpair sys_exit_socketpair as possibly not file I/O related /// Ignoring sys_enter_splice sys_exit_splice as possibly not file I/O related @@ -228,12 +228,12 @@ /// Ignoring sys_enter_time sys_exit_time as possibly not file I/O related /// Ignoring sys_enter_timer_create sys_exit_timer_create as possibly not file I/O related /// Ignoring sys_enter_timer_delete sys_exit_timer_delete as possibly not file I/O related -/// Ignoring sys_enter_timer_getoverrun sys_exit_timer_getoverrun as possibly not file I/O related -/// Ignoring sys_enter_timer_gettime sys_exit_timer_gettime as possibly not file I/O related -/// Ignoring sys_enter_timer_settime sys_exit_timer_settime as possibly not file I/O related /// Ignoring sys_enter_timerfd_create sys_exit_timerfd_create as possibly not file I/O related /// Ignoring sys_enter_timerfd_gettime sys_exit_timerfd_gettime as possibly not file I/O related /// Ignoring sys_enter_timerfd_settime sys_exit_timerfd_settime as possibly not file I/O related +/// Ignoring sys_enter_timer_getoverrun sys_exit_timer_getoverrun as possibly not file I/O related +/// Ignoring sys_enter_timer_gettime sys_exit_timer_gettime as possibly not file I/O related +/// Ignoring sys_enter_timer_settime sys_exit_timer_settime as possibly not file I/O related /// Ignoring sys_enter_times sys_exit_times as possibly not file I/O related /// Ignoring sys_enter_tkill sys_exit_tkill as possibly not file I/O related /// Ignoring sys_enter_umask sys_exit_umask as possibly not file I/O related diff --git a/internal/event/event.go b/internal/event/event.go index 48bde48..c846e04 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -7,7 +7,7 @@ import ( ) var poolOfEventPairs = sync.Pool{ - New: func() interface{} { return &Pair{} }, + New: func() any { return &Pair{} }, } // Event is the common contract implemented by decoded syscall trace events. diff --git a/internal/event/pair.go b/internal/event/pair.go index 3eb8a16..4d3f342 100644 --- a/internal/event/pair.go +++ b/internal/event/pair.go @@ -25,20 +25,13 @@ type Pair struct { Comm string Duration uint64 DurationToPrev uint64 - Bytes uint64 // Number of bytes transferred (read/write/transfer syscalls only) - Equals bool + Bytes uint64 // Number of bytes transferred (read/write/transfer syscalls only) } func NewPair(enterEv Event) *Pair { e := poolOfEventPairs.Get().(*Pair) - e.EnterEv = enterEv - e.ExitEv = nil - e.File = nil - e.Comm = "" - e.Duration = 0 - e.DurationToPrev = 0 - e.Bytes = 0 - e.Equals = false + // Zero all fields via struct literal to prevent stale data from previous pool reuse. + *e = Pair{EnterEv: enterEv} return e } @@ -126,13 +119,7 @@ func (e *Pair) Recycle() { if e.ExitEv != nil { e.ExitEv.Recycle() } - e.EnterEv = nil - e.ExitEv = nil - e.File = nil - e.Comm = "" - e.Duration = 0 - e.DurationToPrev = 0 - e.Bytes = 0 - e.Equals = false + // Zero all fields via struct literal to prevent stale data on pool reuse. + *e = Pair{} poolOfEventPairs.Put(e) } diff --git a/internal/file/file.go b/internal/file/file.go index ab67fe2..fc9c320 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -99,8 +99,11 @@ func parseFlagsFromFdInfo(data []byte) (Flags, error) { for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "flags:") { - flagsStr := strings.Fields(line)[1] - flags, err := strconv.ParseUint(flagsStr, 8, 32) + fields := strings.Fields(line) + if len(fields) < 2 { + return unknownFlag, fmt.Errorf("malformed flags line in fdinfo: %q", line) + } + flags, err := strconv.ParseUint(fields[1], 8, 32) return Flags(flags), err } } @@ -118,7 +121,7 @@ func (f *FdFile) String() string { var sb strings.Builder if len(f.name) == 0 { - sb.WriteString("E:name") // Emtpy name string + sb.WriteString("E:name") // Empty name string } else { sb.WriteString(f.name) } diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 0baac9a..9b1c3cf 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -26,7 +26,7 @@ type Config struct { PprofEnable bool Duration int - // Tracepints flags + // Tracepoints flags TracepointsToAttach []*regexp.Regexp TracepointsToExclude []*regexp.Regexp @@ -234,6 +234,30 @@ func extractTracepointFlags(tracepoints string) (regexes []*regexp.Regexp, err e return regexes, nil } +// TraceFilter builds a globalfilter.Filter from the config's filter fields. +// If GlobalFilter is already active, it is returned as-is. Otherwise, +// individual CLI-level filters (CommFilter, PathFilter, PidFilter, TidFilter) +// are merged into a new filter. +func (cfg Config) TraceFilter() globalfilter.Filter { + filter := cfg.GlobalFilter.Clone() + if filter.IsActive() { + return filter + } + if cfg.CommFilter != "" { + filter.Comm = &globalfilter.StringFilter{Pattern: cfg.CommFilter} + } + if cfg.PathFilter != "" { + filter.File = &globalfilter.StringFilter{Pattern: cfg.PathFilter} + } + if cfg.PidFilter > 0 { + filter.PID = globalfilter.NewEqFilter(int64(cfg.PidFilter)) + } + if cfg.TidFilter > 0 { + filter.TID = globalfilter.NewEqFilter(int64(cfg.TidFilter)) + } + return filter +} + func (flags Config) ShouldIAttachTracepoint(tracepointName string) bool { for _, re := range flags.TracepointsToExclude { if re.MatchString(tracepointName) { diff --git a/internal/flamegraph/iordata.go b/internal/flamegraph/iordata.go index 76e438a..36b5103 100644 --- a/internal/flamegraph/iordata.go +++ b/internal/flamegraph/iordata.go @@ -14,8 +14,7 @@ import ( "ior/internal/file" "ior/internal/types" - // Is there a zstd library part of Go 1.25 - "github.com/DataDog/zstd" + "github.com/DataDog/zstd" // Go stdlib does not include zstd; third-party dep required ) type pathType = string @@ -61,19 +60,13 @@ func LoadFromFile(filename string) (iter.Seq[IterRecord], error) { return iod.iter(), nil } -func cloneString(s string) string { - // Clone the string by creating a new string with the same content - // This is a workaround to avoid using unsafe package - return string([]byte(s)) -} - -func (iod iorData) addEventPair(ev *event.Pair) { +func (iod *iorData) addEventPair(ev *event.Pair) { cnt := Counter{Count: 1, Duration: ev.Duration, DurationToPrev: ev.DurationToPrev, Bytes: ev.Bytes} iod.add(ev.FileName(), ev.EnterEv.GetTraceId(), strings.TrimSpace(ev.Comm), ev.EnterEv.GetPid(), ev.EnterEv.GetTid(), ev.Flags(), cnt) } -func (iod iorData) add(path pathType, traceId traceIdType, comm commType, +func (iod *iorData) add(path pathType, traceId traceIdType, comm commType, pid pidType, tid tidType, flags flagsType, addCnt Counter) { key := recordKey{ @@ -92,14 +85,14 @@ func (iod iorData) add(path pathType, traceId traceIdType, comm commType, iod.records[key] = cnt.add(addCnt) } -func (iod iorData) merge(other iorData) iorData { +func (iod *iorData) merge(other iorData) *iorData { for key, cnt := range other.records { iod.add(key.Path, key.TraceID, key.Comm, key.Pid, key.Tid, key.Flags, cnt) } return iod } -func (iod iorData) serializeToFile(flamegraphName string) (retErr error) { +func (iod *iorData) serializeToFile(flamegraphName string) (retErr error) { hostname, err := hostnameFn() if err != nil { return fmt.Errorf("get hostname: %w", err) @@ -118,24 +111,25 @@ func (iod iorData) serializeToFile(flamegraphName string) (retErr error) { return fmt.Errorf("create temp file %s: %w", tmpFilename, err) } defer func() { - if err := file.Close(); err != nil { - retErr = errors.Join(retErr, fmt.Errorf("close temp file %s: %w", tmpFilename, err)) + // Close file on error paths; on success it is already closed before rename. + if retErr != nil { + file.Close() } }() encoder := zstd.NewWriter(file) - defer func() { - if err := encoder.Close(); err != nil { - retErr = errors.Join(retErr, fmt.Errorf("close zstd writer for %s: %w", tmpFilename, err)) - } - }() gobEncoder := gob.NewEncoder(encoder) if err := gobEncoder.Encode(iod.records); err != nil { return fmt.Errorf("encode ior records: %w", err) } - if err := encoder.Flush(); err != nil { - return fmt.Errorf("flush ior records: %w", err) + // Close encoder before file to flush the final zstd frame, then close + // the file to flush OS buffers. Both must complete before rename. + if err := encoder.Close(); err != nil { + return fmt.Errorf("close zstd writer for %s: %w", tmpFilename, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close temp file %s: %w", tmpFilename, err) } if err := os.Rename(tmpFilename, filename); err != nil { @@ -173,7 +167,7 @@ func (iod *iorData) loadFromFile(filename string) (retErr error) { return nil } -func (iod iorData) serialize() ([]byte, error) { +func (iod *iorData) serialize() ([]byte, error) { var buf bytes.Buffer enc := gob.NewEncoder(&buf) err := enc.Encode(iod.records) @@ -224,7 +218,7 @@ func (ir IterRecord) StringByName(name string) (string, error) { } } -func (iod iorData) iter() iter.Seq[IterRecord] { +func (iod *iorData) iter() iter.Seq[IterRecord] { return func(yield func(IterRecord) bool) { for key, cnt := range iod.records { record := IterRecord{ diff --git a/internal/flamegraph/iordata_test.go b/internal/flamegraph/iordata_test.go index 722cad3..f805aab 100644 --- a/internal/flamegraph/iordata_test.go +++ b/internal/flamegraph/iordata_test.go @@ -90,7 +90,7 @@ func TestMerge(t *testing.T) { t.Log("iod2", iod2) t.Log("iod3", iod3) t.Log("iod4", iod4) - merged := iod1.merge(iod2).merge(iod3).merge(iod4) + merged := *iod1.merge(iod2).merge(iod3).merge(iod4) t.Log("merged", merged) t.Run("Merged correctly", func(t *testing.T) { @@ -217,7 +217,7 @@ func TestMergeEmpty(t *testing.T) { }) empty := newIorData() - merged := iod.merge(empty) + merged := *iod.merge(empty) if len(merged.records) != 1 { t.Errorf("Expected 1 record, got %d", len(merged.records)) diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 600e404..51f3697 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -1,10 +1,10 @@ package flamegraph import ( + "cmp" "encoding/json" "fmt" "slices" - "sort" "strings" "sync" "sync/atomic" @@ -277,8 +277,8 @@ type childSnapshotState struct { func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64, forceKeep bool) (*trieSnapshot, uint64) { total := node.value children := slices.Clone(node.children) - sort.Slice(children, func(i, j int) bool { - return children[i].name < children[j].name + slices.SortFunc(children, func(a, b *trieNode) int { + return cmp.Compare(a.name, b.name) }) childStates := make([]childSnapshotState, 0, len(children)) @@ -335,13 +335,13 @@ func ensureFallbackVisibleChildren(children []childSnapshotState, depth int, min candidates = append(candidates, idx) } } - sort.Slice(candidates, func(i, j int) bool { - left := children[candidates[i]] - right := children[candidates[j]] - if left.total == right.total { - return left.node.name < right.node.name + slices.SortFunc(candidates, func(a, b int) int { + left := children[a] + right := children[b] + if left.total != right.total { + return cmp.Compare(right.total, left.total) } - return left.total > right.total + return cmp.Compare(left.node.name, right.node.name) }) limit := liveTrieMinVisibleChildrenWhenPruned diff --git a/internal/flamegraph/trie.go b/internal/flamegraph/trie.go index 022b846..d7790c2 100644 --- a/internal/flamegraph/trie.go +++ b/internal/flamegraph/trie.go @@ -1,6 +1,9 @@ package flamegraph -import "sort" +import ( + "cmp" + "slices" +) type trieNode struct { name string @@ -35,8 +38,8 @@ func (t *trie) computeTotals() { t.maxDepth = depth } - sort.Slice(node.children, func(i, j int) bool { - return node.children[i].name < node.children[j].name + slices.SortFunc(node.children, func(a, b *trieNode) int { + return cmp.Compare(a.name, b.name) }) total := node.value diff --git a/internal/generate/codegen.go b/internal/generate/codegen.go index 9b9f52c..1c7a9b3 100644 --- a/internal/generate/codegen.go +++ b/internal/generate/codegen.go @@ -1,8 +1,9 @@ package generate import ( + "cmp" "fmt" - "sort" + "slices" "strings" ) @@ -37,8 +38,8 @@ func GenerateTracepointsC(formats []Format) string { accepted = append(accepted, tracepoints...) } - sort.Slice(accepted, func(i, j int) bool { - return accepted[i].Format.ID > accepted[j].Format.ID + slices.SortFunc(accepted, func(a, b GeneratedTracepoint) int { + return cmp.Compare(b.Format.ID, a.Format.ID) }) b.WriteString("\n") @@ -147,6 +148,6 @@ func syscallFormatNames(sc Syscall) []string { if sc.Exit != nil { names = append(names, sc.Exit.Name) } - sort.Strings(names) + slices.Sort(names) return names } diff --git a/internal/generate/typesgo.go b/internal/generate/typesgo.go index dca40fc..9f91981 100644 --- a/internal/generate/typesgo.go +++ b/internal/generate/typesgo.go @@ -295,7 +295,7 @@ func writeGetterMethods(b *strings.Builder, goName, selfRef string) { } func writeSyncPool(b *strings.Builder, goName, selfRef string) { - fmt.Fprintf(b, "var poolOf%ss = sync.Pool{\n\tNew: func() interface{} { return &%s{} },\n}\n\n", goName, goName) + fmt.Fprintf(b, "var poolOf%ss = sync.Pool{\n\tNew: func() any { return &%s{} },\n}\n\n", goName, goName) fmt.Fprintf(b, "func New%s(raw []byte) *%s {\n", goName, goName) fmt.Fprintf(b, "\t%s := poolOf%ss.Get().(*%s)\n", selfRef, goName, goName) fmt.Fprintf(b, "\tif err := binary.Read(bytes.NewReader(raw), binary.LittleEndian, %s); err != nil {\n", selfRef) diff --git a/internal/globalfilter/filter.go b/internal/globalfilter/filter.go index 6d19207..d55c4c1 100644 --- a/internal/globalfilter/filter.go +++ b/internal/globalfilter/filter.go @@ -23,6 +23,26 @@ type NumericFilter struct { Value int64 } +// NewEqFilter creates an equality NumericFilter for a positive value. +// Returns nil if value is not positive. +func NewEqFilter(value int64) *NumericFilter { + if value <= 0 { + return nil + } + return &NumericFilter{Op: OpEq, Value: value} +} + +// EqValue returns the filter's positive equality value if the filter +// represents an exact-match constraint (Op == OpEq and Value > 0). +// Returns (0, false) when the filter is nil, uses a different operator, +// or has a non-positive value. +func (f *NumericFilter) EqValue() (int, bool) { + if f == nil || f.Op != OpEq || f.Value <= 0 { + return 0, false + } + return int(f.Value), true +} + type StringFilter struct { Pattern string } diff --git a/internal/parquet/schema.go b/internal/parquet/schema.go index 2ede444..62d448b 100644 --- a/internal/parquet/schema.go +++ b/internal/parquet/schema.go @@ -1,7 +1,9 @@ package parquet import ( + "os" "strconv" + "time" "ior/internal/flags" "ior/internal/streamrow" @@ -35,6 +37,20 @@ type FileMetadata struct { IORVersion string } +// NewFileMetadata constructs file-level metadata for a parquet trace file, +// populating the hostname, timestamp, version, and recording mode. +func NewFileMetadata(mode string) FileMetadata { + meta := FileMetadata{ + StartedAtUnixNano: uint64(time.Now().UnixNano()), + Mode: mode, + IORVersion: flags.Version, + } + if hostname, err := os.Hostname(); err == nil { + meta.Hostname = hostname + } + return meta +} + // RecordFromStream converts one shared stream row into the persisted format. func RecordFromStream(row streamrow.Row, filterEpoch uint64) Record { return Record{ diff --git a/internal/probemanager/grouping.go b/internal/probemanager/grouping.go index aa4f133..be55659 100644 --- a/internal/probemanager/grouping.go +++ b/internal/probemanager/grouping.go @@ -35,19 +35,17 @@ func GroupTracepoints(names []string) map[string]TracepointPair { } func parseSyscallTracepoint(name string) (base string, isEnter bool, ok bool) { - if strings.HasPrefix(name, sysEnterPrefix) { - base = strings.TrimPrefix(name, sysEnterPrefix) - if base == "" { + if after, found := strings.CutPrefix(name, sysEnterPrefix); found { + if after == "" { return "", false, false } - return base, true, true + return after, true, true } - if strings.HasPrefix(name, sysExitPrefix) { - base = strings.TrimPrefix(name, sysExitPrefix) - if base == "" { + if after, found := strings.CutPrefix(name, sysExitPrefix); found { + if after == "" { return "", false, false } - return base, false, true + return after, false, true } return "", false, false } diff --git a/internal/probemanager/manager.go b/internal/probemanager/manager.go index 4c9dcec..cad755e 100644 --- a/internal/probemanager/manager.go +++ b/internal/probemanager/manager.go @@ -1,9 +1,10 @@ package probemanager import ( + "cmp" "errors" "fmt" - "sort" + "slices" "strings" "sync" ) @@ -244,7 +245,7 @@ func (m *Manager) States() []ProbeState { } out = append(out, state) } - sort.Slice(out, func(i, j int) bool { return out[i].Syscall < out[j].Syscall }) + slices.SortFunc(out, func(a, b ProbeState) int { return cmp.Compare(a.Syscall, b.Syscall) }) return out } diff --git a/internal/statsengine/bench_test.go b/internal/statsengine/bench_test.go index 646bdda..99d5c87 100644 --- a/internal/statsengine/bench_test.go +++ b/internal/statsengine/bench_test.go @@ -1,7 +1,7 @@ package statsengine import ( - "math/rand" + "math/rand/v2" "testing" "time" @@ -9,7 +9,7 @@ import ( ) func BenchmarkSyscallAccumulatorSnapshot(b *testing.B) { - acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewSource(123))) + acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewPCG(123, 0))) traceIDs := []types.TraceId{ types.SYS_ENTER_READ, types.SYS_ENTER_WRITE, diff --git a/internal/statsengine/filerank.go b/internal/statsengine/filerank.go index dd83e8d..d24ab93 100644 --- a/internal/statsengine/filerank.go +++ b/internal/statsengine/filerank.go @@ -1,8 +1,9 @@ package statsengine import ( + "cmp" "container/heap" - "sort" + "slices" "ior/internal/event" "ior/internal/types" @@ -123,11 +124,11 @@ func buildFileSnapshots(inputs []fileSnapshotInput) []FileSnapshot { for _, in := range inputs { out = append(out, in.toSnapshot()) } - sort.Slice(out, func(i, j int) bool { - if out[i].Accesses != out[j].Accesses { - return out[i].Accesses > out[j].Accesses + slices.SortFunc(out, func(a, b FileSnapshot) int { + if a.Accesses != b.Accesses { + return cmp.Compare(b.Accesses, a.Accesses) } - return out[i].Path < out[j].Path + return cmp.Compare(a.Path, b.Path) }) return out } diff --git a/internal/statsengine/process.go b/internal/statsengine/process.go index b00a4bb..3bfd019 100644 --- a/internal/statsengine/process.go +++ b/internal/statsengine/process.go @@ -1,7 +1,8 @@ package statsengine import ( - "sort" + "cmp" + "slices" "time" "ior/internal/event" @@ -115,14 +116,14 @@ func buildProcessSnapshots(inputs []processSnapshotInput, elapsed time.Duration) for _, in := range inputs { result = append(result, in.toSnapshot(rateDiv)) } - sort.Slice(result, func(i, j int) bool { - if result[i].Syscalls != result[j].Syscalls { - return result[i].Syscalls > result[j].Syscalls + slices.SortFunc(result, func(a, b ProcessSnapshot) int { + if a.Syscalls != b.Syscalls { + return cmp.Compare(b.Syscalls, a.Syscalls) } - if result[i].Bytes != result[j].Bytes { - return result[i].Bytes > result[j].Bytes + if a.Bytes != b.Bytes { + return cmp.Compare(b.Bytes, a.Bytes) } - return result[i].PID < result[j].PID + return cmp.Compare(a.PID, b.PID) }) return result } @@ -136,8 +137,14 @@ func (a *processAccumulator) compactIfNeeded() { for _, stats := range a.byPID { ordered = append(ordered, stats) } - sort.Slice(ordered, func(i, j int) bool { - return betterProcessRank(ordered[i], ordered[j]) + slices.SortFunc(ordered, func(a, b *processStats) int { + if betterProcessRank(a, b) { + return -1 + } + if betterProcessRank(b, a) { + return 1 + } + return 0 }) if len(ordered) > a.topN { ordered = ordered[:a.topN] diff --git a/internal/statsengine/syscall.go b/internal/statsengine/syscall.go index 4feeab2..93931d1 100644 --- a/internal/statsengine/syscall.go +++ b/internal/statsengine/syscall.go @@ -1,9 +1,10 @@ package statsengine import ( + "cmp" "math" - "math/rand" - "sort" + "math/rand/v2" + "slices" "time" "ior/internal/event" @@ -55,15 +56,17 @@ type syscallSnapshotInput struct { } func newSyscallAccumulator() *syscallAccumulator { - return newSyscallAccumulatorWithConfig(syscallReservoirSampleCapDefault, rand.New(rand.NewSource(time.Now().UnixNano()))) + return newSyscallAccumulatorWithConfig(syscallReservoirSampleCapDefault, nil) } +// newSyscallAccumulatorWithConfig creates a syscall accumulator with the given +// sample capacity and optional RNG. A nil rng uses the auto-seeded default. func newSyscallAccumulatorWithConfig(sampleCap int, rng *rand.Rand) *syscallAccumulator { if sampleCap <= 0 { sampleCap = syscallReservoirSampleCapDefault } if rng == nil { - rng = rand.New(rand.NewSource(time.Now().UnixNano())) + rng = rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) } return &syscallAccumulator{ @@ -135,11 +138,11 @@ func buildSyscallSnapshots(inputs []syscallSnapshotInput, elapsed time.Duration) for _, in := range inputs { result = append(result, in.toSnapshot(rateDiv)) } - sort.Slice(result, func(i, j int) bool { - if result[i].Count != result[j].Count { - return result[i].Count > result[j].Count + slices.SortFunc(result, func(a, b SyscallSnapshot) int { + if a.Count != b.Count { + return cmp.Compare(b.Count, a.Count) } - return result[i].Name < result[j].Name + return cmp.Compare(a.Name, b.Name) }) return result } @@ -161,8 +164,8 @@ func (s *syscallStats) addSample(duration uint64, cap int, rng *rand.Rand) { return } - idx := rng.Int63n(int64(s.seenLatencies)) - if idx >= int64(cap) { + idx := rng.IntN(int(s.seenLatencies)) + if idx >= cap { return } s.samples[idx] = duration @@ -183,7 +186,7 @@ func (s *syscallStats) ensurePercentiles() { } sorted := append([]uint64(nil), s.samples...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + slices.Sort(sorted) s.cachedP50 = samplePercentile(sorted, 0.50) s.cachedP95 = samplePercentile(sorted, 0.95) s.cachedP99 = samplePercentile(sorted, 0.99) diff --git a/internal/statsengine/syscall_test.go b/internal/statsengine/syscall_test.go index b315bd8..b00582d 100644 --- a/internal/statsengine/syscall_test.go +++ b/internal/statsengine/syscall_test.go @@ -2,7 +2,7 @@ package statsengine import ( "math" - "math/rand" + "math/rand/v2" "testing" "time" @@ -11,7 +11,7 @@ import ( ) func TestSyscallAccumulatorBasicStats(t *testing.T) { - acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewSource(1))) + acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewPCG(1, 0))) traceID := types.SYS_ENTER_READ acc.Add(newPair(traceID, 10, 100, 0)) @@ -54,7 +54,7 @@ func TestSyscallAccumulatorBasicStats(t *testing.T) { } func TestSyscallAccumulatorSortsByCountThenName(t *testing.T) { - acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewSource(2))) + acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewPCG(2, 0))) idA := types.SYS_ENTER_OPENAT idB := types.SYS_ENTER_READ @@ -76,7 +76,7 @@ func TestSyscallAccumulatorSortsByCountThenName(t *testing.T) { } func TestSyscallAccumulatorReservoirPercentilesAccuracy(t *testing.T) { - acc := newSyscallAccumulatorWithConfig(100, rand.New(rand.NewSource(7))) + acc := newSyscallAccumulatorWithConfig(100, rand.New(rand.NewPCG(7, 0))) traceID := types.SYS_ENTER_WRITE for d := uint64(1); d <= 10_000; d++ { @@ -95,7 +95,7 @@ func TestSyscallAccumulatorReservoirPercentilesAccuracy(t *testing.T) { } func TestSyscallAccumulatorZeroElapsedRate(t *testing.T) { - acc := newSyscallAccumulatorWithConfig(32, rand.New(rand.NewSource(9))) + acc := newSyscallAccumulatorWithConfig(32, rand.New(rand.NewPCG(9, 0))) acc.Add(newPair(types.SYS_ENTER_READ, 9, 0, 0)) snap := acc.Snapshot(0) @@ -108,7 +108,7 @@ func TestSyscallAccumulatorZeroElapsedRate(t *testing.T) { } func TestSyscallAccumulatorPercentilesRecomputeAfterThreshold(t *testing.T) { - acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewSource(11))) + acc := newSyscallAccumulatorWithConfig(10_000, rand.New(rand.NewPCG(11, 0))) traceID := types.SYS_ENTER_READ for i := 1; i <= 1000; i++ { diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go index e6e2909..f50eba8 100644 --- a/internal/tui/dashboard/bubbles.go +++ b/internal/tui/dashboard/bubbles.go @@ -1,11 +1,12 @@ package dashboard import ( + "cmp" "fmt" "hash/fnv" "image/color" "math" - "sort" + "slices" "strings" "unicode/utf8" @@ -422,8 +423,8 @@ func (c *bubbleChart) renderBubblesToGrid(grid [][]bubbleCell, width, height int for idx := range c.nodes { order = append(order, idx) } - sort.Slice(order, func(i, j int) bool { - return c.nodes[order[i]].radius < c.nodes[order[j]].radius + slices.SortFunc(order, func(a, b int) int { + return cmp.Compare(c.nodes[a].radius, c.nodes[b].radius) }) if c.selected >= 0 && c.selected < len(c.nodes) { filtered := order[:0] @@ -641,13 +642,13 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i if len(filtered) == 0 { return nil } - sort.Slice(filtered, func(i, j int) bool { - vi := bubbleValue(filtered[i], metric) - vj := bubbleValue(filtered[j], metric) - if vi != vj { - return vi > vj + slices.SortFunc(filtered, func(a, b bubbleDatum) int { + va := bubbleValue(a, metric) + vb := bubbleValue(b, metric) + if va != vb { + return cmp.Compare(vb, va) } - return filtered[i].Label < filtered[j].Label + return cmp.Compare(a.Label, b.Label) }) if len(filtered) > bubbleMaxItems { filtered = filtered[:bubbleMaxItems] diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index f24c87c..df850ab 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -1,9 +1,9 @@ package dashboard import ( + "cmp" "path/filepath" "slices" - "sort" "strconv" "ior/internal/statsengine" @@ -425,11 +425,11 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot { out = append(out, s) } - sort.Slice(out, func(i, j int) bool { - if out[i].Accesses != out[j].Accesses { - return out[i].Accesses > out[j].Accesses + slices.SortFunc(out, func(a, b DirSnapshot) int { + if a.Accesses != b.Accesses { + return cmp.Compare(b.Accesses, a.Accesses) } - return out[i].Dir < out[j].Dir + return cmp.Compare(a.Dir, b.Dir) }) return out } diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go index 92c4834..768783b 100644 --- a/internal/tui/dashboard/icicle.go +++ b/internal/tui/dashboard/icicle.go @@ -1,10 +1,11 @@ package dashboard import ( + "cmp" "fmt" "math" "path/filepath" - "sort" + "slices" "strings" "ior/internal/statsengine" @@ -169,13 +170,13 @@ func sortedIcicleChildren(node *icicleNode, metric bubbleMetric) []*icicleNode { for _, child := range node.children { out = append(out, child) } - sort.Slice(out, func(i, j int) bool { - vi := icicleValue(out[i], metric) - vj := icicleValue(out[j], metric) - if vi != vj { - return vi > vj + slices.SortFunc(out, func(a, b *icicleNode) int { + va := icicleValue(a, metric) + vb := icicleValue(b, metric) + if va != vb { + return cmp.Compare(vb, va) } - return out[i].name < out[j].name + return cmp.Compare(a.name, b.name) }) return out } diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go index 7193952..dd62d13 100644 --- a/internal/tui/dashboard/treemap.go +++ b/internal/tui/dashboard/treemap.go @@ -1,10 +1,11 @@ package dashboard import ( + "cmp" "fmt" "image/color" "math" - "sort" + "slices" "strings" "unicode/utf8" @@ -131,11 +132,11 @@ func buildSyscallTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) [ if len(items) == 0 { return nil } - sort.Slice(items, func(i, j int) bool { - if items[i].Value != items[j].Value { - return items[i].Value > items[j].Value + slices.SortFunc(items, func(a, b syscallTreemapItem) int { + if a.Value != b.Value { + return cmp.Compare(b.Value, a.Value) } - return items[i].Name < items[j].Name + return cmp.Compare(a.Name, b.Name) }) if len(items) > maxSyscallTreemapItems { items = items[:maxSyscallTreemapItems] @@ -174,11 +175,11 @@ func buildFilesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []s if len(items) == 0 { return nil } - sort.Slice(items, func(i, j int) bool { - if items[i].Value != items[j].Value { - return items[i].Value > items[j].Value + slices.SortFunc(items, func(a, b syscallTreemapItem) int { + if a.Value != b.Value { + return cmp.Compare(b.Value, a.Value) } - return items[i].Name < items[j].Name + return cmp.Compare(a.Name, b.Name) }) if len(items) > maxSyscallTreemapItems { items = items[:maxSyscallTreemapItems] @@ -217,11 +218,11 @@ func buildProcessesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) if len(items) == 0 { return nil } - sort.Slice(items, func(i, j int) bool { - if items[i].Value != items[j].Value { - return items[i].Value > items[j].Value + slices.SortFunc(items, func(a, b syscallTreemapItem) int { + if a.Value != b.Value { + return cmp.Compare(b.Value, a.Value) } - return items[i].Name < items[j].Name + return cmp.Compare(a.Name, b.Name) }) if len(items) > maxSyscallTreemapItems { items = items[:maxSyscallTreemapItems] diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index a7b26f8..0552a4f 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -1,11 +1,11 @@ package flamegraph import ( + "cmp" "encoding/json" "fmt" "image/color" "slices" - "sort" "strings" "time" @@ -727,8 +727,8 @@ func framesAtDepthFiltered(frames []tuiFrame, depth int, include map[int]bool) [ indices = append(indices, idx) } } - sort.Slice(indices, func(i, j int) bool { - return frames[indices[i]].Col < frames[indices[j]].Col + slices.SortFunc(indices, func(a, b int) int { + return cmp.Compare(frames[a].Col, frames[b].Col) }) return indices } @@ -878,19 +878,19 @@ func (m Model) visibleTraversalOrder() []int { } indices = append(indices, idx) } - sort.Slice(indices, func(i, j int) bool { - left := m.frames[indices[i]] - right := m.frames[indices[j]] + slices.SortFunc(indices, func(a, b int) int { + left := m.frames[a] + right := m.frames[b] if left.Depth != right.Depth { - return left.Depth < right.Depth + return cmp.Compare(left.Depth, right.Depth) } if left.Col != right.Col { - return left.Col < right.Col + return cmp.Compare(left.Col, right.Col) } if left.Row != right.Row { - return left.Row < right.Row + return cmp.Compare(left.Row, right.Row) } - return indices[i] < indices[j] + return cmp.Compare(a, b) }) return indices } diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index f9f6a89..12e5f8e 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -1,11 +1,12 @@ package flamegraph import ( + "cmp" "fmt" "hash/fnv" "image/color" "math" - "sort" + "slices" "strings" "unicode/utf8" @@ -129,11 +130,11 @@ func allocateChildWidths(children []*snapshotNode, parentTotal uint64, span int) // If proportional rounding culled every child, surface top contributors so // the user can still navigate beyond the root frame. if used == 0 { - sort.Slice(items, func(i, j int) bool { - if items[i].total == items[j].total { - return items[i].idx < items[j].idx + slices.SortFunc(items, func(a, b childWidth) int { + if a.total != b.total { + return cmp.Compare(b.total, a.total) } - return items[i].total > items[j].total + return cmp.Compare(a.idx, b.idx) }) visible := min(span, len(items)) for i := 0; i < visible; i++ { @@ -334,8 +335,8 @@ func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, ava rows := make([]string, 0, (maxRow-rowOffset+1)*barHeight) for row := maxRow; row >= rowOffset; row-- { framesAtRow := rowsByDepth[row] - sort.Slice(framesAtRow, func(i, j int) bool { - return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col + slices.SortFunc(framesAtRow, func(a, b indexedFrame) int { + return cmp.Compare(a.frame.Col, b.frame.Col) }) for repeat := 0; repeat < barHeight; repeat++ { showLabels := repeat == barHeight/2 @@ -680,8 +681,8 @@ func compactMatchRoots(frames []tuiFrame, matchSet map[int]bool) []matchRoot { total: frames[idx].Total, }) } - sort.Slice(roots, func(i, j int) bool { - return len(roots[i].path) < len(roots[j].path) + slices.SortFunc(roots, func(a, b matchRoot) int { + return cmp.Compare(len(a.path), len(b.path)) }) merged := make([]matchRoot, 0, len(roots)) for _, candidate := range roots { diff --git a/internal/tui/pidpicker/proclist.go b/internal/tui/pidpicker/proclist.go index 20e580d..73ff209 100644 --- a/internal/tui/pidpicker/proclist.go +++ b/internal/tui/pidpicker/proclist.go @@ -2,11 +2,12 @@ package pidpicker import ( "bytes" + "cmp" "fmt" "io/fs" "os" "path/filepath" - "sort" + "slices" "strconv" "strings" "sync" @@ -50,8 +51,8 @@ func scanProcessesFrom(procRoot string) ([]ProcessInfo, error) { processes = append(processes, process) } - sort.Slice(processes, func(i, j int) bool { - return processes[i].Pid < processes[j].Pid + slices.SortFunc(processes, func(a, b ProcessInfo) int { + return cmp.Compare(a.Pid, b.Pid) }) return processes, nil } @@ -145,8 +146,8 @@ func scanThreadsFrom(procRoot string, pid int) ([]ProcessInfo, error) { threads = append(threads, thread) } - sort.Slice(threads, func(i, j int) bool { - return threads[i].Pid < threads[j].Pid + slices.SortFunc(threads, func(a, b ProcessInfo) int { + return cmp.Compare(a.Pid, b.Pid) }) return threads, nil } @@ -209,11 +210,11 @@ func scanAllThreadsFrom(procRoot string) ([]ProcessInfo, error) { } wg.Wait() - sort.Slice(threads, func(i, j int) bool { - if threads[i].Pid == threads[j].Pid { - return threads[i].ParentPID < threads[j].ParentPID + slices.SortFunc(threads, func(a, b ProcessInfo) int { + if a.Pid != b.Pid { + return cmp.Compare(a.Pid, b.Pid) } - return threads[i].Pid < threads[j].Pid + return cmp.Compare(a.ParentPID, b.ParentPID) }) return threads, nil } diff --git a/internal/types/generated_types.go b/internal/types/generated_types.go index 63c4319..ff3ed02 100644 --- a/internal/types/generated_types.go +++ b/internal/types/generated_types.go @@ -338,7 +338,7 @@ func (o *OpenEvent) GetTime() uint64 { } var poolOfOpenEvents = sync.Pool{ - New: func() interface{} { return &OpenEvent{} }, + New: func() any { return &OpenEvent{} }, } func NewOpenEvent(raw []byte) *OpenEvent { @@ -405,7 +405,7 @@ func (n *NullEvent) GetTime() uint64 { } var poolOfNullEvents = sync.Pool{ - New: func() interface{} { return &NullEvent{} }, + New: func() any { return &NullEvent{} }, } func NewNullEvent(raw []byte) *NullEvent { @@ -473,7 +473,7 @@ func (f *FdEvent) GetTime() uint64 { } var poolOfFdEvents = sync.Pool{ - New: func() interface{} { return &FdEvent{} }, + New: func() any { return &FdEvent{} }, } func NewFdEvent(raw []byte) *FdEvent { @@ -542,7 +542,7 @@ func (r *RetEvent) GetTime() uint64 { } var poolOfRetEvents = sync.Pool{ - New: func() interface{} { return &RetEvent{} }, + New: func() any { return &RetEvent{} }, } func NewRetEvent(raw []byte) *RetEvent { @@ -611,7 +611,7 @@ func (n *NameEvent) GetTime() uint64 { } var poolOfNameEvents = sync.Pool{ - New: func() interface{} { return &NameEvent{} }, + New: func() any { return &NameEvent{} }, } func NewNameEvent(raw []byte) *NameEvent { @@ -679,7 +679,7 @@ func (p *PathEvent) GetTime() uint64 { } var poolOfPathEvents = sync.Pool{ - New: func() interface{} { return &PathEvent{} }, + New: func() any { return &PathEvent{} }, } func NewPathEvent(raw []byte) *PathEvent { @@ -749,7 +749,7 @@ func (f *FcntlEvent) GetTime() uint64 { } var poolOfFcntlEvents = sync.Pool{ - New: func() interface{} { return &FcntlEvent{} }, + New: func() any { return &FcntlEvent{} }, } func NewFcntlEvent(raw []byte) *FcntlEvent { @@ -818,7 +818,7 @@ func (d *Dup3Event) GetTime() uint64 { } var poolOfDup3Events = sync.Pool{ - New: func() interface{} { return &Dup3Event{} }, + New: func() any { return &Dup3Event{} }, } func NewDup3Event(raw []byte) *Dup3Event { @@ -886,7 +886,7 @@ func (o *OpenByHandleAtEvent) GetTime() uint64 { } var poolOfOpenByHandleAtEvents = sync.Pool{ - New: func() interface{} { return &OpenByHandleAtEvent{} }, + New: func() any { return &OpenByHandleAtEvent{} }, } func NewOpenByHandleAtEvent(raw []byte) *OpenByHandleAtEvent { -- cgit v1.2.3