diff --git a/apps/server/src/services/stylesheet-preflight.test.ts b/apps/server/src/services/stylesheet-preflight.test.ts index 3be7bf5c1..6e9d120ef 100644 --- a/apps/server/src/services/stylesheet-preflight.test.ts +++ b/apps/server/src/services/stylesheet-preflight.test.ts @@ -187,7 +187,7 @@ describe("stylesheet PDF preflight worker", () => { expect(runner.activeWorkerCount).toBe(0); }, 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 result = await runner.run(input); @@ -202,6 +202,31 @@ describe("stylesheet PDF preflight worker", () => { 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 () => { const runner = createStylesheetPreflightRunner( { diff --git a/apps/server/src/services/stylesheet-preflight.ts b/apps/server/src/services/stylesheet-preflight.ts index ca3617be5..99c898d8d 100644 --- a/apps/server/src/services/stylesheet-preflight.ts +++ b/apps/server/src/services/stylesheet-preflight.ts @@ -75,9 +75,10 @@ const workerLocation = () => { url: source ? new URL("../workers/stylesheet-preflight.ts", 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 ? { - execArgv: sourceWorkerExecArgv(), env: { ...process.env, TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)), @@ -106,7 +107,7 @@ export function createStylesheetPreflightRunner( reject: (cause: unknown) => void, ): boolean => { // 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; try { worker = new Worker(location.url, { @@ -116,7 +117,7 @@ export function createStylesheetPreflightRunner( // The source-only tsx compiler heap is outside the production render budget. 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 } : {}), }); } catch (error) { diff --git a/apps/server/src/workers/stylesheet-preflight-inspection.ts b/apps/server/src/workers/stylesheet-preflight-inspection.ts index b70a898bf..2083d96ab 100644 --- a/apps/server/src/workers/stylesheet-preflight-inspection.ts +++ b/apps/server/src/workers/stylesheet-preflight-inspection.ts @@ -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"; type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & { diff --git a/apps/server/src/workers/stylesheet-preflight.ts b/apps/server/src/workers/stylesheet-preflight.ts index 29fa9d330..b8f1a4d23 100644 --- a/apps/server/src/workers/stylesheet-preflight.ts +++ b/apps/server/src/workers/stylesheet-preflight.ts @@ -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 * as React from "react"; -import { renderPreflightPdf } from "@reactive-resume/pdf/server"; import { inspectPreflightPdf } from "./stylesheet-preflight-inspection"; (globalThis as typeof globalThis & { React: typeof React }).React = React; @@ -26,6 +29,13 @@ const send = (result: PdfPreflightResult) => { 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 => { if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) { return; @@ -33,9 +43,11 @@ const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined 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 { + const { renderPreflightPdf } = await initialization; const { input, limits } = workerData as StylesheetPreflightWorkerData; const rendered = await renderPreflightPdf(input, limits); return rendered.ok ? inspectPreflightPdf(rendered, limits) : rendered; @@ -50,10 +62,11 @@ if (parentPort) { parentPort?.postMessage({ type: "preflight_error", cause: serializedCause }); return; } + console.error("[stylesheet-preflight]", cause); send({ ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED", - message: "The PDF preflight worker failed.", + message: sanitizeWorkerCause(cause), diagnostics: [], }); }); diff --git a/apps/web/src/features/resume/stylesheet/preflight.worker.test.ts b/apps/web/src/features/resume/stylesheet/preflight.worker.test.ts index fb40e061e..f6ab7a53c 100644 --- a/apps/web/src/features/resume/stylesheet/preflight.worker.test.ts +++ b/apps/web/src/features/resume/stylesheet/preflight.worker.test.ts @@ -55,4 +55,42 @@ describe("stylesheet preflight worker", () => { 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) => Promise) | 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); + + 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: [], + }), + }), + ); + }); }); diff --git a/apps/web/src/features/resume/stylesheet/preflight.worker.ts b/apps/web/src/features/resume/stylesheet/preflight.worker.ts index 435a9fb29..89e3bf799 100644 --- a/apps/web/src/features/resume/stylesheet/preflight.worker.ts +++ b/apps/web/src/features/resume/stylesheet/preflight.worker.ts @@ -20,6 +20,13 @@ const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflig 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 => { if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) { return; @@ -49,7 +56,7 @@ self.addEventListener("message", async ({ data }: MessageEvent { 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((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 () => { const { createCompileWorkerClient } = await import("./worker-client"); const listeners = new Map>(); diff --git a/apps/web/src/features/resume/stylesheet/store.ts b/apps/web/src/features/resume/stylesheet/store.ts index 0b05c2622..874f36470 100644 --- a/apps/web/src/features/resume/stylesheet/store.ts +++ b/apps/web/src/features/resume/stylesheet/store.ts @@ -113,6 +113,18 @@ const emptySemanticTree = (): SemanticNode => ({ const HISTORY_COALESCE_MS = 500; const MAX_HISTORY_ENTRIES = 50; +const preflightFailureDiagnostic = ( + result: Extract, +): 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< StylesheetStoreState, "setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence" @@ -389,7 +401,14 @@ export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRunti if (candidateValidationEpoch !== validationEpoch) return; if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return; 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; } } diff --git a/apps/web/src/features/resume/stylesheet/worker-client.test.ts b/apps/web/src/features/resume/stylesheet/worker-client.test.ts index 0f1fad7ac..681f1b703 100644 --- a/apps/web/src/features/resume/stylesheet/worker-client.test.ts +++ b/apps/web/src/features/resume/stylesheet/worker-client.test.ts @@ -115,6 +115,20 @@ describe("stylesheet worker clients", () => { 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 () => { vi.useFakeTimers(); const fake = worker(); diff --git a/apps/web/src/features/resume/stylesheet/worker-client.ts b/apps/web/src/features/resume/stylesheet/worker-client.ts index c09841bb4..bc1b2dd4c 100644 --- a/apps/web/src/features/resume/stylesheet/worker-client.ts +++ b/apps/web/src/features/resume/stylesheet/worker-client.ts @@ -128,8 +128,18 @@ export function createPreflightWorkerClient( pending.delete(response.requestId); 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(); + failPending(error); }; const terminate = () => {