santifer/career-ops · warning

⚠️ Browser cleanup failed

Error message

⚠️  Browser cleanup failed: ${err.message}

What it means

A non-fatal warning emitted when browser.close() on the Playwright browser instance rejects during the finally block of a single-document PDF render. The PDF may already have been produced; only teardown failed, typically because the browser process already died or the connection to it was lost.

Solutions

  1. Ignore if the PDF was written — this is teardown-only; verify the output file exists
  2. Update Playwright (npx playwright install --with-deps) so the browser build matches the driver and stops crashing
  3. Check memory limits (dmesg / container OOM logs) and reduce batch concurrency
  4. If it recurs with no crash, wrap close with a browser.process kill fallback

Example fix

// before
await browser.close().catch((err) => console.warn(`⚠️  Browser cleanup failed: ${err.message}`));
// after
await browser.close().catch((err) => {
  if (!err.message.includes('Target closed') && !err.message.includes('Connection closed')) {
    console.warn(`⚠️  Browser cleanup failed: ${err.message}`);
  }
  browser.process()?.kill('SIGKILL');
});
Defensive patterns

Strategy: try-catch

Type guard

function isBenignTeardownError(err) {
  const msg = String(err?.message ?? '');
  return /Target closed|Connection closed|Browser has been closed|Target crashed/i.test(msg);
}

Try / catch

await browser.close().catch((err) => {
  if (!isBenignTeardownError(err)) console.warn(`Browser cleanup failed: ${err.message}`);
});

Prevention

When it happens

Trigger: browser.close() rejects inside the finally of the render helper after launchBrowser({headless:true}) — Playwright browser crashed mid-render, connection closed because the Chromium process was killed (OOM, signal), or the target closed unexpectedly before cleanup.

Common situations: Chromium OOM-killed on large HTML during a long batch; headless Chromium crash from a missing system dependency; container sandbox killing zombie browser processes; calling browser.close() twice via overlapping code paths in a customized fork.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/208d8ea19efd2630. Report an issue: GitHub.

Appendix: source

Thrown at generate-pdf.mjs:1646

 *   reportNum?: string,
 *   inputPath?: string,
 *   workspaceRoot?: string,
 *   maxPages?: number,
 *   strictPages?: boolean,
 *   launchBrowser?: (options: {headless: boolean}) => Promise<import('playwright').Browser>
 * }} [opts]
 * @returns {Promise<{outputPath: string, pageCount: number, size: number}>}
 */
export async function renderHtmlToPdf(html, outputPath, opts = {}) {
  const launchBrowser = opts.launchBrowser || ((options) => chromium.launch(options));
  let browser = null;
  try {
    browser = await launchBrowser({ headless: true });
    return await renderInPage(browser, html, outputPath, opts);
  } finally {
    if (browser) {
      await browser.close().catch((err) => {
        console.warn(`⚠️  Browser cleanup failed: ${err.message}`);
      });
    }
  }
}

/**
 * Render one already-normalized HTML document to a PDF on an already-launched
 * browser. This is the page-level half of the render — it owns the per-document
 * work (theme/print/font injection, temp file, page, PDF, page-budget, manifest)
 * but NOT the browser lifecycle. Both the single-CV path (renderHtmlToPdf) and
 * the batch path (renderBatch) call this exact function, which is what keeps a
 * single-CV render byte-identical whether it runs alone or inside a batch (#2384).
 *
 * The page and the temp HTML file are always cleaned up in a finally, so a
 * throw here (e.g. a strict page-budget overflow) never leaks a page into the
 * shared browser — the caller's remaining documents keep their own fresh pages.
 *
 * @param {import('playwright').Browser} browser - An open browser to render on.

View on GitHub (pinned to aac998c7ed)