fix(desktop): recover full local storage on startup (#3182)

## Summary
- recover already-full Buzz installs before desktop initialization
without deleting healthy caches
- enforce a global 2 MiB UTF-16 byte budget across disposable message,
channel, timeline-skeleton, and sidebar-skeleton caches, regardless of
relay count
- route all disposable cache writes through quota recovery and reserve
roughly 3 MiB of WebKit's observed ~5 MiB quota for durable state
- preserve communities, identities, preferences, drafts, and read state;
match only delimiter-qualified disposable namespaces

## Context
WebKit enforces an approximately 5 MiB per-origin localStorage quota and
Tauri does not expose an app-level knob to raise it to 50 MiB. Buzz
0.4.26 shipped reactive recovery for selected durable writes, but
disposable writers swallowed quota failures and their existing limits
were count-based per relay rather than byte-based per origin.

This PR handles both halves: upgrade recovery for already-wedged origins
and proactive global headroom so disposable snapshots cannot drive the
origin back to the cliff.

## Safety
- startup first probes a one-byte marker; healthy installs retain their
caches
- only if the marker write fails are the four relay-rehydratable cache
namespaces removed
- namespace matching requires the `v1:` delimiter, preventing future
`v10` or similarly named durable keys from matching
- oversized individual snapshots are rejected; crossing the global
budget evicts disposable snapshots only
- failed recovery leaves the marker absent, so the next launch retries

## Verification
- `pnpm test` — 3,670 passed
- `pnpm check`
- `pnpm typecheck`
- push hooks: branch-skew, desktop-check, desktop-test passed
- byte-budget tests cover UTF-16 accounting, multiple relays, oversized
writes, durable-state preservation, healthy startup, full startup,
marker retry, and namespace near misses

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-27 12:49:07 -07:00
committed by GitHub
co-authored by Carl
parent 01c23810fa
commit 174c38e4bd
8 changed files with 299 additions and 14 deletions
@@ -87,6 +87,36 @@ test("remove clears the snapshot for that relay", () => {
assert.equal(readChannelSnapshot(RELAY), null);
});
test("cache write evicts disposable entries and retries at quota", () => {
const original = window.localStorage;
const storage = new Map([
["buzz-channel-messages.v1:relay:old", "big"],
["buzz-timeline-skeleton-shape.v1:old", "small"],
]);
window.localStorage = {
get length() {
return storage.size;
},
key: (index) => [...storage.keys()][index] ?? null,
getItem: (key) => storage.get(key) ?? null,
setItem(key, value) {
if (!storage.has(key) && storage.size >= 2) {
throw new Error("quota exceeded");
}
storage.set(key, value);
},
removeItem: (key) => storage.delete(key),
};
try {
writeChannelSnapshot(RELAY, [makeChannel()]);
assert.deepEqual(readChannelSnapshot(RELAY), [makeChannel()]);
assert.equal(storage.has("buzz-channel-messages.v1:relay:old"), false);
assert.equal(storage.has("buzz-timeline-skeleton-shape.v1:old"), false);
} finally {
window.localStorage = original;
}
});
test("write is tolerant of storage failures", () => {
const original = window.localStorage.setItem;
window.localStorage.setItem = () => {
@@ -13,6 +13,7 @@
import type { Channel } from "@/shared/api/types";
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const STORAGE_KEY_PREFIX = "buzz-channels.v1";
@@ -51,9 +52,22 @@ export function writeChannelSnapshot(
): void {
try {
const key = channelSnapshotKey(relayUrl);
const serialized = JSON.stringify({ version: 1, channels });
if (window.localStorage.getItem(key) === serialized) return;
window.localStorage.setItem(key, serialized);
const previous = window.localStorage.getItem(key);
if (previous) {
try {
const parsed = parseChannelSnapshot(JSON.parse(previous));
if (parsed && JSON.stringify(parsed) === JSON.stringify(channels))
return;
} catch {
// Malformed snapshots are replaced below.
}
}
const serialized = JSON.stringify({
version: 1,
updatedAt: Date.now(),
channels,
});
setLocalStorageItemWithRecovery(key, serialized);
} catch {
// Storage access failures are non-fatal.
}
@@ -17,6 +17,7 @@
import { mergeTimelineHistoryMessages } from "@/features/messages/lib/messageQueryKeys";
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import type { RelayEvent } from "@/shared/api/types";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const STORAGE_KEY_PREFIX = "buzz-channel-messages.v1";
@@ -147,7 +148,7 @@ export function writeMessageSnapshot(
}
evictOldestSnapshots(relayPrefix(relayUrl), key);
window.localStorage.setItem(
setLocalStorageItemWithRecovery(
key,
JSON.stringify({
version: 1,
@@ -2,6 +2,7 @@ import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import { Skeleton } from "@/shared/ui/skeleton";
const TIMELINE_SKELETON_CACHE_PREFIX = "buzz-timeline-skeleton-shape.v1";
@@ -35,6 +36,7 @@ export type TimelineSkeletonRowShape = {
type TimelineSkeletonCachePayload = {
rows: TimelineSkeletonRowShape[];
updatedAt: number;
version: 1;
};
@@ -133,11 +135,12 @@ function writeTimelineSkeletonRows(
const payload: TimelineSkeletonCachePayload = {
rows: rows.slice(0, 4),
updatedAt: Date.now(),
version: 1,
};
try {
window.localStorage.setItem(cacheKey, JSON.stringify(payload));
setLocalStorageItemWithRecovery(cacheKey, JSON.stringify(payload));
} catch {
// localStorage can be unavailable or full in embedded webviews.
}
@@ -2,6 +2,7 @@ import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import {
SidebarGroup,
SidebarGroupContent,
@@ -36,6 +37,7 @@ type SidebarLoadingShape = {
};
type SidebarLoadingCachePayload = SidebarLoadingShape & {
updatedAt: number;
version: 1;
};
@@ -130,11 +132,12 @@ function writeSidebarLoadingShape(
const payload: SidebarLoadingCachePayload = {
channels: shape.channels.slice(0, 3),
directMessages: shape.directMessages.slice(0, 2),
updatedAt: Date.now(),
version: 1,
};
try {
window.localStorage.setItem(cacheKey, JSON.stringify(payload));
setLocalStorageItemWithRecovery(cacheKey, JSON.stringify(payload));
} catch {
// localStorage can be unavailable or full in embedded webviews.
}
+2
View File
@@ -13,6 +13,7 @@ import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
@@ -110,6 +111,7 @@ async function installE2eBridgeIfConfigured() {
async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
renderApp();
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { setLocalStorageItemWithRecovery } from "./localStorageQuota.ts";
import {
recoverLocalStorageQuotaOnStartup,
setLocalStorageItemWithRecovery,
} from "./localStorageQuota.ts";
function makeQuotaLocalStorage({ maxEntries }) {
const store = new Map();
@@ -30,6 +33,144 @@ function install(ls) {
globalThis.localStorage = ls;
}
test("startup recovery removes disposable caches but preserves user state", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 5 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
ls.store.set("buzz-channels.v1:relay", "big");
ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small");
ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small");
ls.store.set("buzz-communities", "keep");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
assert.equal(ls.getItem("buzz-timeline-skeleton-shape.v1:chan"), null);
assert.equal(
ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"),
null,
);
assert.equal(ls.getItem("buzz-communities"), "keep");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
test("healthy startup preserves disposable caches", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:new", "snapshot");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:new"), "snapshot");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
test("startup recovery does not remove namespace near misses", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-channels.v10:durable", "keep");
ls.store.set("buzz-channel-messages.v1-durable", "keep");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channels.v10:durable"), "keep");
assert.equal(ls.getItem("buzz-channel-messages.v1-durable"), "keep");
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), null);
});
test("startup recovery runs only once", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
recoverLocalStorageQuotaOnStartup();
ls.store.set("buzz-channel-messages.v1:relay:new", "new snapshot");
recoverLocalStorageQuotaOnStartup();
assert.equal(
ls.getItem("buzz-channel-messages.v1:relay:new"),
"new snapshot",
);
});
test("startup recovery retries after marker write fails", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 1 });
install(ls);
ls.store.set("buzz-communities", "keep");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), null);
ls.store.delete("buzz-communities");
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
recoverLocalStorageQuotaOnStartup();
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1");
});
test("global cache byte budget evicts only oldest entries needed", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
install(ls);
ls.store.set("buzz-communities", "keep");
const snapshot = (updatedAt) =>
JSON.stringify({ updatedAt, payload: "x".repeat(400_000) });
const oldestKey = "buzz-channel-messages.v1:relay:oldest";
const newerKey = "buzz-channels.v1:relay-newer";
const newestKey = "buzz-channel-messages.v1:relay:newest";
assert.equal(setLocalStorageItemWithRecovery(oldestKey, snapshot(1)), true);
assert.equal(setLocalStorageItemWithRecovery(newerKey, snapshot(2)), true);
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
assert.equal(ls.getItem(oldestKey), null);
assert.notEqual(ls.getItem(newerKey), null);
assert.notEqual(ls.getItem(newestKey), null);
assert.equal(ls.getItem("buzz-communities"), "keep");
});
test("global cache byte budget spans relays and preserves durable state", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
install(ls);
ls.store.set("buzz-communities", "keep");
const largeSnapshot = "x".repeat(600_000);
assert.equal(
setLocalStorageItemWithRecovery(
"buzz-channel-messages.v1:relay-one:chan",
largeSnapshot,
),
true,
);
assert.equal(
setLocalStorageItemWithRecovery(
"buzz-channel-messages.v1:relay-two:chan",
largeSnapshot,
),
true,
);
assert.equal(ls.getItem("buzz-channel-messages.v1:relay-one:chan"), null);
assert.equal(
ls.getItem("buzz-channel-messages.v1:relay-two:chan"),
largeSnapshot,
);
assert.equal(ls.getItem("buzz-communities"), "keep");
});
test("rejects a single cache entry larger than the global byte budget", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
const key = "buzz-channel-messages.v1:relay:oversized";
ls.store.set(key, "previous snapshot");
assert.equal(
setLocalStorageItemWithRecovery(key, "x".repeat(1_100_000)),
false,
);
assert.equal(ls.getItem(key), null);
});
test("writes normally when under quota", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
+98 -7
View File
@@ -8,19 +8,32 @@
*/
const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1",
"buzz-channels.v1",
"buzz-timeline-skeleton-shape.v1",
"buzz-channel-messages.v1:",
"buzz-channels.v1:",
"buzz-sidebar-skeleton-shape.v1:",
"buzz-timeline-skeleton-shape.v1:",
];
const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1";
// Keep disposable snapshots below 2 MiB, leaving roughly 3 MiB of WebKit's
// observed ~5 MiB origin quota for identities, communities, preferences, and
// read state. localStorage strings are UTF-16, so count two bytes per code unit.
const PURE_CACHE_BYTE_BUDGET = 2 * 1024 * 1024;
function isPureCacheKey(key: string): boolean {
return PURE_CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function storageEntryBytes(key: string, value: string): number {
return (key.length + value.length) * 2;
}
function evictPureCacheEntries(): number {
const toRemove: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (
key !== null &&
PURE_CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
) {
if (key !== null && isPureCacheKey(key)) {
toRemove.push(key);
}
}
@@ -30,6 +43,83 @@ function evictPureCacheEntries(): number {
return toRemove.length;
}
function pureCacheEntriesExcluding(excludedKey: string): Array<{
bytes: number;
key: string;
updatedAt: number;
}> {
const entries: Array<{ bytes: number; key: string; updatedAt: number }> = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key === null || key === excludedKey || !isPureCacheKey(key)) continue;
const value = window.localStorage.getItem(key);
if (value === null) continue;
let updatedAt = 0;
try {
const parsed = JSON.parse(value) as { updatedAt?: unknown };
if (
typeof parsed.updatedAt === "number" &&
Number.isFinite(parsed.updatedAt)
) {
updatedAt = parsed.updatedAt;
}
} catch {
// Legacy or malformed cache entries are safe to evict first.
}
entries.push({ bytes: storageEntryBytes(key, value), key, updatedAt });
}
return entries;
}
function trimPureCacheForWrite(key: string, writeBytes: number): void {
const entries = pureCacheEntriesExcluding(key);
let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0);
if (totalBytes + writeBytes <= PURE_CACHE_BYTE_BUDGET) return;
entries.sort((a, b) => a.updatedAt - b.updatedAt);
for (const entry of entries) {
window.localStorage.removeItem(entry.key);
totalBytes -= entry.bytes;
if (totalBytes + writeBytes <= PURE_CACHE_BYTE_BUDGET) return;
}
}
function preparePureCacheWrite(key: string, value: string): boolean {
if (!isPureCacheKey(key)) return true;
const writeBytes = storageEntryBytes(key, value);
if (writeBytes > PURE_CACHE_BYTE_BUDGET) {
window.localStorage.removeItem(key);
return false;
}
trimPureCacheForWrite(key, writeBytes);
return true;
}
/**
* Probes storage once at startup so installs that already filled WebKit
* localStorage recover before app initialization. Healthy installs keep their
* caches; only a failed marker write triggers disposable-cache eviction. The
* marker is written after recovery, so an interrupted/unavailable backend
* retries on the next launch. Load-bearing state is never touched.
*/
export function recoverLocalStorageQuotaOnStartup(): void {
try {
if (window.localStorage.getItem(QUOTA_RECOVERY_MARKER_KEY) === "1") return;
try {
window.localStorage.setItem(QUOTA_RECOVERY_MARKER_KEY, "1");
return;
} catch {
evictPureCacheEntries();
}
window.localStorage.setItem(QUOTA_RECOVERY_MARKER_KEY, "1");
} catch (error) {
console.warn("[localStorageQuota] startup cache cleanup failed:", error);
}
}
let warnedPersistentFailure = false;
function notifyStorageFull(): void {
@@ -56,6 +146,7 @@ export function setLocalStorageItemWithRecovery(
value: string,
): boolean {
try {
if (!preparePureCacheWrite(key, value)) return false;
window.localStorage.setItem(key, value);
return true;
} catch (error) {