diff --git a/.env.production.example b/.env.production.example index 39868372c..b82aae357 100644 --- a/.env.production.example +++ b/.env.production.example @@ -706,12 +706,13 @@ API_TOKEN_HMAC_KEY="" # ENVIRONMENT_AIR_QUALITY_DISABLED="1" # ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ── -# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts -# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and -# those features degrade cleanly (no thumbnails; scanned PDFs read as text -# only). Override only if auto-detection misfires: +# The bundled renderer needs a recent CPU: AVX2 on x86-64, and ARMv8.2 features +# (LSE atomics, RCpc) on arm64. On older CPUs (Celeron/Atom-class NAS chips, the +# Raspberry Pi 4) it is disabled automatically and those features degrade +# cleanly (no thumbnails; scanned PDFs read as text only). Override only if +# auto-detection misfires: # off — force-disable the renderer -# on — force-enable (crashes the container if the CPU truly lacks AVX2) +# on — force-enable (crashes PDF processing if the CPU truly lacks them) # NATIVE_CANVAS="off" diff --git a/src/lib/documents/__tests__/native-canvas-support.test.ts b/src/lib/documents/__tests__/native-canvas-support.test.ts index df282df1d..f1b814d30 100644 --- a/src/lib/documents/__tests__/native-canvas-support.test.ts +++ b/src/lib/documents/__tests__/native-canvas-support.test.ts @@ -1,9 +1,10 @@ /** * Pins the CPU-feature gate for the native canvas renderer: the bundled Skia - * x64 build uses AVX2, and loading it on a CPU without AVX2 is an uncatchable - * SIGILL that crash-loops the whole container (live incident on a Celeron - * NAS). The gate must (a) close on linux/x64 without the avx2 flag, (b) stay - * open everywhere else, (c) honour the NATIVE_CANVAS override in both + * x64 build uses AVX2 and the arm64 musl build LSE atomics and RCpc, and + * loading either on a CPU without them is an uncatchable SIGILL (live + * incidents on a Celeron NAS and a Raspberry Pi 4). The gate must (a) close + * on linux/x64 without the avx2 flag and on linux/arm64 musl without the + * atomics and lrcpc features, (b) stay open everywhere else, (c) honour the NATIVE_CANVAS override in both * directions, and (d) make generateThumbnail fail soft without ever loading * the module. */ @@ -71,19 +72,67 @@ describe("nativeCanvasSupported", () => { }); }); - it("is always open off linux/x64 — AVX is an x86 concept", () => { - // The test process itself (darwin/arm64 or linux CI x64 WITH avx2) must - // never read cpuinfo on non-x64; simulate arm64 explicitly. - const arch = Object.getOwnPropertyDescriptor(process, "arch")!; - Object.defineProperty(process, "arch", { value: "arm64" }); + it("is always open off Linux, without reading cpuinfo", () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "darwin" }); try { expect(nativeCanvasSupported()).toBe(true); expect(readFileSyncMock).not.toHaveBeenCalled(); } finally { - Object.defineProperty(process, "arch", arch); + Object.defineProperty(process, "platform", platform); } }); + describe("on linux/arm64", () => { + // Raspberry Pi 4 (Cortex-A72, ARMv8.0) against a Pi 5 (Cortex-A76). + const PI4 = + "Features\t: fp asimd evtstrm crc32 cpuid\nCPU implementer\t: 0x41\n"; + const PI5 = + "Features\t: fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm lrcpc dcpop asimddp\n"; + + function onLinuxArm64(libc: "musl" | "glibc", fn: () => void): void { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const arch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "arm64" }); + const header = libc === "glibc" ? { glibcVersionRuntime: "2.36" } : {}; + const report = vi + .spyOn(process.report, "getReport") + .mockReturnValue({ header } as ReturnType< + typeof process.report.getReport + >); + try { + fn(); + } finally { + report.mockRestore(); + Object.defineProperty(process, "platform", platform); + Object.defineProperty(process, "arch", arch); + } + } + + it("closes under musl on a core without LSE atomics and RCpc", () => { + readFileSyncMock.mockReturnValue(`processor\t: 0\n${PI4}`); + onLinuxArm64("musl", () => { + expect(nativeCanvasSupported()).toBe(false); + }); + }); + + it("stays open under musl on a core that has both", () => { + readFileSyncMock.mockReturnValue(`processor\t: 0\n${PI5}`); + onLinuxArm64("musl", () => { + expect(nativeCanvasSupported()).toBe(true); + }); + }); + + it("stays open under glibc, whose build needs neither", () => { + readFileSyncMock.mockReturnValue(`processor\t: 0\n${PI4}`); + onLinuxArm64("glibc", () => { + expect(nativeCanvasSupported()).toBe(true); + }); + expect(readFileSyncMock).not.toHaveBeenCalled(); + }); + }); + it("honours NATIVE_CANVAS=off and =on over detection", () => { process.env.NATIVE_CANVAS = "off"; expect(nativeCanvasSupported()).toBe(false); diff --git a/src/lib/documents/__tests__/pdf-isolation.test.ts b/src/lib/documents/__tests__/pdf-isolation.test.ts index 84fed27f2..1b252131b 100644 --- a/src/lib/documents/__tests__/pdf-isolation.test.ts +++ b/src/lib/documents/__tests__/pdf-isolation.test.ts @@ -4,12 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDeflate } from "node:zlib"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const { annotate } = vi.hoisted(() => ({ annotate: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate })); import { gif } from "../../__tests__/gif-fixture"; +import { __resetNativeCanvasSupportForTests } from "../native-canvas-support"; import { MAX_QUEUED_PER_OWNER, READER_WAIT_MS, @@ -219,6 +220,39 @@ describe("an image upload is decoded in the bounded child", () => { }, 90_000); }); +describe("behind a closed canvas gate", () => { + // A CPU the native canvas was not built for ends any process that loads it + // with SIGILL, and pdf.js loads it when it starts. The child must not. + beforeAll(() => { + process.env.NATIVE_CANVAS = "off"; + __resetNativeCanvasSupportForTests(); + }); + afterAll(() => { + delete process.env.NATIVE_CANVAS; + __resetNativeCanvasSupportForTests(); + }); + + it("still reads a document's text layer", async () => { + await expect(readPdfText(fixture)).resolves.toMatch(/\w{4}/); + }, 30_000); + + it("never loads the canvas in the child", async () => { + const result = await runPdfChild( + { + op: "image-thumbnail", + data: gif({ width: 10, height: 10 }, [{ width: 10, height: 10 }]), + options: { longEdge: 320, quality: 70, maxPixels: 1e6 }, + }, + { timeoutMs: 30_000 }, + ); + expect(result).toMatchObject({ + ok: false, + reason: "error", + message: expect.stringMatching(/not supported on this CPU/), + }); + }, 30_000); +}); + describe("a child that ends without an answer", () => { const dir = mkdtempSync(join(tmpdir(), "reader-child-")); const script = (name: string, body: string) => { diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts index b8bb73ee4..88f496254 100644 --- a/src/lib/documents/native-canvas-support.ts +++ b/src/lib/documents/native-canvas-support.ts @@ -15,19 +15,33 @@ * the gate closed those features degrade cleanly (no thumbnails, PDFs read as * text/native only) while the rest of the app runs normally. * - * Detection: Linux x64 reads `/proc/cpuinfo` once and requires the `avx2` - * flag. Non-x64 (the arm64 image) and non-Linux (dev machines) are always - * supported — AVX is an x86 concept. The optional `NATIVE_CANVAS` env var - * overrides: `off` force-disables (e.g. to rule the renderer out while - * debugging), `on` force-enables (only sensible if detection misfires; on a - * CPU that truly lacks AVX2 this WILL crash the process). Unset or any other - * value → auto-detection. + * The arm64 musl build (the one the published arm64 image runs) is compiled + * for ARMv8.2 and later: it uses LSE atomics (`ldaddal`, `casal`) and RCpc + * loads (`ldapr`) throughout, with no runtime fallback, and dies with SIGILL + * as soon as the module loads on an ARMv8.0 core such as the Raspberry Pi 4's + * Cortex-A72. The glibc arm64 build uses neither, so a glibc host is left + * alone. + * + * Detection reads `/proc/cpuinfo` once on Linux: x64 requires the `avx2` + * flag, arm64 under musl requires the `atomics` and `lrcpc` features. Every + * other host (glibc arm64, non-Linux dev machines) is supported. The optional + * `NATIVE_CANVAS` env var overrides: `off` force-disables (e.g. to rule the + * renderer out while debugging), `on` force-enables (only sensible if + * detection misfires; on a CPU that truly lacks the features this WILL crash + * the process). Unset or any other value → auto-detection. */ import { readFileSync } from "node:fs"; import { annotate } from "@/lib/logging/context"; -let cached: boolean | null = null; +type Verdict = + | { supported: true } + | { + supported: false; + reason: "cpu_missing_avx2" | "cpu_missing_lse_rcpc" | "override_off"; + }; + +let cached: Verdict | null = null; let announced = false; /** Read the gate once per process; logs a single annotation when closed. */ @@ -35,43 +49,74 @@ export function nativeCanvasSupported(): boolean { if (cached === null) { cached = detect(); } - if (!cached && !announced) { + if (!cached.supported && !announced) { announced = true; annotate({ action: { name: "documents.nativeCanvas.unsupported" }, meta: { - reason: "cpu_missing_avx2", + reason: cached.reason, arch: process.arch, platform: process.platform, }, }); } - return cached; + return cached.supported; } -function detect(): boolean { +const SUPPORTED: Verdict = { supported: true }; + +function detect(): Verdict { const override = process.env.NATIVE_CANVAS?.trim().toLowerCase(); - if (override === "off") return false; - if (override === "on") return true; + if (override === "off") return { supported: false, reason: "override_off" }; + if (override === "on") return SUPPORTED; - // AVX is an x86 instruction-set concept; the arm64 image and non-Linux dev - // hosts never hit the SIGILL class. - if (process.platform !== "linux" || process.arch !== "x64") return true; + if (process.platform !== "linux") return SUPPORTED; + if (process.arch === "x64") { + return cpuFeatures("flags", ["avx2"]) + ? SUPPORTED + : { supported: false, reason: "cpu_missing_avx2" }; + } + if (process.arch === "arm64" && runsOnMusl()) { + return cpuFeatures("Features", ["atomics", "lrcpc"]) + ? SUPPORTED + : { supported: false, reason: "cpu_missing_lse_rcpc" }; + } + return SUPPORTED; +} +/** + * Whether the first `/proc/cpuinfo` line starting with `label` names every + * feature. An unreadable file or a missing line is an exotic kernel surface; + * it fails OPEN to today's behavior rather than silently disabling a working + * renderer. + */ +function cpuFeatures(label: string, required: string[]): boolean { try { - const cpuinfo = readFileSync("/proc/cpuinfo", "utf8"); - const flagsLine = cpuinfo + const line = readFileSync("/proc/cpuinfo", "utf8") .split("\n") - .find((line) => line.startsWith("flags")); - // No flags line = exotic kernel surface; fail OPEN to today's behavior - // rather than silently disabling a working renderer. - if (!flagsLine) return true; - return /\bavx2\b/.test(flagsLine); + .find((l) => l.startsWith(label)); + if (!line) return true; + const present = new Set( + line + .slice(line.indexOf(":") + 1) + .trim() + .split(/\s+/), + ); + return required.every((feature) => present.has(feature)); } catch { return true; } } +/** A glibc Node reports the glibc it runs on; a musl one reports none. */ +function runsOnMusl(): boolean { + const header = ( + process.report?.getReport() as + { header?: { glibcVersionRuntime?: string } } | undefined + )?.header; + return header !== undefined && header.glibcVersionRuntime === undefined; +} + /** Test hook — the gate is process-cached by design. */ export function __resetNativeCanvasSupportForTests(): void { cached = null; diff --git a/src/lib/documents/pdf-child.mjs b/src/lib/documents/pdf-child.mjs index 28b4f9429..1bb7c7665 100644 --- a/src/lib/documents/pdf-child.mjs +++ b/src/lib/documents/pdf-child.mjs @@ -12,10 +12,29 @@ * server. */ import { writeFileSync } from "node:fs"; +import * as nodeModule from "node:module"; import { renderImageThumbnail } from "./image-engine.mjs"; import { readTextLayer, renderPdfPages } from "./pdf-engine.mjs"; +// The parent closed the canvas gate (`./native-canvas-support.ts`): this CPU +// lacks instructions the native canvas was built with, and loading it ends +// the process with SIGILL, which no try/catch sees. pdf.js asks for it the +// moment it starts and gets on without it when the load throws, so the text +// layer still reads; rendering and image thumbnails answer an error. Both +// engines load pdf.js and the canvas lazily, so this runs before either. +// `registerHooks` arrived in Node 22.15; the image ships a later one. +if (process.env.NATIVE_CANVAS === "off") { + nodeModule.registerHooks?.({ + resolve(specifier, context, nextResolve) { + if (specifier === "@napi-rs/canvas") { + throw new Error("@napi-rs/canvas is not supported on this CPU"); + } + return nextResolve(specifier, context); + }, + }); +} + try { writeFileSync("/proc/self/oom_score_adj", "1000"); } catch { diff --git a/src/lib/documents/pdf-isolation.ts b/src/lib/documents/pdf-isolation.ts index 74167c6b7..fa405ebd7 100644 --- a/src/lib/documents/pdf-isolation.ts +++ b/src/lib/documents/pdf-isolation.ts @@ -43,10 +43,13 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { annotate } from "@/lib/logging/context"; + import type { ImageThumbnailOptions, ImageThumbnailOutcome, } from "./image-engine.mjs"; +import { nativeCanvasSupported } from "./native-canvas-support"; import type { RenderOptions, RenderedDocument } from "./pdf-engine.mjs"; import { createReaderQueue, type ReaderLane } from "./reader-queue"; @@ -329,7 +332,13 @@ export function runOnce( child = fork(script, [], { cwd: /* turbopackIgnore: true */ process.cwd(), // Nothing the server holds: no keys, no database address, no tokens. - env: { NODE_ENV: process.env.NODE_ENV || "production" }, + // A closed canvas gate travels along: pdf.js loads the native canvas + // when it starts, and on a CPU the build was not made for that load + // alone ends the child before it reads a word. + env: { + NODE_ENV: process.env.NODE_ENV || "production", + ...(nativeCanvasSupported() ? {} : { NATIVE_CANVAS: "off" }), + }, execArgv: [`--max-old-space-size=${heapMb}`], serialization: "advanced", stdio: ["ignore", "ignore", "pipe", "ipc"], @@ -396,7 +405,7 @@ export function runOnce( finish({ ok: false, reason: "interrupted" }); }); - child.on("close", () => { + child.on("close", (code, signal) => { if (killedFor) { finish({ ok: false, reason: "limit", limit: killedFor }); return; @@ -410,6 +419,12 @@ export function runOnce( finish({ ok: false, reason: "limit", limit: "memory" }); return; } + // How it ended is the one lead an operator has: SIGKILL is mostly the + // out-of-memory killer, SIGILL a native module built for another CPU. + annotate({ + action: { name: "documents.pdfChild.interrupted" }, + meta: { exitCode: code, signal }, + }); finish({ ok: false, reason: "interrupted" }); });