summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/flamegraph/livetrie.go65
-rw-r--r--internal/flamegraph/livetrie_test.go12
-rw-r--r--internal/tui/dashboard/model.go17
-rw-r--r--internal/tui/flamegraph/ancestry.go127
-rw-r--r--internal/tui/flamegraph/async_refresh_test.go204
-rw-r--r--internal/tui/flamegraph/model.go307
-rw-r--r--internal/tui/flamegraph/search.go6
7 files changed, 684 insertions, 54 deletions
diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go
index 51f3697..5487b9f 100644
--- a/internal/flamegraph/livetrie.go
+++ b/internal/flamegraph/livetrie.go
@@ -19,11 +19,11 @@ const (
liveTrieVisibleChildrenFallbackMaxDepth = 1
)
-type trieSnapshot struct {
+type SnapshotNode struct {
Name string `json:"n"`
Value uint64 `json:"v"`
Total uint64 `json:"t"`
- Children []*trieSnapshot `json:"c,omitempty"`
+ Children []*SnapshotNode `json:"c,omitempty"`
}
// LiveTrie is a thread-safe, append-only trie used for live flamegraph snapshots.
@@ -39,6 +39,13 @@ type LiveTrie struct {
cacheMu sync.Mutex
cacheVersion uint64
cacheJSON []byte
+
+ // Tree cache lets the TUI fetch the snapshot tree without a JSON
+ // marshal+unmarshal round-trip. Built lazily; invalidated on reset and
+ // on field/metric reconfiguration.
+ treeCacheMu sync.Mutex
+ treeVersion uint64
+ treeCache *SnapshotNode
}
// NewLiveTrie constructs an empty live trie with the configured frame/count fields.
@@ -75,6 +82,10 @@ func (lt *LiveTrie) invalidateCache() {
lt.cacheVersion = 0
lt.cacheJSON = nil
lt.cacheMu.Unlock()
+ lt.treeCacheMu.Lock()
+ lt.treeVersion = 0
+ lt.treeCache = nil
+ lt.treeCacheMu.Unlock()
}
// Ingest adds one event pair into the live trie.
@@ -161,6 +172,8 @@ func (lt *LiveTrie) Version() uint64 {
}
// SnapshotJSON returns a compact JSON snapshot for the current trie version.
+// Layered on top of SnapshotTree so the tree-building work is shared with
+// callers that want the typed form directly.
func (lt *LiveTrie) SnapshotJSON() ([]byte, uint64) {
version := lt.Version()
lt.cacheMu.Lock()
@@ -171,12 +184,7 @@ func (lt *LiveTrie) SnapshotJSON() ([]byte, uint64) {
}
lt.cacheMu.Unlock()
- lt.mu.RLock()
- version = lt.version.Load()
- rootTotal := subtreeTotal(lt.root)
- snapshot := buildSnapshot(lt.root, 0, liveTrieMinFraction, rootTotal)
- lt.mu.RUnlock()
-
+ snapshot, version := lt.SnapshotTree()
payload, err := json.Marshal(snapshot)
if err != nil {
return []byte(`{}`), version
@@ -190,6 +198,37 @@ func (lt *LiveTrie) SnapshotJSON() ([]byte, uint64) {
return payload, version
}
+// SnapshotTree returns the live trie snapshot as a typed node tree, bypassing
+// the JSON round-trip. The pointer is safe to retain — buildSnapshot allocates
+// fresh nodes per snapshot, and the trie never mutates a previously returned
+// tree. The TUI uses this on a background goroutine so per-tick refreshes don't
+// block the Bubble Tea update loop.
+func (lt *LiveTrie) SnapshotTree() (*SnapshotNode, uint64) {
+ version := lt.Version()
+ lt.treeCacheMu.Lock()
+ if lt.treeVersion == version && lt.treeCache != nil {
+ tree := lt.treeCache
+ lt.treeCacheMu.Unlock()
+ return tree, version
+ }
+ lt.treeCacheMu.Unlock()
+
+ lt.mu.RLock()
+ version = lt.version.Load()
+ rootTotal := subtreeTotal(lt.root)
+ tree := buildSnapshot(lt.root, 0, liveTrieMinFraction, rootTotal)
+ lt.mu.RUnlock()
+
+ lt.treeCacheMu.Lock()
+ // Only commit if no concurrent caller stored a newer version.
+ if version >= lt.treeVersion {
+ lt.treeVersion = version
+ lt.treeCache = tree
+ }
+ lt.treeCacheMu.Unlock()
+ return tree, version
+}
+
func eventPairToRecord(ep *event.Pair) IterRecord {
return IterRecord{
Path: ep.FileName(),
@@ -263,18 +302,18 @@ func subtreeTotal(node *trieNode) uint64 {
return total
}
-func buildSnapshot(node *trieNode, depth int, minFraction float64, rootTotal uint64) *trieSnapshot {
+func buildSnapshot(node *trieNode, depth int, minFraction float64, rootTotal uint64) *SnapshotNode {
snapshot, _ := buildSnapshotWithTotal(node, depth, minFraction, rootTotal, false)
return snapshot
}
type childSnapshotState struct {
node *trieNode
- snapshot *trieSnapshot
+ snapshot *SnapshotNode
total uint64
}
-func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64, forceKeep bool) (*trieSnapshot, uint64) {
+func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, rootTotal uint64, forceKeep bool) (*SnapshotNode, uint64) {
total := node.value
children := slices.Clone(node.children)
slices.SortFunc(children, func(a, b *trieNode) int {
@@ -297,14 +336,14 @@ func buildSnapshotWithTotal(node *trieNode, depth int, minFraction float64, root
}
ensureFallbackVisibleChildren(childStates, depth, minFraction, rootTotal)
- childSnapshots := make([]*trieSnapshot, 0, len(childStates))
+ childSnapshots := make([]*SnapshotNode, 0, len(childStates))
for _, child := range childStates {
if child.snapshot != nil {
childSnapshots = append(childSnapshots, child.snapshot)
}
}
- snapshot := &trieSnapshot{
+ snapshot := &SnapshotNode{
Name: node.name,
Value: node.value,
Total: total,
diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go
index 5d4ce47..6a825c0 100644
--- a/internal/flamegraph/livetrie_test.go
+++ b/internal/flamegraph/livetrie_test.go
@@ -371,7 +371,7 @@ func TestLiveTrieConcurrentIngestAndSnapshot(t *testing.T) {
defer wg.Done()
for i := 0; i < 500; i++ {
payload, _ := lt.SnapshotJSON()
- var snap trieSnapshot
+ var snap SnapshotNode
if err := json.Unmarshal(payload, &snap); err != nil {
t.Errorf("unmarshal snapshot: %v", err)
return
@@ -416,7 +416,7 @@ func TestLiveTrieStressHighRateConcurrentSnapshot(t *testing.T) {
return
case <-ticker.C:
payload, _ := lt.SnapshotJSON()
- var snap trieSnapshot
+ var snap SnapshotNode
if err := json.Unmarshal(payload, &snap); err != nil {
errCh <- fmt.Errorf("snapshot json invalid: %w", err)
return
@@ -486,17 +486,17 @@ func newTestPair(comm string, pid uint32, tid uint32, path string, duration uint
return pair
}
-func decodeLiveSnapshot(t *testing.T, lt *LiveTrie) trieSnapshot {
+func decodeLiveSnapshot(t *testing.T, lt *LiveTrie) SnapshotNode {
t.Helper()
payload, _ := lt.SnapshotJSON()
- var snap trieSnapshot
+ var snap SnapshotNode
if err := json.Unmarshal(payload, &snap); err != nil {
t.Fatalf("unmarshal snapshot: %v", err)
}
return snap
}
-func findSnapshotPath(t *testing.T, root *trieSnapshot, names ...string) *trieSnapshot {
+func findSnapshotPath(t *testing.T, root *SnapshotNode, names ...string) *SnapshotNode {
t.Helper()
node := root
for _, name := range names {
@@ -508,7 +508,7 @@ func findSnapshotPath(t *testing.T, root *trieSnapshot, names ...string) *trieSn
return node
}
-func findSnapshotChild(node *trieSnapshot, name string) *trieSnapshot {
+func findSnapshotChild(node *SnapshotNode, name string) *SnapshotNode {
for _, child := range node.Children {
if child.Name == name {
return child
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 79e3b38..df8f9f1 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -232,14 +232,17 @@ func (m Model) handleFlameTick() (tea.Model, tea.Cmd) {
if !m.focused || m.activeTab != TabFlame {
return m, nil
}
- var animCmd tea.Cmd
- if m.liveTrie != nil && m.flamegraphModel.RefreshFromLiveTrie() {
- animCmd = m.flamegraphModel.AnimationCmd()
- }
- if animCmd == nil {
- return m, flameTickCmd()
+ // Always re-arm the 200 ms tick. The snapshot refresh itself runs on a
+ // background goroutine via RefreshFromLiveTrieCmd, so even when a previous
+ // refresh is still in flight (the cmd returns nil and skips), the tick
+ // channel stays alive.
+ cmds := []tea.Cmd{flameTickCmd()}
+ if m.liveTrie != nil {
+ if refreshCmd := m.flamegraphModel.RefreshFromLiveTrieCmd(); refreshCmd != nil {
+ cmds = append(cmds, refreshCmd)
+ }
}
- return m, tea.Batch(flameTickCmd(), animCmd)
+ return m, tea.Batch(cmds...)
}
func (m Model) handleBubbleTick() (tea.Model, tea.Cmd) {
diff --git a/internal/tui/flamegraph/ancestry.go b/internal/tui/flamegraph/ancestry.go
new file mode 100644
index 0000000..4065909
--- /dev/null
+++ b/internal/tui/flamegraph/ancestry.go
@@ -0,0 +1,127 @@
+package flamegraph
+
+import "strings"
+
+// frameAncestry stores parent/child relationships between frames keyed by their
+// slice index. It is rebuilt whenever the frames slice is rebuilt, and reused
+// across keystrokes so subtree and filter-visibility lookups run in O(subtree)
+// time instead of O(frames) string scans.
+type frameAncestry struct {
+ parent []int
+ children [][]int
+}
+
+// buildFrameAncestry derives parent/child links for the supplied frame slice
+// using each frame's Path string. Robust to any ordering — the layout pass
+// emits depth-first frames, but withZoomLineage appends ancestor frames at the
+// tail, so contiguous-range tricks are not safe.
+func buildFrameAncestry(frames []tuiFrame) frameAncestry {
+ n := len(frames)
+ ancestry := frameAncestry{
+ parent: make([]int, n),
+ children: make([][]int, n),
+ }
+ if n == 0 {
+ return ancestry
+ }
+
+ byPath := make(map[string]int, n)
+ for idx, frame := range frames {
+ byPath[frame.Path] = idx
+ ancestry.parent[idx] = -1
+ }
+ for idx, frame := range frames {
+ sep := strings.LastIndexByte(frame.Path, pathSeparatorByte)
+ if sep < 0 {
+ continue
+ }
+ parentPath := frame.Path[:sep]
+ parentIdx, ok := byPath[parentPath]
+ if !ok {
+ continue
+ }
+ ancestry.parent[idx] = parentIdx
+ ancestry.children[parentIdx] = append(ancestry.children[parentIdx], idx)
+ }
+ return ancestry
+}
+
+// subtreeSetUsingAncestry fills `set` with the selected frame, every descendant
+// reachable through the child adjacency list, and every ancestor reachable
+// through the parent chain. Matches the visibility contract of the legacy
+// path-prefix walk in computeSubtreeSetInto.
+//
+// If `ancestry` is stale relative to `frames` (different length), it is rebuilt
+// in place. The hot path through the model keeps ancestry warm via
+// rebuildFrames, so the fallback only fires in tests that mutate frames
+// directly.
+func subtreeSetUsingAncestry(frames []tuiFrame, selectedIdx int, ancestry frameAncestry, set map[int]bool) map[int]bool {
+ if set == nil {
+ set = make(map[int]bool)
+ } else {
+ for idx := range set {
+ delete(set, idx)
+ }
+ }
+ if selectedIdx < 0 || selectedIdx >= len(frames) {
+ return set
+ }
+ if len(ancestry.parent) != len(frames) {
+ ancestry = buildFrameAncestry(frames)
+ }
+ markSubtree(ancestry, selectedIdx, set)
+ for cur := ancestry.parent[selectedIdx]; cur >= 0; cur = ancestry.parent[cur] {
+ set[cur] = true
+ }
+ return set
+}
+
+// filterVisibleSetUsingAncestry fills `set` with every match plus its full
+// descendant subtree and ancestor chain — same semantics as the legacy
+// computeFilterVisibleSetInto walk, but in O(sum-of-subtree-sizes) instead of
+// O(frames × matches). Falls back to building ancestry on the fly when the
+// supplied index is stale (see subtreeSetUsingAncestry).
+func filterVisibleSetUsingAncestry(frames []tuiFrame, matchSet map[int]bool, ancestry frameAncestry, set map[int]bool) map[int]bool {
+ if set == nil {
+ set = make(map[int]bool)
+ } else {
+ for idx := range set {
+ delete(set, idx)
+ }
+ }
+ if len(matchSet) == 0 {
+ return set
+ }
+ if len(ancestry.parent) != len(frames) {
+ ancestry = buildFrameAncestry(frames)
+ }
+ for matchIdx := range matchSet {
+ if matchIdx < 0 || matchIdx >= len(frames) {
+ continue
+ }
+ markSubtree(ancestry, matchIdx, set)
+ for cur := ancestry.parent[matchIdx]; cur >= 0; cur = ancestry.parent[cur] {
+ if set[cur] {
+ break
+ }
+ set[cur] = true
+ }
+ }
+ return set
+}
+
+// markSubtree marks a frame and every descendant. Uses an iterative stack
+// to keep allocations bounded for deep trees.
+func markSubtree(ancestry frameAncestry, root int, set map[int]bool) {
+ stack := []int{root}
+ for len(stack) > 0 {
+ last := len(stack) - 1
+ idx := stack[last]
+ stack = stack[:last]
+ if set[idx] {
+ continue
+ }
+ set[idx] = true
+ stack = append(stack, ancestry.children[idx]...)
+ }
+}
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)
+ }
+ })
+ }
+}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 0552a4f..6930170 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -2,13 +2,13 @@ package flamegraph
import (
"cmp"
- "encoding/json"
"fmt"
"image/color"
"slices"
"strings"
"time"
+ coreflamegraph "ior/internal/flamegraph"
common "ior/internal/tui/common"
"charm.land/bubbles/v2/key"
@@ -16,19 +16,68 @@ import (
tea "charm.land/bubbletea/v2"
)
-type snapshotNode struct {
- Name string `json:"n"`
- Value uint64 `json:"v"`
- Total uint64 `json:"t"`
- Children []*snapshotNode `json:"c,omitempty"`
-}
+// snapshotNode aliases the live trie's snapshot type so the TUI can consume
+// trees directly via SnapshotTree() without paying for a JSON marshal+unmarshal
+// round-trip. The JSON tags on SnapshotNode keep the legacy SnapshotJSON path
+// working unchanged.
+type snapshotNode = coreflamegraph.SnapshotNode
type animTickMsg struct{}
+// flameViewCacheKey captures the View() inputs that determine the rendered
+// output. When two consecutive calls produce the same key, the cached content
+// string is reused instead of re-running RenderTerminalView.
+type flameViewCacheKey struct {
+ version uint64
+ selectedIdx int
+ width int
+ height int
+ framesLen int
+ matchCount int
+ visibleCount int
+ searchQuery string
+ statusMessage string
+ zoomPath string
+ searchActive bool
+ showHelp bool
+ paused bool
+ isDark bool
+}
+
+type flameViewCache struct {
+ key flameViewCacheKey
+ content string
+ valid bool
+}
+
+// flameSnapshotReadyMsg carries the result of a background snapshot+layout
+// job. It is emitted by RefreshFromLiveTrieCmd and consumed by Update so the
+// Bubble Tea goroutine can swap in the new state without blocking on JSON or
+// frame layout work.
+type flameSnapshotReadyMsg struct {
+ version uint64
+ layoutWidth int
+ layoutHeight int
+ zoomPath string
+ snapshot *snapshotNode
+ zoomRoot *snapshotNode
+ targetFrames []tuiFrame
+ ancestry frameAncestry
+ globalTotal uint64
+}
+
const animFrameDuration = 33 * time.Millisecond
const flameKeyDebugEnabled = false
+// driveWindow defines how recently a key must have been pressed to count as
+// "user is actively driving". While inside this window, the flamegraph defers
+// snapshot refresh and skips animation so keystrokes land without waiting on
+// JSON+layout work or a 1-second animation chain.
+const driveWindow = 250 * time.Millisecond
+
// LiveTrieSource is the minimal trie contract needed by the flamegraph TUI model.
+// SnapshotJSON is retained for tests and external callers; SnapshotTree is the
+// fast path used by the model's background refresh.
type LiveTrieSource interface {
Fields() []string
CountField() string
@@ -37,6 +86,7 @@ type LiveTrieSource interface {
Reset()
Version() uint64
SnapshotJSON() ([]byte, uint64)
+ SnapshotTree() (*snapshotNode, uint64)
}
type zoomState struct {
@@ -78,8 +128,14 @@ type Model struct {
snapshot *snapshotNode
globalTotal uint64
+ // refreshInFlight is true while a background snapshot+layout job is
+ // running. It coalesces flameTickMsg dispatches so we never queue more
+ // than one snapshot rebuild concurrently.
+ refreshInFlight bool
+
frames []tuiFrame
targetFrames []tuiFrame
+ ancestry frameAncestry
width int
height int
@@ -106,6 +162,19 @@ type Model struct {
animation AnimationState
animating bool
paused bool
+
+ // lastKeyAt records when the user most recently pressed a key. While the
+ // user is actively driving the view (lastKeyAt within driveWindow ago),
+ // the background snapshot refresh is suppressed and snapshot-ready
+ // messages snap directly to target frames without animating. This keeps
+ // keystrokes feeling instant under heavy event load.
+ lastKeyAt time.Time
+
+ // viewCache memoizes the last rendered string keyed on the inputs that
+ // produce it. Bubble Tea may call View() multiple times per state change;
+ // caching avoids re-running RenderTerminalView when nothing visible has
+ // moved. Lives behind a pointer so the value-receiver View() can update it.
+ viewCache *flameViewCache
// hasNavigableSnapshot flips once we have at least one selectable non-root frame.
hasNavigableSnapshot bool
isDark bool
@@ -139,6 +208,7 @@ func NewModel(liveTrie LiveTrieSource) Model {
filterVisible: make(map[int]bool),
subtreeSet: make(map[int]bool),
searchInput: searchInput,
+ viewCache: &flameViewCache{},
fieldPresets: [][]string{
{"comm", "tracepoint", "path"},
{"path", "tracepoint", "comm"},
@@ -171,8 +241,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.animating = m.animation.Tick(0)
m.frames = m.animation.CurrentFrames()
m.clampSelection()
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
return m, m.animationTickCmd()
+ case flameSnapshotReadyMsg:
+ return m.handleSnapshotReady(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
@@ -182,6 +254,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_ = m.handleMouseClick(msg)
return m, nil
case tea.KeyPressMsg:
+ // Stamp every keypress so RefreshFromLiveTrieCmd and the
+ // snapshot-ready handler can detect that the user is actively driving
+ // the view and defer / unanimate accordingly.
+ m.lastKeyAt = time.Now()
if m.searchActive {
handled := false
switch msg.String() {
@@ -261,13 +337,61 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.jumpToRoot()
}
if m.selectedIdx != prev {
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
}
m.recordKeyDebug(msg, handled, m.selectedIdx != prev)
}
return m, nil
}
+// handleSnapshotReady applies the result of a background snapshot+layout job.
+// Discards the result if viewport or zoom changed while the job was in flight
+// (the next tick will dispatch a fresh refresh), or if the user paused after a
+// snapshot already exists. Always clears refreshInFlight so subsequent ticks
+// can dispatch the next refresh.
+func (m Model) handleSnapshotReady(msg flameSnapshotReadyMsg) (tea.Model, tea.Cmd) {
+ m.refreshInFlight = false
+ if msg.snapshot == nil {
+ return m, nil
+ }
+ if msg.layoutWidth != m.width || msg.layoutHeight != m.height || msg.zoomPath != m.zoomPath {
+ return m, nil
+ }
+ if m.paused && m.snapshot != nil {
+ return m, nil
+ }
+
+ prevPath := ""
+ if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) {
+ prevPath = m.frames[m.selectedIdx].Path
+ }
+
+ m.snapshot = msg.snapshot
+ m.globalTotal = msg.globalTotal
+ m.zoomRoot = msg.zoomRoot
+ m.lastVersion = msg.version
+ // Snap directly to target frames while the user is actively pressing keys
+ // — animation would just add latency on top of the work the user wants to
+ // see. Animation resumes on the next refresh after the drive window
+ // expires.
+ animate := !m.userDriving()
+ m.applyTargetFrames(msg.targetFrames, msg.ancestry, prevPath, animate)
+ if !m.animating {
+ return m, nil
+ }
+ return m, m.animationTickCmd()
+}
+
+// userDriving reports whether the user has pressed a key within the recent
+// drive window. Used to skip snapshot refresh and animation while keystrokes
+// are arriving.
+func (m Model) userDriving() bool {
+ if m.lastKeyAt.IsZero() {
+ return false
+ }
+ return time.Since(m.lastKeyAt) < driveWindow
+}
+
// ConsumesKey reports whether the flamegraph should handle a key press before
// dashboard- or app-level shortcuts.
func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
@@ -299,8 +423,27 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
}
}
-// View renders the flamegraph viewport.
+// View renders the flamegraph viewport. Caches the rendered string keyed on
+// the inputs that affect output; skips the cache while animating (frames
+// change every 33 ms anyway, so cache hits are impossible).
func (m Model) View() tea.View {
+ if !m.animating && m.viewCache != nil {
+ key := m.currentViewCacheKey()
+ if m.viewCache.valid && m.viewCache.key == key {
+ return tea.NewView(m.viewCache.content)
+ }
+ content := m.renderViewContent()
+ m.viewCache.key = key
+ m.viewCache.content = content
+ m.viewCache.valid = true
+ return tea.NewView(content)
+ }
+ return tea.NewView(m.renderViewContent())
+}
+
+// renderViewContent assembles the flamegraph string. Pure function over Model
+// state — pulled out so View() can decide whether to memoize the result.
+func (m Model) renderViewContent() string {
extraLines := 1 // selection status line
if m.showHelp {
extraLines++
@@ -322,7 +465,29 @@ func (m Model) View() tea.View {
if m.showHelp {
content += "\n" + m.helpOverlay()
}
- return tea.NewView(content)
+ return content
+}
+
+// currentViewCacheKey snapshots every Model field that influences View()
+// output. If any of these differ between successive View() invocations, the
+// cache misses and the content is rebuilt.
+func (m Model) currentViewCacheKey() flameViewCacheKey {
+ return flameViewCacheKey{
+ version: m.lastVersion,
+ selectedIdx: m.selectedIdx,
+ width: m.width,
+ height: m.height,
+ framesLen: len(m.frames),
+ matchCount: len(m.matchIndices),
+ visibleCount: len(m.filterVisible),
+ searchQuery: m.searchQuery,
+ statusMessage: m.statusMessage,
+ zoomPath: m.zoomPath,
+ searchActive: m.searchActive,
+ showHelp: m.showHelp,
+ paused: m.paused,
+ isDark: m.isDark,
+ }
}
// SetLiveTrie updates the data source used by the flamegraph model.
@@ -342,6 +507,7 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) {
m.zoomLineWidth = 0
m.subtreeSet = make(map[int]bool)
m.filterVisible = make(map[int]bool)
+ m.ancestry = frameAncestry{}
m.animation = NewAnimationState(30, 6.0, 1.0)
m.animating = false
m.hasNavigableSnapshot = false
@@ -380,7 +546,10 @@ func (m *Model) syncCountFieldToTrie() {
m.countField = field
}
-// RefreshFromLiveTrie loads a new snapshot when the source version changes.
+// RefreshFromLiveTrie loads a new snapshot synchronously and returns true when
+// a new snapshot was applied. Retained as a simple facade for tests; the
+// production TUI now uses RefreshFromLiveTrieCmd to do the heavy lifting on a
+// background goroutine.
func (m *Model) RefreshFromLiveTrie() bool {
if m.liveTrie == nil {
return false
@@ -395,12 +564,11 @@ func (m *Model) RefreshFromLiveTrie() bool {
return false
}
- payload, version := m.liveTrie.SnapshotJSON()
- var snapshot snapshotNode
- if err := json.Unmarshal(payload, &snapshot); err != nil {
+ tree, version := m.liveTrie.SnapshotTree()
+ if tree == nil {
return false
}
- m.snapshot = &snapshot
+ m.snapshot = tree
m.globalTotal = snapshotTotal(m.snapshot)
if m.zoomPath != "" {
m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath)
@@ -412,6 +580,77 @@ func (m *Model) RefreshFromLiveTrie() bool {
return true
}
+// RefreshFromLiveTrieCmd returns a tea.Cmd that fetches a snapshot, lays out
+// frames, and builds the ancestry index on a background goroutine, then
+// dispatches a flameSnapshotReadyMsg back to the Bubble Tea Update loop.
+//
+// Returns nil if no refresh is needed: no live trie configured, paused with an
+// existing snapshot, version unchanged, another refresh already in flight, or
+// the user is actively pressing keys. Skipping while driving keeps keystrokes
+// responsive — the next idle tick picks up the latest version.
+//
+// Coalescing via refreshInFlight ensures we never queue more than one
+// background job at a time. Newer ticks just no-op until the in-flight result
+// lands, and the version gate then catches the freshest state.
+func (m *Model) RefreshFromLiveTrieCmd() tea.Cmd {
+ if m.liveTrie == nil {
+ return nil
+ }
+ if m.paused && m.snapshot != nil {
+ return nil
+ }
+ if m.refreshInFlight {
+ return nil
+ }
+ if m.userDriving() && m.snapshot != nil {
+ return nil
+ }
+ version := m.liveTrie.Version()
+ if version == m.lastVersion && m.snapshot != nil {
+ return nil
+ }
+ m.refreshInFlight = true
+
+ // Capture fields the goroutine needs. Avoids reading Model fields
+ // concurrently with the Update goroutine.
+ liveTrie := m.liveTrie
+ width := m.width
+ height := m.height
+ zoomPath := m.zoomPath
+ return func() tea.Msg {
+ tree, ver := liveTrie.SnapshotTree()
+ if tree == nil {
+ return flameSnapshotReadyMsg{version: ver, layoutWidth: width, layoutHeight: height, zoomPath: zoomPath}
+ }
+ var zoomRoot *snapshotNode
+ layoutRoot := tree
+ rootPath := ""
+ if zoomPath != "" {
+ zoomRoot = findNodeByPath(tree, zoomPath)
+ if zoomRoot != nil {
+ layoutRoot = zoomRoot
+ rootPath = zoomPath
+ }
+ }
+ targetFrames := buildTerminalLayoutWithPath(layoutRoot, width, height, rootPath)
+ if zoomPath != "" {
+ targetFrames = applyZoomLineage(targetFrames, tree, zoomPath, width)
+ }
+ ancestry := buildFrameAncestry(targetFrames)
+ return flameSnapshotReadyMsg{
+ version: ver,
+ layoutWidth: width,
+ layoutHeight: height,
+ zoomPath: zoomPath,
+ snapshot: tree,
+ zoomRoot: zoomRoot,
+ targetFrames: targetFrames,
+ ancestry: ancestry,
+ globalTotal: snapshotTotal(tree),
+ }
+ }
+}
+
// LastVersion returns the latest snapshot version loaded into the model.
func (m Model) LastVersion() uint64 {
return m.lastVersion
@@ -466,7 +705,18 @@ func (m *Model) rebuildFrames(animate bool) {
if m.zoomPath != "" {
targetFrames = m.withZoomLineage(targetFrames)
}
+ ancestry := buildFrameAncestry(targetFrames)
+ m.applyTargetFrames(targetFrames, ancestry, prevPath, animate)
+}
+
+// applyTargetFrames installs a prebuilt frame layout and ancestry index,
+// optionally animating from the previous frames. Shared between the
+// synchronous rebuildFrames path and the async snapshot-ready handler so
+// post-swap state stays consistent (animation kickoff, selection restore,
+// filter recompute, subtree highlight).
+func (m *Model) applyTargetFrames(targetFrames []tuiFrame, ancestry frameAncestry, prevPath string, animate bool) {
m.targetFrames = targetFrames
+ m.ancestry = ancestry
m.animation.SetTargets(m.targetFrames)
if animate && len(m.frames) > 0 && !m.animation.Settled() {
m.animating = true
@@ -483,7 +733,7 @@ func (m *Model) rebuildFrames(animate bool) {
m.recomputeFilterState()
m.ensureSelectionNavigable()
m.ensureSelectionVisible()
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
}
func (m *Model) restoreSelectionByPath(path string) {
@@ -1044,7 +1294,7 @@ func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool {
currentRoot := m.currentRootPath()
if clickedPath == currentRoot {
m.selectedIdx = idx
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
return true
}
if m.zoomPath != "" && hasPathBoundaryPrefix(currentRoot, clickedPath) {
@@ -1062,7 +1312,7 @@ func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool {
if sel := m.frameIndexByPath(clickedPath); sel >= 0 {
m.selectedIdx = sel
}
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
m.statusMessage = "Zoom: " + compactFramePath(clickedPath)
return true
}
@@ -1180,10 +1430,17 @@ func (m Model) frameIndexAt(x, y int) int {
}
func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
- if len(frames) == 0 || m.snapshot == nil {
+ return applyZoomLineage(frames, m.snapshot, m.zoomPath, m.width)
+}
+
+// applyZoomLineage prepends the zoom path's ancestors to a zoomed frame
+// layout. Extracted as a free function so the async snapshot refresh can
+// reuse it on a background goroutine without referencing Model state directly.
+func applyZoomLineage(frames []tuiFrame, snapshot *snapshotNode, zoomPath string, width int) []tuiFrame {
+ if len(frames) == 0 || snapshot == nil {
return frames
}
- parts := strings.Split(m.zoomPath, pathSeparator)
+ parts := strings.Split(zoomPath, pathSeparator)
if len(parts) <= 1 {
return frames
}
@@ -1191,7 +1448,7 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
rowShift := len(parts) - 1
out := make([]tuiFrame, 0, len(frames)+len(parts))
for _, frame := range frames {
- if frame.Path == m.zoomPath {
+ if frame.Path == zoomPath {
continue
}
frame.Row += rowShift
@@ -1199,10 +1456,10 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
out = append(out, frame)
}
- rootTotal := snapshotTotal(m.snapshot)
+ rootTotal := snapshotTotal(snapshot)
for depth := range parts {
path := strings.Join(parts[:depth+1], pathSeparator)
- node := findNodeByPath(m.snapshot, path)
+ node := findNodeByPath(snapshot, path)
total := uint64(0)
if node != nil {
total = snapshotTotal(node)
@@ -1216,7 +1473,7 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame {
Name: name,
Col: 0,
Row: depth,
- Width: m.width,
+ Width: width,
Total: total,
Percent: percent,
Fill: terminalFrameColor(name),
diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go
index 6bedc3e..5cd66b1 100644
--- a/internal/tui/flamegraph/search.go
+++ b/internal/tui/flamegraph/search.go
@@ -53,7 +53,7 @@ func (m *Model) jumpMatch(direction int) {
} else {
m.selectedIdx = matches[0]
}
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
return
}
@@ -65,7 +65,7 @@ func (m *Model) jumpMatch(direction int) {
next = 0
}
m.selectedIdx = matches[next]
- m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet)
}
func (m *Model) recomputeFilterState() {
@@ -88,7 +88,7 @@ func (m *Model) recomputeFilterState() {
m.matchIndices[idx] = true
}
}
- m.filterVisible = computeFilterVisibleSetInto(m.frames, m.matchIndices, m.filterVisible)
+ m.filterVisible = filterVisibleSetUsingAncestry(m.frames, m.matchIndices, m.ancestry, m.filterVisible)
}
func orderedMatchIndices(matchSet map[int]bool) []int {