summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph/async_refresh_test.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-11 20:02:47 +0300
committerPaul Buetow <paul@buetow.org>2026-05-11 20:02:47 +0300
commit933be1ba2dbb7f6397a4112969bc85a4eac9d155 (patch)
tree1c9f66ee8321880f322b0ddf8030e64dc2af976b /internal/tui/flamegraph/async_refresh_test.go
parent662dcfd7ca96d0d4157f9d30b04518db5adfbe45 (diff)
speed up flame graph TUI under heavy event load
Move the per-tick snapshot refresh off the Bubble Tea update goroutine, add a frame ancestry index so navigation and filter helpers run in O(subtree) instead of O(frames), skip refresh and animation while the user is actively pressing keys, and memoize View() output. Keystrokes (pause, filter, navigate) now land within one frame even when the live trie ingests thousands of events per tick. - new SnapshotTree() on LiveTrie bypasses JSON marshal+unmarshal - RefreshFromLiveTrieCmd runs SnapshotTree + layout + ancestry on a background goroutine, coalesced via refreshInFlight, and returns a flameSnapshotReadyMsg the Update loop applies cheaply - driveWindow gate (250 ms after last key press) skips refresh dispatch and snaps frames directly to target without animation while the user is driving - View() caches its rendered string keyed on the inputs that affect output; cache is bypassed during animation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui/flamegraph/async_refresh_test.go')
-rw-r--r--internal/tui/flamegraph/async_refresh_test.go204
1 files changed, 204 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/async_refresh_test.go b/internal/tui/flamegraph/async_refresh_test.go
new file mode 100644
index 0000000..d6027de
--- /dev/null
+++ b/internal/tui/flamegraph/async_refresh_test.go
@@ -0,0 +1,204 @@
+package flamegraph
+
+import (
+ "testing"
+ "time"
+
+ coreflamegraph "ior/internal/flamegraph"
+ "ior/internal/types"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+func ingestTwoEventsForAsync(t *testing.T, trie *coreflamegraph.LiveTrie) {
+ t.Helper()
+ for i := 0; i < 2; i++ {
+ traceID := types.SYS_ENTER_READ
+ if i%2 == 0 {
+ traceID = types.SYS_ENTER_WRITE
+ }
+ pair := newBenchmarkPair("worker", traceID, uint32(1000+i), uint32(200000+i), "/srv/app")
+ trie.Ingest(pair)
+ pair.Recycle()
+ }
+}
+
+func TestRefreshFromLiveTrieCmdNilWhenNoTrie(t *testing.T) {
+ m := NewModel(nil)
+ if cmd := m.RefreshFromLiveTrieCmd(); cmd != nil {
+ t.Fatalf("expected nil command when liveTrie is nil")
+ }
+}
+
+func TestRefreshFromLiveTrieCmdProducesSnapshotReady(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 120
+ m.height = 30
+
+ cmd := m.RefreshFromLiveTrieCmd()
+ if cmd == nil {
+ t.Fatalf("expected a refresh command when trie has new events")
+ }
+ if !m.refreshInFlight {
+ t.Fatalf("expected refreshInFlight=true after dispatch")
+ }
+
+ msg := cmd()
+ ready, ok := msg.(flameSnapshotReadyMsg)
+ if !ok {
+ t.Fatalf("expected flameSnapshotReadyMsg, got %T", msg)
+ }
+ if ready.snapshot == nil {
+ t.Fatalf("expected snapshot in ready message")
+ }
+ if ready.layoutWidth != 120 || ready.layoutHeight != 30 {
+ t.Fatalf("ready msg layout = %dx%d, want 120x30", ready.layoutWidth, ready.layoutHeight)
+ }
+ if ready.version == 0 {
+ t.Fatalf("expected non-zero version after ingestion")
+ }
+ if len(ready.ancestry.parent) != len(ready.targetFrames) {
+ t.Fatalf("ancestry length %d != frames length %d", len(ready.ancestry.parent), len(ready.targetFrames))
+ }
+}
+
+func TestRefreshFromLiveTrieCmdCoalescesInFlight(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 80
+ m.height = 24
+
+ if first := m.RefreshFromLiveTrieCmd(); first == nil {
+ t.Fatalf("expected first cmd to dispatch")
+ }
+ if second := m.RefreshFromLiveTrieCmd(); second != nil {
+ t.Fatalf("expected second cmd to coalesce (return nil) while a refresh is in flight")
+ }
+}
+
+func TestRefreshFromLiveTrieCmdSkippedWhileUserDrives(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 80
+ m.height = 24
+
+ // Load an initial snapshot so the drive gate (which requires an existing
+ // snapshot to skip) takes effect.
+ if !m.RefreshFromLiveTrie() {
+ t.Fatalf("expected initial sync refresh to populate snapshot")
+ }
+ ingestTwoEventsForAsync(t, trie)
+
+ m.lastKeyAt = time.Now()
+ if cmd := m.RefreshFromLiveTrieCmd(); cmd != nil {
+ t.Fatalf("expected refresh to be skipped while user is actively pressing keys")
+ }
+
+ // Move the timestamp outside the drive window — should dispatch.
+ m.lastKeyAt = time.Now().Add(-2 * driveWindow)
+ if cmd := m.RefreshFromLiveTrieCmd(); cmd == nil {
+ t.Fatalf("expected refresh to dispatch once drive window expires")
+ }
+}
+
+func TestSnapshotReadyHandlerSnapsToTargetWhileDriving(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 120
+ m.height = 30
+ m.refreshInFlight = true
+
+ cmd := func() tea.Cmd {
+ liveTrie := trie
+ return func() tea.Msg {
+ tree, ver := liveTrie.SnapshotTree()
+ targetFrames := buildTerminalLayoutWithPath(tree, m.width, m.height, "")
+ ancestry := buildFrameAncestry(targetFrames)
+ return flameSnapshotReadyMsg{
+ version: ver,
+ layoutWidth: m.width,
+ layoutHeight: m.height,
+ snapshot: tree,
+ targetFrames: targetFrames,
+ ancestry: ancestry,
+ globalTotal: snapshotTotal(tree),
+ }
+ }
+ }()
+ msg := cmd().(flameSnapshotReadyMsg)
+
+ m.lastKeyAt = time.Now()
+ next, _ := m.handleSnapshotReady(msg)
+ post := next.(Model)
+ if post.animating {
+ t.Fatalf("expected snapshot ready to skip animation while user is driving")
+ }
+ if len(post.frames) != len(msg.targetFrames) {
+ t.Fatalf("expected frames to snap directly to target (len %d != %d)", len(post.frames), len(msg.targetFrames))
+ }
+}
+
+func TestViewCacheReusesContentWhenStateUnchanged(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 120
+ m.height = 30
+ if !m.RefreshFromLiveTrie() {
+ t.Fatalf("expected initial refresh to populate snapshot")
+ }
+
+ // Drain any pending animation so the cache path is exercised.
+ for m.animating {
+ nextModel, _ := m.Update(animTickMsg{})
+ m = nextModel.(Model)
+ }
+
+ first := m.View().Content
+ if !m.viewCache.valid {
+ t.Fatalf("expected viewCache to be marked valid after first View() call")
+ }
+ cachedAddr := &m.viewCache.content
+
+ second := m.View().Content
+ if first != second {
+ t.Fatalf("expected identical content from two consecutive View() calls when state unchanged")
+ }
+ if cachedAddr != &m.viewCache.content {
+ t.Fatalf("expected cache content pointer to remain stable on a hit")
+ }
+}
+
+func BenchmarkRecomputeFilterState(b *testing.B) {
+ // Performance target: 5000-frame filter recompute should remain below 200us/op.
+ cases := []struct {
+ label string
+ frameCount int
+ }{
+ {label: "1000frames", frameCount: 1000},
+ {label: "5000frames", frameCount: 5000},
+ }
+ queries := []string{"sys_", "read", "/srv"}
+
+ for _, tc := range cases {
+ frames := benchmarkFramesForCount(tc.frameCount)
+ decorateFramesForSearch(frames)
+ b.Run(tc.label, func(b *testing.B) {
+ model := NewModel(nil)
+ model.frames = frames
+ model.ancestry = buildFrameAncestry(frames)
+ model.selectedIdx = midDepthFrameIndex(frames)
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ model.applySearchQuery(queries[i%len(queries)])
+ benchIntSink = len(model.matchIndices)
+ }
+ })
+ }
+}