From 4a8f87ab8f008e94528f461b066126c0ef4d6732 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 31 Jul 2026 01:00:45 +0200 Subject: [PATCH 1/2] fix(web): stop custom styles from sticking on Checking (#3283) Concurrent compile requests from editor intelligence were rejecting in-flight edit compiles as stale, and the store swallowed that rejection without leaving compiling. Resolve all compile results and surface compile failures as an error status so styles can apply again. Co-authored-by: Cursor Agent --- .../features/resume/stylesheet/store.test.ts | 91 +++++++++++++++++++ .../src/features/resume/stylesheet/store.ts | 3 + .../resume/stylesheet/worker-client.test.ts | 41 +++++++-- .../resume/stylesheet/worker-client.ts | 21 ++++- 4 files changed, 144 insertions(+), 12 deletions(-) diff --git a/apps/web/src/features/resume/stylesheet/store.test.ts b/apps/web/src/features/resume/stylesheet/store.test.ts index 8eea096fa..4bf4685d1 100644 --- a/apps/web/src/features/resume/stylesheet/store.test.ts +++ b/apps/web/src/features/resume/stylesheet/store.test.ts @@ -741,6 +741,97 @@ describe("stylesheet store runtime", () => { ); }); + 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("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..0b05c2622 100644 --- a/apps/web/src/features/resume/stylesheet/store.ts +++ b/apps/web/src/features/resume/stylesheet/store.ts @@ -349,6 +349,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; 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..0f1fad7ac 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(); }); diff --git a/apps/web/src/features/resume/stylesheet/worker-client.ts b/apps/web/src/features/resume/stylesheet/worker-client.ts index dc919abf4..c09841bb4 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,14 @@ export function createPreflightWorkerClient( pending.delete(response.requestId); request.resolve(response); }; + const onError: WorkerErrorListener = () => { + terminate(); + }; const terminate = () => { if (!worker) return; worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); worker.terminate(); worker = undefined; ready = false; @@ -140,6 +152,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) => { From ba8e1be2ab5206095a7b08c4e26fd9307e2e0ee3 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 31 Jul 2026 07:57:54 +0200 Subject: [PATCH 2/2] fix(stylesheet): harden PDF preflight and surface worker failures (#3284) Co-authored-by: Cursor Agent --- .../src/services/stylesheet-preflight.test.ts | 27 ++++++++- .../src/services/stylesheet-preflight.ts | 7 ++- .../stylesheet-preflight-inspection.ts | 6 +- .../src/workers/stylesheet-preflight.ts | 21 +++++-- .../stylesheet/preflight.worker.test.ts | 38 ++++++++++++ .../resume/stylesheet/preflight.worker.ts | 9 ++- .../features/resume/stylesheet/store.test.ts | 60 +++++++++++++++++++ .../src/features/resume/stylesheet/store.ts | 21 ++++++- .../resume/stylesheet/worker-client.test.ts | 14 +++++ .../resume/stylesheet/worker-client.ts | 12 +++- 10 files changed, 203 insertions(+), 12 deletions(-) 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 = () => {