mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user