mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(documents): keep PDF reading alive on CPUs the canvas build skips
The arm64 musl build of the native canvas uses LSE atomics and RCpc loads throughout, so loading it on an ARMv8.0 core such as the Raspberry Pi 4 ends the process with SIGILL. The CPU gate only knew the x64 AVX2 case, and pdf.js loads the canvas as soon as it starts, so on such a host every PDF read died in the child: no text layer, no index, and the manual read reported a busy reader. The gate now also closes on linux/arm64 under musl when the CPU lacks the atomics or lrcpc feature. A closed gate travels to the PDF child, which refuses to resolve the canvas module, so pdf.js carries on without it and the text layer reads; rendering and thumbnails keep their existing fail-soft path. A child that ends without an answer now logs its exit code and signal. Refs #1311
This commit is contained in:
1 parent
9a6d9b6793
commit
773b943bde
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