summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-07 09:47:05 +0300
committerPaul Buetow <paul@buetow.org>2026-05-07 09:47:05 +0300
commited04ccd8e2297458ec97381806a05dea13090f0f (patch)
tree99b707441b6990686bbd2773c177bdc3b8bf3313
parentac05beabdbaa90597ed0cfcea781d5ab44d123ba (diff)
update docs and ascii banner
-rw-r--r--README.md14
-rw-r--r--cmd/ior/main.go12
-rw-r--r--docs/build-rocky-linux-9.md14
-rw-r--r--docs/parquet-querying.md2
-rw-r--r--docs/tutorial/tutorial.md32
-rw-r--r--internal/flags/flags.go4
-rw-r--r--internal/flags/version.go13
-rw-r--r--internal/ior.go5
-rw-r--r--internal/ior_mode_test.go12
9 files changed, 70 insertions, 38 deletions
diff --git a/README.md b/README.md
index f47f405..532d581 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,23 @@
<img src=assets/ior-small.png />
-I/O Riot NG is an experiments with BPF. This program traces for synchronous I/O syscalls and then analyses the time taken for each of those syscalls. This is especially useful for drawing FlameGraphs like these:
+I/O Riot NG is an experiment with BPF. It traces synchronous I/O syscalls and analyses how long each one took. Useful for drawing FlameGraphs like these:
<img src=assets/screenshot-flames.png />
-Maybe this is a spiritual successor of one of my previous projects, I/O Riot https://codeberg.org/snonux/ioriot, the latter was based on SystemTap and C. The NG is based on Go, C and BPF (via libbpfgo).
+A spiritual successor to one of my previous projects, I/O Riot (https://codeberg.org/snonux/ioriot), which was based on SystemTap and C. The NG is based on Go, C, and BPF (via libbpfgo).
-This works only on Linux!
+Linux only.
## Demo
A short guided tour with animated GIFs of every major surface lives in [`docs/tutorial/tutorial.md`](./docs/tutorial/tutorial.md). Two teasers:
-**Startup — the PID picker:** `sudo ./ior` opens a searchable process list. Navigate with arrow keys, filter by typing, and press `Enter` to start tracing. The dashboard appears immediately after.
+**Startup, the PID picker:** `sudo ./ior` opens a searchable process list. Navigate with arrow keys, filter by typing, press `Enter` to start tracing. The dashboard appears right after.
<img src=docs/tutorial/assets/01-launch.gif width=720 alt="Cold start: PID picker, then the dashboard appears" />
-**Live flamegraph tab:** Once tracing, tab `1` shows a live flamegraph that rebuilds in real time as I/O events arrive. Bars grow and shift with the workload — this is the default landing tab.
+**Live flamegraph tab:** Once tracing, tab `1` shows a live flamegraph that rebuilds in real time as I/O events arrive. Bars grow and shift with the workload. This is the default landing tab.
<img src=docs/tutorial/assets/13-tui-flamegraph.gif width=720 alt="Live in-TUI flamegraph rebuilding from real workload" />
@@ -33,7 +33,7 @@ The demo is fully reproducible: `mage installDemoTools` once, then `sudo -v && m
## Build
Builds a fully static `ior` binary inside a Rocky Linux 9 container and writes
-it to the repo root — no local Go, clang, or libbpfgo setup required:
+it to the repo root. No local Go, clang, or libbpfgo setup required:
```shell
mage buildDocker
@@ -55,7 +55,7 @@ For contributors who need a native build (Fedora / Rocky Linux 9), see
Build on one machine, then `scp ior other-host:/usr/local/bin/` and run it
anywhere. The binary is fully statically linked and uses libbpf CO-RE
(Compile-Once, Run-Everywhere) to adapt field offsets to the target kernel's
-BTF at load time — no recompile per host or kernel version needed.
+BTF at load time. No recompile per host or kernel version needed.
See [docs/build-rocky-linux-9.md](./docs/build-rocky-linux-9.md) for the full
explanation.
diff --git a/cmd/ior/main.go b/cmd/ior/main.go
index 8d93d28..ebd3ef3 100644
--- a/cmd/ior/main.go
+++ b/cmd/ior/main.go
@@ -23,9 +23,17 @@ func main() {
os.Exit(2)
}
- // Run the internal logic of the application.
// flags.Get() is called here at the CLI boundary so internal code never reads the singleton.
- if err := internal.Run(flags.Get()); err != nil {
+ cfg := flags.Get()
+
+ // Handle -version: print the banner plus version and exit.
+ if cfg.ShowVersion {
+ flags.PrintVersion()
+ return
+ }
+
+ // Run the internal logic of the application.
+ if err := internal.Run(cfg); err != nil {
fmt.Printf("Failed to run: %v\n", err)
os.Exit(2)
}
diff --git a/docs/build-rocky-linux-9.md b/docs/build-rocky-linux-9.md
index 424b78e..f83479f 100644
--- a/docs/build-rocky-linux-9.md
+++ b/docs/build-rocky-linux-9.md
@@ -1,7 +1,7 @@
# Building ior on Rocky Linux 9
Verified on a fresh Rocky Linux 9.7 install (kernel `5.14.0-611.5.1.el9_7` or
-newer). Runs on the **stock RHEL 9 kernel** — no kernel upgrade needed.
+newer). Runs on the **stock RHEL 9 kernel**, no kernel upgrade needed.
One build-time caveat: Rocky 9 ships neither `libelf.a` nor `libzstd.a` (no
`*-static` packages). Both must be built from source.
@@ -10,7 +10,7 @@ One build-time caveat: Rocky 9 ships neither `libelf.a` nor `libzstd.a` (no
> `struct trace_event_raw_sys_enter`/`_exit` (the BTF-emitted alias). RHEL 9
> backports an `rt`-tree patch that adds `preempt_lazy_count` to `struct
> trace_entry`, which widens those aliases by 8 bytes and shifts the `args`/`ret`
-> offsets — but the actual context the kernel hands the program is still
+> offsets, but the actual context the kernel hands the program is still
> `struct syscall_trace_enter`/`_exit`, where the offsets did not move. The
> verifier saw the program reading past `max_ctx_offset` and rejected the
> attach with `EACCES`. `ior` now uses `syscall_trace_*` directly (matching
@@ -19,7 +19,7 @@ One build-time caveat: Rocky 9 ships neither `libelf.a` nor `libzstd.a` (no
## Docker build (no Rocky 9 host required)
-The easiest path — builds entirely inside a container from any Docker-capable
+The easiest path. Builds entirely inside a container from any Docker-capable
Linux host:
```shell
@@ -140,15 +140,15 @@ Two reasons it works:
- The Go binary is compiled with `-extldflags "-static"` and links libbpf,
libelf, libzstd, and zlib as static archives. There is no runtime dependency
- on the build host's library versions (a couple of glibc resolver functions —
- `getpwnam_r` and friends — fall back to the target's libc, which is fine on
+ on the build host's library versions (a couple of glibc resolver functions,
+ `getpwnam_r` and friends, fall back to the target's libc, which is fine on
any reasonable distro).
- The BPF object inside the binary is built with libbpf's CO-RE
(Compile-Once, Run-Everywhere) machinery. Field offsets are not baked into
the bytecode; libbpf reads the target kernel's BTF (`/sys/kernel/btf/vmlinux`)
at load time and patches the program for that kernel. As long as the target
- ships BTF — true on every Debian, Ubuntu, Fedora, Arch, RHEL, and ElRepo
- `kernel-ml` build at the time of writing — the same `ior` binary runs without
+ ships BTF (true on every Debian, Ubuntu, Fedora, Arch, RHEL, and ElRepo
+ `kernel-ml` build at the time of writing) the same `ior` binary runs without
recompilation.
Pick one Rocky 9 / Fedora box, do the build dance once, then distribute the
diff --git a/docs/parquet-querying.md b/docs/parquet-querying.md
index 5372325..4c49baf 100644
--- a/docs/parquet-querying.md
+++ b/docs/parquet-querying.md
@@ -2,7 +2,7 @@
ior can record I/O events to a Parquet file (press `R` in TUI, or use the `-parquet` flag in headless mode). This document explains
how to explore those recordings interactively using `clickhouse local`, which is bundled
-inside the standard `clickhouse/clickhouse-server` Docker image — no server, no persistent
+inside the standard `clickhouse/clickhouse-server` Docker image. No server, no persistent
state, no installation needed beyond Docker.
---
diff --git a/docs/tutorial/tutorial.md b/docs/tutorial/tutorial.md
index a3695de..01201a2 100644
--- a/docs/tutorial/tutorial.md
+++ b/docs/tutorial/tutorial.md
@@ -1,6 +1,6 @@
# I/O Riot NG: a guided tour
-This tutorial walks through every major surface of `ior` — the dashboard tabs, the live stream, recording, headless modes, and the in-TUI flamegraph — using short animated GIFs so you can *see* what the keys actually do.
+This tutorial walks through every major surface of `ior` (the dashboard tabs, the live stream, recording, headless modes, and the in-TUI flamegraph) using short animated GIFs so you can *see* what the keys actually do.
Every GIF in this document is regenerated from a [VHS](https://github.com/charmbracelet/vhs) tape under [`tapes/`](./tapes). To rebuild them all, run `sudo -v && mage demo` (see [Regenerating the demo](#regenerating-the-demo)).
@@ -36,7 +36,7 @@ cd ~/git/ior
mage buildDocker # builds inside a Rocky 9 container, ~15 min on first run
```
-For a native build (libbpfgo must be cloned alongside the repo first — see the README):
+For a native build (libbpfgo must be cloned alongside the repo first; see the README):
```shell
mage all
@@ -44,7 +44,7 @@ mage all
ior needs `CAP_BPF`, so every invocation below uses `sudo`.
-The build dance only has to happen once: the resulting `ior` binary is fully statically linked and uses CO-RE, so the same binary runs on any BTF-enabled Linux kernel without recompilation. See the [Compile once, run everywhere](../../README.md#compile-once-run-everywhere) section for details.
+The build dance only has to happen once. The resulting `ior` binary is fully statically linked and uses CO-RE, so the same binary runs on any BTF-enabled Linux kernel without recompilation. See the [Compile once, run everywhere](../../README.md#compile-once-run-everywhere) section for details.
## First launch: the PID picker
@@ -90,7 +90,7 @@ Press `3`. A sortable table of every traced syscall (count, average latency, tot
### 4 · Files
-Press `4`. Per-path counters. The most useful key here is `d`, which toggles **directory grouping** — paths roll up into their parent directory, which is essential when one process touches thousands of files in `/usr/share/...`.
+Press `4`. Per-path counters. The most useful key here is `d`, which toggles **directory grouping**: paths roll up into their parent directory, which is essential when one process touches thousands of files in `/usr/share/...`.
![Files tab toggling directory grouping](./assets/04-files-tab.gif)
@@ -108,7 +108,7 @@ Press `6`. Two histograms: syscall **latency** (how long the syscall ran) and th
### 7 · Stream
-Press `7`. A live tail of every traced event row — comm, PID, TID, syscall, file, FD, return value, bytes, latency, gap. This is the workhorse view; the next section explores it in depth.
+Press `7`. A live tail of every traced event row: comm, PID, TID, syscall, file, FD, return value, bytes, latency, gap. This is the workhorse view; the next section explores it in depth.
![Stream tab live-tailing rows](./assets/07-stream-live.gif)
@@ -118,7 +118,7 @@ Stream has two modes: **Live** (rows scroll past) and **Pause** (`space` toggles
### Pause + stacked filters
-In pause mode, navigate with `j`/`k` (rows) and `←` / `→` (columns). Pressing `Enter` on the selected cell **pushes a new filter onto a stack** and immediately re-filters the ring buffer. Filters are stackable, so you can drill down — first by `Comm`, then by `Syscall`, then by `File`. `Esc` pops the most recent filter (LIFO); keep hitting `Esc` to undo all the way back.
+In pause mode, navigate with `j`/`k` (rows) and `←` / `→` (columns). Pressing `Enter` on the selected cell **pushes a new filter onto a stack** and immediately re-filters the ring buffer. Filters are stackable, so you can drill down: first by `Comm`, then by `Syscall`, then by `File`. `Esc` pops the most recent filter (LIFO); keep hitting `Esc` to undo all the way back.
![Pause, push two filters, undo with Esc](./assets/08-stream-pause-filter.gif)
@@ -134,10 +134,10 @@ The filter is reflected in the bottom status line, and matches the same syntax y
Four keys, four flavours:
-- `e` — quick export of the **current TUI-filter snapshot** to `ior-stream-<timestamp>.csv` in the current working directory. Works from any tab, not just Stream.
-- `x` — quick export of the **paused stream view** specifically (preserves your filter stack).
-- `X` — same as `x`, but prompts for a filename first.
-- `E` — open the most recent stream-exported CSV in your `$EDITOR` (`hx` / `vi` fallback).
+- `e`: quick export of the **current TUI-filter snapshot** to `ior-stream-<timestamp>.csv` in the current working directory. Works from any tab, not just Stream.
+- `x`: quick export of the **paused stream view** specifically (preserves your filter stack).
+- `X`: same as `x`, but prompts for a filename first.
+- `E`: open the most recent stream-exported CSV in your `$EDITOR` (`hx` / `vi` fallback).
![Press 'e', then ls the resulting CSV](./assets/10-stream-csv-export.gif)
@@ -147,9 +147,9 @@ If you don't want CSV export at all, start ior with `-tuiExport=false`; the help
Three modal pickers reshape what the rest of the TUI sees:
-- `p` — **PID picker** (re-opens the launch picker).
-- `t` — **TID picker** for thread-level focus.
-- `o` — **Probes** dialog: enable / disable individual syscall tracepoints.
+- `p`: **PID picker** (re-opens the launch picker).
+- `t`: **TID picker** for thread-level focus.
+- `o`: **Probes** dialog: enable / disable individual syscall tracepoints.
![PID, TID, and probe pickers](./assets/11-pid-tid-probe.gif)
@@ -172,7 +172,7 @@ Press `R` in the dashboard, accept the default filename (`ior-recording-<timesta
![Start, run, and stop a parquet recording](./assets/12-parquet-recording.gif)
-The recorder follows your *current* TUI global filter — narrow with `p`/`t`/`o` first if you want a focused capture.
+The recorder follows your *current* TUI global filter. Narrow with `p`/`t`/`o` first if you want a focused capture.
### Headless modes
@@ -180,7 +180,7 @@ For unattended captures or scripting, skip the TUI entirely. The demo runs all t
![Three headless flows in one tape](./assets/14-headless-modes.gif)
-`-flamegraph` writes one aggregated `.ior.zst` artifact at shutdown — ideal for `ior`'s native flamegraph and integration workflows. `-parquet` streams every row, so the file grows continuously. `-plain` is the lightest weight: CSV to stdout you can pipe into anything.
+`-flamegraph` writes one aggregated `.ior.zst` artifact at shutdown, ideal for `ior`'s native flamegraph and integration workflows. `-parquet` streams every row, so the file grows continuously. `-plain` is the lightest weight: CSV to stdout you can pipe into anything.
## Regenerating the demo
@@ -198,7 +198,7 @@ Or rebuild a single tape after editing it:
TAPE=07-stream-live mage demoOne
```
-Tapes live in [`tapes/`](./tapes), the background workload that drives them is [`scripts/workload.sh`](./scripts/workload.sh), and the resulting assets land in [`assets/`](./assets). VHS records headlessly under `ttyd` + Chromium — no real terminal window opens, so `mage demo` is safe to run in the background while you keep working.
+Tapes live in [`tapes/`](./tapes), the background workload that drives them is [`scripts/workload.sh`](./scripts/workload.sh), and the resulting assets land in [`assets/`](./assets). VHS records headlessly under `ttyd` + Chromium, so no real terminal window opens; `mage demo` is safe to run in the background while you keep working.
## Hotkey Quick Reference
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 9b1c3cf..5d00b74 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -42,6 +42,9 @@ type Config struct {
CollapsedFields []string
CountField string
GlobalFilter globalfilter.Filter
+
+ // ShowVersion prints the banner plus version and exits without running.
+ ShowVersion bool
}
var (
@@ -177,6 +180,7 @@ func parse() error {
flag.BoolVar(&cfg.TestLiveFlames, "testliveflames", false, "Run TUI with continuously-updating synthetic flamegraph data for live keyboard-navigation testing")
flag.DurationVar(&cfg.LiveInterval, "live-interval", cfg.LiveInterval, "Synthetic live flamegraph refresh interval for --testliveflames")
flag.BoolVar(&cfg.TUIExportEnable, "tuiExport", cfg.TUIExportEnable, "Enable TUI CSV snapshot export files (separate from Parquet recording)")
+ flag.BoolVar(&cfg.ShowVersion, "version", false, "Print version banner and exit")
fields := flag.String("fields", "",
fmt.Sprintf("Comma separated list of fields to collapse, valid are: %v", validFields))
flag.StringVar(&cfg.CountField, "count", cfg.CountField,
diff --git a/internal/flags/version.go b/internal/flags/version.go
index 3db0b9f..583d19b 100644
--- a/internal/flags/version.go
+++ b/internal/flags/version.go
@@ -5,11 +5,14 @@ import "fmt"
// Version is the current application version.
const Version = "v0.0.1"
-const asciiBannerTemplate = ` ___ _____ ___ _ _
-|_ _| / / _ \ | _ (_)___| |_
- | | / / (_) | | / / _ \ _|
-|___/_/ \___/ |_|_\_\___/\__| NG
- %s`
+const asciiBannerTemplate = ` ██╗ ██╗ ██████╗ ██████╗ ██╗ ██████╗ ████████╗
+ ██║ ██╔╝ ██╔═══██╗ ██╔══██╗ ██║ ██╔═══██╗ ╚══██╔══╝
+ ██║ ██╔╝ ██║ ██║ ██████╔╝ ██║ ██║ ██║ ██║
+ ██║ ██╔╝ ██║ ██║ ██╔══██╗ ██║ ██║ ██║ ██║
+ ██║██╔╝ ╚██████╔╝ ██║ ██║ ██║ ╚██████╔╝ ██║
+ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝
+ ⚡ Next-Generation BPF I/O Syscall Tracer ⚡
+ %s`
// PrintVersion prints the banner with the current version.
func PrintVersion() {
diff --git a/internal/ior.go b/internal/ior.go
index e6e7233..63da223 100644
--- a/internal/ior.go
+++ b/internal/ior.go
@@ -57,6 +57,11 @@ func dispatchRun(cfg flags.Config) error {
if cfg.TestLiveFlames {
return runTUITestLiveFlamesFn(cfg, tuiTestLiveFlamesStarter(cfg))
}
+ // All remaining modes require tracing, which needs root. Fail fast here so
+ // the TUI never starts (and hangs) when we already know it cannot trace.
+ if getEUID() != 0 {
+ return errRootPrivilegesRequired
+ }
if isHeadlessParquetMode(cfg) {
return runParquetFn(cfg)
}
diff --git a/internal/ior_mode_test.go b/internal/ior_mode_test.go
index a7fcab1..ab0466d 100644
--- a/internal/ior_mode_test.go
+++ b/internal/ior_mode_test.go
@@ -95,13 +95,16 @@ func TestDispatchRunUsesTraceModeWhenRequested(t *testing.T) {
origRunTUI := runTUIFn
origRunTUITestFlames := runTUITestFlamesFn
origRunTUITestLiveFlames := runTUITestLiveFlamesFn
+ origGetEUID := getEUID
defer func() {
runTraceFn = origRunTrace
runParquetFn = origRunParquet
runTUIFn = origRunTUI
runTUITestFlamesFn = origRunTUITestFlames
runTUITestLiveFlamesFn = origRunTUITestLiveFlames
+ getEUID = origGetEUID
}()
+ getEUID = func() int { return 0 }
traceCalled := false
tuiCalled := false
@@ -144,13 +147,16 @@ func TestDispatchRunUsesHeadlessParquetModeWhenRequested(t *testing.T) {
origRunTUI := runTUIFn
origRunTUITestFlames := runTUITestFlamesFn
origRunTUITestLiveFlames := runTUITestLiveFlamesFn
+ origGetEUID := getEUID
defer func() {
runTraceFn = origRunTrace
runParquetFn = origRunParquet
runTUIFn = origRunTUI
runTUITestFlamesFn = origRunTUITestFlames
runTUITestLiveFlamesFn = origRunTUITestLiveFlames
+ getEUID = origGetEUID
}()
+ getEUID = func() int { return 0 }
traceCalled := false
parquetCalled := false
@@ -196,12 +202,15 @@ func TestDispatchRunUsesTUIWhenOnlyPprofEnabled(t *testing.T) {
origRunTUI := runTUIFn
origRunTUITestFlames := runTUITestFlamesFn
origRunTUITestLiveFlames := runTUITestLiveFlamesFn
+ origGetEUID := getEUID
defer func() {
runTraceFn = origRunTrace
runTUIFn = origRunTUI
runTUITestFlamesFn = origRunTUITestFlames
runTUITestLiveFlamesFn = origRunTUITestLiveFlames
+ getEUID = origGetEUID
}()
+ getEUID = func() int { return 0 }
traceCalled := false
tuiCalled := false
@@ -239,12 +248,15 @@ func TestDispatchRunUsesTUIStarterWhenNotPlain(t *testing.T) {
origRunTUI := runTUIFn
origRunTUITestFlames := runTUITestFlamesFn
origRunTUITestLiveFlames := runTUITestLiveFlamesFn
+ origGetEUID := getEUID
defer func() {
runTraceWithContextFn = origRunTraceWithContext
runTUIFn = origRunTUI
runTUITestFlamesFn = origRunTUITestFlames
runTUITestLiveFlamesFn = origRunTUITestLiveFlames
+ getEUID = origGetEUID
}()
+ getEUID = func() int { return 0 }
traceDone := make(chan struct{}, 1)
runTraceWithContextFn = func(_ context.Context, _ flags.Config, started chan<- struct{}, configure func(*eventLoop)) error {