mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
6 files changed
+205
-42
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user