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
This commit is contained in:
+51
-5
@@ -28,8 +28,9 @@ const (
|
|||||||
wsReadTimeout = 90 * time.Second
|
wsReadTimeout = 90 * time.Second
|
||||||
wsPingPeriod = 30 * time.Second
|
wsPingPeriod = 30 * time.Second
|
||||||
stdinWriteTimeout = 2 * time.Second
|
stdinWriteTimeout = 2 * time.Second
|
||||||
screenshotCacheSeconds = 300 * time.Millisecond
|
screenshotCacheSeconds = 300 * time.Millisecond
|
||||||
maxScreenshotCacheTTL = 20 * time.Second
|
maxScreenshotCacheTTL = 20 * time.Second
|
||||||
|
screenshotEvictInterval = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerOptions struct {
|
type ServerOptions struct {
|
||||||
@@ -1336,13 +1337,22 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSparklineURLBySlug = {};
|
||||||
function refreshSparklines() {
|
function refreshSparklines() {
|
||||||
if (!composeMode) return;
|
if (!composeMode) return;
|
||||||
for (const tile of tiles) {
|
for (const tile of tiles) {
|
||||||
const card = cardsBySlug[tile.slug];
|
const card = cardsBySlug[tile.slug];
|
||||||
if (card && card.sparkline) {
|
if (!card || !card.sparkline) continue;
|
||||||
card.sparkline.src = '/cpu-sparkline.svg?container=' + encodeURIComponent(tile.slug) + '&width=80&height=16&_t=' + Date.now();
|
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]);
|
URL.revokeObjectURL(activeObjectURLBySlug[key]);
|
||||||
delete 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) {
|
if (tiles.length === 0) {
|
||||||
grid.innerHTML = '<div class="empty">No containers found. Start containers with the webterm-command label.</div>';
|
grid.innerHTML = '<div class="empty">No containers found. Start containers with the webterm-command label.</div>';
|
||||||
subtitle.textContent = dockerWatchMode ? 'Watching for containers with 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))
|
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 {
|
func (s *LocalServer) Run(ctx context.Context) error {
|
||||||
s.setupDockerFeatures()
|
s.setupDockerFeatures()
|
||||||
|
go s.evictStaleScreenshots(ctx)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", s.host, s.port),
|
Addr: fmt.Sprintf("%s:%d", s.host, s.port),
|
||||||
Handler: s.Handler(),
|
Handler: s.Handler(),
|
||||||
|
|||||||
Reference in New Issue
Block a user