Web Totals

Học từ Facebook #79: Phát hiện crash qua heartbeat, dò proxy cache hỏng

Tab crash thì không kịp gửi log. Facebook để mỗi tab ghi heartbeat 5 giây một lần vào localStorage, tab sau đọc lại để biết tab trước chết, và dùng nhận việc hai pha để chỉ một tab báo cáo.

hunghg255hunghg25525 min read
#facebook#monitoring#localstorage#multi-tab#caching
Học từ Facebook #79: Phát hiện crash qua heartbeat, dò proxy cache hỏng

Trình duyệt giết tab vì hết bộ nhớ ("Aw, Snap!"), hoặc tab treo cứng tới mức người dùng phải đóng. Lúc đó không có error, không có pagehide, không có sendBeacon. JavaScript của trang đã chết trước khi kịp kêu.

Vậy làm sao đội Facebook biết trang upload video crash bao nhiêu lần, và lúc crash trang đang dùng bao nhiêu bộ nhớ?

Câu trả lời: người chết không báo được thì người sống báo thay. Mỗi tab ghi "tôi còn sống" vào localStorage vài giây một lần. Tab nào mở sau (hoặc tab khác đang mở) thấy một bản ghi "đang chạy" mà lâu không cập nhật thì biết tab đó đã chết.

Bài này cũng nói về một kiểu "chết" khác, lặng lẽ hơn: proxy ở giữa cache nhầm trang của người này rồi trả cho người khác.

Tip

Tóm tắt

  • Bandicoot ghi bản ghi của mọi tab vào một khoá localStorage chung, 5 giây một lần: trạng thái, thời điểm, dung lượng heap, số phần tử DOM, URL.
  • Bản ghi ACTIVE mà im lặng hơn 10 phút là crash. Tab ẩn chuyển INACTIVE nên không bị tính nhầm.
  • Nhiều tab cùng thấy một crash: tab đầu tiên ghi tên mình vào bản ghi (pha 1), nhịp sau nếu tên vẫn là mình mới báo (pha 2). Mỗi crash chỉ một báo cáo.
  • DetectBrokenProxyCache so cookie c_user với user id server nhúng trong HTML. Khác nhau nghĩa là HTML này được render cho người khác rồi bị cache lại.
  • Bản viết lại tự đóng phiên khi pagehide, tiêm được lưu trữ và đồng hồ để test, và không xoá dữ liệu của tab khác khi ghi lỗi.

#Facebook làm thế nào?

#BandicootSession (bundle 177.js)

Bốn hàm thuần, quyết định số phận một bản ghi:

const Status = { ACTIVE: 'ACTIVE', INACTIVE: 'INACTIVE', CLOSED: 'CLOSED' };
const STALE = 10 * 60 * 1000;            // 10 phút
const ORPHAN = 14 * 24 * 60 * 60 * 1000; // 14 ngày
const age = (s) => Date.now() - s.lastUpdated;

isTrackedSession = (s) => s.status !== CLOSED && !(s.status === INACTIVE && age(s) > STALE);
isCrashedSession = (s) => s.status === ACTIVE && age(s) > STALE;
isOrphanSession  = (s) => age(s) > ORPHAN;
js

#Bandicoot (bundle 177.js, bản khác trong 26.VideoPlayer/all.js)

Mỗi loại theo dõi có một khoá, ví dụ Bandicoot:<tên>, chứa JSON { sessionId: record } của mọi tab. Mỗi 5 giây (setInterval), tab chạy một nhịp: đọc cả map, cập nhật bản ghi của mình (kèm getCrashTimeData(): thời lượng, số tab, performance.memory, số phần tử DOM, URL), xét bản ghi của các tab khác, ghi cả map lại.

Phần đáng học nhất là nhận việc hai pha trong mỗi nhịp:

