From 3fd550948d17fdf6c57650c7d3efa1ff7e3e1e90 Mon Sep 17 00:00:00 2001 From: Kalashnikxv Date: Tue, 3 Nov 2026 12:39:16 +0100 Subject: [PATCH 1/6] fix(storage): stop a full localStorage from hanging the boot splash On a localStorage at the WebView quota every setItem throws. The write in OnboardingProvider's effect was unguarded, so it threw during commit and took the tree down before heroReady could fire onReady: revealRoot() never ran and #harbor-boot stayed up forever over a hidden #root. The app looked frozen on the splash with no error, idle CPU and nothing on stderr. The recovery for this already existed but could never run: - proactiveStorageCleanup() was called from a useEffect in WebhookLoopMount, a component that only mounts after the render that dies. Call it at the top of mount() instead, before anything writes. - It only reached freeStorageSpace() when the prunable total passed 3.5 MB. A store filled mostly with keys we never prune stays under that while every write already throws, so it no-opped exactly when it was needed. Ask the store whether it still accepts a write instead. - The largest caches were not prunable at all. Add the refetchable ones: ebook OpenLibrary v2, ebook NYT, sports boards, MAL hero, anime art and hosted anime hero. Settings, profiles and installed add-ons stay untouched. OnboardingProvider now persists through setItemWithRecovery like the other callers, so a full store degrades instead of bricking the boot. Measured on a profile that reproduced the hang: cleanup takes localStorage from 3,159,696 to 1,093,786 chars and the app reaches Home. Co-Authored-By: Claude Opus 5 --- src/components/webhook-loop-mount.tsx | 5 -- src/lib/onboarding.tsx | 8 +++- src/lib/storage-recovery.ts | 25 +++++++++- src/main.tsx | 5 ++ tests/storage-quota-boot.test.ts | 68 +++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 tests/storage-quota-boot.test.ts diff --git a/src/components/webhook-loop-mount.tsx b/src/components/webhook-loop-mount.tsx index 5ac1436af..5e96dc3d1 100644 --- a/src/components/webhook-loop-mount.tsx +++ b/src/components/webhook-loop-mount.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef } from "react"; import { useAuth } from "@/lib/auth"; import { useSettings } from "@/lib/settings"; -import { proactiveStorageCleanup } from "@/lib/storage-recovery"; import { runWebhookTick } from "@/lib/webhook-engine"; const TICK_INTERVAL_MS = 30 * 60 * 1000; @@ -17,10 +16,6 @@ export function WebhookLoopMount() { authRef.current = authKey; const runningRef = useRef(false); - useEffect(() => { - proactiveStorageCleanup(); - }, []); - useEffect(() => { const hasDestination = !!settings.webhooks.discordUrl || diff --git a/src/lib/onboarding.tsx b/src/lib/onboarding.tsx index b6c822bae..3258d7fbe 100644 --- a/src/lib/onboarding.tsx +++ b/src/lib/onboarding.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode, } from "react"; +import { setItemWithRecovery } from "@/lib/storage-recovery"; type Flags = { onboarded: boolean; nudges: Record }; @@ -31,7 +32,7 @@ export function parseOnboardingFlags(raw: string | null): Flags { return { ...DEFAULT, ...parsed, - nudges: { [CONTENT_ADVISORY_NUDGE]: true, ...(parsed.nudges ?? {}) }, + nudges: { [CONTENT_ADVISORY_NUDGE]: true, ...parsed.nudges }, }; } catch { return DEFAULT; @@ -46,7 +47,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { ); useEffect(() => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(flags)); + // This provider wraps the whole app, so a bare setItem throwing here (a full store + // throws on every write) takes the tree down during commit, before anything can + // signal the boot splash to clear. Prune and keep going instead. + setItemWithRecovery(STORAGE_KEY, JSON.stringify(flags)); }, [flags]); const finishOnboarding = useCallback(() => setFlags((f) => ({ ...f, onboarded: true })), []); diff --git a/src/lib/storage-recovery.ts b/src/lib/storage-recovery.ts index 755722689..d884ad72e 100644 --- a/src/lib/storage-recovery.ts +++ b/src/lib/storage-recovery.ts @@ -46,7 +46,11 @@ const PRUNABLE_EXACT = new Set([ "harbor.streamBadges.v1", "harbor.manga.popular.v1", "harbor.ebook.metadata.v1", + "harbor.ebook.nyt.v1", "harbor.heroPool.v1", + "harbor.mal.hero.v1", + "harbor.anime.art.v2", + "harbor.anime.hero.hosted.v5", ]); const PRUNABLE_PREFIXES = [ @@ -56,6 +60,8 @@ const PRUNABLE_PREFIXES = [ "harbor.manga.cache.v2.", "harbor.manga.art.", "harbor.ebook.openlibrary.v1.", + "harbor.ebook.openlibrary.v2.", + "harbor.sports.board.v3:", "harbor.tvdbo.", "harbor.playback-history.v1.", ]; @@ -256,6 +262,23 @@ function purgeOldCacheVersions(): void { } } +const PROBE_KEY = "harbor.storage.probe"; + +/** + * The size thresholds below only measure what is prunable, so a store filled mostly by + * keys we never prune stays under them while every write already throws. Ask the store + * itself instead. + */ +function storageRejectsWrites(): boolean { + try { + localStorage.setItem(PROBE_KEY, "1"); + localStorage.removeItem(PROBE_KEY); + return false; + } catch (e) { + return isQuotaError(e); + } +} + export function proactiveStorageCleanup(): void { if (typeof localStorage === "undefined") return; purgeDeadCaches(); @@ -267,7 +290,7 @@ export function proactiveStorageCleanup(): void { total += size; if (size > PROACTIVE_PER_KEY_THRESHOLD) pruneOne(key); } - if (total > PROACTIVE_TOTAL_THRESHOLD) { + if (total > PROACTIVE_TOTAL_THRESHOLD || storageRejectsWrites()) { const r = freeStorageSpace(); console.info( `[storage] proactive total cleanup: ${r.pruned.length} caches, ${r.freedBytes} bytes`, diff --git a/src/main.tsx b/src/main.tsx index 9090ece50..9fcdd287d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import "@/index.css"; import "flag-icons/css/flag-icons.min.css"; import { startTaskbarProgress } from "@/lib/download/taskbar-progress"; import { completeBetaReturnPreferences } from "@/lib/updater/beta-return"; +import { proactiveStorageCleanup } from "@/lib/storage-recovery"; // Loaded only when a DJ window is actually opened; not part of the core entry graph. const DjDeckApp = lazy(() => @@ -221,6 +222,10 @@ function MainRoot() { } async function mount() { + // Must run before anything writes: on a full localStorage every setItem throws, and the + // first unguarded one during the initial render kills the tree before onReady fires, so + // the boot splash never clears. Cleaning up from inside a component is too late. + proactiveStorageCleanup(); await loadInstallFeatures(); const musicEnabled = getInstallFeatures().music; if ( diff --git a/tests/storage-quota-boot.test.ts b/tests/storage-quota-boot.test.ts new file mode 100644 index 000000000..bb8c86ef9 --- /dev/null +++ b/tests/storage-quota-boot.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * A full localStorage used to brick the boot: every setItem threw, the first unguarded one + * ran during the initial render, and the tree died before anything cleared the splash. + * proactiveStorageCleanup() is what has to make the store writable again, so it has to fire + * on a store that actually rejects writes — not only on one whose prunable half is large. + */ +function installQuotaStorage(seed: Array<[string, string]>) { + const store = new Map(); + const used = () => [...store].reduce((n, [k, v]) => n + k.length + v.length, 0); + // Seeded right up to the limit, so the store starts out rejecting every write. + let budget = Number.POSITIVE_INFINITY; + const stub = { + getItem: (k: string) => store.get(k) ?? null, + removeItem: (k: string) => void store.delete(k), + key: (i: number) => [...store.keys()][i] ?? null, + get length() { + return store.size; + }, + setItem(k: string, v: string) { + const next = + used() - (store.get(k)?.length ?? 0) - (store.has(k) ? k.length : 0) + k.length + v.length; + if (next > budget) throw new DOMException("quota", "QuotaExceededError"); + store.set(k, v); + }, + }; + for (const [k, v] of seed) store.set(k, v); + budget = used(); + (globalThis as { localStorage?: unknown }).localStorage = stub; + return store; +} + +const filler = (n: number) => "x".repeat(n); + +test("a localStorage that rejects every write is pruned back into a writable state", async () => { + // Full, but the prunable share stays under PROACTIVE_TOTAL_THRESHOLD, which is exactly the + // shape that used to slip past the size heuristics and leave the store unwritable. + const store = installQuotaStorage([ + ["harbor.settings", filler(300_000)], + ["harbor.installed-addons.p_demo", filler(200_000)], + ["harbor.mal.hero.v1", filler(200_000)], + ["harbor.ebook.openlibrary.v2.anonymous.abc", filler(150_000)], + ["harbor.sports.board.v3:SOCCER@20261031@day", filler(140_000)], + ]); + + assert.throws(() => localStorage.setItem("harbor.onboarding", "{}")); + + const { proactiveStorageCleanup } = await import("@/lib/storage-recovery"); + proactiveStorageCleanup(); + + localStorage.setItem("harbor.onboarding", "{}"); + assert.equal(store.get("harbor.onboarding"), "{}"); + + // Derived caches are refetchable; user data is not. + assert.equal(store.has("harbor.mal.hero.v1"), false); + assert.equal(store.has("harbor.ebook.openlibrary.v2.anonymous.abc"), false); + assert.equal(store.has("harbor.sports.board.v3:SOCCER@20261031@day"), false); + assert.ok(store.has("harbor.settings")); + assert.ok(store.has("harbor.installed-addons.p_demo")); +}); + +test("setItemWithRecovery reports failure instead of throwing when nothing is left to prune", async () => { + installQuotaStorage([["harbor.settings", filler(900)]]); + const { setItemWithRecovery } = await import("@/lib/storage-recovery"); + assert.equal(setItemWithRecovery("harbor.onboarding", filler(500)), false); +}); From 393e66397d59282b22016ad212d60c812f359f12 Mon Sep 17 00:00:00 2001 From: Kalashnikxv Date: Thu, 5 Nov 2026 11:00:14 +0100 Subject: [PATCH 2/6] fix(storage): keep user records out of the prunable lists storageRejectsWrites() is what finally makes freeStorageSpace() run at boot, which turned a dormant bug into data loss on the first launch with a full store: the lists held playback history, the last season picked, live-TV watch counts and the legacy parental key, which still carries hiddenTabs and pinHash, so recovery could silently unlock parental controls. Those are out now, and the boot path frees only what it takes to make the store writable again instead of dropping every cache. Co-Authored-By: Claude Opus 5 --- src/lib/storage-recovery.ts | 24 ++++++++++++----- tests/storage-quota-boot.test.ts | 44 +++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/lib/storage-recovery.ts b/src/lib/storage-recovery.ts index d884ad72e..73acb87ec 100644 --- a/src/lib/storage-recovery.ts +++ b/src/lib/storage-recovery.ts @@ -1,10 +1,12 @@ +// Prunable means the network or a recomputation can give it back. Anything the user did — +// playback history, last season, live-TV watch counts, the legacy parental pin — stays, even +// when it is the biggest thing in the store. Check backup.ts before adding a key here. const PRUNABLE_EXACT = new Set([ "harbor.omdb.v1", "harbor.omdb.misses", "harbor.omdb.budget", "harbor.iptv.hydration.v2", "harbor.iptv.epgmap.v1", - "harbor.iptv.stats.v1", "harbor.dead-streams.v1", "harbor.discover.v1", "harbor.discover.events.v1", @@ -38,11 +40,8 @@ const PRUNABLE_EXACT = new Set([ "harbor.shows.hero.pool.v2", "harbor.mdblist.cards", "harbor.build.rating.v1", - "harbor.parental", - "harbor.lastseason.v1", "harbor.surprise.recent.v1", "harbor.stremio-addons.velocity.v1", - "harbor.playback-history.v1", "harbor.streamBadges.v1", "harbor.manga.popular.v1", "harbor.ebook.metadata.v1", @@ -63,7 +62,6 @@ const PRUNABLE_PREFIXES = [ "harbor.ebook.openlibrary.v2.", "harbor.sports.board.v3:", "harbor.tvdbo.", - "harbor.playback-history.v1.", ]; function isPrunable(key: string): boolean { @@ -279,6 +277,18 @@ function storageRejectsWrites(): boolean { } } +/** + * Boot only: the store already rejects every write, so free just enough to get going again. + * Dropping every cache instead would cost the user a full refetch of the home feed to buy + * headroom nothing is asking for. + */ +function freeUntilWritable(): void { + for (const { key } of prunableEntries()) { + pruneOne(key); + if (!storageRejectsWrites()) return; + } +} + export function proactiveStorageCleanup(): void { if (typeof localStorage === "undefined") return; purgeDeadCaches(); @@ -290,10 +300,12 @@ export function proactiveStorageCleanup(): void { total += size; if (size > PROACTIVE_PER_KEY_THRESHOLD) pruneOne(key); } - if (total > PROACTIVE_TOTAL_THRESHOLD || storageRejectsWrites()) { + if (total > PROACTIVE_TOTAL_THRESHOLD) { const r = freeStorageSpace(); console.info( `[storage] proactive total cleanup: ${r.pruned.length} caches, ${r.freedBytes} bytes`, ); + } else if (storageRejectsWrites()) { + freeUntilWritable(); } } diff --git a/tests/storage-quota-boot.test.ts b/tests/storage-quota-boot.test.ts index bb8c86ef9..139338624 100644 --- a/tests/storage-quota-boot.test.ts +++ b/tests/storage-quota-boot.test.ts @@ -37,12 +37,15 @@ const filler = (n: number) => "x".repeat(n); test("a localStorage that rejects every write is pruned back into a writable state", async () => { // Full, but the prunable share stays under PROACTIVE_TOTAL_THRESHOLD, which is exactly the // shape that used to slip past the size heuristics and leave the store unwritable. + // The user's own records are deliberately the biggest things in here: if any of them were + // ever treated as a disposable cache, they would be the first to go. const store = installQuotaStorage([ - ["harbor.settings", filler(300_000)], - ["harbor.installed-addons.p_demo", filler(200_000)], - ["harbor.mal.hero.v1", filler(200_000)], - ["harbor.ebook.openlibrary.v2.anonymous.abc", filler(150_000)], - ["harbor.sports.board.v3:SOCCER@20261031@day", filler(140_000)], + ["harbor.playback-history.v1.p_demo", filler(400_000)], + ["harbor.iptv.stats.v1", filler(300_000)], + ["harbor.settings", filler(200_000)], + ["harbor.lastseason.v1", filler(10_000)], + ["harbor.parental", filler(400)], + ["harbor.mal.hero.v1", filler(100_000)], ]); assert.throws(() => localStorage.setItem("harbor.onboarding", "{}")); @@ -53,12 +56,35 @@ test("a localStorage that rejects every write is pruned back into a writable sta localStorage.setItem("harbor.onboarding", "{}"); assert.equal(store.get("harbor.onboarding"), "{}"); - // Derived caches are refetchable; user data is not. + // Derived caches are refetchable, so the largest goes first. assert.equal(store.has("harbor.mal.hero.v1"), false); - assert.equal(store.has("harbor.ebook.openlibrary.v2.anonymous.abc"), false); - assert.equal(store.has("harbor.sports.board.v3:SOCCER@20261031@day"), false); + + // Nothing the user did is evictable, however big it is or however full the store gets: + // continue watching, the last season picked, live-TV watch counts, the legacy parental pin. + assert.ok(store.has("harbor.playback-history.v1.p_demo")); + assert.ok(store.has("harbor.iptv.stats.v1")); + assert.ok(store.has("harbor.lastseason.v1")); + assert.ok(store.has("harbor.parental")); assert.ok(store.has("harbor.settings")); - assert.ok(store.has("harbor.installed-addons.p_demo")); +}); + +test("boot cleanup stops as soon as the store takes writes again", async () => { + // Freeing the one biggest cache is enough here, so the rest must still be there: buying + // headroom nobody asked for costs the user a refetch of everything it dropped. + const store = installQuotaStorage([ + ["harbor.settings", filler(300_000)], + ["harbor.mal.hero.v1", filler(200_000)], + ["harbor.ebook.openlibrary.v2.anonymous.abc", filler(150_000)], + ["harbor.sports.board.v3:SOCCER@20261031@day", filler(140_000)], + ]); + + const { proactiveStorageCleanup } = await import("@/lib/storage-recovery"); + proactiveStorageCleanup(); + + localStorage.setItem("harbor.onboarding", "{}"); + assert.equal(store.has("harbor.mal.hero.v1"), false); + assert.ok(store.has("harbor.ebook.openlibrary.v2.anonymous.abc")); + assert.ok(store.has("harbor.sports.board.v3:SOCCER@20261031@day")); }); test("setItemWithRecovery reports failure instead of throwing when nothing is left to prune", async () => { From 4dcb55d007c1acaded057f9e55f2654e7867410f Mon Sep 17 00:00:00 2001 From: Kalashnikxv Date: Fri, 6 Nov 2026 00:33:02 +0100 Subject: [PATCH 3/6] fix(storage): stop pruning EPG matches and Discover affinity, reach the live caches Manual EPG matches come from the Live TV match dialog and Discover's affinity is built from up to 500 recorded interactions, so neither comes back from the network. The build rating goes too, and so do the anti-repeat lists for surprise-me and top picks: a few KB each, nothing to gain by risking them. The other half of the same list was pinning cache versions, so seven live caches had quietly stopped being prunable - jikancatalog2, armkitsucache.v3 and the rest of the anime mapping set, which are exactly the ones that grow. They are prefixes now, matched whatever the owner bumps them to. Ordering in the existing tests could leave a wrongly listed key untouched, so the list has its own guard over the full sweep. --- src/lib/storage-recovery.ts | 41 +++++++++---------- tests/storage-quota-boot.test.ts | 70 ++++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/lib/storage-recovery.ts b/src/lib/storage-recovery.ts index 73acb87ec..a3ea8cbc7 100644 --- a/src/lib/storage-recovery.ts +++ b/src/lib/storage-recovery.ts @@ -1,46 +1,30 @@ -// Prunable means the network or a recomputation can give it back. Anything the user did — -// playback history, last season, live-TV watch counts, the legacy parental pin — stays, even -// when it is the biggest thing in the store. Check backup.ts before adding a key here. +// Prunable means the network or a recomputation can give it back. Anything the user did stays, +// even when it is the biggest thing in the store: playback history, last season, live-TV watch +// counts and manual EPG matches, Discover's learned affinity, the legacy parental pin, the build +// rating. Open the module that owns a key before adding it here — backup.ts groups by prefix, so +// its sections mix real records with caches and cannot answer this on their own. Keys of a few KB +// are not worth the risk either way; this list is for what actually fills the store. const PRUNABLE_EXACT = new Set([ "harbor.omdb.v1", "harbor.omdb.misses", "harbor.omdb.budget", "harbor.iptv.hydration.v2", - "harbor.iptv.epgmap.v1", "harbor.dead-streams.v1", - "harbor.discover.v1", "harbor.discover.events.v1", "harbor.discover.awards.v1", "harbor.discover.rows.v1", "harbor.scroll.v1", "harbor.webhook.lastTick", "harbor.calendar.webhook.last", - "harbor.anidbtvdbcache", - "harbor.armcache", - "harbor.armkitsucache", - "harbor.armsrcmalcache", - "harbor.extkitsucache", "harbor.imdb.tmdb.v1", "harbor.tmdb.imdb.v1", "harbor.tmdb.personName.v1", - "harbor.awards.wikidata.v8", - "harbor.awards.wikidata.v9", - "harbor.awards.wikidata.v10", - "harbor.anime_awards.metas.v2", "harbor.animefillercache.v2", - "harbor.jikancatalog", "harbor.malscorecache", "harbor.anime.recs_by_mal.v1", - "harbor.anime.toppicks.cache.v1", - "harbor.anime.toppicks.shown.v1", - "harbor.anime.toppicks.visit.v1", "harbor.anime.mal_id_by_franchise.v1", - "harbor.picker-cache.v4", - "harbor.picker-cache.v5", "harbor.shows.hero.pool.v2", "harbor.mdblist.cards", - "harbor.build.rating.v1", - "harbor.surprise.recent.v1", "harbor.stremio-addons.velocity.v1", "harbor.streamBadges.v1", "harbor.manga.popular.v1", @@ -52,7 +36,20 @@ const PRUNABLE_EXACT = new Set([ "harbor.anime.hero.hosted.v5", ]); +// Prefixes, not exact keys, for every cache whose owner version-stamps it: pinning a version +// here means the live cache silently stops being prunable the day someone bumps it, which is +// how seven of these ended up unreachable. const PRUNABLE_PREFIXES = [ + "harbor.anidbtvdbcache", + "harbor.armcache", + "harbor.armkitsucache", + "harbor.armsrcmalcache", + "harbor.extkitsucache", + "harbor.jikancatalog", + "harbor.anime_awards.metas.", + "harbor.anime.toppicks.cache.", + "harbor.awards.wikidata.v", + "harbor.graph.wikidata.", "harbor.libraryNameRepair.v1.", "harbor.anilist.collection.v1.", "harbor.manga.cache.v1.", diff --git a/tests/storage-quota-boot.test.ts b/tests/storage-quota-boot.test.ts index 139338624..125bee502 100644 --- a/tests/storage-quota-boot.test.ts +++ b/tests/storage-quota-boot.test.ts @@ -37,14 +37,9 @@ const filler = (n: number) => "x".repeat(n); test("a localStorage that rejects every write is pruned back into a writable state", async () => { // Full, but the prunable share stays under PROACTIVE_TOTAL_THRESHOLD, which is exactly the // shape that used to slip past the size heuristics and leave the store unwritable. - // The user's own records are deliberately the biggest things in here: if any of them were - // ever treated as a disposable cache, they would be the first to go. const store = installQuotaStorage([ ["harbor.playback-history.v1.p_demo", filler(400_000)], - ["harbor.iptv.stats.v1", filler(300_000)], ["harbor.settings", filler(200_000)], - ["harbor.lastseason.v1", filler(10_000)], - ["harbor.parental", filler(400)], ["harbor.mal.hero.v1", filler(100_000)], ]); @@ -56,15 +51,9 @@ test("a localStorage that rejects every write is pruned back into a writable sta localStorage.setItem("harbor.onboarding", "{}"); assert.equal(store.get("harbor.onboarding"), "{}"); - // Derived caches are refetchable, so the largest goes first. + // The cache goes; the 400k of continue watching next to it does not, however full we are. assert.equal(store.has("harbor.mal.hero.v1"), false); - - // Nothing the user did is evictable, however big it is or however full the store gets: - // continue watching, the last season picked, live-TV watch counts, the legacy parental pin. assert.ok(store.has("harbor.playback-history.v1.p_demo")); - assert.ok(store.has("harbor.iptv.stats.v1")); - assert.ok(store.has("harbor.lastseason.v1")); - assert.ok(store.has("harbor.parental")); assert.ok(store.has("harbor.settings")); }); @@ -92,3 +81,60 @@ test("setItemWithRecovery reports failure instead of throwing when nothing is le const { setItemWithRecovery } = await import("@/lib/storage-recovery"); assert.equal(setItemWithRecovery("harbor.onboarding", filler(500)), false); }); + +/** + * The ordering in the tests above means a wrongly listed key may simply never be reached, so + * guard the list itself with the sweep that spares nothing prunable. + */ +test("the full sweep evicts caches and nothing the user did", async () => { + const store = installQuotaStorage([ + ["harbor.playback-history.v1.p_demo", filler(1_000)], + ["harbor.iptv.stats.v1", filler(1_000)], + ["harbor.iptv.epgmap.v1", filler(1_000)], + ["harbor.discover.v1", filler(1_000)], + ["harbor.lastseason.v1", filler(1_000)], + ["harbor.parental", filler(1_000)], + ["harbor.build.rating.v1", filler(1_000)], + ["harbor.settings", filler(1_000)], + ["harbor.mal.hero.v1", filler(1_000)], + ["harbor.tvdbo.tt0903747", filler(1_000)], + ]); + + const { freeStorageSpace } = await import("@/lib/storage-recovery"); + freeStorageSpace(); + + assert.deepEqual([...store.keys()].sort(), [ + "harbor.build.rating.v1", + "harbor.discover.v1", + "harbor.iptv.epgmap.v1", + "harbor.iptv.stats.v1", + "harbor.lastseason.v1", + "harbor.parental", + "harbor.playback-history.v1.p_demo", + "harbor.settings", + ]); +}); + +test("a cache stays prunable after its owner bumps the version", async () => { + // Pinning versions in the list is how harbor.jikancatalog2, harbor.armkitsucache.v3 and five + // others quietly became unreachable: the list still named the version before. + const store = installQuotaStorage([ + ["harbor.jikancatalog2", filler(1_000)], + ["harbor.armkitsucache.v3", filler(1_000)], + ["harbor.anidbtvdbcache.v2", filler(1_000)], + ["harbor.armsrcmalcache.v2", filler(1_000)], + ["harbor.extkitsucache.v2", filler(1_000)], + ["harbor.anime_awards.metas.v6", filler(1_000)], + ["harbor.anime.toppicks.cache.v2", filler(1_000)], + ["harbor.awards.wikidata.v10", filler(1_000)], + ["harbor.graph.wikidata.v2", filler(1_000)], + // A version nobody has shipped yet: the next bump must not need this list touched again. + ["harbor.armkitsucache.v99", filler(1_000)], + ["harbor.settings", filler(1_000)], + ]); + + const { freeStorageSpace } = await import("@/lib/storage-recovery"); + freeStorageSpace(); + + assert.deepEqual([...store.keys()], ["harbor.settings"]); +}); From a6c2f6a78f849522ddf8b3af3a58e86ff858f9b2 Mon Sep 17 00:00:00 2001 From: Kalashnikxv Date: Fri, 6 Nov 2026 13:49:27 +0100 Subject: [PATCH 4/6] fix(storage): protect uploaded badges, size the probe, keep aux windows out harbor.streamBadges.v1 holds badge images the user uploaded as data URLs, up to 260KB each, plus the rules they wrote - none of it refetchable, and largest-first pruning reaches for it early. Out of the list. The write probe asked for one character, so recovery could stop with a few hundred bytes free and the next settings or profile write still threw. It asks for 32KB now. Cleanup ran before the window dispatch in main.tsx, so opening a PIP, modal, captions, DJ or overlay window could evict caches the main window was using. Gated to the main window. PRUNABLE_EXACT is gone: one prefix list, nothing with a version stamp, since the exact/prefix split is what let the pinning happen. The boot call is wrapped too - a blocked or corrupt store rejecting mount() is the failure this is meant to prevent. --- src/lib/storage-recovery.ts | 111 ++++++++++++++++--------------- src/main.tsx | 7 +- tests/storage-quota-boot.test.ts | 30 ++++++++- 3 files changed, 94 insertions(+), 54 deletions(-) diff --git a/src/lib/storage-recovery.ts b/src/lib/storage-recovery.ts index a3ea8cbc7..3836176bf 100644 --- a/src/lib/storage-recovery.ts +++ b/src/lib/storage-recovery.ts @@ -1,68 +1,61 @@ -// Prunable means the network or a recomputation can give it back. Anything the user did stays, -// even when it is the biggest thing in the store: playback history, last season, live-TV watch -// counts and manual EPG matches, Discover's learned affinity, the legacy parental pin, the build -// rating. Open the module that owns a key before adding it here — backup.ts groups by prefix, so -// its sections mix real records with caches and cannot answer this on their own. Keys of a few KB -// are not worth the risk either way; this list is for what actually fills the store. -const PRUNABLE_EXACT = new Set([ - "harbor.omdb.v1", - "harbor.omdb.misses", - "harbor.omdb.budget", - "harbor.iptv.hydration.v2", - "harbor.dead-streams.v1", - "harbor.discover.events.v1", - "harbor.discover.awards.v1", - "harbor.discover.rows.v1", - "harbor.scroll.v1", +// What may be evicted when the store is full. Prunable means the network or a recomputation can +// give it back; anything the user made or did stays, however big it is: playback history, last +// season, live-TV watch counts and manual EPG matches, Discover's learned affinity, uploaded +// stream badges and their rules, the legacy parental pin, the build rating. Open the module that +// owns a key before adding it here - backup.ts groups by prefix, so its sections mix real records +// with caches and cannot answer this on their own. Keys of a few KB are not worth the risk either +// way; this list is for what actually fills the store. +// +// These are prefixes, and stop before any version stamp on purpose. Pinning a version means the +// live cache silently stops being prunable the day its owner bumps it, which is how seven of +// these were unreachable until now. +const PRUNABLE_PREFIXES = [ + "harbor.omdb.", + "harbor.iptv.hydration.v", + "harbor.dead-streams.v", + "harbor.discover.events.v", + "harbor.discover.awards.v", + "harbor.discover.rows.v", + "harbor.scroll.v", "harbor.webhook.lastTick", "harbor.calendar.webhook.last", - "harbor.imdb.tmdb.v1", - "harbor.tmdb.imdb.v1", - "harbor.tmdb.personName.v1", - "harbor.animefillercache.v2", - "harbor.malscorecache", - "harbor.anime.recs_by_mal.v1", - "harbor.anime.mal_id_by_franchise.v1", - "harbor.shows.hero.pool.v2", - "harbor.mdblist.cards", - "harbor.stremio-addons.velocity.v1", - "harbor.streamBadges.v1", - "harbor.manga.popular.v1", - "harbor.ebook.metadata.v1", - "harbor.ebook.nyt.v1", - "harbor.heroPool.v1", - "harbor.mal.hero.v1", - "harbor.anime.art.v2", - "harbor.anime.hero.hosted.v5", -]); - -// Prefixes, not exact keys, for every cache whose owner version-stamps it: pinning a version -// here means the live cache silently stops being prunable the day someone bumps it, which is -// how seven of these ended up unreachable. -const PRUNABLE_PREFIXES = [ "harbor.anidbtvdbcache", "harbor.armcache", "harbor.armkitsucache", "harbor.armsrcmalcache", "harbor.extkitsucache", "harbor.jikancatalog", - "harbor.anime_awards.metas.", - "harbor.anime.toppicks.cache.", + "harbor.imdb.tmdb.v", + "harbor.tmdb.imdb.v", + "harbor.tmdb.personName.v", "harbor.awards.wikidata.v", "harbor.graph.wikidata.", - "harbor.libraryNameRepair.v1.", - "harbor.anilist.collection.v1.", - "harbor.manga.cache.v1.", - "harbor.manga.cache.v2.", + "harbor.anime_awards.metas.", + "harbor.animefillercache.v", + "harbor.malscorecache", + "harbor.anime.recs_by_mal.v", + "harbor.anime.toppicks.cache.", + "harbor.anime.mal_id_by_franchise.v", + "harbor.anime.art.v", + "harbor.anime.hero.hosted.v", + "harbor.anilist.collection.v", + "harbor.shows.hero.pool.v", + "harbor.heroPool.v", + "harbor.mal.hero.v", + "harbor.mdblist.cards", + "harbor.stremio-addons.velocity.v", + "harbor.manga.popular.v", + "harbor.manga.cache.v", "harbor.manga.art.", - "harbor.ebook.openlibrary.v1.", - "harbor.ebook.openlibrary.v2.", - "harbor.sports.board.v3:", + "harbor.ebook.metadata.v", + "harbor.ebook.nyt.v", + "harbor.ebook.openlibrary.v", + "harbor.sports.board.v", + "harbor.libraryNameRepair.v", "harbor.tvdbo.", ]; function isPrunable(key: string): boolean { - if (PRUNABLE_EXACT.has(key)) return true; return PRUNABLE_PREFIXES.some((p) => key.startsWith(p)); } @@ -258,6 +251,9 @@ function purgeOldCacheVersions(): void { } const PROBE_KEY = "harbor.storage.probe"; +/** Room for one more character proves nothing: the next boot write is a settings or profile + * blob, so ask for enough space to land one of those. */ +const PROBE_CHARS = 32 * 1024; /** * The size thresholds below only measure what is prunable, so a store filled mostly by @@ -266,16 +262,17 @@ const PROBE_KEY = "harbor.storage.probe"; */ function storageRejectsWrites(): boolean { try { - localStorage.setItem(PROBE_KEY, "1"); + localStorage.setItem(PROBE_KEY, "x".repeat(PROBE_CHARS)); localStorage.removeItem(PROBE_KEY); return false; } catch (e) { + localStorage.removeItem(PROBE_KEY); return isQuotaError(e); } } /** - * Boot only: the store already rejects every write, so free just enough to get going again. + * Boot only: the store cannot take a realistic write, so free until it can and stop there. * Dropping every cache instead would cost the user a full refetch of the home feed to buy * headroom nothing is asking for. */ @@ -288,6 +285,16 @@ function freeUntilWritable(): void { export function proactiveStorageCleanup(): void { if (typeof localStorage === "undefined") return; + // Runs before createRoot, so a storage that is blocked or corrupt rather than merely full + // must not reject mount() - that leaves the boot splash up with nothing behind it. + try { + pruneUntilUsable(); + } catch (e) { + console.warn("[storage] proactive cleanup skipped", e); + } +} + +function pruneUntilUsable(): void { purgeDeadCaches(); purgeOldCacheVersions(); capLruPrefixes(); diff --git a/src/main.tsx b/src/main.tsx index 9fcdd287d..78a2b1925 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -221,11 +221,16 @@ function MainRoot() { return ; } +/** PIP, modal, captions, HDR overlay, DJ deck and the detached player all load this same + * index.html and share its localStorage. Opening one must not evict caches the main window + * is in the middle of using. */ +const isAuxWindow = isPip || isVideoPip || isModal || isHdrOverlay || isCaptions || isDjDeck; + async function mount() { // Must run before anything writes: on a full localStorage every setItem throws, and the // first unguarded one during the initial render kills the tree before onReady fires, so // the boot splash never clears. Cleaning up from inside a component is too late. - proactiveStorageCleanup(); + if (!isAuxWindow) proactiveStorageCleanup(); await loadInstallFeatures(); const musicEnabled = getInstallFeatures().music; if ( diff --git a/tests/storage-quota-boot.test.ts b/tests/storage-quota-boot.test.ts index 125bee502..0e2f43cb0 100644 --- a/tests/storage-quota-boot.test.ts +++ b/tests/storage-quota-boot.test.ts @@ -39,6 +39,7 @@ test("a localStorage that rejects every write is pruned back into a writable sta // shape that used to slip past the size heuristics and leave the store unwritable. const store = installQuotaStorage([ ["harbor.playback-history.v1.p_demo", filler(400_000)], + ["harbor.streamBadges.v1", filler(250_000)], ["harbor.settings", filler(200_000)], ["harbor.mal.hero.v1", filler(100_000)], ]); @@ -54,6 +55,7 @@ test("a localStorage that rejects every write is pruned back into a writable sta // The cache goes; the 400k of continue watching next to it does not, however full we are. assert.equal(store.has("harbor.mal.hero.v1"), false); assert.ok(store.has("harbor.playback-history.v1.p_demo")); + assert.ok(store.has("harbor.streamBadges.v1")); assert.ok(store.has("harbor.settings")); }); @@ -95,6 +97,7 @@ test("the full sweep evicts caches and nothing the user did", async () => { ["harbor.lastseason.v1", filler(1_000)], ["harbor.parental", filler(1_000)], ["harbor.build.rating.v1", filler(1_000)], + ["harbor.streamBadges.v1", filler(1_000)], ["harbor.settings", filler(1_000)], ["harbor.mal.hero.v1", filler(1_000)], ["harbor.tvdbo.tt0903747", filler(1_000)], @@ -112,6 +115,7 @@ test("the full sweep evicts caches and nothing the user did", async () => { "harbor.parental", "harbor.playback-history.v1.p_demo", "harbor.settings", + "harbor.streamBadges.v1", ]); }); @@ -128,8 +132,18 @@ test("a cache stays prunable after its owner bumps the version", async () => { ["harbor.anime.toppicks.cache.v2", filler(1_000)], ["harbor.awards.wikidata.v10", filler(1_000)], ["harbor.graph.wikidata.v2", filler(1_000)], - // A version nobody has shipped yet: the next bump must not need this list touched again. + ["harbor.mal.hero.v1", filler(1_000)], + ["harbor.ebook.nyt.v1", filler(1_000)], + ["harbor.anime.art.v2", filler(1_000)], + ["harbor.anime.hero.hosted.v5", filler(1_000)], + ["harbor.sports.board.v3:SOCCER@20261031@day", filler(1_000)], + ["harbor.ebook.openlibrary.v2.anonymous.abc", filler(1_000)], + ["harbor.manga.cache.v2.one-piece", filler(1_000)], + // Versions nobody has shipped yet: the next bump must not need this list touched again. ["harbor.armkitsucache.v99", filler(1_000)], + ["harbor.mal.hero.v9", filler(1_000)], + ["harbor.sports.board.v9:SOCCER@20261031@day", filler(1_000)], + ["harbor.manga.cache.v9.one-piece", filler(1_000)], ["harbor.settings", filler(1_000)], ]); @@ -138,3 +152,17 @@ test("a cache stays prunable after its owner bumps the version", async () => { assert.deepEqual([...store.keys()], ["harbor.settings"]); }); + +test("boot recovery frees room for a real write, not for one character", async () => { + // Many small caches behind a big record: a one-character probe is satisfied by the first + // prune, and the settings or profile blob that follows still throws. + const seed: Array<[string, string]> = [["harbor.settings", filler(500_000)]]; + for (let i = 0; i < 10; i++) seed.push([`harbor.tvdbo.tt${i}`, filler(5_000)]); + installQuotaStorage(seed); + + const { proactiveStorageCleanup } = await import("@/lib/storage-recovery"); + proactiveStorageCleanup(); + + // Four times the largest prunable entry, so only a real headroom target can cover it. + localStorage.setItem("harbor.profiles.v1", filler(20_000)); +}); From 82a2a3f011f2a24ceb00a59d706aeb9607d08d59 Mon Sep 17 00:00:00 2001 From: Thunderhawkk Date: Fri, 6 Nov 2026 20:02:04 +0530 Subject: [PATCH 5/6] fix(storage): protect library repair state and OMDb usage budget Preserve the one-time library-name repair marker and the OMDb daily request counter during quota recovery. Keep only rebuildable OMDb rating and miss caches prunable, and test both boot recovery and full-sweep eviction. --- src/lib/storage-recovery.ts | 9 ++++++--- tests/storage-quota-boot.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/lib/storage-recovery.ts b/src/lib/storage-recovery.ts index 3836176bf..e75ea0d9b 100644 --- a/src/lib/storage-recovery.ts +++ b/src/lib/storage-recovery.ts @@ -1,7 +1,8 @@ // What may be evicted when the store is full. Prunable means the network or a recomputation can // give it back; anything the user made or did stays, however big it is: playback history, last // season, live-TV watch counts and manual EPG matches, Discover's learned affinity, uploaded -// stream badges and their rules, the legacy parental pin, the build rating. Open the module that +// stream badges and their rules, the legacy parental pin, the build rating, completed library +// repairs, and OMDb's daily request budget. Open the module that // owns a key before adding it here - backup.ts groups by prefix, so its sections mix real records // with caches and cannot answer this on their own. Keys of a few KB are not worth the risk either // way; this list is for what actually fills the store. @@ -10,7 +11,10 @@ // live cache silently stops being prunable the day its owner bumps it, which is how seven of // these were unreachable until now. const PRUNABLE_PREFIXES = [ - "harbor.omdb.", + // Only OMDb's rating and negative-result caches are rebuildable. The daily + // request budget and key-validity state must survive recovery. + "harbor.omdb.v", + "harbor.omdb.misses", "harbor.iptv.hydration.v", "harbor.dead-streams.v", "harbor.discover.events.v", @@ -51,7 +55,6 @@ const PRUNABLE_PREFIXES = [ "harbor.ebook.nyt.v", "harbor.ebook.openlibrary.v", "harbor.sports.board.v", - "harbor.libraryNameRepair.v", "harbor.tvdbo.", ]; diff --git a/tests/storage-quota-boot.test.ts b/tests/storage-quota-boot.test.ts index 0e2f43cb0..fe4e4525f 100644 --- a/tests/storage-quota-boot.test.ts +++ b/tests/storage-quota-boot.test.ts @@ -40,6 +40,17 @@ test("a localStorage that rejects every write is pruned back into a writable sta const store = installQuotaStorage([ ["harbor.playback-history.v1.p_demo", filler(400_000)], ["harbor.streamBadges.v1", filler(250_000)], + ["harbor.libraryNameRepair.v1.p_demo", "1699999999999"], + [ + "harbor.omdb.budget", + JSON.stringify({ + used: 920, + limit: 1000, + resetAt: 2000000000000, + exhausted: false, + keyInvalid: false, + }), + ], ["harbor.settings", filler(200_000)], ["harbor.mal.hero.v1", filler(100_000)], ]); @@ -56,6 +67,8 @@ test("a localStorage that rejects every write is pruned back into a writable sta assert.equal(store.has("harbor.mal.hero.v1"), false); assert.ok(store.has("harbor.playback-history.v1.p_demo")); assert.ok(store.has("harbor.streamBadges.v1")); + assert.equal(store.get("harbor.libraryNameRepair.v1.p_demo"), "1699999999999"); + assert.equal(JSON.parse(store.get("harbor.omdb.budget")!).used, 920); assert.ok(store.has("harbor.settings")); }); @@ -98,7 +111,21 @@ test("the full sweep evicts caches and nothing the user did", async () => { ["harbor.parental", filler(1_000)], ["harbor.build.rating.v1", filler(1_000)], ["harbor.streamBadges.v1", filler(1_000)], + ["harbor.libraryNameRepair.v1.p_demo", "1699999999999"], + ["harbor.libraryNameRepair.v99.p_demo", "1699999999999"], + [ + "harbor.omdb.budget", + JSON.stringify({ + used: 920, + limit: 1000, + resetAt: 2000000000000, + exhausted: false, + keyInvalid: false, + }), + ], ["harbor.settings", filler(1_000)], + ["harbor.omdb.v1", filler(1_000)], + ["harbor.omdb.misses", filler(1_000)], ["harbor.mal.hero.v1", filler(1_000)], ["harbor.tvdbo.tt0903747", filler(1_000)], ]); @@ -112,6 +139,9 @@ test("the full sweep evicts caches and nothing the user did", async () => { "harbor.iptv.epgmap.v1", "harbor.iptv.stats.v1", "harbor.lastseason.v1", + "harbor.libraryNameRepair.v1.p_demo", + "harbor.libraryNameRepair.v99.p_demo", + "harbor.omdb.budget", "harbor.parental", "harbor.playback-history.v1.p_demo", "harbor.settings", From 0621f161c2cf83a713104dc4d8f39af9c5f6e784 Mon Sep 17 00:00:00 2001 From: Thunderhawkk Date: Fri, 6 Nov 2026 20:18:31 +0530 Subject: [PATCH 6/6] fix(storage): preserve webhook and addon velocity history Exclude webhook delivery baselines and historical addon-star snapshots from automatic eviction. Add boot quota and full-sweep regressions guarding both records. --- src/lib/storage-recovery.ts | 5 ++-- tests/storage-quota-boot.test.ts | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/lib/storage-recovery.ts b/src/lib/storage-recovery.ts index e75ea0d9b..feab7d071 100644 --- a/src/lib/storage-recovery.ts +++ b/src/lib/storage-recovery.ts @@ -2,7 +2,8 @@ // give it back; anything the user made or did stays, however big it is: playback history, last // season, live-TV watch counts and manual EPG matches, Discover's learned affinity, uploaded // stream badges and their rules, the legacy parental pin, the build rating, completed library -// repairs, and OMDb's daily request budget. Open the module that +// repairs, webhook delivery history, addon velocity history, and OMDb's daily request +// budget. Open the module that // owns a key before adding it here - backup.ts groups by prefix, so its sections mix real records // with caches and cannot answer this on their own. Keys of a few KB are not worth the risk either // way; this list is for what actually fills the store. @@ -22,7 +23,6 @@ const PRUNABLE_PREFIXES = [ "harbor.discover.rows.v", "harbor.scroll.v", "harbor.webhook.lastTick", - "harbor.calendar.webhook.last", "harbor.anidbtvdbcache", "harbor.armcache", "harbor.armkitsucache", @@ -47,7 +47,6 @@ const PRUNABLE_PREFIXES = [ "harbor.heroPool.v", "harbor.mal.hero.v", "harbor.mdblist.cards", - "harbor.stremio-addons.velocity.v", "harbor.manga.popular.v", "harbor.manga.cache.v", "harbor.manga.art.", diff --git a/tests/storage-quota-boot.test.ts b/tests/storage-quota-boot.test.ts index fe4e4525f..84cb4b0e9 100644 --- a/tests/storage-quota-boot.test.ts +++ b/tests/storage-quota-boot.test.ts @@ -72,6 +72,32 @@ test("a localStorage that rejects every write is pruned back into a writable sta assert.ok(store.has("harbor.settings")); }); +test("boot cleanup preserves large user histories instead of treating them as caches", async () => { + // A >256 KB snapshot record used to be pruned before checking whether storage was full. + // Its historical stars cannot be reconstructed from today's community-addon index. + const velocity = JSON.stringify({ + snapshots: [{ fetchedAt: 1, stars: { addon: filler(280_000) } }], + }); + const ledger = JSON.stringify({ + "__baseline__:all:discord": "2026-11-05", + "all:discord:tt123": "2026-11-05", + }); + const store = installQuotaStorage([ + ["harbor.settings", filler(300_000)], + ["harbor.stremio-addons.velocity.v1", velocity], + ["harbor.calendar.webhook.last", ledger], + ["harbor.mal.hero.v1", filler(100_000)], + ]); + + const { proactiveStorageCleanup } = await import("@/lib/storage-recovery"); + proactiveStorageCleanup(); + + assert.equal(store.get("harbor.stremio-addons.velocity.v1"), velocity); + assert.equal(store.get("harbor.calendar.webhook.last"), ledger); + assert.equal(store.has("harbor.mal.hero.v1"), false); + localStorage.setItem("harbor.onboarding", "{}"); +}); + test("boot cleanup stops as soon as the store takes writes again", async () => { // Freeing the one biggest cache is enough here, so the rest must still be there: buying // headroom nobody asked for costs the user a refetch of everything it dropped. @@ -111,6 +137,22 @@ test("the full sweep evicts caches and nothing the user did", async () => { ["harbor.parental", filler(1_000)], ["harbor.build.rating.v1", filler(1_000)], ["harbor.streamBadges.v1", filler(1_000)], + [ + "harbor.calendar.webhook.last", + JSON.stringify({ + "__baseline__:all:discord": "2026-11-05", + "all:discord:tt123": "2026-11-05", + }), + ], + [ + "harbor.stremio-addons.velocity.v1", + JSON.stringify({ + snapshots: [ + { fetchedAt: 1, stars: { addon: 3 } }, + { fetchedAt: 2, stars: { addon: 20 } }, + ], + }), + ], ["harbor.libraryNameRepair.v1.p_demo", "1699999999999"], ["harbor.libraryNameRepair.v99.p_demo", "1699999999999"], [ @@ -135,6 +177,7 @@ test("the full sweep evicts caches and nothing the user did", async () => { assert.deepEqual([...store.keys()].sort(), [ "harbor.build.rating.v1", + "harbor.calendar.webhook.last", "harbor.discover.v1", "harbor.iptv.epgmap.v1", "harbor.iptv.stats.v1", @@ -146,6 +189,7 @@ test("the full sweep evicts caches and nothing the user did", async () => { "harbor.playback-history.v1.p_demo", "harbor.settings", "harbor.streamBadges.v1", + "harbor.stremio-addons.velocity.v1", ]); });