summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-09 10:53:18 +0300
committerPaul Buetow <paul@buetow.org>2026-05-09 10:53:18 +0300
commit8da473aed2c3e901615294df398b26db5aea6032 (patch)
treea62807c29441c56776910558ece56361202f7bb2
parentf3aed5203b309f1d452a5dc3f05c1ecba8e1134b (diff)
add auto-reset timer for dashboard aggregates
Live flamegraph trie and stats engine grow unboundedly during long traces. Add a periodic auto-reset (same effect as the 'r' key) so they stay bounded. - New CLI flag -resetTimer=30s (default 30s, 0 disables). - Hotkey I cycles the cadence: off -> 15s -> 30s -> 60s -> 5m -> off. Custom intervals (e.g. -resetTimer=47s) advance to the first preset greater than the current value, then wrap to off. - autoResetTickMsg carries a generation counter so changing the cadence drops in-flight ticks scheduled under the previous interval. - Dashboard chrome shows 'auto-reset: 30s' or 'auto-reset: off'.
-rw-r--r--internal/flags/flags.go16
-rw-r--r--internal/flags/flags_test.go40
-rw-r--r--internal/tui/common/keys.go10
-rw-r--r--internal/tui/dashboard/model.go101
-rw-r--r--internal/tui/help.go2
-rw-r--r--internal/tui/tui.go53
6 files changed, 208 insertions, 14 deletions
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 5d00b74..bbdf4b8 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -42,6 +42,7 @@ type Config struct {
CollapsedFields []string
CountField string
GlobalFilter globalfilter.Filter
+ ResetTimer time.Duration
// ShowVersion prints the banner plus version and exits without running.
ShowVersion bool
@@ -58,6 +59,12 @@ func init() {
current.Store(&defaults)
}
+// DefaultResetTimer is the default cadence for the dashboard's auto-reset
+// timer. It periodically clears aggregate state (live flamegraph trie and
+// stats engine) — the same effect as pressing `r` — to prevent unbounded
+// growth during long traces. A value of 0 disables auto-reset entirely.
+const DefaultResetTimer = 30 * time.Second
+
// NewFlags returns a configuration instance initialized with project defaults.
func NewFlags() Config {
return Config{
@@ -69,6 +76,7 @@ func NewFlags() Config {
TUIExportEnable: true,
CollapsedFields: []string{"comm", "tracepoint", "path"},
CountField: "count",
+ ResetTimer: DefaultResetTimer,
}
}
@@ -180,6 +188,8 @@ func parse() error {
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, "Synthetic live flamegraph refresh interval for --testliveflames")
flag.BoolVar(&cfg.TUIExportEnable, "tuiExport", cfg.TUIExportEnable, "Enable TUI CSV snapshot export files (separate from Parquet recording)")
+ flag.DurationVar(&cfg.ResetTimer, "resetTimer", cfg.ResetTimer,
+ "Auto-reset interval for aggregate dashboard state (flamegraph trie + stats engine); set to 0 to disable")
flag.BoolVar(&cfg.ShowVersion, "version", false, "Print version banner and exit")
fields := flag.String("fields", "",
fmt.Sprintf("Comma separated list of fields to collapse, valid are: %v", validFields))
@@ -220,6 +230,12 @@ func parse() error {
return fmt.Errorf("invalid count field: %s", cfg.CountField)
}
+ // A negative reset timer would imply auto-resets in the past, which is
+ // nonsensical. 0 disables, anything positive enables.
+ if cfg.ResetTimer < 0 {
+ return fmt.Errorf("invalid resetTimer: %s (must be >= 0; 0 disables)", cfg.ResetTimer)
+ }
+
setCurrent(cfg)
return nil
}
diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go
index f5274d8..77c167c 100644
--- a/internal/flags/flags_test.go
+++ b/internal/flags/flags_test.go
@@ -171,3 +171,43 @@ func TestParseInvalidTracepointRegexReturnsError(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
+
+func TestParseResetTimerDefault(t *testing.T) {
+ cfg, err := parseForTest(t)
+ if err != nil {
+ t.Fatalf("parse returned error: %v", err)
+ }
+ if cfg.ResetTimer != DefaultResetTimer {
+ t.Fatalf("default reset timer = %v, want %v", cfg.ResetTimer, DefaultResetTimer)
+ }
+}
+
+func TestParseResetTimerOverride(t *testing.T) {
+ cfg, err := parseForTest(t, "-resetTimer", "45s")
+ if err != nil {
+ t.Fatalf("parse returned error: %v", err)
+ }
+ if cfg.ResetTimer != 45*time.Second {
+ t.Fatalf("reset timer = %v, want 45s", cfg.ResetTimer)
+ }
+}
+
+func TestParseResetTimerZeroDisables(t *testing.T) {
+ cfg, err := parseForTest(t, "-resetTimer", "0")
+ if err != nil {
+ t.Fatalf("parse returned error: %v", err)
+ }
+ if cfg.ResetTimer != 0 {
+ t.Fatalf("reset timer = %v, want 0 (disabled)", cfg.ResetTimer)
+ }
+}
+
+func TestParseResetTimerNegativeReturnsError(t *testing.T) {
+ _, err := parseForTest(t, "-resetTimer", "-5s")
+ if err == nil {
+ t.Fatalf("expected parse error for negative reset timer")
+ }
+ if !strings.Contains(err.Error(), "invalid resetTimer") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index 1dd2833..315edef 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -35,6 +35,12 @@ type KeyMap struct {
Enter key.Binding
Esc key.Binding
Refresh key.Binding
+ // AutoReset toggles/cycles the dashboard's auto-reset timer. The
+ // timer periodically clears aggregate state (same as Refresh) to
+ // prevent unbounded growth of the flamegraph trie and stats engine.
+ // Bound to capital `I` because lowercase `t` is the TID picker; we
+ // keep `i` unbound so future use isn't blocked.
+ AutoReset key.Binding
}
// Keys contains the default shared key map.
@@ -72,6 +78,7 @@ func DefaultKeyMap() KeyMap {
Enter: keyBinding("select", "enter"),
Esc: keyBinding("back", "esc"),
Refresh: keyBinding("reset baseline", "r"),
+ AutoReset: keyBinding("auto-reset", "I"),
}
}
@@ -113,6 +120,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.Probes,
k.Record,
k.Refresh,
+ k.AutoReset,
k.Quit,
}
if help := k.Export.Help(); help.Key != "" || help.Desc != "" {
@@ -152,7 +160,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
if help := k.Export.Help(); help.Key != "" || help.Desc != "" {
controls = append(controls, k.Export)
}
- controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Record, k.Refresh, k.Quit)
+ controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Record, k.Refresh, k.AutoReset, k.Quit)
controls = append(controls, k.Visualize, k.Metric, k.Sort, k.ReverseSort, k.Filter, k.FilterUndo)
return [][]key.Binding{
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 42a9ad4..0437f05 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -40,6 +40,13 @@ type refreshTickMsg struct{}
type streamTickMsg struct{}
type flameTickMsg struct{}
type bubbleTickMsg struct{}
+
+// autoResetTickMsg fires when the auto-reset timer elapses. It carries the
+// generation it was scheduled for so that stale ticks (from a previous
+// interval setting) are ignored rather than triggering a wrong-cadence reset.
+type autoResetTickMsg struct {
+ generation uint64
+}
type streamEditorDoneMsg struct {
err error
}
@@ -64,8 +71,14 @@ type Model struct {
width int
height int
- refreshEvery time.Duration
- keys common.KeyMap
+ refreshEvery time.Duration
+ // autoResetEvery is the cadence for the periodic auto-reset of
+ // aggregate state (live trie + stats engine). Zero disables it.
+ autoResetEvery time.Duration
+ // autoResetGen is incremented every time autoResetEvery changes so
+ // in-flight ticks scheduled under the previous cadence can be ignored.
+ autoResetGen uint64
+ keys common.KeyMap
globalFilter globalfilter.Filter
filterStack []string
recordingStatus string
@@ -141,6 +154,9 @@ func (m Model) Init() tea.Cmd {
cmds = append(cmds, bubbleTickCmdFn())
}
}
+ if cmd := m.autoResetTickCmd(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
if len(cmds) == 1 {
return cmds[0]
}
@@ -160,6 +176,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleFlameTick()
case bubbleTickMsg:
return m.handleBubbleTick()
+ case autoResetTickMsg:
+ return m.handleAutoResetTick(msg)
case messages.StatsTickMsg:
return m.handleStatsTick(msg)
case tea.KeyPressMsg:
@@ -896,6 +914,60 @@ func (m *Model) resetBaselineCmd() tea.Cmd {
return func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }
}
+// autoResetTickCmd returns a command that fires an autoResetTickMsg after
+// the current auto-reset interval. Returns nil when the timer is disabled
+// (interval <= 0), so callers can compose it without extra branching.
+func (m Model) autoResetTickCmd() tea.Cmd {
+ if m.autoResetEvery <= 0 {
+ return nil
+ }
+ gen := m.autoResetGen
+ return tea.Tick(m.autoResetEvery, func(time.Time) tea.Msg {
+ return autoResetTickMsg{generation: gen}
+ })
+}
+
+// handleAutoResetTick fires the same reset path as the `r` key (live trie
+// + stats engine) and re-arms the timer for the next tick. Stale ticks
+// from a previous cadence are dropped via the generation counter so that
+// changing the interval does not double-fire.
+func (m Model) handleAutoResetTick(msg autoResetTickMsg) (tea.Model, tea.Cmd) {
+ if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 {
+ return m, nil
+ }
+ resetCmd := m.resetBaselineCmd()
+ nextTick := m.autoResetTickCmd()
+ switch {
+ case resetCmd == nil && nextTick == nil:
+ return m, nil
+ case resetCmd == nil:
+ return m, nextTick
+ case nextTick == nil:
+ return m, resetCmd
+ default:
+ return m, tea.Batch(resetCmd, nextTick)
+ }
+}
+
+// SetAutoResetInterval reconfigures the auto-reset cadence. A zero or
+// negative value disables the timer. Returns a tea.Cmd that arms the new
+// timer (or nil when disabling). The generation counter is bumped so any
+// in-flight tick scheduled under the previous interval is ignored.
+func (m *Model) SetAutoResetInterval(d time.Duration) tea.Cmd {
+ if d < 0 {
+ d = 0
+ }
+ m.autoResetEvery = d
+ m.autoResetGen++
+ return m.autoResetTickCmd()
+}
+
+// AutoResetInterval reports the current auto-reset cadence. Zero means
+// the timer is disabled.
+func (m Model) AutoResetInterval() time.Duration {
+ return m.autoResetEvery
+}
+
// LatestSnapshot returns the most recently received snapshot.
func (m Model) LatestSnapshot() *statsengine.Snapshot {
return m.latest
@@ -1021,17 +1093,24 @@ func (m Model) View() tea.View {
func (m Model) filterSummary() string {
summary := "filter: " + m.globalFilter.Summary()
- if len(m.filterStack) == 0 {
- if m.recordingStatus == "" {
- return summary
- }
- return summary + " | " + m.recordingStatus
+ if len(m.filterStack) > 0 {
+ summary += " | stack: " + strings.Join(m.filterStack, " | ")
}
- summary += " | stack: " + strings.Join(m.filterStack, " | ")
- if m.recordingStatus == "" {
- return summary
+ if m.recordingStatus != "" {
+ summary += " | " + m.recordingStatus
+ }
+ summary += " | " + m.autoResetStatus()
+ return summary
+}
+
+// autoResetStatus is the human-readable label for the current
+// auto-reset cadence shown in the dashboard chrome. "off" when the
+// timer is disabled, otherwise the configured interval (e.g. "30s").
+func (m Model) autoResetStatus() string {
+ if m.autoResetEvery <= 0 {
+ return "auto-reset: off"
}
- return summary + " | " + m.recordingStatus
+ return "auto-reset: " + m.autoResetEvery.String()
}
func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model, flameModel *flamegraphtui.Model) string {
diff --git a/internal/tui/help.go b/internal/tui/help.go
index d63c198..21a78ac 100644
--- a/internal/tui/help.go
+++ b/internal/tui/help.go
@@ -63,7 +63,7 @@ func (m Model) helpSections() []helpSection {
{
title: "Dashboard Tabs",
lines: []string{
- "tab/shift+tab tabs 1..7 jump tab r reset baseline R parquet rec",
+ "tab/shift+tab tabs 1..7 jump tab r reset baseline I auto-reset R parquet rec",
"sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom",
"sys/files/proc tables: s sort S reverse sort",
"sys/proc: v bubbles b metric events/bytes",
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index a0a033b..e1d5581 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -284,6 +284,7 @@ func TraceFiltersFromContext(ctx context.Context) (globalfilter.Filter, bool) {
// RunWithTraceStarterConfig starts the TUI with explicit runtime flags.
func RunWithTraceStarterConfig(cfg flags.Config, starter TraceStarter) error {
model := newModelWithRuntimeConfig(cfg.PidFilter, filterFromConfig(cfg), cfg.PidFilter, cfg.TidFilter, cfg.TUIExportEnable, starter)
+ model.dashboard.SetAutoResetInterval(cfg.ResetTimer)
program := tea.NewProgram(model)
_, err := program.Run()
return err
@@ -292,6 +293,7 @@ func RunWithTraceStarterConfig(cfg flags.Config, starter TraceStarter) error {
// RunTestFlamesWithTraceStarterConfig starts test-flames mode with explicit runtime flags.
func RunTestFlamesWithTraceStarterConfig(cfg flags.Config, starter TraceStarter) error {
model := newModelWithRuntimeConfig(1, filterFromConfig(cfg), 1, -1, cfg.TUIExportEnable, starter)
+ model.dashboard.SetAutoResetInterval(cfg.ResetTimer)
program := tea.NewProgram(model)
_, err := program.Run()
return err
@@ -376,7 +378,12 @@ func NewModel(initialPID int, startTrace TraceStarter) Model {
// NewModelWithConfig creates the top-level TUI model with explicit runtime flags.
func NewModelWithConfig(cfg flags.Config, initialPID int, startTrace TraceStarter) Model {
- return newModelWithRuntimeConfig(initialPID, filterFromConfig(cfg), cfg.PidFilter, cfg.TidFilter, cfg.TUIExportEnable, startTrace)
+ model := newModelWithRuntimeConfig(initialPID, filterFromConfig(cfg), cfg.PidFilter, cfg.TidFilter, cfg.TUIExportEnable, startTrace)
+ // Seed the dashboard's auto-reset cadence from the parsed CLI flag
+ // (default DefaultResetTimer; 0 disables). Init() will arm the
+ // underlying tea.Tick when the dashboard becomes active.
+ model.dashboard.SetAutoResetInterval(cfg.ResetTimer)
+ return model
}
func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter, startupPidFilter, startupTidFilter int, exportEnabled bool, startTrace TraceStarter) Model {
@@ -649,9 +656,53 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo
next, cmd := m.reselectTID()
return next, cmd, true
}
+ if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.AutoReset) {
+ next, cmd := m.cycleAutoResetInterval()
+ return next, cmd, true
+ }
return m, nil, false
}
+// autoResetCycle is the ordered set of cadences exposed via the `I`
+// hotkey. The first entry (0) disables the timer; the rest are
+// progressively longer to give users a quick way to slow auto-resets
+// down on long traces or turn them off entirely.
+var autoResetCycle = []time.Duration{
+ 0,
+ 15 * time.Second,
+ 30 * time.Second,
+ 60 * time.Second,
+ 5 * time.Minute,
+}
+
+// nextAutoResetInterval returns the next entry in autoResetCycle after
+// `current`. If `current` is not in the cycle (e.g. the user passed a
+// custom -resetTimer like 47s), the next entry is the first cycle value
+// strictly greater than current; if there is none, we wrap to 0 (off).
+func nextAutoResetInterval(current time.Duration) time.Duration {
+ for i, d := range autoResetCycle {
+ if d == current {
+ return autoResetCycle[(i+1)%len(autoResetCycle)]
+ }
+ }
+ for _, d := range autoResetCycle {
+ if d > current {
+ return d
+ }
+ }
+ return autoResetCycle[0]
+}
+
+// cycleAutoResetInterval advances the dashboard's auto-reset cadence to
+// the next preset and re-arms the timer. The new cadence takes effect
+// on the next tick; any in-flight tick from the previous cadence is
+// dropped via the dashboard model's generation counter.
+func (m Model) cycleAutoResetInterval() (tea.Model, tea.Cmd) {
+ next := nextAutoResetInterval(m.dashboard.AutoResetInterval())
+ cmd := m.dashboard.SetAutoResetInterval(next)
+ return m, cmd
+}
+
func (m Model) updateDashboardForModal(msg tea.Msg) (Model, tea.Cmd) {
if _, isKey := msg.(tea.KeyPressMsg); isKey || m.screen != ScreenDashboard {
return m, nil