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
@@ -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", []);
+29
View File
@@ -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 });
});
});