From 62104fbcabf811b6cd31db15f0f72db1f9d3c6e6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 13 May 2026 10:25:07 +0300 Subject: cap filterStack undo history to 50 levels to prevent unbounded growth Adds maxFilterHistory=50 and evicts the oldest entry from both the history and label-stack slices in filterStack.push whenever the cap is exceeded. Covers the fix with a new table-driven unit test. Co-Authored-By: Claude Sonnet 4.6 --- internal/tui/filterstack.go | 13 +++++++++++++ internal/tui/tui_test.go | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/internal/tui/filterstack.go b/internal/tui/filterstack.go index 016d845..28667d9 100644 --- a/internal/tui/filterstack.go +++ b/internal/tui/filterstack.go @@ -7,6 +7,11 @@ import ( "ior/internal/globalfilter/presenter" ) +// maxFilterHistory is the maximum number of undo levels retained by filterStack. +// Once this cap is reached, the oldest entry is evicted so the slices never +// grow without bound across long-running sessions or automated filter changes. +const maxFilterHistory = 50 + // filterStack manages the trace filter chain: the active filter, the undo // history, and the human-readable label stack displayed in the status bar. // It owns all filter mutation and label-generation logic so the top-level @@ -29,6 +34,8 @@ func (f *filterStack) current() globalfilter.Filter { // push records oldFilter in the history stack, sets global to newFilter, and // appends an action label. Returns true when the filter actually changed. +// When the history depth would exceed maxFilterHistory the oldest entry is +// evicted from both slices to keep memory usage bounded. func (f *filterStack) push(newFilter globalfilter.Filter, action string) bool { next := newFilter.Clone() if f.global.Equal(next) { @@ -36,6 +43,12 @@ func (f *filterStack) push(newFilter globalfilter.Filter, action string) bool { } f.history = append(f.history, f.global.Clone()) f.stack = append(f.stack, globalFilterActionLabel(f.global, next, action)) + // Evict the oldest undo level once the cap is exceeded so the slices + // never grow without bound across long-running sessions. + if len(f.history) > maxFilterHistory { + f.history = f.history[1:] + f.stack = f.stack[1:] + } f.global = next return true } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ba0f8ed..a685719 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1725,6 +1725,27 @@ func TestDashboardFooterShowsGlobalFilterStack(t *testing.T) { } } +func TestFilterStackHistoryCapEvictsOldestEntries(t *testing.T) { + // Push maxFilterHistory+10 distinct filters and verify the slices never + // exceed the cap. The oldest entries must be evicted, and the most-recent + // maxFilterHistory entries must be retained. + fs := newFilterStack(globalfilter.Filter{}) + for i := 0; i < maxFilterHistory+10; i++ { + f := globalfilter.Filter{} + f.FD = globalfilter.NewEqFilter(int64(i + 1)) // unique per iteration + fs.push(f, "") + } + if len(fs.history) > maxFilterHistory { + t.Fatalf("history exceeds cap: got %d, want <= %d", len(fs.history), maxFilterHistory) + } + if len(fs.stack) > maxFilterHistory { + t.Fatalf("stack exceeds cap: got %d, want <= %d", len(fs.stack), maxFilterHistory) + } + if len(fs.history) != len(fs.stack) { + t.Fatalf("history and stack lengths must match: history=%d stack=%d", len(fs.history), len(fs.stack)) + } +} + func TestProcessesTabEnterAppliesSelectedProcessAsGlobalFilter(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard -- cgit v1.2.3