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;
}