Merge branch 'main' of github.com:amruthpillai/reactive-resume

This commit is contained in:
Amruth Pillai
2026-07-31 17:24:40 +02:00
10 changed files with 346 additions and 23 deletions
@@ -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(
{
@@ -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) {
@@ -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 & {
@@ -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<PdfPreflightResult> {
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: [],
});
});
@@ -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<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: [],
});
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<PreflightWorkerRe
self.postMessage(response);
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 = {
type: "preflight_result",
requestId: data.requestId,
@@ -741,6 +741,157 @@ 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("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 () => {
const { createCompileWorkerClient } = await import("./worker-client");
const listeners = new Map<string, Set<EventListener>>();
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;
@@ -113,6 +113,18 @@ const emptySemanticTree = (): SemanticNode => ({
const HISTORY_COALESCE_MS = 500;
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<
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;
}
}
@@ -5,20 +5,33 @@ import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker
type Listener = (event: MessageEvent) => void;
function worker() {
const listeners = new Set<Listener>();
const listeners = new Map<string, Set<EventListener>>();
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();
@@ -10,12 +10,15 @@ import type {
} from "./protocol";
type WorkerListener = (event: MessageEvent<unknown>) => 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<T> = {
@@ -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<CompileWorkerResponse> {
@@ -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<StylesheetWorker>((resolvePromise, rejectPromise) => {