fix: harden against three production Sentry crashes (#328)

Three production crashes from the snapotter/node Sentry project.

feature-status (NODE-12): a valid-JSON-but-wrong-shape installed.json
crashed boot via Object.keys(data.bundles). readInstalled() now
normalizes any unusable shape to { bundles: {} }, and the boot recovery
call is wrapped so cleanup can never fatal startup.

image-viewer (NODE-15/17/18): drag-to-pan read .x off an undefined
use-gesture memo on pointerUp or a pinch-into-pan. A guarded pure helper
(resolvePanStart) now falls back to the live pan offset.

Fastify (NODE-14): raised pluginTimeout to 60s so slow self-hosted boots
do not fatal at @fastify/static.
This commit is contained in:
SnapOtter
2026-06-22 23:25:22 +08:00
committed by GitHub
parent a3301e0a3a
commit 8952e9ba47
6 changed files with 148 additions and 8 deletions
+18 -2
View File
@@ -191,9 +191,19 @@ try {
// Start the cooperative cancellation listener (Redis pub/sub)
await startCancelListener();
// Set up AI feature directories and recover from interrupted installs
// Set up AI feature directories and recover from interrupted installs. Both are
// best-effort and must never block boot: ensureAiDirs swallows its own errors,
// and recovery (clearing stale locks and partial downloads) is wrapped here so a
// malformed installed.json or unreadable models dir degrades to a warning rather
// than a fatal startup crash (Sentry NODE-12).
ensureAiDirs();
recoverInterruptedInstalls();
try {
recoverInterruptedInstalls();
} catch (err) {
console.warn(
`[feature-status] Interrupted-install recovery failed (continuing): ${(err as Error).message}`,
);
}
function parseTrustProxy(value: string): boolean | number | string {
if (value === "true") return true;
@@ -209,6 +219,12 @@ const app = Fastify({
bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824,
trustProxy: parseTrustProxy(env.TRUST_PROXY),
routerOptions: { maxParamLength: 500 },
// Self-hosted boots can be slow: venv bootstrap, AI-model verification, and
// SPA static serving all touch disk, and some deployments sit on slow or
// contended volumes. avvio's default 10s pluginTimeout fataled boot at
// '@fastify/static' on those hosts (Sentry NODE-14). 60s tolerates slow
// startup I/O while still surfacing a genuinely deadlocked plugin.
pluginTimeout: 60_000,
});
// Image processing (especially AI batch) can run for tens of minutes.
+20 -1
View File
@@ -91,6 +91,25 @@ interface InstalledData {
let installedCache: InstalledData | null = null;
/**
* Coerce a parsed installed.json into a well-formed InstalledData. The file can
* be valid JSON but the wrong shape (`{}`, `{"bundles": null}`, a bare array,
* a number, or an older format) which would otherwise crash callers that do
* `Object.keys(data.bundles)`, `id in data.bundles`, or `data.bundles[id]`
* (seen in production as a fatal boot TypeError, "Cannot convert undefined or
* null to object"). Any unusable shape degrades to an empty install set,
* matching the corrupt-JSON fallback below.
*/
function normalizeInstalled(parsed: unknown): InstalledData {
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const bundles = (parsed as { bundles?: unknown }).bundles;
if (typeof bundles === "object" && bundles !== null && !Array.isArray(bundles)) {
return parsed as InstalledData;
}
}
return { bundles: {} };
}
function readInstalled(): InstalledData {
if (installedCache) return installedCache;
@@ -101,7 +120,7 @@ function readInstalled(): InstalledData {
try {
const raw = readFileSync(INSTALLED_PATH, "utf-8");
installedCache = JSON.parse(raw) as InstalledData;
installedCache = normalizeInstalled(JSON.parse(raw));
} catch {
console.warn("[feature-status] installed.json is corrupt or unreadable, treating as empty");
installedCache = { bundles: {} };
@@ -0,0 +1,23 @@
// Pure, framework-free helpers for ImageViewer drag-to-pan. No React, no DOM,
// no @use-gesture, so the offset math stays unit-testable in isolation.
export interface Point {
x: number;
y: number;
}
/**
* Resolve the pan offset a drag started from. @use-gesture only populates
* `memo` on the first drag frame, but the handler can still fire on a later
* frame without that first frame having run with panning active: on pointerUp,
* or when a concurrent pinch flips the viewer into actual-size (pan) mode
* mid-gesture. Reading `memo.x` directly then threw in production
* (Sentry NODE-15 / NODE-17 / NODE-18: "Cannot read properties of undefined
* (reading 'x')", across Chrome/Safari/Firefox). Fall back to the current pan
* offset whenever memo is missing; the caller persists the return value as the
* next frame's memo.
*/
export function resolvePanStart(first: boolean, memo: Point | undefined, panOffset: Point): Point {
if (first || !memo) return { ...panOffset };
return memo;
}
@@ -1,6 +1,7 @@
import { useGesture } from "@use-gesture/react";
import { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { type Point, resolvePanStart } from "@/components/common/image-viewer-drag";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
@@ -148,12 +149,9 @@ export function ImageViewer({
},
onDrag: ({ movement: [mx, my], first, memo }) => {
if (fitModeRef.current !== "actual") return;
if (first) {
memo = { ...panOffset };
}
const start = memo as { x: number; y: number };
const start = resolvePanStart(first, memo as Point | undefined, panOffset);
setPanOffset({ x: start.x + mx, y: start.y + my });
return memo;
return start;
},
},
{