From c2b10f11454894b4a304f82267d004e7b31ae4a3 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Tue, 17 Feb 2026 21:08:07 +0000 Subject: [PATCH] Fix SVG preview memory leaks in dashboard and server Browser-side: - Sparkline images now use blob object URLs with explicit revocation instead of cache-busted img.src URLs that accumulate in Safari's internal image cache - renderTiles() now clears thumbnailCache, sparkline object URLs, pending refresh timers, and etag cache on re-render - Prevents unbounded growth of dashboard state objects Server-side: - Add periodic eviction of stale screenshotCache entries (every 60s, removes entries older than maxScreenshotCacheTTL) to prevent unbounded server memory growth from cached SVG strings --- webterm/server.go | 56 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/webterm/server.go b/webterm/server.go index 9396ca5..70426fe 100644 --- a/webterm/server.go +++ b/webterm/server.go @@ -28,8 +28,9 @@ const ( wsReadTimeout = 90 * time.Second wsPingPeriod = 30 * time.Second stdinWriteTimeout = 2 * time.Second - screenshotCacheSeconds = 300 * time.Millisecond - maxScreenshotCacheTTL = 20 * time.Second + screenshotCacheSeconds = 300 * time.Millisecond + maxScreenshotCacheTTL = 20 * time.Second + screenshotEvictInterval = 60 * time.Second ) type ServerOptions struct { @@ -1336,13 +1337,22 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { } catch (_) {} } + const activeSparklineURLBySlug = {}; function refreshSparklines() { if (!composeMode) return; for (const tile of tiles) { const card = cardsBySlug[tile.slug]; - if (card && card.sparkline) { - card.sparkline.src = '/cpu-sparkline.svg?container=' + encodeURIComponent(tile.slug) + '&width=80&height=16&_t=' + Date.now(); - } + if (!card || !card.sparkline) continue; + const slug = tile.slug; + const url = '/cpu-sparkline.svg?container=' + encodeURIComponent(slug) + '&width=80&height=16&_t=' + Date.now(); + fetch(url).then(r => r.ok ? r.blob() : null).then(blob => { + if (!blob) return; + const prev = activeSparklineURLBySlug[slug]; + const objectURL = URL.createObjectURL(blob); + activeSparklineURLBySlug[slug] = objectURL; + if (card.sparkline) card.sparkline.src = objectURL; + if (prev) URL.revokeObjectURL(prev); + }).catch(() => {}); } } @@ -1379,6 +1389,20 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) { URL.revokeObjectURL(activeObjectURLBySlug[key]); delete activeObjectURLBySlug[key]; } + for (const key in activeSparklineURLBySlug) { + URL.revokeObjectURL(activeSparklineURLBySlug[key]); + delete activeSparklineURLBySlug[key]; + } + for (const key in thumbnailCache) { + delete thumbnailCache[key]; + } + for (const key in pendingRefresh) { + clearTimeout(pendingRefresh[key]); + delete pendingRefresh[key]; + } + for (const key in etagBySlug) { + delete etagBySlug[key]; + } if (tiles.length === 0) { grid.innerHTML = '
No containers found. Start containers with the webterm-command label.
'; subtitle.textContent = dockerWatchMode ? 'Watching for containers with webterm-command label...' : ''; @@ -1581,8 +1605,30 @@ func (s *LocalServer) Handler() http.Handler { return s.loggingMiddleware(s.gzipMiddleware(mux)) } +// evictStaleScreenshots periodically removes screenshot cache entries +// older than maxScreenshotCacheTTL to prevent unbounded memory growth. +func (s *LocalServer) evictStaleScreenshots(ctx context.Context) { + ticker := time.NewTicker(screenshotEvictInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.mu.Lock() + for key, entry := range s.screenshotCache { + if time.Since(entry.when) > maxScreenshotCacheTTL { + delete(s.screenshotCache, key) + } + } + s.mu.Unlock() + } + } +} + func (s *LocalServer) Run(ctx context.Context) error { s.setupDockerFeatures() + go s.evictStaleScreenshots(ctx) server := &http.Server{ Addr: fmt.Sprintf("%s:%d", s.host, s.port), Handler: s.Handler(),