freezeDetector.ts3.6 KBView on GitHub import { push, readAll, isEnabled } from '../buffer';
import { saveSnapshot, type StoredSnapshot } from './snapshotStore';
const RECENT_TIMELINE_LIMIT = 200;
const SNAPSHOT_DEBOUNCE_MS = 5000;
let worker: Worker | null = null;
let initialized = false;
let lastSnapshotAt = 0;
let getStoreSnapshot: (() => Record<string, unknown>) | null = null;
let snapCounter = 0;
function nextSnapshotId(): string {
snapCounter++;
return `snap_${Date.now().toString(36)}_${snapCounter.toString(36)}`;
}
function captureSnapshot(
reason: StoredSnapshot['reason'],
blockedMs: number,
): StoredSnapshot {
const recent = readAll().slice(-RECENT_TIMELINE_LIMIT);
const storeState = getStoreSnapshot ? getStoreSnapshot() : {};
const memory =
typeof performance !== 'undefined' &&
(performance as Performance & { memory?: { usedJSHeapSize: number; totalJSHeapSize: number } })
.memory
? {
usedJSHeapSize: (performance as Performance & {
memory?: { usedJSHeapSize: number };
}).memory!.usedJSHeapSize,
totalJSHeapSize: (performance as Performance & {
memory?: { totalJSHeapSize: number };
}).memory!.totalJSHeapSize,
}
: undefined;
return {
id: nextSnapshotId(),
ts: Date.now(),
reason,
blockedMs,
recentTimeline: recent,
storeKeys: Object.keys(storeState),
memory,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
url: typeof location !== 'undefined' ? location.href : '',
viewport: {
w: typeof window !== 'undefined' ? window.innerWidth : 0,
h: typeof window !== 'undefined' ? window.innerHeight : 0,
},
};
}
async function recordFreeze(reason: StoredSnapshot['reason'], blockedMs: number) {
const now = Date.now();
if (now - lastSnapshotAt < SNAPSHOT_DEBOUNCE_MS) {
// still emit the timeline entry, but skip the snapshot to avoid spam
push({ source: 'freeze', reason, blockedMs });
return;
}
lastSnapshotAt = now;
const snap = captureSnapshot(reason, blockedMs);
push({ source: 'freeze', reason, blockedMs, snapshotId: snap.id });
await saveSnapshot(snap);
}
export interface FreezeDetectorOptions {
getStoreSnapshot?: () => Record<string, unknown>;
}
export function initFreezeDetector(opts: FreezeDetectorOptions = {}): void {
if (initialized || typeof window === 'undefined' || !isEnabled()) return;
initialized = true;
if (opts.getStoreSnapshot) getStoreSnapshot = opts.getStoreSnapshot;
// Worker heartbeat detection — survives main-thread freezes
try {
worker = new Worker(new URL('./freeze.worker.ts', import.meta.url), {
type: 'module',
});
worker.onmessage = (e) => {
if (e.data?.type === 'ping') {
worker?.postMessage({ type: 'pong' });
} else if (e.data?.type === 'freeze') {
void recordFreeze('worker-heartbeat-miss', e.data.blockedMs);
}
};
worker.postMessage({ type: 'start' });
} catch {
// Worker bundling failed; fall back to rAF gap detection only
}
// requestAnimationFrame gap detection (in-process, weaker signal)
if (typeof requestAnimationFrame !== 'undefined') {
let lastFrame = performance.now();
const RAF_GAP_THRESHOLD_MS = 500;
const loop = () => {
const now = performance.now();
const gap = now - lastFrame;
if (gap > RAF_GAP_THRESHOLD_MS) {
void recordFreeze('raf-gap', gap);
}
lastFrame = now;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}
export function disposeFreezeDetector(): void {
worker?.terminate();
worker = null;
initialized = false;
}