if (isCrashedSession(s)) {
  if (Date.now() - (s.loggerReportTime || 0) > 10_000) { s.loggerReportTime = Date.now(); s.loggerReportSession = myId; } // pha 1
  else if (s.loggerReportSession == myId) { logCrash(id, s); return null; }                                                 // pha 2
}
js
Code gốc đã đổi tên biến: heartbeat và nhận việc hai pha
heartbeat() {
  const sessions = this.read();
  sessions[this.id] = { ...sessions[this.id], ...this.getCrashTimeData(),
                        lastUpdated: Date.now(), status: this.status() };
  this.write(this.checkCrashes(sessions));
}

getCrashTimeData() {
  const mem = performance.memory || {};            // chỉ Chromium có
  return { duration: Date.now() - this.startTime, tabs: Object.keys(this.read()).length,
           jsHeapSizeLimit: mem.jsHeapSizeLimit, usedJSHeapSize: mem.usedJSHeapSize, …,
           elementsInDOM: document.getElementsByTagName('*').length, uri: location.href };
}

checkCrashes(sessions) {
  return filterTracked(sessions, (s, id) => {        // bỏ CLOSED, INACTIVE cũ, quá 14 ngày
    if (isCrashedSession(s)) {
      if (Date.now() - (s.loggerReportTime || 0) > 10_000) {
        s.loggerReportTime = Date.now();             // pha 1: nhận việc
        s.loggerReportSession = this.id;
      } else if (s.loggerReportSession == this.id) {
        this.logCrash(id, s, this.getLogTimeData()); // pha 2: vẫn là mình → báo
        return null;                                 // xoá khỏi map
      }
    }
    return s;
  });
}
js

Dữ liệu getCrashTimeData được ghi lúc tab còn sống, nên khi báo cáo, người báo gửi được trạng thái cuối cùng của tab đã chết: heap đang dùng bao nhiêu, DOM có bao nhiêu phần tử, ở URL nào. Đó là manh mối tốt nhất cho crash vì hết bộ nhớ.

Các chi tiết khác:

  • Lúc khởi động, thử setItem('__test…'); lỗi thì coi như trình duyệt không hỗ trợ.
  • visibilitychange chuyển trạng thái giữa ACTIVE và INACTIVE.
  • endSession() ghi CLOSED. Module không tự gọi nó khi đóng tab; lớp con (VideoUploadCrashMonitor, AdsUnifiedLoggingCrashLogger) gọi khi việc của chúng xong, ví dụ upload hoàn tất. Thông điệp báo của bên upload: "Detected video upload did not complete due to a crash".
  • Khi ghi lỗi (đầy bộ nhớ): báo lỗi tối đa 5 giây một lần, rồi xoá cả khoá chung. Xoá cũng lỗi thì dừng hẳn.
sequenceDiagram
  participant A as Tab A
  participant S as localStorage
  participant B as Tab B
  participant C as Tab C
  A->>S: heartbeat ACTIVE (5s/lần)
  Note over A: crash, ngừng ghi
  B->>S: 10 phút sau: thấy A ACTIVE nhưng im lặng
  B->>S: pha 1: A.claim = B
  C->>S: thấy claim của B còn mới, bỏ qua
  B->>S: nhịp sau: claim vẫn là B
  B->>B: báo cáo crash của A, xoá bản ghi

#Hai chỗ đáng bàn

Ghi lỗi thì xoá cả khoá. Khoá chứa bản ghi của mọi tab, kể cả bản ghi của tab vừa crash mà chưa ai báo. Một tab gặp QuotaExceededError xoá luôn bằng chứng của tab khác. Có lẽ đội viết coi đầy bộ nhớ là hiếm, nhưng tình huống đầy bộ nhớ lại hay đi cùng trang đang nặng, đúng lúc dễ crash.

Đọc, sửa, ghi không có khoá. Hai tab chạy nhịp sát nhau: A đọc, B ghi, A ghi đè bản của B. B mất một nhịp heartbeat (5 giây so với ngưỡng 10 phút, không đáng kể), và nếu B vừa nhận việc thì A ghi đè mất. Nhận việc hai pha chịu được điều này: ai ghi sau thì thắng, người thua thấy tên khác ở nhịp sau và bỏ qua. Trường hợp xấu nhất là crash được báo trễ một nhịp, không phải báo hai lần.

