chore(release): v1.28.27 — runs on CPUs without AVX2

The bundled Skia x64 build (document thumbnails, scanned-PDF rasterization)
uses AVX2 instructions; on an x86-64 host without them — Celeron/Atom-class
NAS CPUs, pre-2013 Xeons, VMs masking CPU flags — the first canvas call is an
uncatchable SIGILL that kills the process and crash-loops the container (live
incident: a Synology DS1520+, exit 132 ~18 s after every boot, reported with
kernel traps and the correct root cause in hand).

The renderer is now gated on a one-time CPU-feature check before the module
ever loads: linux/x64 requires the avx2 cpuinfo flag; arm64 and non-Linux are
unaffected; unreadable cpuinfo fails open. Behind a closed gate, thumbnail
generation and PDF rasterization take their existing fail-soft paths (vault
shows type icons; PDFs read as text) and the boot-time thumbnail backfill
skips enqueueing. NATIVE_CANVAS=off|on overrides detection, documented in the
env example and whitelisted in compose.
This commit is contained in:
Marc-André Bombeck committed 2026-07-11 10:02:29 +02:00
1 parent da0e509095
commit 45da6c3c7d
11 files changed
+257 -3

No files matched your search

@@ -0,0 +1,120 @@
/**
* 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
* directions, and (d) make generateThumbnail fail soft without ever loading
* the module.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { readFileSyncMock } = vi.hoisted(() => ({
readFileSyncMock: vi.fn(),
}));
vi.mock("node:fs", () => ({ readFileSync: readFileSyncMock }));
import {
nativeCanvasSupported,
__resetNativeCanvasSupportForTests,
} from "../native-canvas-support";
const FLAGS_WITH_AVX2 =
"flags\t\t: fpu vme sse sse2 ssse3 sse4_1 sse4_2 avx avx2 bmi1\n";
const FLAGS_WITHOUT_AVX2 =
"flags\t\t: fpu vme sse sse2 ssse3 sse4_1 sse4_2 rdrand\n";
function onLinuxX64(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: "x64" });
try {
fn();
} finally {
Object.defineProperty(process, "platform", platform);
Object.defineProperty(process, "arch", arch);
}
}
beforeEach(() => {
__resetNativeCanvasSupportForTests();
readFileSyncMock.mockReset();
delete process.env.NATIVE_CANVAS;
});
afterEach(() => {
delete process.env.NATIVE_CANVAS;
});
describe("nativeCanvasSupported", () => {
it("closes on linux/x64 when /proc/cpuinfo lacks the avx2 flag", () => {
readFileSyncMock.mockReturnValue(`processor: 0\n${FLAGS_WITHOUT_AVX2}`);
onLinuxX64(() => {
expect(nativeCanvasSupported()).toBe(false);
});
});
it("stays open on linux/x64 with avx2 present", () => {
readFileSyncMock.mockReturnValue(`processor: 0\n${FLAGS_WITH_AVX2}`);
onLinuxX64(() => {
expect(nativeCanvasSupported()).toBe(true);
});
});
it("fails OPEN when cpuinfo is unreadable (exotic surface, keep today's behavior)", () => {
readFileSyncMock.mockImplementation(() => {
throw new Error("EACCES");
});
onLinuxX64(() => {
expect(nativeCanvasSupported()).toBe(true);
});
});
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" });
try {
expect(nativeCanvasSupported()).toBe(true);
expect(readFileSyncMock).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "arch", arch);
}
});
it("honours NATIVE_CANVAS=off and =on over detection", () => {
process.env.NATIVE_CANVAS = "off";
expect(nativeCanvasSupported()).toBe(false);
__resetNativeCanvasSupportForTests();
process.env.NATIVE_CANVAS = "on";
readFileSyncMock.mockReturnValue(FLAGS_WITHOUT_AVX2);
onLinuxX64(() => {
expect(nativeCanvasSupported()).toBe(true);
});
});
it("caches the verdict per process", () => {
readFileSyncMock.mockReturnValue(`processor: 0\n${FLAGS_WITH_AVX2}`);
onLinuxX64(() => {
nativeCanvasSupported();
nativeCanvasSupported();
});
expect(readFileSyncMock).toHaveBeenCalledTimes(1);
});
});
describe("generateThumbnail behind a closed gate", () => {
it("fails soft without loading the canvas module", async () => {
process.env.NATIVE_CANVAS = "off";
__resetNativeCanvasSupportForTests();
const { generateThumbnail } = await import("../thumbnail");
const result = await generateThumbnail(
Buffer.from([0xff, 0xd8, 0xff]),
"image/jpeg",
);
expect(result).toEqual({ ok: false });
});
});
@@ -0,0 +1,79 @@
/**
* v1.28.27 — CPU-feature gate for the native canvas renderer.
*
* The bundled `@napi-rs/canvas` (Skia) x64 builds use AVX2 instructions. On an
* x86-64 host WITHOUT AVX2 — Celeron/Atom-class NAS CPUs (Goldmont Plus and
* older) are the common self-hosting case — the first canvas call dies with
* SIGILL (`trap invalid opcode … in skia.linux-x64-musl.node`), which kills the
* whole Node process and puts the container into an infinite restart loop
* (live incident: a Synology DS1520+ self-host, exit code 132 ~18 s after
* every boot once the thumbnail backfill touched Skia).
*
* A SIGILL cannot be caught in-process, so the only safe posture is to never
* load the module on an unsupported CPU. Every canvas consumer (document
* thumbnails, scanned-PDF rasterization) already has a fail-soft path — with
* 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.
*/
import { readFileSync } from "node:fs";
import { annotate } from "@/lib/logging/context";
let cached: boolean | null = null;
let announced = false;
/** Read the gate once per process; logs a single annotation when closed. */
export function nativeCanvasSupported(): boolean {
if (cached === null) {
cached = detect();
}
if (!cached && !announced) {
announced = true;
annotate({
action: { name: "documents.nativeCanvas.unsupported" },
meta: {
reason: "cpu_missing_avx2",
arch: process.arch,
platform: process.platform,
},
});
}
return cached;
}
function detect(): boolean {
const override = process.env.NATIVE_CANVAS?.trim().toLowerCase();
if (override === "off") return false;
if (override === "on") return true;
// 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;
try {
const cpuinfo = readFileSync("/proc/cpuinfo", "utf8");
const flagsLine = cpuinfo
.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);
} catch {
return true;
}
}
/** Test hook — the gate is process-cached by design. */
export function __resetNativeCanvasSupportForTests(): void {
cached = null;
announced = false;
}
+13
View File
@@ -34,6 +34,8 @@ import { Buffer } from "node:buffer";
import { annotate } from "@/lib/logging/context";
import { nativeCanvasSupported } from "./native-canvas-support";
/**
* Whether the PDF rasterizer is available in this build. The native
* `@napi-rs/canvas` binary is compiled in and traced into the standalone image
@@ -123,6 +125,17 @@ export async function rasterizePdf(
maxPages: number = RASTER_MAX_PAGES,
): Promise<RasterResult> {
let doc: PdfDocument | null = null;
// CPU-feature gate: pdfjs renders through @napi-rs/canvas (Skia), whose x64
// build uses AVX2 — an unsupported CPU dies with an uncatchable SIGILL on
// the first render. Degrade to the existing no-raster path instead (scanned
// PDFs stay unreadable for image-only providers; everything else works).
if (!nativeCanvasSupported()) {
annotate({
action: { name: "documents.rasterize.failed" },
meta: { reason: "native_canvas_unsupported" },
});
return { ok: false };
}
try {
// Lazy import: keeps pdfjs (DOMMatrix) + @napi-rs/canvas out of the eager
// server chunk. Resolved at first rasterization, then module-cached.
+14
View File
@@ -28,6 +28,8 @@
*/
import { Buffer } from "node:buffer";
import { nativeCanvasSupported } from "./native-canvas-support";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { annotate } from "@/lib/logging/context";
@@ -217,6 +219,18 @@ export async function generateThumbnail(
mimeType: string,
): Promise<ThumbnailResult> {
try {
// CPU-feature gate BEFORE the module ever loads: the bundled Skia x64
// build uses AVX2, and on a CPU without it the first canvas call is an
// uncatchable SIGILL that kills the whole process (crash-loop incident on
// a Celeron NAS). With the gate closed, thumbnails simply do not exist —
// the vault falls back to kind icons.
if (!nativeCanvasSupported()) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: { reason: "native_canvas_unsupported" },
});
return { ok: false };
}
// Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path)
// out of the eager server chunk. Resolved on first generation, then cached.
const napi =
@@ -19,6 +19,7 @@
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
import { prisma } from "@/lib/db";
import { nativeCanvasSupported } from "@/lib/documents/native-canvas-support";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import { annotate } from "@/lib/logging/context";
@@ -107,6 +108,10 @@ export async function enqueueBootTimeThumbnailBackfill(): Promise<{
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0 };
// On a CPU the bundled Skia build cannot run on (no AVX2), thumbnail jobs
// would only churn the queue — every generation no-ops behind the gate.
if (!nativeCanvasSupported()) return { enqueued: 0 };
// Distinct users with at least one thumbnailable, not-yet-thumbnailed doc.
const rows = await prisma.inboundDocument.findMany({
where: {