mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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>
802 lines
24 KiB
JavaScript
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;
|
|
}
|
|
});
|