#DetectBrokenProxyCache (bundle 283.js, gọi từ CometRootDeferred.js)

Toàn bộ module:

function run(renderedUserId, cookieName) {
  const fromCookie = Cookie.get(cookieName);
  if (fromCookie != renderedUserId && fromCookie != null && renderedUserId != '0') {
    new AsyncSignal('/common/scribe_endpoint.php', {
      c: 'si_detect_broken_proxy_cache',
      m: cookieName + ' ' + renderedUserId + ' ' + fromCookie,
    }).send();
  }
}

// lúc rảnh, sau khi trang đã lên
JSScheduler.scheduleSpeculativeCallback(() => DetectBrokenProxyCache.run(USER_ID, 'c_user'));
js

Server render HTML cho user renderedUserId. Cookie c_user cho biết trình duyệt này đang đăng nhập là ai. Nếu proxy công ty, CDN cấu hình sai hay cache của nhà mạng giữ lại một trang HTML cá nhân hoá rồi trả cho người khác, hai giá trị sẽ khác nhau. Trang không sửa được gì (đã lỡ hiện), nhưng ít nhất server biết có proxy đang làm hỏng việc. renderedUserId == '0' là trang chưa đăng nhập, ai xem cũng như nhau nên bỏ qua.

#Viết lại

Một nhịp: ghi bản ghi của mình, dọn bản ghi hết hạn, nhận việc rồi báo.

src/lib/crashHeartbeat.ts
  function beat() {
    if (broken || !running) return;
    const t = now();
    const sessions = read();
    sessions[sessionId] = {
      ...sessions[sessionId],
      status,
      startedAt,
      lastUpdated: t,
      data: options.collect?.(),
    };
    for (const [id, record] of Object.entries(sessions)) {
      if (id === sessionId) continue;
      const silent = t - record?.lastUpdated;
      // đóng đàng hoàng, tab ẩn rồi im lặng (không biết có crash không), hoặc quá cũ: bỏ
      const forget =
        !record?.status ||
        record.status === 'closed' ||
        (record.status === 'inactive' && silent > crashAfterMs) ||
        silent > forgetAfterMs;
      if (forget) {
        delete sessions[id];
      } else if (isCrashed(record, t, crashAfterMs)) {
        if (record.claimBy === sessionId) {
          // Pha 2: nhận việc từ nhịp trước và không ai giành lại → mình báo.
          delete sessions[id];
          options.onCrash(id, record);
        } else if (!record.claimAt || t - record.claimAt > claimMs) {
          // Pha 1: chưa ai nhận (hoặc người nhận cũng đã chết) → ghi tên mình vào.
          record.claimBy = sessionId;
          record.claimAt = t;
        }
      }
    }
    write(sessions);
  }
ts

#Đi qua code

  • Một nhịp luôn ghi bản ghi của mình trước khi xét tab khác. Nhờ vậy tab vừa thức dậy sau khi máy ngủ không tự thấy mình "im lặng".
  • forget gom ba trường hợp không cần theo dõi nữa: đóng đàng hoàng, tab ẩn rồi im lặng (không biết nó crash hay bị trình duyệt dọn), và bản ghi quá cũ. Giống isTrackedSession và isOrphanSession của bản gốc.
  • Pha 1 chỉ nhận việc khi chưa ai nhận, hoặc người nhận cũng đã im lặng quá claimMs (hai nhịp). Pha 2 chạy ở nhịp sau, nếu tên trong bản ghi vẫn là mình.
  • write lỗi thì đặt broken, dừng timer và gọi onError. Không xoá khoá chung, nên bằng chứng crash của tab khác vẫn còn cho tab tiếp theo.
  • manual: true tắt timer và listener; demo dùng nó để chạy bốn "tab" với một đồng hồ giả trong cùng một trang.

