on main help sign in
fix(ui): clear image cache when FilePicker closes to prevent unbounded memory growth (#2158)

* fix(ui): clear image cache when FilePicker closes to prevent unbounded memory growth * fix(ui): only reset image cache on FilePicker close, not on new session * fix(ui): reset image cache after Dialog close * fix(ui): rename image.Reset to image.ResetCache for clarity

commitfb9eb927de330fb1e23772daefdee871710a4829 authorM1xA <AnyCPU@users.noreply.github.com> dateMon 9 Feb 2026, 08:44 (8 months ago) parentc3d4ece
3 files changed+62
internal/ui/image/image.go+7 internal/ui/image/image_test.go+46 internal/ui/model/ui.go+9
internal/ui/image/image.go+7
@@ -68,6 +68,13 @@ var (
6868 cachedMutex sync.RWMutex
6969)
7070
71+// ResetCache clears the image cache, freeing all cached decoded images.
72+func ResetCache() {
73+ cachedMutex.Lock()
74+ clear(cachedImages)
75+ cachedMutex.Unlock()
76+}
77+
7178// fitImage resizes the image to fit within the specified dimensions in
7279// terminal cells, maintaining the aspect ratio.
7380func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
internal/ui/image/image_test.gonew file+46
@@ -0,0 +1,46 @@
1+package image
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestResetCache(t *testing.T) {
11+ t.Parallel()
12+
13+ cachedMutex.Lock()
14+ cachedImages[imageKey{id: "a", cols: 10, rows: 10}] = cachedImage{
15+ img: image.NewRGBA(image.Rect(0, 0, 1, 1)),
16+ cols: 10,
17+ rows: 10,
18+ }
19+ cachedImages[imageKey{id: "b", cols: 20, rows: 20}] = cachedImage{
20+ img: image.NewRGBA(image.Rect(0, 0, 1, 1)),
21+ cols: 20,
22+ rows: 20,
23+ }
24+ cachedMutex.Unlock()
25+
26+ ResetCache()
27+
28+ cachedMutex.RLock()
29+ length := len(cachedImages)
30+ cachedMutex.RUnlock()
31+
32+ require.Equal(t, 0, length)
33+}
34+
35+func TestResetIdempotent(t *testing.T) {
36+ t.Parallel()
37+
38+ // Calling Reset on an empty cache should not panic.
39+ ResetCache()
40+
41+ cachedMutex.RLock()
42+ length := len(cachedImages)
43+ cachedMutex.RUnlock()
44+
45+ require.Equal(t, 0, length)
46+}
internal/ui/model/ui.go+9
@@ -41,6 +41,7 @@ import (
4141 "github.com/charmbracelet/crush/internal/ui/common"
4242 "github.com/charmbracelet/crush/internal/ui/completions"
4343 "github.com/charmbracelet/crush/internal/ui/dialog"
44+ fimage "github.com/charmbracelet/crush/internal/ui/image"
4445 "github.com/charmbracelet/crush/internal/ui/logo"
4546 "github.com/charmbracelet/crush/internal/ui/styles"
4647 "github.com/charmbracelet/crush/internal/ui/util"
@@ -1137,6 +1138,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
11371138 break
11381139 }
11391140
1141+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
1142+ defer fimage.ResetCache()
1143+ }
1144+
11401145 m.dialog.CloseFrontDialog()
11411146
11421147 if isOnboarding {
@@ -1351,6 +1356,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13511356 m.dialog.CloseDialog(dialog.FilePickerID)
13521357 return nil
13531358 },
1359+ func() tea.Msg {
1360+ fimage.ResetCache()
1361+ return nil
1362+ },
13541363 ))
13551364
13561365 case dialog.ActionRunCustomCommand: