diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 13:36:51 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 13:36:51 +0200 |
| commit | ef12ce837176bd21deb455eb50a6c839af02b510 (patch) | |
| tree | c262ceeda0b419236a4b0b1826df8eb5e418b852 | |
| parent | 10c5d48413afaef88626419d8c4bf9fbf6f1c902 (diff) | |
Add live flamegraph test modes and dynamic synthetic live feed
| -rw-r--r-- | internal/flags/flags.go | 4 | ||||
| -rw-r--r-- | internal/flags/flags_test.go | 20 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie.go | 8 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie_test.go | 64 | ||||
| -rw-r--r-- | internal/flamegraph/testfixture.go | 120 | ||||
| -rw-r--r-- | internal/ior.go | 98 | ||||
| -rw-r--r-- | internal/ior_mode_test.go | 259 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 37 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 39 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 119 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 219 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 4 | ||||
| -rw-r--r-- | internal/tui/tui.go | 106 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 100 |
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) } } |
