on main help sign in
fix(ui): clear image cache when FilePicker closes to prevent unbounded memory growth (#2158)
fb9eb92M1xA
internal/ui/image/image.go+7
@@ -68,6 +68,13 @@ var (
68 cachedMutex sync.RWMutex
68 cachedMutex sync.RWMutex
69)
69)
70
70
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+
71// fitImage resizes the image to fit within the specified dimensions in
78// fitImage resizes the image to fit within the specified dimensions in
72// terminal cells, maintaining the aspect ratio.
79// terminal cells, maintaining the aspect ratio.
73func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
80func 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 (
41 "github.com/charmbracelet/crush/internal/ui/common"
41 "github.com/charmbracelet/crush/internal/ui/common"
42 "github.com/charmbracelet/crush/internal/ui/completions"
42 "github.com/charmbracelet/crush/internal/ui/completions"
43 "github.com/charmbracelet/crush/internal/ui/dialog"
43 "github.com/charmbracelet/crush/internal/ui/dialog"
44+ fimage "github.com/charmbracelet/crush/internal/ui/image"
44 "github.com/charmbracelet/crush/internal/ui/logo"
45 "github.com/charmbracelet/crush/internal/ui/logo"
45 "github.com/charmbracelet/crush/internal/ui/styles"
46 "github.com/charmbracelet/crush/internal/ui/styles"
46 "github.com/charmbracelet/crush/internal/ui/util"
47 "github.com/charmbracelet/crush/internal/ui/util"
@@ -1137,6 +1138,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1137 break
1138 break
1138 }
1139 }
1139
1140
1141+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
1142+ defer fimage.ResetCache()
1143+ }
1144+
1140 m.dialog.CloseFrontDialog()
1145 m.dialog.CloseFrontDialog()
1141
1146
1142 if isOnboarding {
1147 if isOnboarding {
@@ -1351,6 +1356,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1351 m.dialog.CloseDialog(dialog.FilePickerID)
1356 m.dialog.CloseDialog(dialog.FilePickerID)
1352 return nil
1357 return nil
1353 },
1358 },
1359+ func() tea.Msg {
1360+ fimage.ResetCache()
1361+ return nil
1362+ },
1354 ))
1363 ))
1355
1364
1356 case dialog.ActionRunCustomCommand:
1365 case dialog.ActionRunCustomCommand: