From 408cbb26c827693d8149d060c1751cb8767d7d8f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 22 Aug 2026 11:06:00 -0400 Subject: [PATCH] fix(producer): fall back to a temp dir when the font cache is unwritable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The font cache holds Google Fonts downloads between runs. It is an optimisation — the bytes are still fetchable without it — but failing to create it was fatal, because `fontCacheDir` called `mkdirSync` unguarded during compile and the throw propagated straight out of the render. A first-time user on 0.8.8 lost their very first render to it: EPERM: operation not permitted, mkdir '/.cache/hyperframes/fonts/inter' and the remediation the CLI offered was "Try --docker for containerized rendering", which does not address an unwritable host directory. Fall back to one temp root per process — the same shape the Lambda cache root in this file already uses — and warn once with the env var that makes the fallback unnecessary. A run still de-duplicates its own downloads; it just cannot reuse them next time. Reproduced end to end before and after: pointing HYPERFRAMES_FONT_CACHE_DIR at a path under a chmod 500 parent used to print "Render failed / EACCES" and now renders, emitting a single actionable warning. Closes #3412. --- ...deterministicFonts-unwritableCache.test.ts | 68 +++++++++++++++++++ .../src/services/deterministicFonts.ts | 38 ++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 packages/producer/src/services/deterministicFonts-unwritableCache.test.ts diff --git a/packages/producer/src/services/deterministicFonts-unwritableCache.test.ts b/packages/producer/src/services/deterministicFonts-unwritableCache.test.ts new file mode 100644 index 0000000000..86b213b34d --- /dev/null +++ b/packages/producer/src/services/deterministicFonts-unwritableCache.test.ts @@ -0,0 +1,68 @@ +/** + * The font cache holds Google Fonts downloads between runs. It is an + * optimisation: the bytes are still fetchable without it, so losing the place + * to keep them is a slower render, not a failed one. + * + * It used to be fatal. `fontCacheDir` called `mkdirSync` unguarded during + * compile, so an unwritable cache root threw straight out and a first-time user + * lost their very first render to + * `EPERM: operation not permitted, mkdir '/.cache/hyperframes/fonts/inter'`. + */ + +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let lockedRoot: string; +let prevCacheEnv: string | undefined; + +const FACE_URL = "https://fonts.gstatic.com/s/inter/v1/inter.woff2"; +const FACE_BYTES = "INTER_BYTES"; + +beforeAll(() => { + prevCacheEnv = process.env.HYPERFRAMES_FONT_CACHE_DIR; + lockedRoot = mkdtempSync(join(tmpdir(), "hf-font-locked-")); + chmodSync(lockedRoot, 0o500); + // A path *under* a read-only parent: creating it is what fails. + process.env.HYPERFRAMES_FONT_CACHE_DIR = join(lockedRoot, "nested"); +}); + +afterAll(() => { + if (prevCacheEnv === undefined) delete process.env.HYPERFRAMES_FONT_CACHE_DIR; + else process.env.HYPERFRAMES_FONT_CACHE_DIR = prevCacheEnv; + chmodSync(lockedRoot, 0o700); + rmSync(lockedRoot, { recursive: true, force: true }); +}); + +const fetchImpl = (async (input: unknown) => { + const url = String(input); + if (url.startsWith("https://fonts.googleapis.com/")) { + return new Response( + `@font-face { font-family: 'Inter'; font-style: normal; font-weight: 300; src: url(${FACE_URL}) format('woff2'); }`, + { status: 200 }, + ); + } + if (url === FACE_URL) return new Response(FACE_BYTES, { status: 200 }); + return new Response("", { status: 404 }); +}) as unknown as typeof fetch; + +const HTML = `

Upright

`; + +describe("unwritable font cache", () => { + it("still resolves fonts instead of aborting the render", async () => { + const { injectDeterministicFontFaces } = await import("./deterministicFonts.js"); + + const result = await injectDeterministicFontFaces(HTML, { + allowSystemFontCapture: false, + fetchImpl, + }); + + // The point: we got here at all. Before the fallback this threw EACCES. + expect(result).toContain('font-family: "Inter"'); + // And the fetched face still made it in, via the temp-directory cache. + expect(result).toContain(Buffer.from(FACE_BYTES).toString("base64")); + }); +}); diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 0a20486de8..72560e8548 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -746,12 +746,44 @@ function fontSlug(familyName: string): string { .replace(/^-|-$/g, ""); } +/** + * A cache that cannot be created must not end a render. This directory only + * holds Google Fonts downloads between runs — the bytes are still fetchable, so + * losing the place to keep them is a slower render, not a failed one. Before + * this fallback existed an unwritable cache root threw straight out of compile: + * a first-time user lost their very first render to + * `EPERM: operation not permitted, mkdir '/.cache/hyperframes/fonts/inter'`. + * + * Same shape as the Lambda root above: one temp directory per process, so a run + * still de-duplicates its own downloads, it just cannot reuse them next time. + */ +let fallbackFontCacheRoot: string | null = null; +let warnedFontCacheFallback = false; + function fontCacheDir(slug: string): string { const dir = join(resolveFontCacheRoot(), slug); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + try { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; + } catch (err) { + fallbackFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-")); + if (!warnedFontCacheFallback) { + warnedFontCacheFallback = true; + const reason = err instanceof Error ? err.message : String(err); + defaultLogger.warn( + `[Compiler] Font cache is not writable (${reason}). Caching to a temporary ` + + `directory for this run instead; fonts will be re-downloaded next time. Set ` + + `HYPERFRAMES_FONT_CACHE_DIR to a writable path to keep them.`, + ); + } + const fallback = join(fallbackFontCacheRoot, slug); + if (!existsSync(fallback)) { + mkdirSync(fallback, { recursive: true }); + } + return fallback; } - return dir; } // A short, stable discriminator for a single subset's woff2. Google Fonts'