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 { ); }); + it("does not leave Checking stuck when compile rejects for the current edit", async () => { + const runtime = createStylesheetStoreRuntime({ + resumeId: "resume-1", + initial, + resumeData: defaultResumeData, + debounceMs: 0, + compile: () => Promise.reject(new Error("Discarded stale stylesheet compiler result.")), + preflight: vi.fn(), + mutate: vi.fn(), + }); + + runtime.store.getState().setSourceText("edited source"); + await vi.runAllTimersAsync(); + + expect(runtime.store.getState().status).not.toBe("compiling"); + 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>(); + const fake = { + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn((type: string, listener: EventListener) => { + const bucket = listeners.get(type) ?? new Set(); + bucket.add(listener); + listeners.set(type, bucket); + }), + removeEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.get(type)?.delete(listener); + }), + emit(data: unknown) { + for (const listener of listeners.get("message") ?? []) { + (listener as (event: MessageEvent) => void)(new MessageEvent("message", { data })); + } + }, + }; + const client = createCompileWorkerClient(() => fake); + const runtime = createStylesheetStoreRuntime({ + resumeId: "resume-1", + initial, + resumeData: defaultResumeData, + debounceMs: 0, + compile: client.compile, + preflight: async ({ editGeneration }) => ({ + type: "preflight_result", + requestId: editGeneration, + editGeneration, + result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) }, + }), + mutate: async ({ editGeneration }) => ({ + stylesheet: stylesheet("edited source"), + revision: 4, + renderDataVersion: 7, + editGeneration, + diagnostics: [], + }), + }); + + runtime.store.getState().setSourceText("edited source"); + await vi.runAllTimersAsync(); + expect(runtime.store.getState().status).toBe("compiling"); + expect(fake.postMessage).toHaveBeenCalledTimes(1); + + // Mobile Design remount / accordion reopen refreshes intelligence while the edit is compiling. + runtime.store.getState().refreshIntelligence(); + expect(fake.postMessage).toHaveBeenCalledTimes(2); + + fake.emit({ + type: "compile_result", + requestId: 1, + editGeneration: 1, + program: { languageVersion: 1, rules: [] }, + diagnostics: [], + colorTokens: [], + }); + fake.emit({ + type: "compile_result", + requestId: 2, + editGeneration: 1, + program: { languageVersion: 1, rules: [] }, + diagnostics: [], + colorTokens: [], + }); + await vi.runAllTimersAsync(); + + expect(runtime.store.getState().status).toBe("applied"); + expect(runtime.store.getState().applied.text).toBe("edited source"); + }); + it("terminates both worker clients and clears the store on cleanup", () => { const destroy = vi.fn(); let mutationSignal: AbortSignal | undefined; diff --git a/apps/web/src/features/resume/stylesheet/store.ts b/apps/web/src/features/resume/stylesheet/store.ts index f4d77c2ce..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" @@ -349,6 +361,9 @@ export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRunti compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree), ); } catch { + if (candidateValidationEpoch !== validationEpoch) return; + if (destroyed || candidate.generation !== store.getState().editGeneration) return; + patch({ status: "error" }); return; } if (candidateValidationEpoch !== validationEpoch) return; @@ -386,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 30fb64129..681f1b703 100644 --- a/apps/web/src/features/resume/stylesheet/worker-client.test.ts +++ b/apps/web/src/features/resume/stylesheet/worker-client.test.ts @@ -5,20 +5,33 @@ import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker type Listener = (event: MessageEvent) => void; function worker() { - const listeners = new Set(); + const listeners = new Map>(); return { postMessage: vi.fn(), terminate: vi.fn(), - addEventListener: vi.fn((_type: string, listener: Listener) => listeners.add(listener)), - removeEventListener: vi.fn((_type: string, listener: Listener) => listeners.delete(listener)), + addEventListener: vi.fn((type: string, listener: EventListener) => { + const bucket = listeners.get(type) ?? new Set(); + bucket.add(listener); + listeners.set(type, bucket); + }), + removeEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.get(type)?.delete(listener); + }), emit(data: unknown) { - for (const listener of listeners) listener(new MessageEvent("message", { data })); + for (const listener of listeners.get("message") ?? []) { + (listener as Listener)(new MessageEvent("message", { data })); + } + }, + emitError(event: ErrorEvent) { + for (const listener of listeners.get("error") ?? []) { + listener(event); + } }, }; } describe("stylesheet worker clients", () => { - it("rejects stale compiler results by request id", async () => { + it("resolves older compiler results so callers can generation-check without aborting", async () => { const fake = worker(); const client = createCompileWorkerClient(() => fake); const first = client.compile({ editGeneration: 1 } as never); @@ -27,8 +40,18 @@ describe("stylesheet worker clients", () => { fake.emit({ type: "compile_result", requestId: 1, editGeneration: 1, program: null, diagnostics: [] }); fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] }); - await expect(first).rejects.toThrow("stale"); - await expect(second).resolves.toMatchObject({ requestId: 2 }); + await expect(first).resolves.toMatchObject({ requestId: 1, editGeneration: 1 }); + await expect(second).resolves.toMatchObject({ requestId: 2, editGeneration: 2 }); + }); + + it("rejects pending compiles when the worker reports an error", async () => { + const fake = worker(); + const client = createCompileWorkerClient(() => fake); + const pending = client.compile({ editGeneration: 1 } as never); + + fake.emitError({ message: "Failed to load compiler worker" } as ErrorEvent); + + await expect(pending).rejects.toThrow("Failed to load compiler worker"); }); it("terminates and recreates a timed-out preflight worker", async () => { @@ -68,7 +91,9 @@ describe("stylesheet worker clients", () => { client.warmup(); expect(createWorker).toHaveBeenCalledOnce(); - expect(fake.addEventListener).toHaveBeenCalledOnce(); + expect(fake.addEventListener).toHaveBeenCalledTimes(2); + expect(fake.addEventListener).toHaveBeenCalledWith("message", expect.any(Function)); + expect(fake.addEventListener).toHaveBeenCalledWith("error", expect.any(Function)); expect(fake.postMessage).not.toHaveBeenCalled(); }); @@ -90,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 dc919abf4..bc1b2dd4c 100644 --- a/apps/web/src/features/resume/stylesheet/worker-client.ts +++ b/apps/web/src/features/resume/stylesheet/worker-client.ts @@ -10,12 +10,15 @@ import type { } from "./protocol"; type WorkerListener = (event: MessageEvent) => void; +type WorkerErrorListener = (event: ErrorEvent) => void; export type StylesheetWorker = { postMessage(message: unknown, transfer?: Transferable[]): void; terminate(): void; addEventListener(type: "message", listener: WorkerListener): void; + addEventListener(type: "error", listener: WorkerErrorListener): void; removeEventListener(type: "message", listener: WorkerListener): void; + removeEventListener(type: "error", listener: WorkerErrorListener): void; }; type Pending = { @@ -34,13 +37,17 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker) const request = pending.get(response.requestId); if (!request) return; pending.delete(response.requestId); - if (response.requestId !== latestRequestId) { - request.reject(new Error("Discarded stale stylesheet compiler result.")); - return; - } + // Resolve every in-flight compile. Callers already generation-check; rejecting "stale" + // results aborts the edit pipeline and can leave the editor stuck on Checking. request.resolve(response); }; + const onError: WorkerErrorListener = (event) => { + const error = new Error(event.message || "Stylesheet compiler worker failed to load."); + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); return { compile(input: CompileWorkerInput): Promise { @@ -53,6 +60,7 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker) }, destroy() { worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); worker.terminate(); for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated.")); pending.clear(); @@ -120,10 +128,24 @@ export function createPreflightWorkerClient( pending.delete(response.requestId); request.resolve(response); }; + 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 = () => { if (!worker) return; worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); worker.terminate(); worker = undefined; ready = false; @@ -140,6 +162,7 @@ export function createPreflightWorkerClient( if (readiness) return readiness.promise; worker = createWorker(); worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); let resolve!: (value: StylesheetWorker) => void; let reject!: (error: Error) => void; const promise = new Promise((resolvePromise, rejectPromise) => {