summaryrefslogtreecommitdiff
path: root/internal
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 /internal
parent10c5d48413afaef88626419d8c4bf9fbf6f1c902 (diff)
Add live flamegraph test modes and dynamic synthetic live feed
Diffstat (limited to 'internal')
-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,