From 48b82d57dbb631db76916697c593eda21addb208 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 08:44:04 -0700 Subject: [PATCH] fix: reduce browser storage pressure --- CHANGELOG.md | 4 ++ static/sessions.js | 14 ++++++ static/sw.js | 37 +++++++++------ tests/test_issue2389_storage_pressure.py | 59 ++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 tests/test_issue2389_storage_pressure.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..23f9b8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Service worker updates now delete old shell caches before creating the new versioned cache, reducing temporary Cache Storage pressure during frequent releases. Deleting sessions also prunes the localStorage maps that track viewed counts, completion unread state, and observed streaming state so stale per-session entries do not accumulate indefinitely. Closes #2389. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/sessions.js b/static/sessions.js index 771e1d80..8ab50b58 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -170,6 +170,14 @@ function _clearSessionCompletionUnread(sid) { _saveSessionCompletionUnread(); } +function _clearSessionViewedCount(sid) { + if (!sid) return; + const counts = _getSessionViewedCounts(); + if (!Object.prototype.hasOwnProperty.call(counts, sid)) return; + delete counts[sid]; + _saveSessionViewedCounts(); +} + function _hasSessionCompletionUnread(sid) { if (!sid) return false; return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), sid); @@ -810,6 +818,12 @@ function _clearHandoffStorageForSession(sid) { _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null); _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null); } catch {} + // Session deletion should also prune per-session tracking maps. Otherwise + // heavy users accumulate one localStorage entry per deleted session forever, + // which increases quota pressure and can make future UI persistence fail. + try { _clearSessionViewedCount(sid); } catch {} + try { _clearSessionCompletionUnread(sid); } catch {} + try { _forgetObservedStreamingSession(sid); } catch {} } function _getHandoffDismissedAt(sid) { diff --git a/static/sw.js b/static/sw.js index ebfccf35..9ea770b1 100644 --- a/static/sw.js +++ b/static/sw.js @@ -39,28 +39,35 @@ const SHELL_ASSETS = [ './manifest.json', ]; -// Install: pre-cache the app shell +function deleteOldShellCaches() { + return caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ); +} + +// Install: prune old shell caches first, then pre-cache the app shell. Doing +// this before caches.open(CACHE_NAME) avoids a temporary double-cache window on +// quota-sensitive browsers during frequent version bumps. self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(SHELL_ASSETS).catch((err) => { - // Non-fatal: if any asset fails, still activate - console.warn('[sw] Shell pre-cache partial failure:', err); - }); - }) + deleteOldShellCaches().then(() => + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(SHELL_ASSETS).catch((err) => { + // Non-fatal: if any asset fails, still activate + console.warn('[sw] Shell pre-cache partial failure:', err); + }); + }) + ) ); self.skipWaiting(); }); -// Activate: clean up old caches +// Activate: keep the old-cache cleanup as a safety net in case install was +// interrupted or an older worker was already waiting. self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) - ) - ) - ); + event.waitUntil(deleteOldShellCaches()); self.clients.claim(); }); diff --git a/tests/test_issue2389_storage_pressure.py b/tests/test_issue2389_storage_pressure.py new file mode 100644 index 00000000..5ab8baac --- /dev/null +++ b/tests/test_issue2389_storage_pressure.py @@ -0,0 +1,59 @@ +"""Regression coverage for storage-pressure cleanup from issue #2389.""" +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SW_SRC = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") +SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_block(src: str, name: str, window: int = 1600) -> str: + idx = src.find(f"function {name}(") + assert idx != -1, f"missing function {name}" + return src[idx : idx + window] + + +def test_service_worker_install_deletes_old_caches_before_opening_new_cache(): + install_idx = SW_SRC.find("self.addEventListener('install'") + assert install_idx != -1, "service worker must define an install handler" + install_block = SW_SRC[install_idx : SW_SRC.find("self.addEventListener('activate'", install_idx)] + cleanup_idx = install_block.find("deleteOldShellCaches().then") + open_idx = install_block.find("caches.open(CACHE_NAME)") + assert cleanup_idx != -1, "install must delete stale shell caches before pre-cache" + assert open_idx != -1, "install must still pre-cache the current shell cache" + assert cleanup_idx < open_idx, ( + "opening the new shell cache before deleting old ones creates a temporary " + "double-cache window that increases quota pressure" + ) + + +def test_service_worker_keeps_activate_cleanup_safety_net(): + activate_idx = SW_SRC.find("self.addEventListener('activate'") + assert activate_idx != -1, "service worker must define an activate handler" + activate_block = SW_SRC[activate_idx : activate_idx + 500] + assert "event.waitUntil(deleteOldShellCaches())" in activate_block + assert "self.clients.claim()" in activate_block + + +def test_deleted_sessions_prune_all_session_tracking_maps(): + assert "const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';" in SESSIONS_SRC + assert "const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';" in SESSIONS_SRC + assert "const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';" in SESSIONS_SRC + assert "function _clearSessionViewedCount(sid)" in SESSIONS_SRC + + clear_block = _function_block(SESSIONS_SRC, "_clearHandoffStorageForSession") + assert "_clearSessionViewedCount(sid)" in clear_block + assert "_clearSessionCompletionUnread(sid)" in clear_block + assert "_forgetObservedStreamingSession(sid)" in clear_block + + +def test_session_viewed_count_prune_is_best_effort_and_persists_when_changed(): + viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount") + assert "Object.prototype.hasOwnProperty.call(counts, sid)" in viewed_block + assert "delete counts[sid]" in viewed_block + assert "_saveSessionViewedCounts()" in viewed_block + + clear_block = _function_block(SESSIONS_SRC, "_clearHandoffStorageForSession") + assert "try { _clearSessionViewedCount(sid); } catch {}" in clear_block + assert "try { _clearSessionCompletionUnread(sid); } catch {}" in clear_block + assert "try { _forgetObservedStreamingSession(sid); } catch {}" in clear_block