mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
da0e509095
commit
45da6c3c7d
11 files changed
+257
-3
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user