mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
fix(stylesheet): harden PDF preflight and surface worker failures (#3284)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4a8f87ab8f
commit
ba8e1be2ab
@@ -187,7 +187,7 @@ describe("stylesheet PDF preflight worker", () => {
|
|||||||
expect(runner.activeWorkerCount).toBe(0);
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
it("does not expose internal errors from a failed worker", async () => {
|
it("does not expose internal errors from a failed worker bootstrap", async () => {
|
||||||
const runner = createStylesheetPreflightRunner({}, failedWorker);
|
const runner = createStylesheetPreflightRunner({}, failedWorker);
|
||||||
|
|
||||||
const result = await runner.run(input);
|
const result = await runner.run(input);
|
||||||
@@ -202,6 +202,31 @@ describe("stylesheet PDF preflight worker", () => {
|
|||||||
expect(runner.queuedPreflightCount).toBe(0);
|
expect(runner.queuedPreflightCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces sanitized render failures from inside the worker catch path", async () => {
|
||||||
|
const throwingWorker = new URL(
|
||||||
|
`data:text/javascript,${encodeURIComponent(`
|
||||||
|
import { parentPort } from "node:worker_threads";
|
||||||
|
parentPort.postMessage({ type: "ready" });
|
||||||
|
parentPort.postMessage({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: "The PDF preflight worker failed. (Error: Canvas is already closed)",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
`)}`,
|
||||||
|
);
|
||||||
|
const runner = createStylesheetPreflightRunner({ timeoutMs: 5_000 }, throwingWorker);
|
||||||
|
|
||||||
|
const result = await runner.run(input);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: "The PDF preflight worker failed. (Error: Canvas is already closed)",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => {
|
it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => {
|
||||||
const runner = createStylesheetPreflightRunner(
|
const runner = createStylesheetPreflightRunner(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ const workerLocation = () => {
|
|||||||
url: source
|
url: source
|
||||||
? new URL("../workers/stylesheet-preflight.ts", import.meta.url)
|
? new URL("../workers/stylesheet-preflight.ts", import.meta.url)
|
||||||
: new URL("./stylesheet-preflight-worker.mjs", import.meta.url),
|
: new URL("./stylesheet-preflight-worker.mjs", import.meta.url),
|
||||||
|
// Production must not inherit parent execArgv (e.g. --import/--input-type); source workers need tsx.
|
||||||
|
execArgv: source ? sourceWorkerExecArgv() : [],
|
||||||
...(source
|
...(source
|
||||||
? {
|
? {
|
||||||
execArgv: sourceWorkerExecArgv(),
|
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
|
TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
|
||||||
@@ -106,7 +107,7 @@ export function createStylesheetPreflightRunner(
|
|||||||
reject: (cause: unknown) => void,
|
reject: (cause: unknown) => void,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// The URL seam is internal to the server package and keeps worker failure tests independent from the PDF renderer.
|
// The URL seam is internal to the server package and keeps worker failure tests independent from the PDF renderer.
|
||||||
const location = testWorkerUrl ? { source: false, url: testWorkerUrl } : workerLocation();
|
const location = testWorkerUrl ? { source: false, url: testWorkerUrl, execArgv: [] as string[] } : workerLocation();
|
||||||
let worker: Worker;
|
let worker: Worker;
|
||||||
try {
|
try {
|
||||||
worker = new Worker(location.url, {
|
worker = new Worker(location.url, {
|
||||||
@@ -116,7 +117,7 @@ export function createStylesheetPreflightRunner(
|
|||||||
// The source-only tsx compiler heap is outside the production render budget.
|
// The source-only tsx compiler heap is outside the production render budget.
|
||||||
maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0),
|
maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0),
|
||||||
},
|
},
|
||||||
...("execArgv" in location ? { execArgv: location.execArgv } : {}),
|
execArgv: location.execArgv,
|
||||||
...("env" in location ? { env: location.env } : {}),
|
...("env" in location ? { env: location.env } : {}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { PdfPreflightPageLimits, PdfPreflightResult, RenderPreflightPdfResult } from "@reactive-resume/pdf/server";
|
import type {
|
||||||
|
PdfPreflightPageLimits,
|
||||||
|
PdfPreflightResult,
|
||||||
|
RenderPreflightPdfResult,
|
||||||
|
} from "@reactive-resume/pdf/preflight";
|
||||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||||
|
|
||||||
type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & {
|
type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { PdfPreflightPageLimits, PdfPreflightResult, StylesheetPreflightInput } from "@reactive-resume/pdf/server";
|
import type {
|
||||||
|
PdfPreflightPageLimits,
|
||||||
|
PdfPreflightResult,
|
||||||
|
StylesheetPreflightInput,
|
||||||
|
} from "@reactive-resume/pdf/preflight";
|
||||||
import { parentPort, workerData } from "node:worker_threads";
|
import { parentPort, workerData } from "node:worker_threads";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { renderPreflightPdf } from "@reactive-resume/pdf/server";
|
|
||||||
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
|
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
|
||||||
|
|
||||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||||
@@ -26,6 +29,13 @@ const send = (result: PdfPreflightResult) => {
|
|||||||
parentPort?.postMessage(result);
|
parentPort?.postMessage(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeWorkerCause = (cause: unknown): string => {
|
||||||
|
if (!(cause instanceof Error)) return "The PDF preflight worker failed.";
|
||||||
|
const detail = cause.message.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||||
|
if (!detail) return "The PDF preflight worker failed.";
|
||||||
|
return `The PDF preflight worker failed. (${cause.name}: ${detail})`;
|
||||||
|
};
|
||||||
|
|
||||||
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||||
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||||
return;
|
return;
|
||||||
@@ -33,9 +43,11 @@ const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined
|
|||||||
return { name: cause.name, message: cause.message, issues: cause.issues };
|
return { name: cause.name, message: cause.message, issues: cause.issues };
|
||||||
};
|
};
|
||||||
|
|
||||||
parentPort?.postMessage({ type: "ready" });
|
const initialization = import("@reactive-resume/pdf/preflight");
|
||||||
|
void initialization.then(() => parentPort?.postMessage({ type: "ready" }));
|
||||||
|
|
||||||
async function run(): Promise<PdfPreflightResult> {
|
async function run(): Promise<PdfPreflightResult> {
|
||||||
|
const { renderPreflightPdf } = await initialization;
|
||||||
const { input, limits } = workerData as StylesheetPreflightWorkerData;
|
const { input, limits } = workerData as StylesheetPreflightWorkerData;
|
||||||
const rendered = await renderPreflightPdf(input, limits);
|
const rendered = await renderPreflightPdf(input, limits);
|
||||||
return rendered.ok ? inspectPreflightPdf(rendered, limits) : rendered;
|
return rendered.ok ? inspectPreflightPdf(rendered, limits) : rendered;
|
||||||
@@ -50,10 +62,11 @@ if (parentPort) {
|
|||||||
parentPort?.postMessage({ type: "preflight_error", cause: serializedCause });
|
parentPort?.postMessage({ type: "preflight_error", cause: serializedCause });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.error("[stylesheet-preflight]", cause);
|
||||||
send({
|
send({
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
message: "The PDF preflight worker failed.",
|
message: sanitizeWorkerCause(cause),
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,4 +55,42 @@ describe("stylesheet preflight worker", () => {
|
|||||||
cause: { name: "ZodError", message: "Invalid resume data", issues },
|
cause: { name: "ZodError", message: "Invalid resume data", issues },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes a sanitized cause when PDF preflight throws an unexpected error", async () => {
|
||||||
|
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
vi.stubGlobal("self", {
|
||||||
|
postMessage,
|
||||||
|
addEventListener: vi.fn((_type, listener) => {
|
||||||
|
handler = listener as typeof handler;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
mocks.renderPreflightPdf.mockRejectedValueOnce(new Error("Canvas is already closed"));
|
||||||
|
vi.resetModules();
|
||||||
|
await import("./preflight.worker");
|
||||||
|
|
||||||
|
await handler?.({
|
||||||
|
data: {
|
||||||
|
type: "preflight",
|
||||||
|
requestId: 8,
|
||||||
|
editGeneration: 4,
|
||||||
|
input: {} as never,
|
||||||
|
limits: {} as never,
|
||||||
|
},
|
||||||
|
} as unknown as MessageEvent<PreflightWorkerRequest>);
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 8,
|
||||||
|
editGeneration: 4,
|
||||||
|
result: expect.objectContaining({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: expect.stringContaining("Canvas is already closed"),
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflig
|
|||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sanitizeWorkerCause = (cause: unknown): string => {
|
||||||
|
if (!(cause instanceof Error)) return "The PDF preflight worker failed.";
|
||||||
|
const detail = cause.message.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||||
|
if (!detail) return "The PDF preflight worker failed.";
|
||||||
|
return `The PDF preflight worker failed. (${cause.name}: ${detail})`;
|
||||||
|
};
|
||||||
|
|
||||||
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||||
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||||
return;
|
return;
|
||||||
@@ -49,7 +56,7 @@ self.addEventListener("message", async ({ data }: MessageEvent<PreflightWorkerRe
|
|||||||
self.postMessage(response);
|
self.postMessage(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
|
const result = failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", sanitizeWorkerCause(cause));
|
||||||
const response: PreflightWorkerResponse = {
|
const response: PreflightWorkerResponse = {
|
||||||
type: "preflight_result",
|
type: "preflight_result",
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
|
|||||||
@@ -759,6 +759,66 @@ describe("stylesheet store runtime", () => {
|
|||||||
expect(runtime.store.getState().status).toBe("error");
|
expect(runtime.store.getState().status).toBe("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces browser preflight failures as diagnostics when the worker returns an empty list", async () => {
|
||||||
|
let resolveMutate!: (value: MutationResult & { diagnostics: never[] }) => void;
|
||||||
|
const mutate = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<MutationResult & { diagnostics: never[] }>((resolve) => {
|
||||||
|
resolveMutate = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: "The PDF preflight worker failed.",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("@version 1;\nsection { color: teal; }");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(runtime.store.getState().diagnostics).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
severity: "error",
|
||||||
|
message: "The PDF preflight worker failed.",
|
||||||
|
range: {
|
||||||
|
start: { line: 1, column: 1, offset: 0 },
|
||||||
|
end: { line: 1, column: 1, offset: 0 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(runtime.store.getState().diagnostics.some(({ severity }) => severity === "error")).toBe(true);
|
||||||
|
expect(mutate).toHaveBeenCalled();
|
||||||
|
resolveMutate({
|
||||||
|
stylesheet: stylesheet("@version 1;\n"),
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
it("finishes an edit when refreshIntelligence interleaves through the shared compile client", async () => {
|
it("finishes an edit when refreshIntelligence interleaves through the shared compile client", async () => {
|
||||||
const { createCompileWorkerClient } = await import("./worker-client");
|
const { createCompileWorkerClient } = await import("./worker-client");
|
||||||
const listeners = new Map<string, Set<EventListener>>();
|
const listeners = new Map<string, Set<EventListener>>();
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ const emptySemanticTree = (): SemanticNode => ({
|
|||||||
const HISTORY_COALESCE_MS = 500;
|
const HISTORY_COALESCE_MS = 500;
|
||||||
const MAX_HISTORY_ENTRIES = 50;
|
const MAX_HISTORY_ENTRIES = 50;
|
||||||
|
|
||||||
|
const preflightFailureDiagnostic = (
|
||||||
|
result: Extract<PreflightWorkerResponse["result"], { ok: false }>,
|
||||||
|
): SemanticCssDiagnostic => ({
|
||||||
|
code: result.code,
|
||||||
|
severity: "error",
|
||||||
|
message: result.message,
|
||||||
|
range: {
|
||||||
|
start: { line: 1, column: 1, offset: 0 },
|
||||||
|
end: { line: 1, column: 1, offset: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const inactiveState = (): Omit<
|
const inactiveState = (): Omit<
|
||||||
StylesheetStoreState,
|
StylesheetStoreState,
|
||||||
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
|
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
|
||||||
@@ -389,7 +401,14 @@ export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRunti
|
|||||||
if (candidateValidationEpoch !== validationEpoch) return;
|
if (candidateValidationEpoch !== validationEpoch) return;
|
||||||
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
|
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
|
||||||
if (!preflight.result.ok) {
|
if (!preflight.result.ok) {
|
||||||
patch({ diagnostics: [...compiled.diagnostics, ...preflight.result.diagnostics], status: "error" });
|
patch({
|
||||||
|
diagnostics: [
|
||||||
|
...compiled.diagnostics,
|
||||||
|
...preflight.result.diagnostics,
|
||||||
|
preflightFailureDiagnostic(preflight.result),
|
||||||
|
],
|
||||||
|
status: "error",
|
||||||
|
});
|
||||||
if (candidate.transition !== "edit_source") return;
|
if (candidate.transition !== "edit_source") return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ describe("stylesheet worker clients", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects pending preflight work when the worker emits an error event", async () => {
|
||||||
|
const fake = worker();
|
||||||
|
const client = createPreflightWorkerClient(() => fake, 5_000);
|
||||||
|
const pending = client.preflight({ editGeneration: 1 } as never);
|
||||||
|
const outcome = pending.catch((error: unknown) => error);
|
||||||
|
|
||||||
|
fake.emit({ type: "preflight_ready" });
|
||||||
|
await Promise.resolve();
|
||||||
|
fake.emitError({ message: "Worker crashed" } as ErrorEvent);
|
||||||
|
|
||||||
|
expect(await outcome).toMatchObject({ message: "Worker crashed" });
|
||||||
|
expect(fake.terminate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects structured resume-data failures without waiting for the timeout", async () => {
|
it("rejects structured resume-data failures without waiting for the timeout", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const fake = worker();
|
const fake = worker();
|
||||||
|
|||||||
@@ -128,8 +128,18 @@ export function createPreflightWorkerClient(
|
|||||||
pending.delete(response.requestId);
|
pending.delete(response.requestId);
|
||||||
request.resolve(response);
|
request.resolve(response);
|
||||||
};
|
};
|
||||||
const onError: WorkerErrorListener = () => {
|
const failPending = (error: Error) => {
|
||||||
|
for (const request of pending.values()) {
|
||||||
|
if (request.timer) clearTimeout(request.timer);
|
||||||
|
request.reject(error);
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError: WorkerErrorListener = (event) => {
|
||||||
|
const error = new Error(event.message || "Stylesheet preflight worker failed.");
|
||||||
terminate();
|
terminate();
|
||||||
|
failPending(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const terminate = () => {
|
const terminate = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user