Khác bản gốc:

  • Tự đóng phiên (closed) khi pagehide, nên đóng tab bình thường không cần lớp con nhớ gọi endSession().
  • Ngưỡng crash, nhịp, thời gian quên đều là tham số; lưu trữ và đồng hồ tiêm vào được (manual: true để test hoặc mô phỏng không cần timer thật).
  • Phần dữ liệu đính kèm là hàm collect() do người dùng truyền, thay vì cố định heap/DOM/URL.
  • Ghi lỗi thì dừng theo dõi và gọi onError, không xoá dữ liệu của tab khác.
  • checkProxyCache là hàm thuần trả kết quả; gửi tín hiệu đi đâu là việc của người gọi.
Xem toàn bộ src/lib/crashHeartbeat.ts
src/lib/crashHeartbeat.ts
/**
 * Phát hiện tab bị crash bằng heartbeat trong localStorage (ý tưởng từ Bandicoot của Facebook):
 * - Mỗi tab ghi bản ghi của mình vào MỘT khoá chung (map sessionId → record) mỗi `heartbeatMs`.
 * - Bản ghi còn "active" mà lâu không được cập nhật nghĩa là tab đã chết mà không kịp đóng.
 * - Nhiều tab cùng thấy một crash: dùng "nhận việc hai pha" để chỉ một tab báo cáo.
 * Khác bản gốc: tự đóng phiên khi pagehide, lưu trữ và đồng hồ tiêm vào được,
 * ghi lỗi thì dừng thay vì xoá luôn dữ liệu của các tab khác.
 */

export type SessionStatus = 'active' | 'inactive' | 'closed';

export interface SessionRecord {
  status: SessionStatus;
  startedAt: number;
  lastUpdated: number;
  /** tab đang nhận việc báo cáo crash của bản ghi này, và lúc nhận */
  claimBy?: string;
  claimAt?: number;
  data?: Record<string, unknown>;
}

