Merge pull request #1312 from MBombeck/fix/pdf-reader-crash-report

fix(documents): keep PDF reading alive on CPUs the canvas build skips
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-11-01 01:45:56 +01:00
commit 577e43004f
6 files changed
+205 -42

No files matched your search

+6 -5
View File
@@ -706,12 +706,13 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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"
@@ -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);
@@ -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) => {
+69 -24
View File
@@ -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;
+19
View File
@@ -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 {
+17 -2
View File
@@ -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<R extends PdfChildRequest>(
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<R extends PdfChildRequest>(
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<R extends PdfChildRequest>(
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" });
});