diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/dashboard/model.go | 17 | ||||
| -rw-r--r-- | internal/tui/flamegraph/ancestry.go | 127 | ||||
| -rw-r--r-- | internal/tui/flamegraph/async_refresh_test.go | 204 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 307 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 6 |
5 files changed, 626 insertions, 35 deletions
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 { |
