Recover failed conversion workers and validate Nitro downloads correctly
CI / check (push) Successful in 1m36s
CI / deploy (push) Successful in 1m15s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 14:18:28 +02:00
1 parent a78d8256e0
commit 7880d5d5df
5 files changed
+91 -8

No files matched your search

+5 -2
View File
@@ -750,7 +750,7 @@ export async function importSingleFurni(params: {
!existsSync(/*turbopackIgnore: true*/ nitroPath) !existsSync(/*turbopackIgnore: true*/ nitroPath)
) { ) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" }); const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" });
if (dl.ok) nitroDownloadOk = true; if (dl.ok) nitroDownloadOk = true;
} }
@@ -843,7 +843,10 @@ export async function importSingleFurni(params: {
warnings, warnings,
error: isUpdate error: isUpdate
? "No .nitro file could be created; existing furniture was preserved" ? "No .nitro file could be created; existing furniture was preserved"
: "Import rolled back: no .nitro file could be created", : `Import rolled back: no .nitro file could be created. ${warnings
.filter((warning) => /conversion|download|SWF/.test(warning))
.slice(-3)
.join(". ")}`,
}; };
} }
+17 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { validatePngBytes, validateSwfBytes } from "./download"; import { createNitroBundle } from "../../swf/nitro-builder";
import {
validateNitroBytes,
validatePngBytes,
validateSwfBytes,
} from "./download";
describe("import/core/download validators", () => { describe("import/core/download validators", () => {
it("accepts FWS/CWS/ZWS swf magic", () => { it("accepts FWS/CWS/ZWS swf magic", () => {
@@ -22,3 +27,14 @@ describe("import/core/download validators", () => {
expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false); expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false);
}); });
}); });
it("validates nitro bundles separately from PNG files", () => {
const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const bundle = createNitroBundle({ name: "chair" }, png, "chair");
expect(validateNitroBytes(bundle)).toBe(true);
expect(validatePngBytes(bundle)).toBe(false);
expect(validateNitroBytes(png)).toBe(false);
expect(validateNitroBytes(Buffer.from("<html>Access denied</html>"))).toBe(
false,
);
});
+18 -1
View File
@@ -1,4 +1,5 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { parseNitroBundle } from "../../swf/nitro-builder";
import { browserHeaders } from "./browser-headers"; import { browserHeaders } from "./browser-headers";
@@ -18,6 +19,20 @@ export function validatePngBytes(buffer: Buffer): boolean {
); );
} }
export function validateNitroBytes(buffer: Buffer): boolean {
try {
const bundle = parseNitroBundle(buffer);
return (
!!bundle.json &&
typeof bundle.json === "object" &&
!Array.isArray(bundle.json) &&
validatePngBytes(bundle.png)
);
} catch {
return false;
}
}
export async function tryDownloadCandidates( export async function tryDownloadCandidates(
urls: string[], urls: string[],
destPath: string, destPath: string,
@@ -33,7 +48,7 @@ export async function tryDownloadCandidates(
export async function downloadFile( export async function downloadFile(
url: string, url: string,
destPath: string, destPath: string,
options?: { maxRetries?: number; validate?: "swf" | "png" }, options?: { maxRetries?: number; validate?: "swf" | "png" | "nitro" },
): Promise<{ ok: boolean; size: number }> { ): Promise<{ ok: boolean; size: number }> {
const maxRetries = options?.maxRetries ?? 3; const maxRetries = options?.maxRetries ?? 3;
const baseDelay = 1000; const baseDelay = 1000;
@@ -79,6 +94,8 @@ export async function downloadFile(
} }
continue; continue;
} }
if (options?.validate === "nitro" && !validateNitroBytes(buffer))
return { ok: false, size: 0 };
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer); await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
return { ok: true, size: buffer.length }; return { ok: true, size: buffer.length };
} catch (err) { } catch (err) {
@@ -0,0 +1,45 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
convert: vi.fn(() => ({ bundle: Buffer.from("converted") })),
icon: vi.fn(() => Buffer.from("icon")),
}));
vi.mock("./index", () => ({
convertSwfToNitro: mocks.convert,
extractIconFromSwf: mocks.icon,
}));
vi.mock("node:worker_threads", () => ({
Worker: class extends EventEmitter {
postMessage() {
queueMicrotask(() =>
this.emit("error", new Error("worker module missing")),
);
}
},
}));
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
vi.clearAllMocks();
});
describe("conversion worker recovery", () => {
it("falls back when worker startup rejects the conversion job", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);
const { convertSwfToNitroAsync } = await import("./conversion-pool");
const data = Buffer.from("SWF");
const result = await convertSwfToNitroAsync(data, "chair");
expect(result.bundle.toString()).toBe("converted");
expect(mocks.convert).toHaveBeenCalledWith(data, "chair");
});
it("recovers icon extraction from a worker startup error", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);
const { extractIconFromSwfAsync } = await import("./conversion-pool");
expect(
(await extractIconFromSwfAsync(Buffer.from("SWF"), "chair"))?.toString(),
).toBe("icon");
});
});
+6 -4
View File
@@ -154,8 +154,10 @@ export async function convertSwfToNitroAsync(
buffer: Buffer, buffer: Buffer,
classname: string, classname: string,
): Promise<ConversionResult> { ): Promise<ConversionResult> {
const res = await run({ type: "convert", buffer, classname }); const res = await run({ type: "convert", buffer, classname }).catch(
if (res.ok && res.result) return res.result; () => null,
);
if (res?.ok && res.result) return res.result;
// Worker failed for this item — fall back to the main thread. // Worker failed for this item — fall back to the main thread.
return convertSwfToNitro(buffer, classname); return convertSwfToNitro(buffer, classname);
} }
@@ -164,7 +166,7 @@ export async function extractIconFromSwfAsync(
buffer: Buffer, buffer: Buffer,
classname: string, classname: string,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
const res = await run({ type: "icon", buffer, classname }); const res = await run({ type: "icon", buffer, classname }).catch(() => null);
if (res.ok) return res.icon ?? null; if (res?.ok) return res.icon ?? null;
return extractIconFromSwf(buffer, classname); return extractIconFromSwf(buffer, classname);
} }