export interface StorageLike {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

export interface CrashMonitorOptions {
  storage: StorageLike;
  /** khoá chung cho mọi tab cùng loại, vd 'crash:video-upload' */
  key: string;
  sessionId?: string;
  now?: () => number;
  heartbeatMs?: number;
  /** active mà im lặng lâu hơn mức này thì coi là crash (bản gốc: 10 phút) */
  crashAfterMs?: number;
  /** bản ghi cũ hơn mức này bị dọn, kể cả chưa ai báo (bản gốc: 14 ngày) */
  forgetAfterMs?: number;
  /** dữ liệu gắn vào heartbeat để biết lúc crash trang đang thế nào */
  collect?: () => Record<string, unknown>;
  onCrash: (sessionId: string, record: SessionRecord) => void;
  onError?: (error: unknown) => void;
  /** true: không tự hẹn giờ, không nghe sự kiện trang; người gọi tự gọi beat() (test, mô phỏng) */
  manual?: boolean;
}

export interface CrashMonitor {
  readonly sessionId: string;
  start(): void;
  /** một nhịp: ghi bản ghi của mình, rồi xét các bản ghi khác */
  beat(): void;
  setVisible(visible: boolean): void;
  stop(): void;
}

export function isCrashed(record: SessionRecord, now: number, crashAfterMs: number) {
  return record.status === 'active' && now - record.lastUpdated > crashAfterMs;
}

export function createCrashMonitor(options: CrashMonitorOptions): CrashMonitor {
  const {
    storage,
    key,
    now = Date.now,
    heartbeatMs = 5_000,
    crashAfterMs = 10 * 60_000,
    forgetAfterMs = 14 * 24 * 3_600_000,
  } = options;
  // Cửa sổ nhận việc phải dài hơn một nhịp: tab khác có ít nhất một nhịp để "giành" lại.
  const claimMs = heartbeatMs * 2;
  const sessionId = options.sessionId ?? Math.random().toString(36).slice(2, 9);
  let status: SessionStatus = 'active';
  let startedAt = 0;
  let running = false;
  let timer: ReturnType<typeof setInterval> | null = null;
  let broken = false;

  const read = (): Record<string, SessionRecord> => {
    try {
      const parsed = JSON.parse(storage.getItem(key) ?? '{}');
      return parsed && typeof parsed === 'object' ? parsed : {};
    } catch {
      return {}; // JSON hỏng: coi như rỗng, lần ghi sau sẽ thay
    }
  };

  const write = (sessions: Record<string, SessionRecord>) => {
    try {
      storage.setItem(key, JSON.stringify(sessions));
    } catch (error) {
      // Đầy bộ nhớ: dừng theo dõi, KHÔNG xoá khoá chung (còn bằng chứng crash của tab khác).
      broken = true;
      stop();
      options.onError?.(error);
    }
  };

  function beat() {
    if (broken || !running) return;
    const t = now();
    const sessions = read();
    sessions[sessionId] = {
      ...sessions[sessionId],
      status,
      startedAt,
      lastUpdated: t,
      data: options.collect?.(),
    };
    for (const [id, record] of Object.entries(sessions)) {
      if (id === sessionId) continue;
      const silent = t - record?.lastUpdated;
      // đóng đàng hoàng, tab ẩn rồi im lặng (không biết có crash không), hoặc quá cũ: bỏ
      const forget =
        !record?.status ||
        record.status === 'closed' ||
        (record.status === 'inactive' && silent > crashAfterMs) ||
        silent > forgetAfterMs;
      if (forget) {
        delete sessions[id];
      } else if (isCrashed(record, t, crashAfterMs)) {
        if (record.claimBy === sessionId) {
          // Pha 2: nhận việc từ nhịp trước và không ai giành lại → mình báo.
          delete sessions[id];
          options.onCrash(id, record);
        } else if (!record.claimAt || t - record.claimAt > claimMs) {
          // Pha 1: chưa ai nhận (hoặc người nhận cũng đã chết) → ghi tên mình vào.
          record.claimBy = sessionId;
          record.claimAt = t;
        }
      }
    }
    write(sessions);
  }

  function update(next: SessionStatus) {
    status = next;
    beat();
  }

  const onVisibility = () => update(document.visibilityState === 'hidden' ? 'inactive' : 'active');
  const onPageHide = () => stop();

  function stop() {
    if (timer) clearInterval(timer);
    timer = null;
    if (!running) return;
    if (!broken) update('closed');
    running = false;
    if (!options.manual) {
      document.removeEventListener('visibilitychange', onVisibility);
      window.removeEventListener('pagehide', onPageHide);
    }
  }

  return {
    sessionId,
    beat,
    stop,
    setVisible: (visible) => update(visible ? 'active' : 'inactive'),
    start() {
      if (running || broken) return;
      running = true;
      startedAt = now();
      status = 'active';
      beat();
      if (!options.manual) {
        timer = setInterval(beat, heartbeatMs);
        document.addEventListener('visibilitychange', onVisibility);
        window.addEventListener('pagehide', onPageHide);
      }
    },
  };
}

/**
 * Dò proxy/CDN cache nhầm trang cá nhân hoá (ý tưởng từ DetectBrokenProxyCache):
 * server nhúng user id vào HTML; cookie đăng nhập là của người đang dùng trình duyệt.
 * Hai giá trị khác nhau nghĩa là HTML này được render cho NGƯỜI KHÁC rồi bị cache lại.
 */
export function checkProxyCache(renderedUserId: string, cookieUserId: string | null) {
  const broken = renderedUserId !== '0' && cookieUserId != null && cookieUserId !== renderedUserId;
  return { broken, renderedUserId, cookieUserId };
}

export function readCookie(
  name: string,
  cookie = typeof document === 'undefined' ? '' : document.cookie,
) {
  for (const part of cookie.split(';')) {
    const eq = part.indexOf('=');
    if (eq > 0 && part.slice(0, eq).trim() === name)
      return decodeURIComponent(part.slice(eq + 1).trim());
  }
  return null;
}
ts

#Tại sao không làm cách khác?

