Files
buzz/desktop/src/shared/lib/localStorageSweep.test.mjs
Will Pfleger f35930104b fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels (#5599)
Desktop input latency regressed sharply for users on v0.5.9 and worsened
on latest main: multi-second stalls when clicking back into the app,
slow fresh boots, intermittent lockups, and scroll/mouse degradation.
Reverting to `119a84897` (pre-0.5.9) was confirmed to resolve it,
isolating the regression to that range. Profiling a live production
renderer plus a commit-level audit of the range found three independent,
additive causes — fixed here — plus a long-standing `get_channels` cost
that made every remaining refetch expensive, also addressed here.

## 1. Focus-return refetch storm (`refetchOnWindowFocus`)

#5490 wired TanStack's `focusManager` to app focus and flipped ~20 query
sites to `refetchOnWindowFocus: true`. A focus return after >60s away
fires them all within milliseconds — and a click into an unfocused
window *is* a focus return, so the burst runs before the click is
processed. That is the "click into the composer, wait 5 seconds"
symptom, and it also explains why mouse input feels worse than keyboard
(clicks arrive with focus transitions; typing happens while already
focused). A 5-second `sample` of a live production renderer caught a
single window activity-state transition consuming ~1.25s of main-thread
time, dominated by `JSON.parse` in the focus listener's microtask drain.

#5535 already established the fix pattern but applied it to only two
families (channels, home-feed). This PR extends the same 5-minute
`staleTime` discipline to the remaining families: pulse (×5), workflows
(×4), agents (×4), forum (×2), presence, user-status, custom-emoji,
channel-templates, and the persona catalog. Polling cadences and
push-invalidation paths are untouched — interval refetches and
`invalidateQueries` both bypass `staleTime`, so live-update behavior is
unchanged. Each gated family exports its focus-refetch policy as an
options object that the production hook spreads into `useQuery`, and a
`focusRefetchPolicy.test.mjs` drives a `QueryObserver` with that same
production object — locking the policy behaviorally (fresh focus return
→ 0 fetches; stale → refetch) and failing if a hook's
`staleTime`/`refetchOnWindowFocus` wiring drifts.

Four families deliberately keep tighter freshness, all surfaces where
the 5-minute gate would suppress the only refresh path and none of which
feed the app-wide storm: `repo-sync-status` keeps its fresh focus
refetch (its inline comment documents the "committed in a terminal,
switched back to the app" flow as intended); the workflow-runs list
stale-gates at 10s because a remotely-started run has no push
invalidation and its conditional 1s poll is off while the cache shows no
active runs; the workflow list queries (`useChannelWorkflowsQuery` and
the all-channels aggregate) stale-gate at 10s because they have no poll
and no relay subscription, and mutation-driven invalidation only covers
this renderer — remote workflow creates/edits/deletes surface only via
focus refetch; and the managed-agent log stale-gates at one poll tick
(30s) so returning to a live agent log refreshes immediately. Run
approvals keep the 5-minute gate under
`RUN_APPROVALS_FOCUS_STALE_TIME_MS` — their focused 10s poll already
covers freshness.

## 2. Synchronous localStorage sweep on the boot/focus path

#5453's stale-cache sweep synchronously `getItem` + `JSON.parse`s every
whitelisted localStorage entry on the main thread (multi-MB on seasoned
profiles), scheduled with a `requestIdleCallback` timeout of 1.5s that
guaranteed it landed mid-boot, and re-armed on every hidden→visible
transition — stacking it onto the exact moment the focus storm fires.
#5454's `trimSelfProfileCaches()` additionally scanned every
localStorage key on every `writeSelfProfileCache()` call (which fires
per relay self-profile delivery at boot).

Now: the first sweep waits `BOOT_SWEEP_FLOOR_MS` (30s) after startup,
the scan is time-sliced across idle callbacks, and the visibility
trigger is removed — boot-delayed plus hourly still covers the 14-day
TTL contract. The sliced sweep re-checks staleness immediately before
each removal (a key rewritten fresh mid-sweep survives), isolates
per-key storage errors so one bad entry can't strand the rest of the
snapshot, defers oversized values once rather than parsing them on a
zero-budget slice, guarantees forward progress on timeout-fired
callbacks, and cancels its scheduled slice when stopped. The profile
trim keeps a lazily-initialized memoized key count so the common
under-cap write is O(1); the full parse scan runs only when the count
exceeds a cap, resyncs if external deletions made it stale, and a failed
scan skips the trim instead of aborting the write. Sweep semantics
(rules, TTLs, eviction) are unchanged, and tests cover the scheduling,
slice-progress, error-isolation, defer-once, and trim short-circuit
behaviors.

## 3. The macOS window was never opaque

#5478's glass appearance is correctly opt-in at the CSS layer, but the
compositor cost was baked in deeper than its native `on_webview_ready`
transparency call: the main window is declared `"transparent": true` in
`tauri.conf.json` (added for the original glass work in #1671), which
makes tao call `NSWindow.setOpaque(false)` at creation and resolve every
later `set_background_color(None)` to `clearColor` — and no runtime
`setOpaque(true)` path exists through tauri, while wry's runtime
background setter can only force the WKWebView's `drawsBackground` off,
never back on. So "restore the platform default" was unreachable: every
launch, glass or not, ran with a non-opaque NSWindow, defeating
WindowServer's opaque-window compositing fast path and forcing full
window compositing every frame — compounded by the existing
`backdrop-blur` chrome overlapping the scrolling timeline. This matches
the compositor-shaped symptoms (scroll and pointer input degrading
first).

The window is now created opaque (`"transparent": false`) and the
NSWindow layer is never made transparent at runtime. Glass never needed
a transparent window: behind-window `NSVisualEffectView` vibrancy
renders inside opaque windows (this is how Finder and Notes draw vibrant
sidebars); it only requires a transparent WKWebView canvas, which the
`set_window_vibrancy` enable path already establishes at runtime
(`macos-private-api` compiles that in independent of the window flag).
Enabling glass installs the vibrancy layer and then makes only the
webview canvas see-through; disabling clears the vibrancy layer — the
canvas may stay non-drawing afterwards (wry's flag is one-way at
runtime), which is harmless because glass-off CSS paints fully opaque
above an always-opaque NSWindow. The boot-path first-frame backing
writes touch only the NSWindow backing color and are therefore inert to
glass state regardless of how they order against the `ThemeProvider`'s
vibrancy call on a persisted-glass-on cold boot. Glass-off users (the
default) get an end-to-end opaque window from boot for the first time.

## 4. `get_channels`: serial round-trips and a multi-MB payload on every
refetch

The stale gates in (1) cut refetch frequency; this cuts the cost of the
refetches that legitimately remain (boot, and focus returns after more
than 5 minutes away — previously still a multi-second stall).
`get_channels` made ~8 fully serial relay round-trips (~3.2–3.6s at
1,100+ channels), then shipped the full `ChannelInfo` list — including
every channel's member pubkeys — across IPC, where the renderer's
`JSON.parse` of the multi-MB payload froze the main thread (the ~1.25s
stall captured in the live sample).

- **Concurrent stages**: the membership chain, the open-channel
directory scan, and the hidden-DM snapshot run concurrently, as do the
member-count and last-message queries that follow. The critical path
drops from ~8 sequential round-trips to 2 phases. Filters, limits,
pagination, and merge semantics are unchanged.
- **Not-modified short-circuit**: the command now takes a
client-supplied content hash (FNV-1a 64 over the channel list,
canonicalized by id and excluding `last_message_at`) and omits the
channel list from the response when nothing else changed. Last-message
timestamps — which change on nearly every message anywhere — ship as a
small separate map that the client overlays onto its cached list with
reference preservation, so React Query's structural sharing also skips
downstream re-renders. On a typical refocus the renderer parses
kilobytes instead of megabytes. The hash is stored in the query cache
itself, tying its lifecycle to the data it describes so a community
switch can never leak a stale hash.

The E2E mock bridge speaks the new payload shape — including the
complete `last_messages` map the client treats as authoritative — and
hash canonicalization plus overlay reference-preservation are
unit-tested on both sides.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-11 19:47:19 -04:00

802 lines
24 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
BOOT_SWEEP_FLOOR_MS,
LOCAL_STORAGE_SWEEP_RULES,
SWEEP_CHUNK_ENTRY_BUDGET,
SWEEP_FALLBACK_TIMER_MS,
SWEEP_IDLE_TIMEOUT_MS,
SWEEP_LARGE_VALUE_DEFER_BYTES,
startLocalStorageSweep,
sweepStaleLocalStorage,
} from "./localStorageSweep.ts";
const DAY_MS = 24 * 60 * 60 * 1_000;
function makeLocalStorage(entries = []) {
const store = new Map(entries);
return {
store,
get length() {
return store.size;
},
key: (index) => [...store.keys()][index] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
}
function installWindow(localStorage, overrides = {}) {
globalThis.window = { localStorage, ...overrides };
}
const snapshot = (updatedAt) => JSON.stringify({ updatedAt, payload: "cache" });
test("sweeps stale whitelisted caches and keeps fresh or durable state", () => {
const now = 100 * DAY_MS;
const entries = LOCAL_STORAGE_SWEEP_RULES.flatMap(
({ keyPrefix, maxAgeMs }, index) => [
[`${keyPrefix}stale-a-${index}`, snapshot(now - maxAgeMs)],
[`${keyPrefix}stale-b-${index}`, snapshot(now - maxAgeMs - DAY_MS)],
[`${keyPrefix}fresh-${index}`, snapshot(now - maxAgeMs + 1)],
],
);
entries.push(["buzz-communities", snapshot(0)]);
entries.push(["buzz-theme", snapshot(0)]);
entries.push(["buzz-self-profile.v1:offline", snapshot(0)]);
const localStorage = makeLocalStorage(entries);
installWindow(localStorage);
assert.equal(
sweepStaleLocalStorage(now),
LOCAL_STORAGE_SWEEP_RULES.length * 2,
);
for (const { keyPrefix } of LOCAL_STORAGE_SWEEP_RULES) {
assert.equal(
[...localStorage.store.keys()].some((key) =>
key.startsWith(`${keyPrefix}stale-`),
),
false,
);
assert.equal(
[...localStorage.store.keys()].some((key) =>
key.startsWith(`${keyPrefix}fresh-`),
),
true,
);
}
assert.equal(localStorage.getItem("buzz-communities"), snapshot(0));
assert.equal(localStorage.getItem("buzz-theme"), snapshot(0));
assert.equal(
localStorage.getItem("buzz-self-profile.v1:offline"),
snapshot(0),
);
});
test("uses the newest per-profile timestamp for user-label cache buckets", () => {
const now = 100 * DAY_MS;
const localStorage = makeLocalStorage([
[
"buzz-user-labels.v1:all-stale",
JSON.stringify({
profiles: {
first: { updatedAt: now - 20 * DAY_MS },
second: { updatedAt: now - 14 * DAY_MS },
},
}),
],
[
"buzz-user-labels.v1:one-fresh",
JSON.stringify({
profiles: {
stale: { updatedAt: now - 20 * DAY_MS },
fresh: { updatedAt: now - DAY_MS },
},
}),
],
]);
installWindow(localStorage);
assert.equal(sweepStaleLocalStorage(now), 1);
assert.equal(localStorage.getItem("buzz-user-labels.v1:all-stale"), null);
assert.notEqual(localStorage.getItem("buzz-user-labels.v1:one-fresh"), null);
});
test("leaves malformed and timestamp-free cache entries untouched", () => {
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:malformed", "not json"],
["buzz-channels.v1:no-timestamp", JSON.stringify({ payload: "cache" })],
["buzz-observed-unread.v1:bad-timestamp", snapshot(Number.NaN)],
]);
installWindow(localStorage);
assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0);
assert.equal(localStorage.store.size, 3);
});
test("storage access failures warn and never escape", () => {
const originalWarn = console.warn;
const warnings = [];
console.warn = (...args) => warnings.push(args);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis.window, "localStorage", {
configurable: true,
get() {
throw new Error("SecurityError");
},
});
try {
assert.doesNotThrow(() => sweepStaleLocalStorage(100 * DAY_MS));
assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0);
assert.equal(warnings.length, 2);
} finally {
console.warn = originalWarn;
}
});
test("scheduler setup failures warn and never escape startup", () => {
const originalWarn = console.warn;
const warnings = [];
console.warn = (...args) => warnings.push(args);
installWindow(makeLocalStorage(), {
clearInterval() {},
setInterval() {
throw new Error("timer unavailable");
},
});
try {
let stop;
assert.doesNotThrow(() => {
stop = startLocalStorageSweep();
});
assert.doesNotThrow(() => stop());
assert.equal(warnings.length, 1);
} finally {
console.warn = originalWarn;
}
});
test("fallback path: boot-floor setTimeout fires first, then per-slice setTimeout processes entries", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const timeouts = new Map();
let nextId = 50;
globalThis.setTimeout = (callback, delay) => {
const id = nextId++;
timeouts.set(id, { callback, delay });
return id;
};
globalThis.clearTimeout = (id) => timeouts.delete(id);
// Stale entry with updatedAt=0 (older than any 14-day TTL)
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:startup", snapshot(0)],
]);
installWindow(localStorage, {
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
// One boot-floor timer scheduled; no sweep yet.
assert.equal(timeouts.size, 1);
const [bootId, bootEntry] = [...timeouts.entries()][0];
assert.equal(bootEntry.delay, BOOT_SWEEP_FLOOR_MS);
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
"sweep must not run before boot floor",
);
// Fire boot timer — sweepChunked snapshots keys and schedules a slice.
timeouts.delete(bootId);
bootEntry.callback();
// One per-slice fallback timer now pending; still no removal.
assert.equal(timeouts.size, 1);
const [, sliceEntry] = [...timeouts.entries()][0];
assert.equal(sliceEntry.delay, SWEEP_FALLBACK_TIMER_MS);
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
"sweep must not run before slice fires",
);
// Fire slice — stale entry removed.
sliceEntry.callback();
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
"stale entry removed after slice fires",
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
test("scheduler: boot-floor gate, idle callback with SWEEP_IDLE_TIMEOUT_MS, hourly interval, no visibilitychange listener", () => {
const originalNow = Date.now;
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const now = 100 * DAY_MS;
Date.now = () => now;
const intervals = new Map();
const idleCallbacks = new Map();
let nextIntervalId = 1;
let nextIdleId = 100;
// Capture the boot setTimeout.
let bootCallback = null;
globalThis.setTimeout = (callback, _delay) => {
bootCallback = callback;
return 9999;
};
globalThis.clearTimeout = () => {};
// Track visibilitychange listeners to assert none are registered.
const visibilityListeners = [];
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:stale", snapshot(now - 14 * DAY_MS)],
]);
installWindow(localStorage, {
requestIdleCallback(callback, options) {
const id = nextIdleId++;
idleCallbacks.set(id, { callback, options });
return id;
},
cancelIdleCallback(id) {
idleCallbacks.delete(id);
},
setInterval(callback, delay) {
const id = nextIntervalId++;
intervals.set(id, { callback, delay });
return id;
},
clearInterval: (id) => intervals.delete(id),
});
globalThis.document = {
addEventListener(event, fn) {
if (event === "visibilitychange") visibilityListeners.push(fn);
},
removeEventListener() {},
visibilityState: "visible",
};
const stop = startLocalStorageSweep();
try {
// Boot floor: no idle callbacks before the boot timer fires.
assert.equal(idleCallbacks.size, 0, "no idle callback before boot floor");
assert.equal(visibilityListeners.length, 0, "no visibilitychange listener");
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:stale"),
null,
"sweep not run before boot floor",
);
// Fire boot timer — sweepChunked schedules first idle callback.
assert.ok(bootCallback, "boot timer was registered");
bootCallback();
assert.equal(idleCallbacks.size, 1, "idle callback scheduled after boot");
const [firstId, firstEntry] = [...idleCallbacks.entries()][0];
assert.equal(
firstEntry.options.timeout,
SWEEP_IDLE_TIMEOUT_MS,
"idle callback uses SWEEP_IDLE_TIMEOUT_MS, not a forcing 1500ms",
);
// Fire idle callback with ample time remaining — sweep completes in one slice.
firstEntry.callback({ timeRemaining: () => 50 });
idleCallbacks.delete(firstId);
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:stale"),
null,
"stale entry removed after idle callback fires",
);
// Hourly interval is registered.
assert.equal(intervals.size, 1);
const [{ callback: intervalCallback, delay }] = intervals.values();
assert.equal(delay, 60 * 60 * 1_000);
// Hourly fires — schedules another idle callback sweep.
localStorage.setItem(
"buzz-channel-messages.v1:interval",
snapshot(now - 14 * DAY_MS),
);
intervalCallback();
assert.equal(
idleCallbacks.size,
1,
"idle callback scheduled for hourly sweep",
);
const hourlyEntry = [...idleCallbacks.values()][0];
hourlyEntry.callback({ timeRemaining: () => 50 });
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:interval"),
null,
"hourly sweep removes stale entry",
);
// Visibility change does NOT trigger sweep — listener was never registered.
localStorage.setItem(
"buzz-channel-messages.v1:vis",
snapshot(now - 14 * DAY_MS),
);
assert.equal(
visibilityListeners.length,
0,
"visibilitychange listener absent",
);
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:vis"),
null,
"visibility change does not trigger sweep",
);
} finally {
stop();
Date.now = originalNow;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
globalThis.document = undefined;
}
});
test("chunked sweep: keys processed across multiple idle slices; stale entries removed only at end", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
let bootCallback = null;
globalThis.setTimeout = (callback, _delay) => {
bootCallback = callback;
return 1;
};
globalThis.clearTimeout = () => {};
const idleCallbacks = [];
const now = 100 * DAY_MS;
// Create more entries than SWEEP_CHUNK_ENTRY_BUDGET so multiple slices fire.
const entryCount = SWEEP_CHUNK_ENTRY_BUDGET + 5;
const entries = Array.from({ length: entryCount }, (_, i) => [
`buzz-channel-messages.v1:key-${i}`,
snapshot(now - 14 * DAY_MS), // all stale
]);
const localStorage = makeLocalStorage(entries);
installWindow(localStorage, {
requestIdleCallback(callback, _options) {
idleCallbacks.push(callback);
return idleCallbacks.length;
},
cancelIdleCallback() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
// Fire boot timer.
assert.ok(bootCallback);
bootCallback();
// First idle callback scheduled — fire it with zero time remaining so it
// processes exactly one entry (min-progress guarantee) and reschedules.
assert.equal(idleCallbacks.length, 1, "first idle callback scheduled");
idleCallbacks[0]({ timeRemaining: () => 0 });
// With zero time remaining the slice stops after one entry;
// stale keys not yet removed (second slice pending).
const removedAfterFirst = entries.filter(
([key]) => localStorage.getItem(key) === null,
).length;
assert.equal(
removedAfterFirst,
0,
"no keys removed until all slices complete",
);
// A second idle callback was scheduled for the remaining entries.
assert.equal(idleCallbacks.length, 2, "second idle callback scheduled");
idleCallbacks[1]({ timeRemaining: () => 50 }); // ample time — completes all
// All stale keys now removed.
const remaining = entries.filter(
([key]) => localStorage.getItem(key) !== null,
);
assert.equal(
remaining.length,
0,
"all stale entries removed after final slice",
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
test("chunked sweep: keys deleted between snapshot and slice are silently skipped", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
let bootCallback = null;
globalThis.setTimeout = (callback, _delay) => {
bootCallback = callback;
return 1;
};
globalThis.clearTimeout = () => {};
const idleCallbacks = [];
// Use real Date.now() so timestamps are relative to the actual clock.
const now = Date.now();
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:gone", snapshot(now - 14 * DAY_MS)],
["buzz-channel-messages.v1:fresh", snapshot(now - DAY_MS)],
]);
installWindow(localStorage, {
requestIdleCallback(callback, _options) {
idleCallbacks.push(callback);
return idleCallbacks.length;
},
cancelIdleCallback() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
bootCallback();
assert.equal(idleCallbacks.length, 1);
// Delete "gone" externally before the slice fires.
localStorage.store.delete("buzz-channel-messages.v1:gone");
// Slice processes — should not throw on missing key.
assert.doesNotThrow(() => {
idleCallbacks[0]({ timeRemaining: () => 50 });
});
// "fresh" key untouched.
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:fresh"),
null,
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
// --- New hardening tests ---
test("chunked sweep: key rewritten fresh before batch removal is not removed", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
let bootCallback = null;
globalThis.setTimeout = (callback, _delay) => {
bootCallback = callback;
return 1;
};
globalThis.clearTimeout = () => {};
const idleCallbacks = [];
const now = Date.now();
const staleValue = JSON.stringify({
updatedAt: now - 14 * DAY_MS,
payload: "old",
});
const freshValue = JSON.stringify({
updatedAt: now - DAY_MS,
payload: "new",
});
// "target" will be rewritten fresh between slices; "other" stays stale.
// Two entries force two slices when timeRemaining() === 0 (1 entry per slice).
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:target", staleValue],
["buzz-channel-messages.v1:other", staleValue],
]);
installWindow(localStorage, {
requestIdleCallback(callback, _options) {
idleCallbacks.push(callback);
return idleCallbacks.length;
},
cancelIdleCallback() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
bootCallback();
assert.equal(idleCallbacks.length, 1);
// Slice 1: zero budget → processes exactly one entry ("target", judged stale).
idleCallbacks[0]({ timeRemaining: () => 0 });
// App rewrites "target" to a fresh value before slice 2 fires.
localStorage.setItem("buzz-channel-messages.v1:target", freshValue);
assert.equal(idleCallbacks.length, 2, "second slice scheduled");
// Slice 2: processes "other", then batch-removes. Re-check on "target"
// finds it fresh — must not be removed.
idleCallbacks[1]({ timeRemaining: () => 50 });
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:target"),
freshValue,
"key rewritten fresh before batch removal must not be removed",
);
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:other"),
null,
"still-stale key must be removed",
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
test("chunked sweep: getItem error on one key does not abort sweep; stale siblings still removed", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const originalWarn = console.warn;
const warnings = [];
console.warn = (...args) => warnings.push(args);
let bootCallback = null;
globalThis.setTimeout = (callback, _delay) => {
bootCallback = callback;
return 1;
};
globalThis.clearTimeout = () => {};
const idleCallbacks = [];
const now = Date.now();
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:error-key", "value"],
["buzz-channel-messages.v1:stale-key", snapshot(now - 14 * DAY_MS)],
["buzz-channel-messages.v1:fresh-key", snapshot(now - DAY_MS)],
]);
// Override getItem so that reading "error-key" throws.
const realGetItem = localStorage.getItem.bind(localStorage);
localStorage.getItem = (key) => {
if (key === "buzz-channel-messages.v1:error-key") {
throw new Error("storage error");
}
return realGetItem(key);
};
installWindow(localStorage, {
requestIdleCallback(callback, _options) {
idleCallbacks.push(callback);
return idleCallbacks.length;
},
cancelIdleCallback() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
bootCallback();
assert.equal(idleCallbacks.length, 1);
// Fire with ample time — all entries processed in one slice.
idleCallbacks[0]({ timeRemaining: () => 50 });
// Stale key removed despite the per-key error on its sibling.
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:stale-key"),
null,
"stale-key must be removed despite error on error-key",
);
// Fresh key untouched.
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:fresh-key"),
null,
"fresh-key must not be removed",
);
// Exactly one warning emitted for the failing key.
const sweepWarnings = warnings.filter((w) =>
String(w[0]).includes("[localStorageSweep]"),
);
assert.equal(
sweepWarnings.length,
1,
"exactly one warning per sweep for key errors",
);
} finally {
stop();
console.warn = originalWarn;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
test("fallback path: multiple slices when entries exceed SWEEP_CHUNK_ENTRY_BUDGET; sweep converges", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const timeouts = [];
let nextId = 200;
globalThis.setTimeout = (callback, delay) => {
const id = nextId++;
timeouts.push({ id, callback, delay });
return id;
};
globalThis.clearTimeout = (id) => {
const idx = timeouts.findIndex((t) => t.id === id);
if (idx !== -1) timeouts.splice(idx, 1);
};
const now = Date.now();
const entryCount = SWEEP_CHUNK_ENTRY_BUDGET + 5;
const entries = Array.from({ length: entryCount }, (_, i) => [
`buzz-channel-messages.v1:key-${i}`,
snapshot(now - 14 * DAY_MS), // all stale
]);
const localStorage = makeLocalStorage(entries);
// No requestIdleCallback on window → forces fallback path.
installWindow(localStorage, {
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
// Boot timer registered.
const bootTimer = timeouts.find((t) => t.delay === BOOT_SWEEP_FLOOR_MS);
assert.ok(bootTimer, "boot timer registered");
timeouts.splice(timeouts.indexOf(bootTimer), 1);
bootTimer.callback();
// Process all per-slice timers until no more are scheduled.
let slicesProcessed = 0;
const MAX_SLICES = 20;
while (slicesProcessed < MAX_SLICES) {
const sliceTimer = timeouts.find(
(t) => t.delay === SWEEP_FALLBACK_TIMER_MS,
);
if (!sliceTimer) break;
timeouts.splice(timeouts.indexOf(sliceTimer), 1);
sliceTimer.callback();
slicesProcessed++;
}
// entryCount > SWEEP_CHUNK_ENTRY_BUDGET → at least 2 slices required.
assert.ok(
slicesProcessed >= 2,
`expected ≥2 slices, got ${slicesProcessed}`,
);
// All stale entries removed.
const remaining = entries.filter(
([key]) => localStorage.getItem(key) !== null,
);
assert.equal(
remaining.length,
0,
"all stale entries must be removed via fallback path",
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
test("chunked sweep: large values deferred once on zero-budget slices then parsed; converges when all values are large", () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
let bootCallback = null;
globalThis.setTimeout = (callback, _delay) => {
bootCallback = callback;
return 1;
};
globalThis.clearTimeout = () => {};
const idleCallbacks = [];
const now = Date.now();
// Build values exceeding SWEEP_LARGE_VALUE_DEFER_BYTES with a valid updatedAt.
const largePayload = "x".repeat(SWEEP_LARGE_VALUE_DEFER_BYTES + 1);
const largeStaleValue = JSON.stringify({
updatedAt: now - 14 * DAY_MS,
payload: largePayload,
});
assert.ok(
largeStaleValue.length > SWEEP_LARGE_VALUE_DEFER_BYTES,
"test value must exceed the defer threshold",
);
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:large-a", largeStaleValue],
["buzz-channel-messages.v1:large-b", largeStaleValue],
]);
installWindow(localStorage, {
requestIdleCallback(callback, _options) {
idleCallbacks.push(callback);
return idleCallbacks.length;
},
cancelIdleCallback() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
bootCallback();
assert.equal(idleCallbacks.length, 1, "first slice scheduled after boot");
// Slice 0 (zero budget): large-a is deferred (not yet parsed).
idleCallbacks[0]({ timeRemaining: () => 0 });
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:large-a"),
null,
"large-a must not be removed after first slice (deferred)",
);
assert.ok(
idleCallbacks.length >= 2,
"second slice scheduled after deferral",
);
// Fire remaining slices with zero budget until sweep converges.
// Worst-case: 2 defer slices (one per key) + 2 parse slices = 4 total.
const MAX_SLICES = 20;
let sliceCount = 1; // slice 0 already fired
while (sliceCount < MAX_SLICES && idleCallbacks.length > sliceCount) {
idleCallbacks[sliceCount]({ timeRemaining: () => 0 });
sliceCount++;
}
// Both large stale entries must be removed after convergence.
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:large-a"),
null,
"large-a must be removed after convergence",
);
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:large-b"),
null,
"large-b must be removed after convergence",
);
// Sweep converged in a bounded number of slices (≤ 2 * number of keys).
assert.ok(
sliceCount <= MAX_SLICES,
`sweep did not converge within ${MAX_SLICES} slices`,
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});