mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
+18
-2
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -120,6 +120,61 @@ describe("installed.json management", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for NODE-12 (Sentry): installed.json that is *valid JSON* but
|
||||
// whose shape lacks a usable `bundles` object (e.g. "{}", '{"bundles":null}',
|
||||
// a top-level array, or an older format) used to crash at boot with
|
||||
// "Cannot convert undefined or null to object" via Object.keys(data.bundles) in
|
||||
// recoverInterruptedInstalls, `bundleId in data.bundles` in isFeatureInstalled,
|
||||
// and installed.bundles[...] in getFeatureStates. readInstalled() must coerce
|
||||
// any unusable shape to { bundles: {} } so these never throw.
|
||||
describe("malformed installed.json shape (NODE-12 regression)", () => {
|
||||
const BAD_SHAPES: Array<[string, string]> = [
|
||||
["object with no bundles key", JSON.stringify({})],
|
||||
["bundles is null", JSON.stringify({ bundles: null })],
|
||||
["bundles is an array", JSON.stringify({ bundles: [] })],
|
||||
["bundles is a string", JSON.stringify({ bundles: "nope" })],
|
||||
["top-level array", JSON.stringify([{ ocr: {} }])],
|
||||
["top-level number", JSON.stringify(42)],
|
||||
["top-level null", JSON.stringify(null)],
|
||||
["unrelated shape", JSON.stringify({ version: 2, installed: ["ocr"] })],
|
||||
];
|
||||
|
||||
for (const [label, contents] of BAD_SHAPES) {
|
||||
it(`recoverInterruptedInstalls does not throw when installed.json is ${label}`, () => {
|
||||
// A present manifest is what drives the Object.keys(data.bundles) loop.
|
||||
writeTestManifest({ "background-removal": { models: [] } });
|
||||
writeFileSync(installedPath, contents);
|
||||
mod.invalidateCache();
|
||||
expect(() => mod.recoverInterruptedInstalls()).not.toThrow();
|
||||
});
|
||||
|
||||
it(`isFeatureInstalled returns false (no throw) when installed.json is ${label}`, () => {
|
||||
writeFileSync(installedPath, contents);
|
||||
mod.invalidateCache();
|
||||
expect(() => mod.isFeatureInstalled("background-removal")).not.toThrow();
|
||||
expect(mod.isFeatureInstalled("background-removal")).toBe(false);
|
||||
});
|
||||
|
||||
it(`getFeatureStates reports all not_installed (no throw) when installed.json is ${label}`, () => {
|
||||
writeFileSync(installedPath, contents);
|
||||
mod.invalidateCache();
|
||||
expect(() => mod.getFeatureStates()).not.toThrow();
|
||||
expect(mod.getFeatureStates().every((s) => s.status === "not_installed")).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it("still reads a valid bundles object after rejecting bad shapes", () => {
|
||||
writeFileSync(
|
||||
installedPath,
|
||||
JSON.stringify({
|
||||
bundles: { ocr: { version: "1.0.0", installedAt: "2026-01-01T00:00:00.000Z", models: [] } },
|
||||
}),
|
||||
);
|
||||
mod.invalidateCache();
|
||||
expect(mod.isFeatureInstalled("ocr")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache behavior", () => {
|
||||
it("isFeatureInstalled reads from cache on second call", () => {
|
||||
mod.markInstalled("ocr", "1.0.0", []);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePanStart } from "@/components/common/image-viewer-drag";
|
||||
|
||||
describe("resolvePanStart (ImageViewer drag-to-pan)", () => {
|
||||
it("starts from a copy of the current pan offset on the first frame", () => {
|
||||
const panOffset = { x: 10, y: 20 };
|
||||
const start = resolvePanStart(true, undefined, panOffset);
|
||||
expect(start).toEqual({ x: 10, y: 20 });
|
||||
// Must be a copy, not the live state object, so accumulating movement
|
||||
// does not mutate the committed offset.
|
||||
expect(start).not.toBe(panOffset);
|
||||
});
|
||||
|
||||
it("reuses memo on subsequent frames so the drag accumulates from one anchor", () => {
|
||||
const memo = { x: 5, y: 6 };
|
||||
expect(resolvePanStart(false, memo, { x: 0, y: 0 })).toBe(memo);
|
||||
});
|
||||
|
||||
// Regression for NODE-15 / NODE-17 / NODE-18 (Sentry): a non-first frame can
|
||||
// arrive with memo never set: on pointerUp, or when a concurrent pinch flips
|
||||
// the viewer into actual-size (pan) mode mid-gesture. The old handler read
|
||||
// `memo.x` directly and threw "Cannot read properties of undefined (reading
|
||||
// 'x')" across Chrome/Safari/Firefox. It must fall back to the live offset.
|
||||
it("falls back to the current pan offset when memo is missing on a non-first frame", () => {
|
||||
const panOffset = { x: 3, y: 4 };
|
||||
expect(() => resolvePanStart(false, undefined, panOffset)).not.toThrow();
|
||||
expect(resolvePanStart(false, undefined, panOffset)).toEqual({ x: 3, y: 4 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user