diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-09 10:53:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-09 10:53:18 +0300 |
| commit | 8da473aed2c3e901615294df398b26db5aea6032 (patch) | |
| tree | a62807c29441c56776910558ece56361202f7bb2 | |
| parent | f3aed5203b309f1d452a5dc3f05c1ecba8e1134b (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.go | 16 | ||||
| -rw-r--r-- | internal/flags/flags_test.go | 40 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 10 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 101 | ||||
| -rw-r--r-- | internal/tui/help.go | 2 | ||||
| -rw-r--r-- | internal/tui/tui.go | 53 |
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 |
