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

+9
View File
@@ -270,3 +270,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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"
+13
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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: {