summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 13:36:51 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 13:36:51 +0200
commitef12ce837176bd21deb455eb50a6c839af02b510 (patch)
treec262ceeda0b419236a4b0b1826df8eb5e418b852
parent10c5d48413afaef88626419d8c4bf9fbf6f1c902 (diff)
Add live flamegraph test modes and dynamic synthetic live feed
-rw-r--r--internal/flags/flags.go4
-rw-r--r--internal/flags/flags_test.go20
-rw-r--r--internal/flamegraph/livetrie.go8
-rw-r--r--internal/flamegraph/livetrie_test.go64
-rw-r--r--internal/flamegraph/testfixture.go120
-rw-r--r--internal/ior.go98
-rw-r--r--internal/ior_mode_test.go259
-rw-r--r--internal/tui/dashboard/model.go4
-rw-r--r--internal/tui/dashboard/model_test.go37
-rw-r--r--internal/tui/flamegraph/controls.go39
-rw-r--r--internal/tui/flamegraph/model.go119
-rw-r--r--internal/tui/flamegraph/model_test.go219
-rw-r--r--internal/tui/flamegraph/renderer.go4
-rw-r--r--internal/tui/tui.go106
-rw-r--r--internal/tui/tui_test.go100
15 files changed, 1172 insertions, 29 deletions
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index bd21768..0df1d2d 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -58,6 +58,8 @@ type Flags struct {
PlainMode bool
FlamegraphEnable bool
LiveFlamegraph bool
+ TestFlames bool
+ TestLiveFlames bool
LiveInterval time.Duration
OpenCommand string
FlamegraphName string
@@ -184,6 +186,8 @@ func parse() error {
flag.BoolVar(&cfg.PlainMode, "plain", false, "Enable plain CSV output mode (disable TUI)")
flag.BoolVar(&cfg.FlamegraphEnable, "flamegraph", false, "Enable flamegraph builder")
flag.BoolVar(&cfg.LiveFlamegraph, "live", false, "Enable live flamegraph mode")
+ flag.BoolVar(&cfg.TestFlames, "testflames", false, "Run TUI with static synthetic flamegraph data for keyboard-navigation testing")
+ 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, "Live flamegraph refresh interval")
flag.StringVar(&cfg.OpenCommand, "open", "", "Command to open live flamegraph URL (used with -live); use {url} placeholder or URL is appended")
flag.StringVar(&cfg.FlamegraphName, "name", cfg.FlamegraphName, "Name of the flamegraph, used to generate the SVG file")
diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go
index 3534916..63b668c 100644
--- a/internal/flags/flags_test.go
+++ b/internal/flags/flags_test.go
@@ -127,6 +127,26 @@ func TestParseIorWatchIntervalFlag(t *testing.T) {
}
}
+func TestParseTestFlamesFlag(t *testing.T) {
+ cfg, err := parseForTest(t, "--testflames")
+ if err != nil {
+ t.Fatalf("parse returned error: %v", err)
+ }
+ if !cfg.TestFlames {
+ t.Fatalf("expected --testflames to enable static flamegraph test mode")
+ }
+}
+
+func TestParseTestLiveFlamesFlag(t *testing.T) {
+ cfg, err := parseForTest(t, "--testliveflames")
+ if err != nil {
+ t.Fatalf("parse returned error: %v", err)
+ }
+ if !cfg.TestLiveFlames {
+ t.Fatalf("expected --testliveflames to enable synthetic live flamegraph test mode")
+ }
+}
+
func TestParseDefaultCollapsedFieldsOrder(t *testing.T) {
cfg, err := parseForTest(t)
if err != nil {
diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go
index db46af5..0d42b6b 100644
--- a/internal/flamegraph/livetrie.go
+++ b/internal/flamegraph/livetrie.go
@@ -88,6 +88,12 @@ func (lt *LiveTrie) invalidateCache() {
// Ingest adds one event pair into the live trie and recycles the pair.
func (lt *LiveTrie) Ingest(ep *event.Pair) {
record := eventPairToRecord(ep)
+ lt.AddRecord(record)
+ ep.Recycle()
+}
+
+// AddRecord adds one already-decoded flamegraph record into the live trie.
+func (lt *LiveTrie) AddRecord(record IterRecord) {
value := record.Cnt.ValueByName(lt.countField)
lt.mu.Lock()
@@ -95,8 +101,6 @@ func (lt *LiveTrie) Ingest(ep *event.Pair) {
lt.addLocked(frames, value)
lt.version.Add(1)
lt.mu.Unlock()
-
- ep.Recycle()
}
// Reset clears the trie so live snapshots start from a new baseline.
diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go
index e569e00..632f668 100644
--- a/internal/flamegraph/livetrie_test.go
+++ b/internal/flamegraph/livetrie_test.go
@@ -61,6 +61,70 @@ func TestLiveTrieVersionIncrementsPerIngest(t *testing.T) {
}
}
+func TestLiveTrieAddRecordIncrementsVersion(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ lt.AddRecord(IterRecord{
+ Path: "/tmp/demo/read",
+ TraceID: types.SYS_ENTER_READ,
+ Comm: "demo",
+ Pid: 1001,
+ Tid: 1001,
+ Cnt: Counter{Count: 7, Duration: 70, DurationToPrev: 14, Bytes: 28},
+ })
+
+ if got := lt.Version(); got != 1 {
+ t.Fatalf("version = %d, want 1", got)
+ }
+ snap := decodeLiveSnapshot(t, lt)
+ if snap.Total != 7 {
+ t.Fatalf("root total = %d, want 7", snap.Total)
+ }
+}
+
+func TestSeedTestFlameDataBuildsStaticFixture(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ SeedTestFlameData(lt)
+
+ if got := lt.Version(); got == 0 {
+ t.Fatalf("expected seed fixture to add records")
+ }
+ snap := decodeLiveSnapshot(t, lt)
+ if snap.Total == 0 {
+ t.Fatalf("expected non-empty seeded snapshot")
+ }
+ if findSnapshotChild(&snap, "api") == nil {
+ t.Fatalf("expected seeded snapshot to include api branch")
+ }
+ if findSnapshotChild(&snap, "worker") == nil {
+ t.Fatalf("expected seeded snapshot to include worker branch")
+ }
+}
+
+func TestSeedTestLiveFlameDataVariesByTick(t *testing.T) {
+ lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+
+ SeedTestLiveFlameData(lt, 0)
+ snapTick0 := decodeLiveSnapshot(t, lt)
+ apiTick0 := findSnapshotPath(t, &snapTick0, "api").Total
+ workerTick0 := findSnapshotPath(t, &snapTick0, "worker").Total
+
+ lt.Reset()
+ SeedTestLiveFlameData(lt, 1)
+ snapTick1 := decodeLiveSnapshot(t, lt)
+ apiTick1 := findSnapshotPath(t, &snapTick1, "api").Total
+ workerTick1 := findSnapshotPath(t, &snapTick1, "worker").Total
+
+ if apiTick0 == apiTick1 && workerTick0 == workerTick1 {
+ t.Fatalf("expected phase shift to alter branch totals, got api=%d worker=%d for both ticks", apiTick0, workerTick0)
+ }
+ if apiTick0 <= workerTick0 {
+ t.Fatalf("expected api to dominate at tick 0, got api=%d worker=%d", apiTick0, workerTick0)
+ }
+ if workerTick1 <= apiTick1 {
+ t.Fatalf("expected worker to dominate at tick 1, got worker=%d api=%d", workerTick1, apiTick1)
+ }
+}
+
func TestLiveTrieResetClearsDataAndAdvancesVersion(t *testing.T) {
lt := NewLiveTrie([]string{"comm"}, "count")
lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1))
diff --git a/internal/flamegraph/testfixture.go b/internal/flamegraph/testfixture.go
new file mode 100644
index 0000000..2774925
--- /dev/null
+++ b/internal/flamegraph/testfixture.go
@@ -0,0 +1,120 @@
+package flamegraph
+
+import (
+ "ior/internal/types"
+ "strings"
+)
+
+// SeedTestFlameData populates a deterministic static flamegraph fixture.
+// Intended for keyboard-navigation validation in TUI test-flame mode.
+func SeedTestFlameData(liveTrie *LiveTrie) {
+ if liveTrie == nil {
+ return
+ }
+ for _, record := range testFlameRecords() {
+ liveTrie.AddRecord(record)
+ }
+}
+
+// SeedTestLiveFlameData populates deterministic synthetic data for a given live tick.
+// The data shape stays navigable while branch weights shift by phase so the
+// terminal flamegraph visibly changes over time.
+func SeedTestLiveFlameData(liveTrie *LiveTrie, tick uint64) {
+ if liveTrie == nil {
+ return
+ }
+ phase := tick % 4
+ for _, base := range testFlameRecords() {
+ weight := liveTestWeight(base, phase)
+ liveTrie.AddRecord(withTestFlameWeight(base, weight))
+ }
+}
+
+func testFlameRecords() []IterRecord {
+ return []IterRecord{
+ newTestFlameRecord("api", "/srv/api/lib/http/client/read", 2001, 2201, types.SYS_ENTER_READ, 180),
+ newTestFlameRecord("api", "/srv/api/lib/json/encode/write", 2001, 2201, types.SYS_ENTER_WRITE, 120),
+ newTestFlameRecord("api", "/srv/api/storage/postgres/query/read", 2001, 2201, types.SYS_ENTER_READ, 240),
+ newTestFlameRecord("api", "/srv/api/storage/postgres/commit/fsync", 2001, 2201, types.SYS_ENTER_FSYNC, 70),
+ newTestFlameRecord("worker", "/srv/worker/queue/pop/read", 2002, 2202, types.SYS_ENTER_READ, 160),
+ newTestFlameRecord("worker", "/srv/worker/queue/push/write", 2002, 2202, types.SYS_ENTER_WRITE, 145),
+ newTestFlameRecord("worker", "/srv/worker/cache/redis/get/read", 2002, 2202, types.SYS_ENTER_READ, 95),
+ newTestFlameRecord("worker", "/srv/worker/cache/redis/set/write", 2002, 2202, types.SYS_ENTER_WRITE, 90),
+ newTestFlameRecord("ingest", "/srv/ingest/parser/csv/read", 2003, 2203, types.SYS_ENTER_READ, 110),
+ newTestFlameRecord("ingest", "/srv/ingest/parser/csv/normalize/write", 2003, 2203, types.SYS_ENTER_WRITE, 80),
+ newTestFlameRecord("ingest", "/srv/ingest/uploader/s3/put/writev", 2003, 2203, types.SYS_ENTER_WRITEV, 75),
+ newTestFlameRecord("batch", "/srv/batch/jobs/report/open", 2004, 2204, types.SYS_ENTER_OPENAT, 55),
+ newTestFlameRecord("batch", "/srv/batch/jobs/report/close", 2004, 2204, types.SYS_ENTER_CLOSE, 35),
+ newTestFlameRecord("batch", "/srv/batch/jobs/report/rename", 2004, 2204, types.SYS_ENTER_RENAMEAT, 20),
+ }
+}
+
+func newTestFlameRecord(comm, path string, pid, tid uint32, traceID types.TraceId, weight uint64) IterRecord {
+ return IterRecord{
+ Path: path,
+ TraceID: traceID,
+ Comm: comm,
+ Pid: pid,
+ Tid: tid,
+ Cnt: Counter{
+ Count: weight,
+ Duration: weight * 1000,
+ DurationToPrev: weight * 350,
+ Bytes: weight * 4096,
+ },
+ }
+}
+
+func withTestFlameWeight(record IterRecord, weight uint64) IterRecord {
+ record.Cnt = Counter{
+ Count: weight,
+ Duration: weight * 1000,
+ DurationToPrev: weight * 350,
+ Bytes: weight * 4096,
+ }
+ return record
+}
+
+func liveTestWeight(record IterRecord, phase uint64) uint64 {
+ base := record.Cnt.Count
+ multiplier := uint64(1)
+
+ switch phase {
+ case 0:
+ if record.Comm == "api" {
+ multiplier += 4
+ }
+ if strings.Contains(record.Path, "/lib/") {
+ multiplier += 2
+ }
+ case 1:
+ if record.Comm == "worker" {
+ multiplier += 4
+ }
+ if strings.Contains(record.Path, "/queue/") {
+ multiplier += 2
+ }
+ case 2:
+ if record.Comm == "ingest" {
+ multiplier += 4
+ }
+ if strings.Contains(record.Path, "/uploader/") || strings.Contains(record.Path, "/parser/") {
+ multiplier += 2
+ }
+ case 3:
+ if record.Comm == "batch" {
+ multiplier += 4
+ }
+ if strings.Contains(record.Path, "/report/") {
+ multiplier += 2
+ }
+ }
+
+ if strings.Contains(record.Path, "/storage/") && phase%2 == 0 {
+ multiplier++
+ }
+ if strings.Contains(record.Path, "/cache/") && phase%2 == 1 {
+ multiplier++
+ }
+ return base * multiplier
+}
diff --git a/internal/ior.go b/internal/ior.go
index 46d0a84..521da5a 100644
--- a/internal/ior.go
+++ b/internal/ior.go
@@ -36,10 +36,12 @@ type tracepointLink interface {
}
var (
- runTraceFn = runTrace
- runTraceWithContextFn = runTraceWithContext
- runTUIFn = tui.RunWithTraceStarter
- getEUID = os.Geteuid
+ runTraceFn = runTrace
+ runTraceWithContextFn = runTraceWithContext
+ runTUIFn = tui.RunWithTraceStarter
+ runTUITestFlamesFn = tui.RunTestFlamesWithTraceStarter
+ runTUITestLiveFlamesFn = tui.RunTestFlamesWithTraceStarter
+ getEUID = os.Geteuid
errRootPrivilegesRequired = errors.New("tracing requires root privileges (run with sudo)")
)
@@ -120,6 +122,12 @@ func attachTracepointsWith(module tracepointModule, shouldAttach func(string) bo
func Run() error {
flags.PrintVersion()
cfg := flags.Get()
+ if cfg.TestFlames && cfg.IorDataFile != "" {
+ return errors.New("--testflames and -ior are mutually exclusive")
+ }
+ if cfg.TestLiveFlames && cfg.IorDataFile != "" {
+ return errors.New("--testliveflames and -ior are mutually exclusive")
+ }
iorFile := cfg.IorDataFile
var noTraceRun bool
@@ -205,6 +213,12 @@ func dispatchRun(cfg flags.Flags) error {
if err := validateRunConfig(cfg); err != nil {
return err
}
+ if cfg.TestFlames {
+ return runTUITestFlamesFn(tuiTestFlamesStarter())
+ }
+ if cfg.TestLiveFlames {
+ return runTUITestLiveFlamesFn(tuiTestLiveFlamesStarter())
+ }
if shouldRunTraceMode(cfg) {
return runTraceFn()
}
@@ -212,6 +226,15 @@ func dispatchRun(cfg flags.Flags) error {
}
func validateRunConfig(cfg flags.Flags) error {
+ if cfg.TestFlames && (cfg.PlainMode || cfg.FlamegraphEnable || cfg.LiveFlamegraph) {
+ return errors.New("--testflames cannot be combined with -plain, -flamegraph, or -live")
+ }
+ if cfg.TestLiveFlames && (cfg.PlainMode || cfg.FlamegraphEnable || cfg.LiveFlamegraph) {
+ return errors.New("--testliveflames cannot be combined with -plain, -flamegraph, or -live")
+ }
+ if cfg.TestFlames && cfg.TestLiveFlames {
+ return errors.New("--testflames and --testliveflames are mutually exclusive")
+ }
if cfg.LiveFlamegraph && cfg.FlamegraphEnable {
return errors.New("-live and -flamegraph are mutually exclusive")
}
@@ -224,6 +247,73 @@ func validateRunConfig(cfg flags.Flags) error {
return nil
}
+func tuiTestFlamesStarter() tui.TraceStarter {
+ return func(ctx context.Context) error {
+ engine, streamBuf, liveTrie := buildTestFlamesRuntime(flags.Get())
+ if bindings, ok := tui.RuntimeBindingsFromContext(ctx); ok {
+ bindings.SetDashboardSnapshotSource(engine)
+ bindings.SetEventStreamSource(streamBuf)
+ bindings.SetLiveTrie(liveTrie)
+ }
+ return nil
+ }
+}
+
+func tuiTestLiveFlamesStarter() tui.TraceStarter {
+ return func(ctx context.Context) error {
+ engine, streamBuf, liveTrie := buildTestLiveFlamesRuntime(ctx, flags.Get())
+ if bindings, ok := tui.RuntimeBindingsFromContext(ctx); ok {
+ bindings.SetDashboardSnapshotSource(engine)
+ bindings.SetEventStreamSource(streamBuf)
+ bindings.SetLiveTrie(liveTrie)
+ }
+ return nil
+ }
+}
+
+func buildTestFlamesRuntime(cfg flags.Flags) (*statsengine.Engine, *eventstream.RingBuffer, *flamegraph.LiveTrie) {
+ engine := statsengine.NewEngine(64)
+ streamBuf := eventstream.NewRingBuffer()
+ liveTrie := flamegraph.NewLiveTrie(cfg.CollapsedFields, cfg.CountField)
+ flamegraph.SeedTestFlameData(liveTrie)
+ return engine, streamBuf, liveTrie
+}
+
+func buildTestLiveFlamesRuntime(ctx context.Context, cfg flags.Flags) (*statsengine.Engine, *eventstream.RingBuffer, *flamegraph.LiveTrie) {
+ engine := statsengine.NewEngine(64)
+ streamBuf := eventstream.NewRingBuffer()
+ liveTrie := flamegraph.NewLiveTrie(cfg.CollapsedFields, cfg.CountField)
+ flamegraph.SeedTestLiveFlameData(liveTrie, 0)
+
+ interval := cfg.LiveInterval
+ if interval <= 0 {
+ interval = 200 * time.Millisecond
+ }
+ go runSyntheticLiveFlames(ctx, liveTrie, interval)
+ return engine, streamBuf, liveTrie
+}
+
+func runSyntheticLiveFlames(ctx context.Context, liveTrie *flamegraph.LiveTrie, interval time.Duration) {
+ if liveTrie == nil {
+ return
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ tick := uint64(1)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ // Keep a moving synthetic workload profile so the live test flamegraph
+ // visibly changes shape over time instead of only increasing totals.
+ liveTrie.Reset()
+ flamegraph.SeedTestLiveFlameData(liveTrie, tick)
+ tick++
+ }
+ }
+}
+
func shouldRunTraceMode(cfg flags.Flags) bool {
return cfg.PlainMode || cfg.FlamegraphEnable || cfg.LiveFlamegraph
}
diff --git a/internal/ior_mode_test.go b/internal/ior_mode_test.go
index d509cf2..3ae876a 100644
--- a/internal/ior_mode_test.go
+++ b/internal/ior_mode_test.go
@@ -1,7 +1,9 @@
package internal
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"testing"
"time"
@@ -40,6 +42,18 @@ func TestShouldRunTraceMode(t *testing.T) {
if !shouldRunTraceMode(withLive) {
t.Fatalf("expected live mode to use trace mode")
}
+
+ withTestFlames := base
+ withTestFlames.TestFlames = true
+ if shouldRunTraceMode(withTestFlames) {
+ t.Fatalf("expected --testflames to stay in TUI mode")
+ }
+
+ withTestLiveFlames := base
+ withTestLiveFlames.TestLiveFlames = true
+ if shouldRunTraceMode(withTestLiveFlames) {
+ t.Fatalf("expected --testliveflames to stay in TUI mode")
+ }
}
func TestShouldAutoStopByDuration(t *testing.T) {
@@ -76,9 +90,13 @@ func TestShouldAutoStopByDuration(t *testing.T) {
func TestDispatchRunUsesTraceModeWhenRequested(t *testing.T) {
origRunTrace := runTraceFn
origRunTUI := runTUIFn
+ origRunTUITestFlames := runTUITestFlamesFn
+ origRunTUITestLiveFlames := runTUITestLiveFlamesFn
defer func() {
runTraceFn = origRunTrace
runTUIFn = origRunTUI
+ runTUITestFlamesFn = origRunTUITestFlames
+ runTUITestLiveFlamesFn = origRunTUITestLiveFlames
}()
traceCalled := false
@@ -91,6 +109,14 @@ func TestDispatchRunUsesTraceModeWhenRequested(t *testing.T) {
tuiCalled = true
return nil
}
+ runTUITestFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestFlamesFn should not be called in trace mode")
+ return nil
+ }
+ runTUITestLiveFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestLiveFlamesFn should not be called in trace mode")
+ return nil
+ }
cfg := flags.Flags{PlainMode: true}
if err := dispatchRun(cfg); err != nil {
@@ -107,9 +133,13 @@ func TestDispatchRunUsesTraceModeWhenRequested(t *testing.T) {
func TestDispatchRunUsesTUIWhenOnlyPprofEnabled(t *testing.T) {
origRunTrace := runTraceFn
origRunTUI := runTUIFn
+ origRunTUITestFlames := runTUITestFlamesFn
+ origRunTUITestLiveFlames := runTUITestLiveFlamesFn
defer func() {
runTraceFn = origRunTrace
runTUIFn = origRunTUI
+ runTUITestFlamesFn = origRunTUITestFlames
+ runTUITestLiveFlamesFn = origRunTUITestLiveFlames
}()
traceCalled := false
@@ -122,6 +152,14 @@ func TestDispatchRunUsesTUIWhenOnlyPprofEnabled(t *testing.T) {
tuiCalled = true
return nil
}
+ runTUITestFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestFlamesFn should not be called for regular TUI mode")
+ return nil
+ }
+ runTUITestLiveFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestLiveFlamesFn should not be called for regular TUI mode")
+ return nil
+ }
cfg := flags.Flags{PprofEnable: true}
if err := dispatchRun(cfg); err != nil {
@@ -138,9 +176,13 @@ func TestDispatchRunUsesTUIWhenOnlyPprofEnabled(t *testing.T) {
func TestDispatchRunUsesTUIStarterWhenNotPlain(t *testing.T) {
origRunTraceWithContext := runTraceWithContextFn
origRunTUI := runTUIFn
+ origRunTUITestFlames := runTUITestFlamesFn
+ origRunTUITestLiveFlames := runTUITestLiveFlamesFn
defer func() {
runTraceWithContextFn = origRunTraceWithContext
runTUIFn = origRunTUI
+ runTUITestFlamesFn = origRunTUITestFlames
+ runTUITestLiveFlamesFn = origRunTUITestLiveFlames
}()
traceDone := make(chan struct{}, 1)
@@ -164,6 +206,14 @@ func TestDispatchRunUsesTUIStarterWhenNotPlain(t *testing.T) {
}
return nil
}
+ runTUITestFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestFlamesFn should not be called for normal starter path")
+ return nil
+ }
+ runTUITestLiveFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestLiveFlamesFn should not be called for normal starter path")
+ return nil
+ }
cfg := flags.Flags{}
if err := dispatchRun(cfg); err != nil {
@@ -183,9 +233,13 @@ func TestDispatchRunUsesTUIStarterWhenNotPlain(t *testing.T) {
func TestDispatchRunRejectsLiveAndFlamegraph(t *testing.T) {
origRunTrace := runTraceFn
origRunTUI := runTUIFn
+ origRunTUITestFlames := runTUITestFlamesFn
+ origRunTUITestLiveFlames := runTUITestLiveFlamesFn
defer func() {
runTraceFn = origRunTrace
runTUIFn = origRunTUI
+ runTUITestFlamesFn = origRunTUITestFlames
+ runTUITestLiveFlamesFn = origRunTUITestLiveFlames
}()
runTraceFn = func() error {
@@ -196,6 +250,14 @@ func TestDispatchRunRejectsLiveAndFlamegraph(t *testing.T) {
t.Fatalf("runTUIFn should not be called for invalid flag combos")
return nil
}
+ runTUITestFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestFlamesFn should not be called for invalid flag combos")
+ return nil
+ }
+ runTUITestLiveFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestLiveFlamesFn should not be called for invalid flag combos")
+ return nil
+ }
cfg := flags.Flags{LiveFlamegraph: true, FlamegraphEnable: true}
err := dispatchRun(cfg)
@@ -207,6 +269,106 @@ func TestDispatchRunRejectsLiveAndFlamegraph(t *testing.T) {
}
}
+func TestDispatchRunUsesTestFlamesModeWhenRequested(t *testing.T) {
+ origRunTrace := runTraceFn
+ origRunTUI := runTUIFn
+ origRunTUITestFlames := runTUITestFlamesFn
+ origRunTUITestLiveFlames := runTUITestLiveFlamesFn
+ defer func() {
+ runTraceFn = origRunTrace
+ runTUIFn = origRunTUI
+ runTUITestFlamesFn = origRunTUITestFlames
+ runTUITestLiveFlamesFn = origRunTUITestLiveFlames
+ }()
+
+ traceCalled := false
+ regularTUICalled := false
+ testFlamesCalled := false
+ runTraceFn = func() error {
+ traceCalled = true
+ return nil
+ }
+ runTUIFn = func(tui.TraceStarter) error {
+ regularTUICalled = true
+ return nil
+ }
+ runTUITestFlamesFn = func(starter tui.TraceStarter) error {
+ testFlamesCalled = true
+ if starter == nil {
+ t.Fatalf("expected non-nil starter for test flames mode")
+ }
+ return starter(context.Background())
+ }
+ runTUITestLiveFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestLiveFlamesFn should not be called for --testflames")
+ return nil
+ }
+
+ cfg := flags.Flags{TestFlames: true}
+ if err := dispatchRun(cfg); err != nil {
+ t.Fatalf("dispatchRun returned error: %v", err)
+ }
+ if traceCalled {
+ t.Fatalf("did not expect runTraceFn for test flames mode")
+ }
+ if regularTUICalled {
+ t.Fatalf("did not expect runTUIFn for test flames mode")
+ }
+ if !testFlamesCalled {
+ t.Fatalf("expected runTUITestFlamesFn to be called")
+ }
+}
+
+func TestDispatchRunUsesTestLiveFlamesModeWhenRequested(t *testing.T) {
+ origRunTrace := runTraceFn
+ origRunTUI := runTUIFn
+ origRunTUITestFlames := runTUITestFlamesFn
+ origRunTUITestLiveFlames := runTUITestLiveFlamesFn
+ defer func() {
+ runTraceFn = origRunTrace
+ runTUIFn = origRunTUI
+ runTUITestFlamesFn = origRunTUITestFlames
+ runTUITestLiveFlamesFn = origRunTUITestLiveFlames
+ }()
+
+ traceCalled := false
+ regularTUICalled := false
+ testLiveFlamesCalled := false
+ runTraceFn = func() error {
+ traceCalled = true
+ return nil
+ }
+ runTUIFn = func(tui.TraceStarter) error {
+ regularTUICalled = true
+ return nil
+ }
+ runTUITestFlamesFn = func(tui.TraceStarter) error {
+ t.Fatalf("runTUITestFlamesFn should not be called for --testliveflames")
+ return nil
+ }
+ runTUITestLiveFlamesFn = func(starter tui.TraceStarter) error {
+ testLiveFlamesCalled = true
+ if starter == nil {
+ t.Fatalf("expected non-nil starter for test live flames mode")
+ }
+ return starter(context.Background())
+ }
+
+ cfg := flags.Flags{TestLiveFlames: true}
+ if err := dispatchRun(cfg); err != nil {
+ t.Fatalf("dispatchRun returned error: %v", err)
+ }
+ if traceCalled {
+ t.Fatalf("did not expect runTraceFn for test live flames mode")
+ }
+ if regularTUICalled {
+ t.Fatalf("did not expect runTUIFn for test live flames mode")
+ }
+ if !testLiveFlamesCalled {
+ t.Fatalf("expected runTUITestLiveFlamesFn to be called")
+ }
+}
+
func TestValidateRunConfigRejectsIorWatchWithoutIor(t *testing.T) {
cfg := flags.Flags{IorWatchInterval: time.Second}
err := validateRunConfig(cfg)
@@ -229,6 +391,103 @@ func TestValidateRunConfigRejectsNegativeIorWatchInterval(t *testing.T) {
}
}
+func TestValidateRunConfigRejectsTestFlamesWithTraceFlags(t *testing.T) {
+ cfg := flags.Flags{TestFlames: true, PlainMode: true}
+ err := validateRunConfig(cfg)
+ if err == nil {
+ t.Fatalf("expected error for --testflames with trace-mode flags")
+ }
+ if err.Error() != "--testflames cannot be combined with -plain, -flamegraph, or -live" {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateRunConfigRejectsTestLiveFlamesWithTraceFlags(t *testing.T) {
+ cfg := flags.Flags{TestLiveFlames: true, PlainMode: true}
+ err := validateRunConfig(cfg)
+ if err == nil {
+ t.Fatalf("expected error for --testliveflames with trace-mode flags")
+ }
+ if err.Error() != "--testliveflames cannot be combined with -plain, -flamegraph, or -live" {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestValidateRunConfigRejectsBothTestModes(t *testing.T) {
+ cfg := flags.Flags{TestFlames: true, TestLiveFlames: true}
+ err := validateRunConfig(cfg)
+ if err == nil {
+ t.Fatalf("expected error when both test flame modes are enabled")
+ }
+ if err.Error() != "--testflames and --testliveflames are mutually exclusive" {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestBuildTestFlamesRuntimeSeedsLiveTrie(t *testing.T) {
+ cfg := flags.NewFlags()
+ _, streamBuf, liveTrie := buildTestFlamesRuntime(cfg)
+ if streamBuf == nil {
+ t.Fatalf("expected stream buffer in test flames runtime")
+ }
+ if liveTrie == nil {
+ t.Fatalf("expected live trie in test flames runtime")
+ }
+ if liveTrie.Version() == 0 {
+ t.Fatalf("expected seeded live trie version to be non-zero")
+ }
+
+ payload, _ := liveTrie.SnapshotJSON()
+ var snap map[string]any
+ if err := json.Unmarshal(payload, &snap); err != nil {
+ t.Fatalf("decode snapshot: %v", err)
+ }
+ total, ok := snap["t"].(float64)
+ if !ok || total <= 0 {
+ t.Fatalf("expected seeded snapshot total > 0, got %v", snap["t"])
+ }
+}
+
+func TestBuildTestLiveFlamesRuntimeContinuouslyUpdatesLiveTrie(t *testing.T) {
+ cfg := flags.NewFlags()
+ cfg.LiveInterval = 15 * time.Millisecond
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ _, streamBuf, liveTrie := buildTestLiveFlamesRuntime(ctx, cfg)
+ if streamBuf == nil {
+ t.Fatalf("expected stream buffer in test live flames runtime")
+ }
+ if liveTrie == nil {
+ t.Fatalf("expected live trie in test live flames runtime")
+ }
+
+ initialVersion := liveTrie.Version()
+ if initialVersion == 0 {
+ t.Fatalf("expected seeded live trie version to be non-zero")
+ }
+ initialSnapshot, _ := liveTrie.SnapshotJSON()
+
+ sawUpdate := false
+ deadline := time.Now().Add(300 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if liveTrie.Version() <= initialVersion {
+ time.Sleep(10 * time.Millisecond)
+ continue
+ }
+ currentSnapshot, _ := liveTrie.SnapshotJSON()
+ if !bytes.Equal(initialSnapshot, currentSnapshot) {
+ sawUpdate = true
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if !sawUpdate {
+ t.Fatalf("expected test live flames snapshot shape to change over time (version > %d)", initialVersion)
+ }
+}
+
func TestRunTraceWithContextRequiresRoot(t *testing.T) {
origGetEUID := getEUID
defer func() { getEUID = origGetEUID }()
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 7ec1362..b1d23bb 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -134,8 +134,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
var animCmd tea.Cmd
- if m.liveTrie != nil && !m.flamegraphModel.Paused() && (!m.flamegraphModel.HasSnapshot() || m.liveTrie.Version() != m.flamegraphModel.LastVersion()) {
- m.flamegraphModel.RefreshFromLiveTrie()
+ if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() {
animCmd = m.flamegraphModel.AnimationCmd()
}
if animCmd != nil {
@@ -368,6 +367,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
if m.width > 0 && m.height > 0 {
m.flamegraphModel.SetViewport(m.width, m.height)
}
+ m.flamegraphModel.RefreshFromLiveTrie()
}
// SetDarkMode updates dashboard child models for the active theme.
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 1dc1cc1..8904a2f 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -198,20 +198,45 @@ func TestFlameTickRefreshesFlamegraphModel(t *testing.T) {
}
}
-func TestFlameTickLoadsInitialSnapshotWithoutVersionChange(t *testing.T) {
+func TestSetLiveTriePreloadsInitialSnapshotWithoutVersionChange(t *testing.T) {
liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.SetLiveTrie(liveTrie)
m.activeTab = TabFlame
- if m.flamegraphModel.HasSnapshot() {
- t.Fatalf("expected fresh flame model to start without snapshot")
+ if !m.flamegraphModel.HasSnapshot() {
+ t.Fatalf("expected SetLiveTrie to preload a baseline snapshot")
}
next, _ := m.Update(flameTickMsg{})
model := next.(Model)
if !model.flamegraphModel.HasSnapshot() {
- t.Fatalf("expected flame tick to load initial snapshot even when trie version is unchanged")
+ t.Fatalf("expected flame tick to retain initial snapshot even when trie version is unchanged")
+ }
+}
+
+func TestFlameTickPausedContinuesBootstrapRefresh(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.SetLiveTrie(liveTrie)
+ m.activeTab = TabFlame
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ model := next.(Model)
+
+ next, _ = model.Update(flameTickMsg{})
+ model = next.(Model)
+ initialVersion := model.flamegraphModel.LastVersion()
+
+ liveTrie.Reset()
+ if liveTrie.Version() == initialVersion {
+ t.Fatalf("expected reset to advance trie version")
+ }
+
+ next, _ = model.Update(flameTickMsg{})
+ model = next.(Model)
+ if got, want := model.flamegraphModel.LastVersion(), liveTrie.Version(); got != want {
+ t.Fatalf("expected paused flame tick bootstrap to refresh version, got %d want %d", got, want)
}
}
@@ -359,10 +384,10 @@ func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) {
m.width = 120
m.height = 30
- next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
model := next.(Model)
if !strings.Contains(model.View().Content, "[PAUSED]") {
- t.Fatalf("expected flame pause key to toggle paused state")
+ t.Fatalf("expected flame space key to toggle paused state")
}
next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})})
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
index 959a5b0..f411a13 100644
--- a/internal/tui/flamegraph/controls.go
+++ b/internal/tui/flamegraph/controls.go
@@ -29,6 +29,7 @@ func (m *Model) resetBaseline() {
m.matchIndices = make(map[int]bool)
m.filterVisible = make(map[int]bool)
m.subtreeSet = make(map[int]bool)
+ m.hasNavigableSnapshot = false
m.statusMessage = "Baseline reset"
}
@@ -55,6 +56,7 @@ func (m *Model) cycleFieldOrder() {
m.matchIndices = make(map[int]bool)
m.filterVisible = make(map[int]bool)
m.subtreeSet = make(map[int]bool)
+ m.hasNavigableSnapshot = false
m.statusMessage = "Order: " + strings.Join(nextPreset, "/")
}
@@ -68,13 +70,16 @@ func (m Model) toolbarLine() string {
state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]")
}
order := m.currentFieldPresetLabel()
- line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | p:pause", state, compactFramePath(m.currentRootPath()), order)
+ line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order)
if m.searchQuery != "" {
line += " | filter:" + m.searchQuery
}
if m.statusMessage != "" {
line += " | " + m.statusMessage
}
+ if m.lastKeyDebug != "" {
+ line += " | " + m.lastKeyDebug
+ }
width := m.width
if width <= 0 {
width = 80
@@ -87,10 +92,40 @@ func (m Model) helpOverlay() string {
if width <= 0 {
width = 80
}
- help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches p pause r reset baseline o order ? help"
+ help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches space/p pause r reset baseline o order ? help"
return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width))
}
+func (m Model) selectionStatusLine() string {
+ width := m.width
+ if width <= 0 {
+ width = 80
+ }
+ mode := "LIVE"
+ if m.paused {
+ mode = "PAUSED"
+ }
+ if len(m.frames) == 0 {
+ line := fmt.Sprintf("[%s] sel:none | arrows/hjkl navigate | enter zoom | / filter", mode)
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
+ }
+ selIdx := m.selectedIdx
+ if selIdx < 0 || selIdx >= len(m.frames) {
+ selIdx = 0
+ }
+ frame := m.frames[selIdx]
+ systemShare := frame.Percent
+ if m.globalTotal > 0 {
+ systemShare = percentOfTotal(frame.Total, m.globalTotal)
+ }
+ line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total:%d | %.2f%% system",
+ mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, frame.Total, systemShare)
+ if m.searchQuery != "" {
+ line += " | filter:" + m.searchQuery
+ }
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
+}
+
func (m Model) currentFieldPresetLabel() string {
if len(m.fieldPresets) == 0 {
return "n/a"
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index cca2fe5..b205d33 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -79,6 +79,7 @@ type Model struct {
subtreeSet map[int]bool
showHelp bool
statusMessage string
+ lastKeyDebug string
fieldPresets [][]string
fieldIndex int
@@ -86,8 +87,11 @@ type Model struct {
animation AnimationState
animating bool
paused bool
- isDark bool
- keys flameKeyMap
+ // hasNavigableSnapshot flips once we have at least one selectable non-root
+ // frame. Paused mode can still bootstrap snapshots until then.
+ hasNavigableSnapshot bool
+ isDark bool
+ keys flameKeyMap
}
// tuiFrame stores one terminal flamegraph frame cell.
@@ -159,56 +163,78 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyPressMsg:
if m.searchActive {
+ handled := false
switch msg.String() {
case "esc":
+ handled = true
m.clearSearch()
+ m.recordKeyDebug(msg, handled, false)
return m, nil
case "enter":
+ handled = true
m.applySearchQuery(m.searchInput.Value())
m.searchActive = false
m.searchInput.Blur()
+ m.recordKeyDebug(msg, handled, false)
return m, nil
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
_ = cmd
+ m.recordKeyDebug(msg, true, false)
return m, nil
}
prev := m.selectedIdx
+ handled := false
switch {
case isSearchOpenKey(msg):
+ handled = true
m.openSearch()
case isNextMatchKey(msg):
+ handled = true
m.jumpMatch(1)
case isPrevMatchKey(msg):
+ handled = true
m.jumpMatch(-1)
case isPauseKey(msg):
+ handled = true
m.togglePause()
case isResetBaselineKey(msg):
+ handled = true
m.resetBaseline()
case isCycleOrderKey(msg):
+ handled = true
m.cycleFieldOrder()
case isHelpToggleKey(msg):
+ handled = true
m.toggleHelp()
case isZoomInKey(msg, m.keys):
+ handled = true
m.zoomIn()
case isZoomUndoKey(msg, m.keys):
+ handled = true
m.zoomUndo()
case isZoomResetKey(msg, m.keys):
+ handled = true
m.zoomReset()
case isMoveShallowerKey(msg, m.keys):
- m.moveVerticalWithFallback(-1, 1)
+ handled = true
+ m.moveVerticalWithFallback(-1, 1, -1)
case isMoveDeeperKey(msg, m.keys):
- m.moveVerticalWithFallback(1, -1)
+ handled = true
+ m.moveVerticalWithFallback(1, -1, 1)
case isPrevSiblingKey(msg, m.keys):
+ handled = true
m.moveSibling(-1)
case isNextSiblingKey(msg, m.keys):
+ handled = true
m.moveSibling(1)
}
if m.selectedIdx != prev {
m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
}
+ m.recordKeyDebug(msg, handled, m.selectedIdx != prev)
}
return m, nil
}
@@ -251,6 +277,7 @@ func (m Model) View() tea.View {
if m.snapshot != nil && len(m.frames) == 0 {
content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion))
}
+ content += "\n" + m.selectionStatusLine()
if m.showHelp {
content += "\n" + m.helpOverlay()
}
@@ -273,6 +300,7 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) {
m.filterVisible = make(map[int]bool)
m.animation = NewAnimationState(30, 6.0, 1.0)
m.animating = false
+ m.hasNavigableSnapshot = false
}
// RefreshFromLiveTrie loads a new snapshot when the source version changes.
@@ -280,7 +308,8 @@ func (m *Model) RefreshFromLiveTrie() bool {
if m.liveTrie == nil {
return false
}
- if m.paused {
+ // Keep bootstrapping while paused until we have a navigable snapshot.
+ if m.paused && m.snapshot != nil && m.hasNavigableSnapshot {
return false
}
version := m.liveTrie.Version()
@@ -359,6 +388,9 @@ func (m *Model) rebuildFrames(animate bool) {
m.animating = false
m.frames = append(m.frames[:0], m.targetFrames...)
}
+ if len(m.frames) > 1 {
+ m.hasNavigableSnapshot = true
+ }
m.clampSelection()
m.recomputeFilterState()
m.ensureSelectionNavigable()
@@ -451,27 +483,33 @@ func (m *Model) moveVertical(delta int) {
m.selectedIdx = best
}
-func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta int) {
+func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta, traversalDelta int) {
before := m.selectedIdx
m.moveVertical(primaryDelta)
if m.selectedIdx == before && fallbackDelta != 0 {
m.moveVertical(fallbackDelta)
}
+ if m.selectedIdx == before && traversalDelta != 0 {
+ m.moveTraversal(traversalDelta)
+ }
}
func (m *Model) moveSibling(delta int) {
if len(m.frames) == 0 {
return
}
+ before := m.selectedIdx
m.clampSelection()
m.ensureSelectionNavigable()
current := m.frames[m.selectedIdx]
siblings := m.framesAtDepth(current.Depth)
if len(siblings) <= 1 {
+ m.moveTraversal(delta)
return
}
pos := indexOf(siblings, m.selectedIdx)
if pos < 0 {
+ m.moveTraversal(delta)
return
}
next := pos + delta
@@ -482,6 +520,9 @@ func (m *Model) moveSibling(delta int) {
next = len(siblings) - 1
}
m.selectedIdx = siblings[next]
+ if m.selectedIdx == before {
+ m.moveTraversal(delta)
+ }
}
func framesAtDepth(frames []tuiFrame, depth int) []int {
@@ -602,6 +643,67 @@ func (m *Model) ensureSelectionNavigable() {
}
}
+func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) {
+ keyID := keyString(msg)
+ if keyID == "" {
+ keyID = fmt.Sprintf("code:%d", msg.Code)
+ }
+ sel := "-"
+ selIdx := m.selectedIdx
+ if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) {
+ sel = compactFramePath(m.frames[m.selectedIdx].Path)
+ }
+ m.lastKeyDebug = fmt.Sprintf("dbg frames=%d idx=%d key=%q code=%d handled=%t moved=%t sel=%s", len(m.frames), selIdx, keyID, msg.Code, handled, moved, sel)
+}
+
+func (m *Model) moveTraversal(delta int) {
+ if len(m.frames) == 0 || delta == 0 {
+ return
+ }
+ order := m.visibleTraversalOrder()
+ if len(order) == 0 {
+ return
+ }
+ pos := indexOf(order, m.selectedIdx)
+ if pos < 0 {
+ pos = 0
+ }
+ next := pos + delta
+ if next < 0 {
+ next = 0
+ }
+ if next >= len(order) {
+ next = len(order) - 1
+ }
+ m.selectedIdx = order[next]
+}
+
+func (m Model) visibleTraversalOrder() []int {
+ indices := make([]int, 0, len(m.frames))
+ include := m.navigableFrameSet()
+ for idx := range m.frames {
+ if include != nil && !include[idx] {
+ continue
+ }
+ indices = append(indices, idx)
+ }
+ sort.Slice(indices, func(i, j int) bool {
+ left := m.frames[indices[i]]
+ right := m.frames[indices[j]]
+ if left.Depth != right.Depth {
+ return left.Depth < right.Depth
+ }
+ if left.Col != right.Col {
+ return left.Col < right.Col
+ }
+ if left.Row != right.Row {
+ return left.Row < right.Row
+ }
+ return indices[i] < indices[j]
+ })
+ return indices
+}
+
func keyString(msg tea.KeyPressMsg) string {
if s := msg.String(); s != "" {
return s
@@ -612,7 +714,10 @@ func keyString(msg tea.KeyPressMsg) string {
func isSearchOpenKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "/" }
func isNextMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "n" }
func isPrevMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "N" }
-func isPauseKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "p" }
+func isPauseKey(msg tea.KeyPressMsg) bool {
+ k := keyString(msg)
+ return k == "p" || k == " " || k == "space" || msg.Code == tea.KeySpace
+}
func isResetBaselineKey(msg tea.KeyPressMsg) bool {
return keyString(msg) == "r"
}
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index f58f890..7387ac6 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -2,6 +2,7 @@ package flamegraph
import (
"reflect"
+ "strings"
"testing"
coreflamegraph "ior/internal/flamegraph"
@@ -53,6 +54,59 @@ func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) {
}
}
+func TestRefreshFromLiveTrieAllowsInitialLoadWhilePaused(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+ m.paused = true
+
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected initial paused refresh to load first snapshot")
+ }
+ if m.snapshot == nil {
+ t.Fatalf("expected snapshot to be available after initial paused refresh")
+ }
+ if changed := m.RefreshFromLiveTrie(); changed {
+ t.Fatalf("expected subsequent paused refresh to be skipped once snapshot exists")
+ }
+}
+
+func TestRefreshFromLiveTriePausedBlocksAfterNavigableSnapshot(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+ m.paused = true
+ m.snapshot = &snapshotNode{Name: "root", Total: 1}
+ m.frames = []tuiFrame{
+ {Name: "root", Path: "root"},
+ {Name: "child", Path: "root" + pathSeparator + "child"},
+ }
+ m.hasNavigableSnapshot = true
+ m.lastVersion = 1
+
+ if changed := m.RefreshFromLiveTrie(); changed {
+ t.Fatalf("expected paused refresh to remain frozen once navigable snapshot exists")
+ }
+ if got, want := m.lastVersion, uint64(1); got != want {
+ t.Fatalf("expected version to remain unchanged while paused, got %d want %d", got, want)
+ }
+}
+
+func TestRefreshFromLiveTriePausedKeepsBootstrappingWithoutNavigableSnapshot(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ m := NewModel(trie)
+ m.paused = true
+ m.snapshot = &snapshotNode{Name: "root", Total: 1}
+ m.frames = []tuiFrame{{Name: "root", Path: "root"}}
+ m.hasNavigableSnapshot = false
+ m.lastVersion = 1
+
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected paused refresh to continue bootstrapping before navigation is possible")
+ }
+ if got, want := m.lastVersion, trie.Version(); got != want {
+ t.Fatalf("expected paused bootstrap refresh to track trie version, got %d want %d", got, want)
+ }
+}
+
func TestKeyboardNavigationDeepNarrowTree(t *testing.T) {
m := NewModel(nil)
m.frames = []tuiFrame{
@@ -106,6 +160,141 @@ func TestKeyboardNavigationShallowWideSiblings(t *testing.T) {
}
}
+func TestHorizontalTraversalFallbackFromRoot(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"},
+ {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"},
+ }
+ m.selectedIdx = 0
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected right arrow from root to move to first traversable frame, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"})
+ if m.selectedIdx != 2 {
+ t.Fatalf("expected vi right key to move to next frame, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected left arrow to move back to previous frame, got idx %d", m.selectedIdx)
+ }
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"})
+ if m.selectedIdx != 0 {
+ t.Fatalf("expected vi left key to move back to root, got idx %d", m.selectedIdx)
+ }
+}
+
+func TestPausedStateStillAllowsNavigation(t *testing.T) {
+ m := NewModel(nil)
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Path: "root"},
+ {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"},
+ }
+ m.paused = true
+ m.selectedIdx = 0
+
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ if m.selectedIdx != 1 {
+ t.Fatalf("expected navigation to work while paused, got idx %d", m.selectedIdx)
+ }
+}
+
+func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ coreflamegraph.SeedTestFlameData(trie)
+
+ m := NewModel(trie)
+ m.SetViewport(180, 40)
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected seeded fixture refresh to load frames")
+ }
+ if len(m.frames) < 2 {
+ t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames))
+ }
+
+ visited := map[int]bool{m.selectedIdx: true}
+ for i := 0; i < len(m.frames)*4; i++ {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ visited[m.selectedIdx] = true
+ }
+ for i := 0; i < len(m.frames)*4; i++ {
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
+ visited[m.selectedIdx] = true
+ }
+
+ if got, want := len(visited), len(m.frames); got != want {
+ t.Fatalf("expected arrow traversal to visit all frames: visited=%d frames=%d", got, want)
+ }
+ if !strings.Contains(m.View().Content, "sel:") {
+ t.Fatalf("expected view to expose selected-frame status line")
+ }
+}
+
+func TestLiveFixtureArrowTraversalWhileStreamingVisitsAllFrames(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ coreflamegraph.SeedTestLiveFlameData(trie, 0)
+
+ m := NewModel(trie)
+ m.SetViewport(180, 40)
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected initial refresh to load frames")
+ }
+ if len(m.frames) < 2 {
+ t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames))
+ }
+
+ selectedPath := func(model Model) string {
+ if len(model.frames) == 0 || model.selectedIdx < 0 || model.selectedIdx >= len(model.frames) {
+ return ""
+ }
+ return model.frames[model.selectedIdx].Path
+ }
+
+ visitedPaths := map[string]bool{selectedPath(m): true}
+ moves := 0
+ for i := 0; i < len(m.frames)*4; i++ {
+ trie.Reset()
+ coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1))
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected refresh after synthetic live ingest at step %d", i)
+ }
+ before := selectedPath(m)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
+ after := selectedPath(m)
+ if after != before {
+ moves++
+ }
+ visitedPaths[after] = true
+ }
+ for i := 0; i < len(m.frames)*4; i++ {
+ trie.Reset()
+ coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1+len(m.frames)*4))
+ if changed := m.RefreshFromLiveTrie(); !changed {
+ t.Fatalf("expected refresh after synthetic live ingest (reverse) at step %d", i)
+ }
+ before := selectedPath(m)
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
+ after := selectedPath(m)
+ if after != before {
+ moves++
+ }
+ visitedPaths[after] = true
+ }
+
+ if moves == 0 {
+ t.Fatalf("expected live-stream navigation to change selection at least once")
+ }
+ if len(visitedPaths) < 8 {
+ t.Fatalf("expected traversal across live updates to reach multiple frame paths, got %d", len(visitedPaths))
+ }
+}
+
func TestKeyboardNavigationSingleNodeClamped(t *testing.T) {
m := NewModel(nil)
m.frames = []tuiFrame{{Name: "root", Depth: 0, Col: 0, Path: "root"}}
@@ -350,9 +539,17 @@ func TestControlPauseToggle(t *testing.T) {
if !m.paused {
t.Fatalf("expected pause to toggle on")
}
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ if m.paused {
+ t.Fatalf("expected space key to toggle pause off")
+ }
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ if !m.paused {
+ t.Fatalf("expected space key to toggle pause on")
+ }
m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"})
if m.paused {
- t.Fatalf("expected pause to toggle off")
+ t.Fatalf("expected p key to toggle pause off")
}
}
@@ -374,6 +571,26 @@ func TestControlResetBaseline(t *testing.T) {
}
}
+func TestViewIncludesSelectionStatusBar(t *testing.T) {
+ m := NewModel(nil)
+ m.width = 120
+ m.height = 20
+ m.frames = []tuiFrame{
+ {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 100, Percent: 100, Path: "root"},
+ {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 40, Percent: 40, Path: "root" + pathSeparator + "child"},
+ }
+ m.selectedIdx = 1
+ m.globalTotal = 100
+
+ view := m.View().Content
+ if !strings.Contains(view, "[LIVE] sel:2/2 child") {
+ t.Fatalf("expected selection status bar to include selected frame info, got %q", view)
+ }
+ if !strings.Contains(view, "40.00% system") {
+ t.Fatalf("expected selection status bar to include selected share, got %q", view)
+ }
+}
+
func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) {
liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
m := NewModel(liveTrie)
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index 517929e..67ad66e 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -435,10 +435,10 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat
}
if isSelected {
- selectedBg := lipgloss.Color("99")
+ selectedBg := lipgloss.Color("129")
selectedFg := lipgloss.Color("15")
if !isDark {
- selectedBg = lipgloss.Color("93")
+ selectedBg = lipgloss.Color("129")
selectedFg = lipgloss.Color("15")
}
return base.Background(selectedBg).Foreground(selectedFg).Bold(true).Underline(true)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 7918c0f..0381784 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -166,6 +166,16 @@ func RunWithTraceStarter(starter TraceStarter) error {
return err
}
+// RunTestFlamesWithTraceStarter starts the TUI directly on dashboard/flame view
+// with a synthetic static flamegraph source.
+func RunTestFlamesWithTraceStarter(starter TraceStarter) error {
+ cfg := flags.Get()
+ model := newModelWithRuntimeConfig(1, 1, cfg.TUIExportEnable, starter)
+ program := tea.NewProgram(model)
+ _, err := program.Run()
+ return err
+}
+
// Model is the top-level Bubble Tea model that routes between PID picker and dashboard.
type Model struct {
screen Screen
@@ -195,6 +205,15 @@ type Model struct {
keyboardEnhancements tea.KeyboardEnhancementsMsg
keyboardEnhancementsKnown bool
+
+ lastKeyEventID string
+ lastKeyEventAt time.Time
+ lastKeyEventWasPress bool
+ // Some terminals emit release+press for a single physical key event.
+ // When we fallback-handle a release as a press, suppress the immediate
+ // matching press to avoid double-handling.
+ suppressPressKeyID string
+ suppressPressUntil time.Time
}
// NewModel creates the top-level TUI model.
@@ -269,6 +288,12 @@ func initialWindowSizeCmd() tea.Cmd {
// Update routes messages, transitions screens, and manages tracing startup state.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ normalizedMsg, ok := m.normalizeKeyEvent(msg)
+ if !ok {
+ return m, nil
+ }
+ msg = normalizedMsg
+
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -382,6 +407,87 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateActiveModel(msg)
}
+func (m *Model) normalizeKeyEvent(msg tea.Msg) (tea.Msg, bool) {
+ switch keyMsg := msg.(type) {
+ case tea.KeyPressMsg:
+ keyID := keyEventID(keyMsg)
+ if m.shouldSuppressPress(keyID) {
+ return nil, false
+ }
+ m.recordKeyEvent(keyMsg, true)
+ return keyMsg, true
+ case tea.KeyReleaseMsg:
+ pressMsg := tea.KeyPressMsg(keyMsg)
+ keyID := keyEventID(pressMsg)
+ if m.lastKeyEventWasPress && keyID != "" && keyID == m.lastKeyEventID && time.Since(m.lastKeyEventAt) <= 500*time.Millisecond {
+ // Some terminals emit both press+release; avoid handling release as a duplicate.
+ m.lastKeyEventWasPress = false
+ return nil, false
+ }
+ if !releaseHasIdentity(pressMsg) {
+ // Ignore release messages that don't carry enough identity information.
+ // Some terminals emit these before a usable press event.
+ return nil, false
+ }
+ // Fallback: treat release as press for terminals that only emit release events.
+ m.armPressSuppression(keyID)
+ m.recordKeyEvent(pressMsg, false)
+ return pressMsg, true
+ default:
+ return msg, true
+ }
+}
+
+func (m *Model) shouldSuppressPress(keyID string) bool {
+ if m.suppressPressKeyID == "" {
+ return false
+ }
+ if time.Now().After(m.suppressPressUntil) {
+ m.clearPressSuppression()
+ return false
+ }
+ if keyID == "" || keyID != m.suppressPressKeyID {
+ return false
+ }
+ m.clearPressSuppression()
+ return true
+}
+
+func (m *Model) armPressSuppression(keyID string) {
+ if keyID == "" {
+ return
+ }
+ // Keep this short so fast repeated key presses still work naturally.
+ m.suppressPressKeyID = keyID
+ m.suppressPressUntil = time.Now().Add(60 * time.Millisecond)
+}
+
+func (m *Model) clearPressSuppression() {
+ m.suppressPressKeyID = ""
+ m.suppressPressUntil = time.Time{}
+}
+
+func (m *Model) recordKeyEvent(msg tea.KeyPressMsg, wasPress bool) {
+ m.lastKeyEventID = keyEventID(msg)
+ m.lastKeyEventAt = time.Now()
+ m.lastKeyEventWasPress = wasPress
+}
+
+func keyEventID(msg tea.KeyPressMsg) string {
+ return fmt.Sprintf("code:%d/mod:%d", msg.Code, msg.Mod)
+}
+
+func releaseHasIdentity(msg tea.KeyPressMsg) bool {
+ if msg.Code != 0 {
+ return true
+ }
+ if msg.Text != "" {
+ return true
+ }
+ keyStr := msg.String()
+ return keyStr != "" && keyStr != "\x00"
+}
+
func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.screen {
case ScreenPIDPicker:
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 7d2a439..6cdc427 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -303,13 +303,107 @@ func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) {
m.width = 120
m.height = 30
- next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
updated := next.(Model)
if updated.screen != ScreenDashboard {
- t.Fatalf("expected flame pause key to keep dashboard screen, got %v", updated.screen)
+ t.Fatalf("expected flame space key to keep dashboard screen, got %v", updated.screen)
}
if !strings.Contains(updated.View().Content, "[PAUSED]") {
- t.Fatalf("expected flame pause key to toggle flame paused state")
+ t.Fatalf("expected flame space key to toggle flame paused state")
+ }
+}
+
+func TestFlameSpaceKeyReleaseFallbackTogglesPause(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ updated := next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key release fallback to toggle flame paused state")
+ }
+}
+
+func TestFlameSpacePressReleaseDoesNotDoubleTogglePause(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ updated := next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key press to pause flame")
+ }
+
+ next, _ = updated.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ updated = next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key release after key press to be ignored as duplicate")
+ }
+}
+
+func TestFlameSpaceReleasePressDoesNotDoubleTogglePause(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ updated := next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected key release fallback to pause flame")
+ }
+
+ next, _ = updated.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ updated = next.(Model)
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected immediate matching key press after release fallback to be ignored")
+ }
+}
+
+func TestNormalizeKeyEventReleaseFallbackSuppressesImmediatePressOnly(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ normalized, ok := m.normalizeKeyEvent(tea.KeyReleaseMsg{Code: tea.KeySpace, Text: " "})
+ if !ok {
+ t.Fatalf("expected release fallback to be handled")
+ }
+ if _, isPress := normalized.(tea.KeyPressMsg); !isPress {
+ t.Fatalf("expected release fallback to normalize to KeyPressMsg, got %T", normalized)
+ }
+
+ if normalized, ok = m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}); ok {
+ t.Fatalf("expected immediate matching press to be suppressed, got %T", normalized)
+ }
+
+ time.Sleep(70 * time.Millisecond)
+ if normalized, ok = m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}); !ok {
+ t.Fatalf("expected press to be accepted after suppression window")
+ }
+ if _, isPress := normalized.(tea.KeyPressMsg); !isPress {
+ t.Fatalf("expected accepted message to be KeyPressMsg, got %T", normalized)
+ }
+}
+
+func TestNormalizeKeyEventIgnoresUnidentifiedRelease(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+
+ if normalized, ok := m.normalizeKeyEvent(tea.KeyReleaseMsg{}); ok {
+ t.Fatalf("expected unidentified release to be ignored, got %T", normalized)
+ }
+
+ normalized, ok := m.normalizeKeyEvent(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ if !ok {
+ t.Fatalf("expected subsequent real key press to be handled")
+ }
+ if _, isPress := normalized.(tea.KeyPressMsg); !isPress {
+ t.Fatalf("expected normalized message to be KeyPressMsg, got %T", normalized)
}
}