diff options
Diffstat (limited to 'internal')
| -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 |