  • Gửi log trong beforeunload/pagehide: crash thật không bắn sự kiện nào. Ngược lại, có pagehide là biết tab đóng đàng hoàng, đó là lý do bản viết lại dùng nó để đánh dấu closed.
  • Mỗi tab một khoá riêng: tránh được ghi đè, nhưng mỗi nhịp phải quét toàn bộ localStorage để tìm tab khác. Bản gốc chỉ quét một lần lúc khởi động (dọn khoá mồ côi), còn lại đọc một khoá.
  • Tab nào thấy crash cũng báo: demo đếm song song, 3 tab sống cho 3 báo cáo cho mỗi crash. Đếm trùng làm số liệu crash phồng gấp vài lần, tuỳ người dùng mở bao nhiêu tab.
  • Web Locks API (navigator.locks.request): lock thật giữa các tab, sạch hơn nhận việc hai pha. Nhưng lock được giải phóng khi tab chết, nên dùng để "tab khác biết ngay khi tab này chết" cũng được: tab B chờ lock của tab A, lấy được lock nghĩa là A đã đi. Có lẽ Bandicoot ra đời trước khi Web Locks phổ biến.
  • Reporting API / Crash Reports (ReportingObserver không bắt được, phải cấu hình Reporting-Endpoints ở header): Chromium gửi báo cáo crash "oom" lên server của bạn mà không cần JavaScript. Chỉ có ở Chromium (tính tới 10/2026) và không kèm dữ liệu ứng dụng.

#Edge case và cạm bẫy

