From 45da6c3c7d0cfa70139688eeae550745ce9990fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 11 Jul 2026 10:02:29 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.27=20=E2=80=94=20runs?= =?UTF-8?q?=20on=20CPUs=20without=20AVX2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.production.example | 9 ++ CHANGELOG.md | 13 ++ docker-compose.yml | 1 + docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../__tests__/native-canvas-support.test.ts | 120 ++++++++++++++++++ src/lib/documents/native-canvas-support.ts | 79 ++++++++++++ src/lib/documents/rasterize-pdf.ts | 13 ++ src/lib/documents/thumbnail.ts | 14 ++ src/lib/jobs/document-thumbnail-backfill.ts | 5 + 11 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/lib/documents/__tests__/native-canvas-support.test.ts create mode 100644 src/lib/documents/native-canvas-support.ts diff --git a/.env.production.example b/.env.production.example index 9613948f2..11a138c88 100644 --- a/.env.production.example +++ b/.env.production.example @@ -270,3 +270,12 @@ API_TOKEN_HMAC_KEY="" # force the public-host guard, so an internal instance is not blocked). # OPENMETEO_BASE_URL="https://archive-api.open-meteo.com" # OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com" + +# ── 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: +# off — force-disable the renderer +# on — force-enable (crashes the container if the CPU truly lacks AVX2) +# NATIVE_CANVAS="off" diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c9750e9..bb6cc3e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.28.27] — 2026-07-11 — Runs on CPUs without AVX2 + +Self-hosts on older x86-64 CPUs — Celeron/Atom-class NAS boxes, pre-2013 +Xeons, and VMs that mask newer CPU flags — crashed in a restart loop since the +document renderer arrived: the bundled rasterizer uses AVX2 instructions, and +a CPU without them kills the whole process the first time a thumbnail or a +scanned-PDF render runs. The renderer is now gated on a one-time CPU-feature +check: on unsupported hardware, thumbnails and scanned-PDF rasterization +simply switch off (the vault shows type icons; PDFs are read as text) while +everything else runs normally. `NATIVE_CANVAS=off|on` overrides the detection +if ever needed. Reported by a self-hoster with kernel traps in hand — thanks, +that made it a same-day fix. + ## [1.28.26] — 2026-07-11 — Internal restructuring for maintainability No user-facing change. Eight of the largest source files were split along diff --git a/docker-compose.yml b/docker-compose.yml index 5c866d457..67d5d65d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,7 @@ services: # whitelist — vars not listed here don't propagate even though # docker-compose reads .env for `${VAR}` substitution at compose-up time). SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}" + NATIVE_CANVAS: "${NATIVE_CANVAS:-}" # v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare # the real visitor IP is in `cf-connecting-ip`; set # TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9bb79d675..9684fc0b9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.26 + version: 1.28.27 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index d3a680a9e..99de08eee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.26", + "version": "1.28.27", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 496d3dd95..e601d7ac5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.26"; + /* @sw-version-fallback */ "v1.28.27"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/documents/__tests__/native-canvas-support.test.ts b/src/lib/documents/__tests__/native-canvas-support.test.ts new file mode 100644 index 000000000..df978c965 --- /dev/null +++ b/src/lib/documents/__tests__/native-canvas-support.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/documents/native-canvas-support.ts b/src/lib/documents/native-canvas-support.ts new file mode 100644 index 000000000..b8bb73ee4 --- /dev/null +++ b/src/lib/documents/native-canvas-support.ts @@ -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; +} diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index 4ee3f164a..fa4da7222 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -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 { 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. diff --git a/src/lib/documents/thumbnail.ts b/src/lib/documents/thumbnail.ts index d660e21d0..8d975954f 100644 --- a/src/lib/documents/thumbnail.ts +++ b/src/lib/documents/thumbnail.ts @@ -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 { 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 = diff --git a/src/lib/jobs/document-thumbnail-backfill.ts b/src/lib/jobs/document-thumbnail-backfill.ts index 399f7d4ec..0b80f25ef 100644 --- a/src/lib/jobs/document-thumbnail-backfill.ts +++ b/src/lib/jobs/document-thumbnail-backfill.ts @@ -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: {