fix(desktop): bound read-state localStorage growth and recover from quota errors (#1502)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-03 09:00:27 -07:00
committed by GitHub
co-authored by Brain
parent bbf8d8912a
commit a3cf7eec1d
7 changed files with 367 additions and 12 deletions
@@ -10,6 +10,11 @@ export const READ_STATE_HORIZON_SECONDS = 7 * 24 * 60 * 60;
export const MAX_CONTEXTS = 10_000;
// Local-storage cap on within-horizon msg:/thread: markers. Generous multiple
// of what the 32 KB publish budget can round-trip (~290 entries), so anything
// beyond it is local-only dead weight that other devices never see anyway.
export const LOCAL_MAX_PRUNABLE_CONTEXTS = 1_000;
// Maximum plaintext byte length for the JSON blob passed to nip44EncryptToSelf.
// NIP-44 v2 hard-caps plaintext at 65,535 bytes; the relay enforces a 256 KB
// content limit. 32 KB gives ample headroom for NIP-44 overhead (~1.4×
@@ -18,6 +18,7 @@ import {
readStoredReadState,
writeStoredReadState,
} from "@/features/channels/readState/readStateStorage";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id";
const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id";
@@ -33,7 +34,7 @@ function getOrCreatePersisted(key: string, generator: () => string): string {
let value = localStorage.getItem(key);
if (!value) {
value = generator();
localStorage.setItem(key, value);
setLocalStorageItemWithRecovery(key, value);
}
return value;
}
@@ -61,7 +62,10 @@ function loadExtraSlotIds(pubkey: string): string[] {
}
function saveExtraSlotIds(pubkey: string, ids: string[]): void {
localStorage.setItem(localExtraSlotIdsKey(pubkey), JSON.stringify(ids));
setLocalStorageItemWithRecovery(
localExtraSlotIdsKey(pubkey),
JSON.stringify(ids),
);
}
export type ApplyRemoteContextResult = "unchanged" | "advanced";
@@ -518,7 +522,7 @@ export class ReadStateManager {
if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
if (parsed.blob.client_id !== this.clientId) {
this.slotId = generateHex(16);
localStorage.setItem(slotIdKey(this.pubkey), this.slotId);
setLocalStorageItemWithRecovery(slotIdKey(this.pubkey), this.slotId);
break;
}
}
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
pruneStaleContexts,
readStoredReadState,
writeStoredReadState,
} from "./readStateStorage.ts";
import {
LOCAL_MAX_PRUNABLE_CONTEXTS,
READ_STATE_HORIZON_SECONDS,
localPublishableContextKey,
localReadStateKey,
localSourceCreatedAtKey,
} from "./readStateFormat.ts";
function makeLocalStorage() {
const store = new Map();
return {
get size() {
return store.size;
},
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
}
function installLocalStorage() {
const ls = makeLocalStorage();
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
globalThis.window.localStorage = ls;
globalThis.localStorage = ls;
return ls;
}
const NOW = 1_750_000_000;
test("pruneStaleContexts drops msg/thread markers older than horizon", () => {
const cutoff = NOW - READ_STATE_HORIZON_SECONDS;
const contexts = new Map([
["channel-1", cutoff - 999_999],
[`thread:${"a".repeat(64)}`, cutoff - 1],
[`thread:${"b".repeat(64)}`, cutoff + 1],
[`msg:${"c".repeat(64)}`, cutoff - 1],
[`msg:${"d".repeat(64)}`, cutoff + 1],
]);
const pruned = pruneStaleContexts(contexts, NOW);
assert.equal(pruned.has("channel-1"), true, "channel keys never pruned");
assert.equal(pruned.has(`thread:${"a".repeat(64)}`), false);
assert.equal(pruned.has(`thread:${"b".repeat(64)}`), true);
assert.equal(pruned.has(`msg:${"c".repeat(64)}`), false);
assert.equal(pruned.has(`msg:${"d".repeat(64)}`), true);
});
test("pruneStaleContexts caps within-horizon prunable entries, newest kept", () => {
const contexts = new Map();
const total = LOCAL_MAX_PRUNABLE_CONTEXTS + 50;
for (let i = 0; i < total; i++) {
contexts.set(`msg:${String(i).padStart(64, "0")}`, NOW - i);
}
const pruned = pruneStaleContexts(contexts, NOW);
assert.equal(pruned.size, LOCAL_MAX_PRUNABLE_CONTEXTS);
// Newest (i=0) survives; oldest (i=total-1) evicted.
assert.equal(pruned.has(`msg:${String(0).padStart(64, "0")}`), true);
assert.equal(pruned.has(`msg:${String(total - 1).padStart(64, "0")}`), false);
});
test("writeStoredReadState prunes all three keys consistently", () => {
installLocalStorage();
const pubkey = "f".repeat(64);
const staleThread = `thread:${"a".repeat(64)}`;
const freshThread = `thread:${"b".repeat(64)}`;
const nowSeconds = Math.floor(Date.now() / 1_000);
const stale = nowSeconds - READ_STATE_HORIZON_SECONDS - 10;
writeStoredReadState(
pubkey,
new Map([
["channel-1", stale],
[staleThread, stale],
[freshThread, nowSeconds],
]),
new Set(["channel-1", staleThread, freshThread]),
new Map([
["channel-1", stale],
[staleThread, stale],
[freshThread, nowSeconds],
]),
);
const state = JSON.parse(
window.localStorage.getItem(localReadStateKey(pubkey)),
);
assert.deepEqual(Object.keys(state).sort(), ["channel-1", freshThread]);
const publishable = JSON.parse(
window.localStorage.getItem(localPublishableContextKey(pubkey)),
);
assert.deepEqual(publishable.sort(), ["channel-1", freshThread]);
const sourceCreatedAt = JSON.parse(
window.localStorage.getItem(localSourceCreatedAtKey(pubkey)),
);
assert.deepEqual(Object.keys(sourceCreatedAt).sort(), [
"channel-1",
freshThread,
]);
});
test("writeStoredReadState round-trips through readStoredReadState", () => {
installLocalStorage();
const pubkey = "e".repeat(64);
const nowSeconds = Math.floor(Date.now() / 1_000);
writeStoredReadState(
pubkey,
new Map([["channel-9", nowSeconds]]),
new Set(["channel-9"]),
new Map([["channel-9", nowSeconds]]),
);
const stored = readStoredReadState(pubkey);
assert.equal(stored.contexts.get("channel-9"), nowSeconds);
assert.equal(stored.publishableContextIds.has("channel-9"), true);
assert.equal(stored.contextSourceCreatedAt.get("channel-9"), nowSeconds);
});
test("writeStoredReadState survives a throwing localStorage.setItem", () => {
const ls = installLocalStorage();
ls.setItem = () => {
throw new Error("QuotaExceededError");
};
const pubkey = "d".repeat(64);
const nowSeconds = Math.floor(Date.now() / 1_000);
assert.doesNotThrow(() => {
writeStoredReadState(
pubkey,
new Map([["channel-1", nowSeconds]]),
new Set(["channel-1"]),
new Map([["channel-1", nowSeconds]]),
);
});
});
@@ -4,7 +4,12 @@ import {
localPublishableContextKey,
localReadStateKey,
localSourceCreatedAtKey,
LOCAL_MAX_PRUNABLE_CONTEXTS,
MSG_PREFIX,
READ_STATE_HORIZON_SECONDS,
THREAD_PREFIX,
} from "@/features/channels/readState/readStateFormat";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
export type StoredReadState = {
contexts: Map<string, number>;
@@ -98,28 +103,74 @@ export function readStoredReadState(pubkey: string): StoredReadState {
};
}
function isPrunableContextKey(contextId: string): boolean {
return (
contextId.startsWith(MSG_PREFIX) || contextId.startsWith(THREAD_PREFIX)
);
}
/**
* Drops msg:/thread: markers older than the relay's 7-day horizon, then caps
* the survivors at LOCAL_MAX_PRUNABLE_CONTEXTS (oldest first). Channel keys
* are never pruned — they are small, bounded by membership, and losing one
* would resurrect the channel's unread badge. Mirrors the eviction order the
* publish path already applies in trimContextsToBudget.
*/
export function pruneStaleContexts(
contexts: ReadonlyMap<string, number>,
nowUnixSeconds: number,
): Map<string, number> {
const cutoff = nowUnixSeconds - READ_STATE_HORIZON_SECONDS;
const kept = new Map<string, number>();
const prunable: [string, number][] = [];
for (const [contextId, timestamp] of contexts) {
if (!isPrunableContextKey(contextId)) {
kept.set(contextId, timestamp);
} else if (timestamp >= cutoff) {
prunable.push([contextId, timestamp]);
}
}
if (prunable.length > LOCAL_MAX_PRUNABLE_CONTEXTS) {
prunable.sort((a, b) => b[1] - a[1]);
prunable.length = LOCAL_MAX_PRUNABLE_CONTEXTS;
}
for (const [contextId, timestamp] of prunable) {
kept.set(contextId, timestamp);
}
return kept;
}
export function writeStoredReadState(
pubkey: string,
contexts: ReadonlyMap<string, number>,
publishableContextIds: ReadonlySet<string>,
contextSourceCreatedAt: ReadonlyMap<string, number>,
): void {
const pruned = pruneStaleContexts(contexts, Math.floor(Date.now() / 1_000));
const state: Record<string, string> = {};
for (const [contextId, timestamp] of contexts) {
for (const [contextId, timestamp] of pruned) {
state[contextId] = new Date(timestamp * 1_000).toISOString();
}
localStorage.setItem(localReadStateKey(pubkey), JSON.stringify(state));
localStorage.setItem(
setLocalStorageItemWithRecovery(
localReadStateKey(pubkey),
JSON.stringify(state),
);
setLocalStorageItemWithRecovery(
localPublishableContextKey(pubkey),
JSON.stringify([...publishableContextIds]),
JSON.stringify([...publishableContextIds].filter((id) => pruned.has(id))),
);
const sourceState: Record<string, number> = {};
for (const [contextId, createdAt] of contextSourceCreatedAt) {
sourceState[contextId] = createdAt;
if (pruned.has(contextId)) {
sourceState[contextId] = createdAt;
}
}
localStorage.setItem(
setLocalStorageItemWithRecovery(
localSourceCreatedAtKey(pubkey),
JSON.stringify(sourceState),
);
@@ -1,5 +1,6 @@
import type { Workspace } from "./types";
import { homeDir } from "@tauri-apps/api/path";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const WORKSPACES_KEY = "buzz-workspaces";
const ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id";
@@ -52,7 +53,7 @@ export function loadWorkspaces(): Workspace[] {
return entry;
}) as Workspace[];
if (didStrip) {
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(cleaned));
setLocalStorageItemWithRecovery(WORKSPACES_KEY, JSON.stringify(cleaned));
}
return cleaned;
} catch {
@@ -61,7 +62,7 @@ export function loadWorkspaces(): Workspace[] {
}
export function saveWorkspaces(workspaces: Workspace[]): void {
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaces));
setLocalStorageItemWithRecovery(WORKSPACES_KEY, JSON.stringify(workspaces));
}
export function clearWorkspaceStorage(): void {
@@ -74,7 +75,7 @@ export function loadActiveWorkspaceId(): string | null {
}
export function saveActiveWorkspaceId(id: string): void {
localStorage.setItem(ACTIVE_WORKSPACE_KEY, id);
setLocalStorageItemWithRecovery(ACTIVE_WORKSPACE_KEY, id);
}
export function normalizeRelayUrl(url: string): string {
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { setLocalStorageItemWithRecovery } from "./localStorageQuota.ts";
function makeQuotaLocalStorage({ maxEntries }) {
const store = new Map();
return {
store,
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem(key, value) {
if (!store.has(key) && store.size >= maxEntries) {
throw new Error("QuotaExceededError");
}
store.set(key, value);
},
removeItem: (key) => store.delete(key),
};
}
function install(ls) {
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
globalThis.window.localStorage = ls;
globalThis.localStorage = ls;
}
test("writes normally when under quota", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
assert.equal(setLocalStorageItemWithRecovery("k", "v"), true);
assert.equal(ls.getItem("k"), "v");
});
test("evicts pure caches and retries on quota failure", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
ls.store.set("buzz-channels.v1:relay", "big");
assert.equal(setLocalStorageItemWithRecovery("k", "v"), true);
assert.equal(ls.getItem("k"), "v");
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
});
test("returns false when eviction frees nothing", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-workspaces", "keep");
ls.store.set("buzz-active-workspace-id", "keep");
assert.equal(setLocalStorageItemWithRecovery("k", "v"), false);
assert.equal(ls.getItem("k"), null);
assert.equal(ls.getItem("buzz-workspaces"), "keep");
});
@@ -0,0 +1,78 @@
/**
* Quota-aware localStorage writes with pure-cache eviction recovery.
*
* The desktop webview caps localStorage at ~5 MB per origin. Writers of
* load-bearing state (read-state, workspaces) must not leak QuotaExceededError
* into React click/render paths. On a failed write this evicts snapshot caches
* — safe to drop, they repaint from the relay — and retries the write once.
*/
const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1",
"buzz-channels.v1",
"buzz-timeline-skeleton-shape.v1",
];
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))
) {
toRemove.push(key);
}
}
for (const key of toRemove) {
window.localStorage.removeItem(key);
}
return toRemove.length;
}
let warnedPersistentFailure = false;
function notifyStorageFull(): void {
if (warnedPersistentFailure) return;
warnedPersistentFailure = true;
// Dynamic import keeps this module usable from node unit tests.
import("sonner")
.then(({ toast }) => {
toast.error("Local storage is full", {
description:
"Buzz could not save some local data — read positions may not persist across restarts.",
});
})
.catch(() => {});
}
/**
* Writes to localStorage; on failure (quota exceeded), evicts pure snapshot
* caches and retries once. Returns false when the write still fails — callers
* keep working from in-memory state.
*/
export function setLocalStorageItemWithRecovery(
key: string,
value: string,
): boolean {
try {
window.localStorage.setItem(key, value);
return true;
} catch (error) {
try {
if (evictPureCacheEntries() > 0) {
window.localStorage.setItem(key, value);
return true;
}
} catch {
// Fall through to failure reporting.
}
console.warn(
"[localStorageQuota] write failed after cache eviction:",
key,
error,
);
notifyStorageFull();
return false;
}
}