  • Máy ngủ: laptop gập lại 30 phút với tab đang hiện. Khi mở lại, tab nào chạy nhịp trước có thể thấy tab kia ACTIVE mà im lặng 30 phút. Nhịp đầu tiên chỉ nhận việc (pha 1). Nếu trong 5 giây đó tab kia kịp ghi heartbeat, bản ghi hết "im lặng" và nhịp sau không báo. Hai pha vô tình cũng chống được báo nhầm loại này, nhưng không chắc chắn nếu timer của tab kia chạy trễ hơn một nhịp.
  • Tab ẩn bị bóp timer: Chrome gom timer của tab ẩn lâu (hơn 5 phút) về khoảng 1 lần/phút. Vì vậy phải đánh dấu inactive khi ẩn; nếu không, tab ẩn lâu bị coi là crash.
  • bfcache: pagehide có persisted = true khi trang vào back/forward cache. Bản viết lại đánh dấu closed; nếu người dùng bấm Back, cần start() lại trong pageshow.
  • Bộ nhớ dùng chung giữa các tab không có giao dịch: không dựa vào hai pha cho việc cần đúng tuyệt đối (trừ tiền, gửi tin). Cho đếm crash thì đủ.
  • Proxy cache: chuyện này lẽ ra không xảy ra nếu server gửi Cache-Control: private, no-store cho trang cá nhân hoá. Kiểm tra phía client là lưới an toàn cho những proxy không tôn trọng header, không thay thế header.
  • So sánh != lỏng của bản gốc: cookie là chuỗi, user id có thể là số; != cho phép '42' != 42 là false. Bản viết lại nhận cả hai là chuỗi và so !==.

#Dùng trong dự án

import { checkProxyCache, createCrashMonitor, readCookie } from './crashHeartbeat';

// theo dõi riêng trang soạn bài: crash lúc đang soạn là mất bài
const monitor = createCrashMonitor({
  storage: localStorage,
  key: 'crash:editor',
  collect: () => ({ url: location.pathname, nodes: document.getElementsByTagName('*').length }),
  onCrash: (id, record) =>
    navigator.sendBeacon('/log/crash', JSON.stringify({ id, ...record.data, silentFor: Date.now() - record.lastUpdated })),
  onError: () => console.warn('không theo dõi crash được: localStorage đầy'),
});
monitor.start();

const proxy = checkProxyCache(window.__RENDERED_USER_ID__, readCookie('uid'));
if (proxy.broken) navigator.sendBeacon('/log/proxy-cache', JSON.stringify(proxy));
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { checkProxyCache, createCrashMonitor } from './crashHeartbeat';

function setup() {
  const map = new Map<string, string>();
  const storage = { getItem: (k: string) => map.get(k) ?? null, setItem: (k: string, v: string) => void map.set(k, v), removeItem: (k: string) => void map.delete(k) };
  const clock = { t: 0 };
  const reports: string[] = [];
  const tab = (id: string) =>
    createCrashMonitor({ storage, key: 'k', sessionId: id, now: () => clock.t, manual: true, crashAfterMs: 60_000,
      onCrash: (dead) => reports.push(`${id}>${dead}`) });
  return { clock, reports, tab, map };
}

describe('createCrashMonitor', () => {
  it('mỗi crash chỉ một tab báo, dù thứ tự nhịp ngẫu nhiên', () => {
    for (let trial = 0; trial < 100; trial++) {
      const { clock, reports, tab } = setup();
      const tabs = ['A', 'B', 'C', 'D'].map(tab);
      tabs.forEach((t) => t.start());
      const alive = tabs.slice(1); // A crash
      for (let i = 0; i < 40; i++) {
        clock.t += 5_000;
        [...alive].sort(() => Math.random() - 0.5).forEach((t) => t.beat());
      }
      expect(reports).toHaveLength(1);
      expect(reports[0]).toMatch(/>A$/);
    }
  });

  it('tab ẩn không bị coi là crash', () => {
    const { clock, reports, tab } = setup();
    const [a, b] = [tab('A'), tab('B')];
    a.start(); b.start(); a.setVisible(false);
    for (let i = 0; i < 40; i++) { clock.t += 5_000; b.beat(); }
    expect(reports).toEqual([]);
  });

  it('ghi lỗi thì dừng, không xoá dữ liệu của tab khác', () => {
    const { map, clock } = setup();
    map.set('k', '{"X":{"status":"active","startedAt":0,"lastUpdated":0}}');
    let errors = 0;
    const storage = { getItem: (k: string) => map.get(k) ?? null, setItem: () => { throw new Error('quota'); }, removeItem: () => {} };
    createCrashMonitor({ storage, key: 'k', now: () => clock.t, manual: true, onCrash() {}, onError: () => errors++ }).start();
    expect(errors).toBe(1);
    expect(map.has('k')).toBe(true);
  });
});

it('checkProxyCache', () => {
  expect(checkProxyCache('1', '2').broken).toBe(true);
  expect(checkProxyCache('1', '1').broken).toBe(false);
  expect(checkProxyCache('0', '2').broken).toBe(false); // trang chưa đăng nhập
  expect(checkProxyCache('1', null).broken).toBe(false);
});
ts

#So với thư viện và chuẩn web

  • Sentry, Datadog RUM: bắt lỗi JavaScript và long task, nhưng tab bị giết thì SDK cũng chết theo. Muốn đếm loại crash này vẫn cần heartbeat kiểu Bandicoot hoặc Reporting API.
  • Reporting API (Reporting-Endpoints + loại báo cáo crash): trình duyệt tự gửi báo cáo khi tab crash, có lý do (oom, unresponsive). Chỉ Chromium (tính tới 10/2026).
  • Web Locks API: có ở mọi trình duyệt chính; dùng để bầu một tab "leader" hoặc để biết tab khác đã chết, thay cho nhận việc qua localStorage.
  • BroadcastChannel: nhắn giữa các tab cùng origin, nhưng tin nhắn không lưu lại; tab mở sau không biết tab trước đã chết. Heartbeat cần thứ gì đó tồn tại sau khi tab chết, nên phải là storage.
  • performance.memory: chỉ Chromium, đã lỗi thời; thay bằng performance.measureUserAgentSpecificMemory() (cần cross-origin isolation, cũng chỉ Chromium, tính tới 10/2026).

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz