santifer/career-ops · warning
⚠️ Temporary HTML cleanup failed: ${err.message}
Error message
⚠️ Temporary HTML cleanup failed: ${err.message} What it means
fs.promises.unlink on the temporary HTML file failed with an error code other than ENOENT (ENOENT is deliberately ignored because the temp file may already be gone). The rendered PDF is unaffected — only the intermediate .html file next to it survives on disk. Typical codes: EPERM/EBUSY when another process (Chromium, antivirus, indexer) still holds the handle — classic on Windows — or EACCES/EROFS when the output directory's permissions or mount changed between write and cleanup.
Source
Thrown at generate-pdf.mjs:1597
// Close the page so a batch does not accumulate pages into the shared
// browser (leak → OOM). Optional-chained: the single path's browser.close()
// already reclaims the page, and minimal test doubles may omit close().
if (page && typeof page.close === 'function') {
await page.close().catch((err) => {
console.warn(`⚠️ Page cleanup failed: ${err.message}`);
});
}
// Close the per-document context too, so the JS-disabled context created
// above does not accumulate in the shared browser across a batch (#2384).
if (context && typeof context.close === 'function') {
await context.close().catch((err) => {
console.warn(`⚠️ Context cleanup failed: ${err.message}`);
});
}
// Clean up temp file
await unlink(tmpHtmlPath).catch((err) => {
if (err?.code !== 'ENOENT') {
console.warn(`⚠️ Temporary HTML cleanup failed: ${err.message}`);
}
});
}
}
/**
* Render many already-normalized HTML documents through ONE shared Chromium.
*
* Maintainer conditions (#2384): the browser is launched once via the same
* opts.launchBrowser seam the single path uses, and closed in a finally at the
* batch boundary — it never outlives the batch and is torn down even if a
* document throws. Each entry renders on its own page (renderInPage), and a
* failing entry is captured as `{ok:false, error}` without stopping the rest.
*
* The browser is owned here: renderBatch closes whatever launchBrowser returns.
* launchBrowser is a launch *factory*, not a caller-owned handle, so this does
* not break test injection — the stub returns a fresh browser to be closed.
*View on GitHub (pinned to 60398d6549)
Solutions
- Ignore it — delete the leftover .html manually; the PDF is complete.
- On Windows, retry the delete after a short delay, once Chromium has released the handle.
- If EACCES/EPERM repeats, fix ownership/permissions of the output directory (chmod/chown).
- Exclude the output directory from antivirus scanning.
Example fix
// before
await unlink(tmpHtmlPath).catch((err) => {
if (err?.code !== 'ENOENT') {
console.warn(`⚠️ Temporary HTML cleanup failed: ${err.message}`);
}
});
// after — one retry for transient handle locks (EBUSY/EPERM, common on Windows)
await unlink(tmpHtmlPath).catch(async (err) => {
if (err?.code === 'EBUSY' || err?.code === 'EPERM') {
await new Promise((r) => setTimeout(r, 250));
return unlink(tmpHtmlPath).catch((err2) => {
if (err2?.code !== 'ENOENT') console.warn(`⚠️ Temporary HTML cleanup failed: ${err2.message}`);
});
}
if (err?.code !== 'ENOENT') console.warn(`⚠️ Temporary HTML cleanup failed: ${err.message}`);
}); Defensive patterns
Strategy: retry
Validate before calling
import { stat } from 'node:fs/promises';
const st = await stat(tmpHtmlPath).catch(() => null);
if (st?.isFile()) await unlink(tmpHtmlPath); // only attempt the delete when it exists and is a file Try / catch
try {
await unlink(tmpHtmlPath);
} catch (err) {
if (err.code === 'EBUSY' || err.code === 'EPERM') await sleep(250).then(() => unlink(tmpHtmlPath)).catch(() => {});
else if (err.code !== 'ENOENT') console.warn(err.message);
} // classify by err.code: ENOENT=ignore, EBUSY/EPERM=retry once, rest=warn Prevention
- Close or release anything that read the temp HTML before unlinking it
- Write temp files to os.tmpdir() instead of next to the PDF when the output dir may be locked
- Exclude build output dirs from antivirus and indexer scanning on Windows
When it happens
Trigger: Windows: Chromium or real-time antivirus still holds the temp HTML open when unlink runs (EBUSY/EPERM); the output dir was chmod'ed or is on a read-only mount (EACCES/EROFS); tmpHtmlPath points at a directory (EISDIR/EPERM); a network filesystem with lazy handle release.
Common situations: Windows dev machines with antivirus scanning generated files; CI containers writing into a root-owned output dir; NFS/SMB mounts with deferred handle release.
Related errors
- ⚠️ Could not save report: ${err.message}
- ⚠️ Could not save report: ${err.message}
- ⚠️ Could not save HTML: ${err.message}
- ${label} escapes the tracker workspace: ${absPath}
- cannot read ${rel || '.'}: ${err.message}
AI-assisted analysis of santifer/career-ops@60398d6549 (2026-08-20).
Data as JSON: /api/errors/7f7c51c1e6663bc0.
Report an issue: GitHub.