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