From e6a31aab9722ed4ac924b0be79ec7553e1d8b37f Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 9 Aug 2026 14:30:55 +0200 Subject: [PATCH] fix(stylesheet): warm and reuse the server PDF preflight worker The server PDF preflight spawned a fresh worker per semantic-CSS edit, each racing a 15s startup deadline to cold-load the ~721kB+5MB PDF runtime. That load is super-linear in CPU (~3s at 1 vCPU, >15s on a throttled/shared vCPU), so on a constrained box every edit hit the startup-timeout path and returned STYLESHEET_PREFLIGHT_WORKER_FAILED. Since the service only advances the applied stylesheet when preflight passes, custom styles never applied and the editor stuck on Checking. Warm one worker at boot and reuse it (message-based input, respawn on crash/timeout), so the cold load is paid once instead of per edit. Raise the render deadline 5s->30s (a rich resume renders ~5-18s on a slow box) and the readiness ceiling to 120s so the one-time warm completes even when throttled. Surface worker load failures instead of an unhandled-rejection crash, and log runner-side failure paths so the previously opaque failure is diagnosable. Verified in node:24-slim under --cpus=0.25/0.35/0.5: all reused requests pass. Claude-Session: https://claude.ai/code/session_01ULhhLQ24DvnYwzP4afDuye --- apps/server/src/index.ts | 5 + .../src/services/stylesheet-preflight.test.ts | 143 +++++--- .../src/services/stylesheet-preflight.ts | 316 +++++++++++++----- .../src/workers/stylesheet-preflight.ts | 69 ++-- .../pdf/src/semantic/preflight-reference.ts | 5 +- 5 files changed, 379 insertions(+), 159 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 82d11ff49..2aff6e7a3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,6 +2,7 @@ import { pathToFileURL } from "node:url"; import { serve } from "@hono/node-server"; import { env } from "@reactive-resume/env/server"; import { createApp } from "./http/app"; +import { stylesheetPreflightRunner } from "./services/stylesheet-preflight"; import { runStartupChecks } from "./startup/checks"; export { createApp } from "./http/app"; @@ -23,6 +24,10 @@ async function main() { console.info(`🚀 Up and running on http://localhost:${info.port}`); }, ); + + // Load the heavy PDF preflight runtime once, now, so the first semantic-CSS edit + // does not pay (and time out on) the cold worker start. + stylesheetPreflightRunner.warmup(); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/apps/server/src/services/stylesheet-preflight.test.ts b/apps/server/src/services/stylesheet-preflight.test.ts index 6e9d120ef..d04b51316 100644 --- a/apps/server/src/services/stylesheet-preflight.test.ts +++ b/apps/server/src/services/stylesheet-preflight.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { createStylesheetPreflightRunner, STYLESHEET_PREFLIGHT_LIMITS } from "./stylesheet-preflight"; @@ -13,6 +13,17 @@ const input = { stylesheet: validStylesheet, } as const; +// Runners now own a long-lived, reused worker; destroy them so no worker thread +// outlives its test. +const runners: Array<{ destroy(): Promise }> = []; +const track = }>(runner: T): T => { + runners.push(runner); + return runner; +}; +afterEach(async () => { + await Promise.all(runners.splice(0).map((runner) => runner.destroy())); +}); + const memoryExhaustionWorker = new URL( `data:text/javascript,${encodeURIComponent(` const retained = []; @@ -27,35 +38,61 @@ const failedWorker = new URL( `data:text/javascript,${encodeURIComponent('throw new Error("sensitive worker details");')}`, ); +// Counts how many preflights this single worker instance served so a reuse test +// can prove the worker is not respawned per request. +const reuseCountingWorker = new URL( + `data:text/javascript,${encodeURIComponent(` + import { parentPort } from "node:worker_threads"; + let served = 0; + parentPort.postMessage({ type: "ready" }); + parentPort.on("message", (message) => { + if (message?.type !== "preflight") return; + served += 1; + parentPort.postMessage({ + type: "result", + requestId: message.requestId, + result: { ok: true, pageCount: 1, byteCount: served, diagnostics: [] }, + }); + }); + `)}`, +); + const delayedSuccessfulWorker = new URL( `data:text/javascript,${encodeURIComponent(` - import { parentPort, workerData } from "node:worker_threads"; + import { parentPort } from "node:worker_threads"; parentPort.postMessage({ type: "ready" }); - setTimeout(() => { - parentPort.postMessage({ - ok: true, - pageCount: 1, - byteCount: Number(workerData.input.data.basics.name), - diagnostics: [], - }); - }, 300); + parentPort.on("message", (message) => { + if (message?.type !== "preflight") return; + setTimeout(() => { + parentPort.postMessage({ + type: "result", + requestId: message.requestId, + result: { + ok: true, + pageCount: 1, + byteCount: Number(message.input.data.basics.name), + diagnostics: [], + }, + }); + }, 300); + }); `)}`, ); const delayedReadyWorker = new URL( `data:text/javascript,${encodeURIComponent(` import { parentPort } from "node:worker_threads"; - setTimeout(() => { - parentPort.postMessage({ type: "ready" }); + parentPort.on("message", (message) => { + if (message?.type !== "preflight") return; setTimeout(() => { parentPort.postMessage({ - ok: true, - pageCount: 1, - byteCount: 1, - diagnostics: [], + type: "result", + requestId: message.requestId, + result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [] }, }); }, 10); - }, 50); + }); + setTimeout(() => parentPort.postMessage({ type: "ready" }), 50); `)}`, ); @@ -101,7 +138,7 @@ const invalidInput = () => { describe("stylesheet PDF preflight worker", () => { it("keeps the production resource policy fixed and immutable", () => { expect(STYLESHEET_PREFLIGHT_LIMITS).toEqual({ - timeoutMs: 5_000, + timeoutMs: 30_000, maxPages: 20, maxBytes: 10_000_000, maxPageWidthPt: 2_000, @@ -115,7 +152,7 @@ describe("stylesheet PDF preflight worker", () => { }); it("accepts a bounded candidate render in an isolated worker", async () => { - const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 }); + const runner = track(createStylesheetPreflightRunner({ timeoutMs: 15_000 })); const result = await runner.run(input); @@ -131,8 +168,22 @@ describe("stylesheet PDF preflight worker", () => { expect(runner.activeWorkerCount).toBe(0); }, 45_000); + it("reuses one warm worker across sequential requests instead of cold-starting each one", async () => { + const runner = track(createStylesheetPreflightRunner({}, reuseCountingWorker)); + + const first = await runner.run(input); + const second = await runner.run(input); + const third = await runner.run(input); + + // A single reused worker increments its per-instance counter; a per-request + // worker would report byteCount 1 every time. + expect([first, second, third].map((result) => (result.ok ? result.byteCount : -1))).toEqual([1, 2, 3]); + expect(runner.activeWorkerCount).toBe(0); + expect(runner.queuedPreflightCount).toBe(0); + }); + it("preserves structured resume-data failures across the worker boundary", async () => { - const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 }); + const runner = track(createStylesheetPreflightRunner({ timeoutMs: 15_000 })); const result = runner.run(invalidInput()); @@ -145,7 +196,7 @@ describe("stylesheet PDF preflight worker", () => { }, 20_000); it("terminates a worker when the render exceeds its deadline", async () => { - const runner = createStylesheetPreflightRunner({ timeoutMs: 1 }, neverCompletesWorker); + const runner = track(createStylesheetPreflightRunner({ timeoutMs: 1 }, neverCompletesWorker)); const result = await runner.run(input); @@ -155,15 +206,15 @@ describe("stylesheet PDF preflight worker", () => { }); it("starts the authored render deadline after the worker runtime is ready", async () => { - const runner = createStylesheetPreflightRunner({ timeoutMs: 20 }, delayedReadyWorker); + const runner = track(createStylesheetPreflightRunner({ timeoutMs: 20 }, delayedReadyWorker)); await expect(runner.run(input)).resolves.toEqual(expect.objectContaining({ ok: true, pageCount: 1 })); expect(runner.activeWorkerCount).toBe(0); }); it("returns deterministic output byte and page limit codes", async () => { - const byteRunner = createStylesheetPreflightRunner({ maxBytes: 16 }); - const pageRunner = createStylesheetPreflightRunner({ maxPages: 0 }); + const byteRunner = track(createStylesheetPreflightRunner({ maxBytes: 16 })); + const pageRunner = track(createStylesheetPreflightRunner({ maxPages: 0 })); await expect(byteRunner.run(input)).resolves.toEqual( expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT" }), @@ -176,9 +227,8 @@ describe("stylesheet PDF preflight worker", () => { }, 30_000); it("maps worker heap exhaustion to a controlled memory-limit result", async () => { - const runner = createStylesheetPreflightRunner( - { maxOldGenerationMb: 8, timeoutMs: 10_000 }, - memoryExhaustionWorker, + const runner = track( + createStylesheetPreflightRunner({ maxOldGenerationMb: 8, timeoutMs: 10_000 }, memoryExhaustionWorker), ); const result = await runner.run(input); @@ -188,7 +238,7 @@ describe("stylesheet PDF preflight worker", () => { }, 15_000); it("does not expose internal errors from a failed worker bootstrap", async () => { - const runner = createStylesheetPreflightRunner({}, failedWorker); + const runner = track(createStylesheetPreflightRunner({}, failedWorker)); const result = await runner.run(input); @@ -207,15 +257,22 @@ describe("stylesheet PDF preflight worker", () => { `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: [], + parentPort.on("message", (message) => { + if (message?.type !== "preflight") return; + parentPort.postMessage({ + type: "result", + requestId: message.requestId, + result: { + 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 runner = track(createStylesheetPreflightRunner({ timeoutMs: 5_000 }, throwingWorker)); const result = await runner.run(input); @@ -228,13 +285,15 @@ describe("stylesheet PDF preflight worker", () => { }); it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => { - const runner = createStylesheetPreflightRunner( - { - timeoutMs: 500, - maxConcurrentWorkers: 1, - maxQueuedRequests: 2, - }, - delayedSuccessfulWorker, + const runner = track( + createStylesheetPreflightRunner( + { + timeoutMs: 500, + maxConcurrentWorkers: 1, + maxQueuedRequests: 2, + }, + delayedSuccessfulWorker, + ), ); const completionOrder: number[] = []; const accepted = [1, 2, 3].map((number) => @@ -262,7 +321,7 @@ describe("stylesheet PDF preflight worker", () => { }, 5_000); it("does not leak a slot when the worker constructor throws synchronously", async () => { - const runner = createStylesheetPreflightRunner({}, synchronousFailureWorker); + const runner = track(createStylesheetPreflightRunner({}, synchronousFailureWorker)); await expect(runner.run(input)).resolves.toEqual( expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED" }), diff --git a/apps/server/src/services/stylesheet-preflight.ts b/apps/server/src/services/stylesheet-preflight.ts index 99c898d8d..521c382fe 100644 --- a/apps/server/src/services/stylesheet-preflight.ts +++ b/apps/server/src/services/stylesheet-preflight.ts @@ -23,8 +23,12 @@ type StylesheetPreflightLimits = { }; const SOURCE_WORKER_LOADER_HEAP_MB = 256; -const SOURCE_WORKER_STARTUP_TIMEOUT_MS = 30_000; -const WORKER_STARTUP_TIMEOUT_MS = 15_000; +// The worker is warmed once and reused, so the one-time cold load (which is +// super-linear in available CPU — measured ~9s at 0.5 vCPU, ~53s at 0.35, ~93s at +// 0.25) is paid at startup, not per request. This ceiling must exceed that cold +// load or warmup is killed mid-bootstrap and never completes on a throttled box; +// it only bounds a genuinely stuck bootstrap and is off the per-edit path. +const WORKER_READINESS_TIMEOUT_MS = 120_000; type SerializedPreflightCause = { name: string; @@ -33,13 +37,16 @@ type SerializedPreflightCause = { }; type StylesheetPreflightWorkerMessage = - | PdfPreflightResult | { type: "ready" } - | { type: "preflight_error"; cause: SerializedPreflightCause }; + | { type: "load_error"; message: string } + | { type: "result"; requestId: number; result: PdfPreflightResult } + | { type: "preflight_error"; requestId: number; cause: SerializedPreflightCause }; export type NodeStylesheetPreflightRunner = StylesheetPreflightRunner & { readonly activeWorkerCount: number; readonly queuedPreflightCount: number; + warmup(): void; + destroy(): Promise; }; const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({ @@ -88,31 +95,140 @@ const workerLocation = () => { }; }; +type PendingRequest = { + resolve: (result: PdfPreflightResult) => void; + reject: (cause: unknown) => void; + renderTimer?: ReturnType; +}; + +type Readiness = { + promise: Promise; + resolve: (worker: Worker) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + export function createStylesheetPreflightRunner( overrides: Partial = {}, testWorkerUrl?: URL, ): NodeStylesheetPreflightRunner { const limits = Object.freeze({ ...STYLESHEET_PREFLIGHT_LIMITS, ...overrides }); - let activeWorkerCount = 0; - // ponytail: Keep admission process-local and bounded; upgrade to a distributed/pooled queue only for multi-process coordination. + + let worker: Worker | undefined; + let ready = false; + let readiness: Readiness | undefined; + let destroyed = false; + let requestSeq = 0; + + const pending = new Map(); + // ponytail: process-local, bounded admission; upgrade to a pooled/distributed queue only for multi-process coordination. const queue: Array<{ input: StylesheetPreflightInput; - resolve: (result: PdfPreflightResult) => void; - reject: (cause: unknown) => void; + resolve: PendingRequest["resolve"]; + reject: PendingRequest["reject"]; }> = []; - const runWorker = ( - input: StylesheetPreflightInput, - resolve: (result: PdfPreflightResult) => void, - 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 teardownWorker = () => { + const dead = worker; + worker = undefined; + ready = false; + if (readiness) { + clearTimeout(readiness.timer); + readiness.reject(new Error("Stylesheet preflight worker did not become ready.")); + readiness = undefined; + } + if (!dead) return; + dead.off("message", onMessage); + dead.off("error", onError); + dead.off("exit", onExit); + void dead.terminate().catch(() => undefined); + }; + + const clearPending = (requestId: number): PendingRequest | undefined => { + const entry = pending.get(requestId); + if (!entry) return undefined; + if (entry.renderTimer) clearTimeout(entry.renderTimer); + pending.delete(requestId); + return entry; + }; + + const drainQueue = () => { + while (!destroyed && pending.size < limits.maxConcurrentWorkers && queue.length > 0) { + const next = queue.shift(); + if (!next) return; + dispatch(next.input, next.resolve, next.reject); + } + }; + + const resolveRequest = (requestId: number, result: PdfPreflightResult) => { + const entry = clearPending(requestId); + if (!entry) return; + entry.resolve(result); + drainQueue(); + }; + + const rejectRequest = (requestId: number, cause: unknown) => { + const entry = clearPending(requestId); + if (!entry) return; + entry.reject(cause); + drainQueue(); + }; + + // A crashed/exited/timed-out worker is torn down and its in-flight requests are + // failed; the next dispatch (including any queued requests) spawns a fresh one, + // so a poisoned render never lingers across requests. + const failWorker = (result: PdfPreflightFailure) => { + teardownWorker(); + const stale = [...pending.keys()]; + for (const requestId of stale) resolveRequest(requestId, result); + drainQueue(); + }; + + function onMessage(message: StylesheetPreflightWorkerMessage) { + if (message.type === "ready") { + if (readiness) { + clearTimeout(readiness.timer); + ready = true; + const resolveReady = readiness.resolve; + const readyWorker = worker; + readiness = undefined; + if (readyWorker) resolveReady(readyWorker); + } + return; + } + if (message.type === "load_error") { + failWorker(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", message.message)); + return; + } + if (message.type === "result") { + resolveRequest(message.requestId, message.result); + return; + } + if (message.type === "preflight_error") { + rejectRequest( + message.requestId, + Object.assign(new Error(message.cause.message), { name: message.cause.name, issues: message.cause.issues }), + ); + } + } + + function onError(error: Error) { + console.warn("[stylesheet-preflight] worker error:", error.message); + failWorker(workerFailure(error)); + } + + function onExit(code: number) { + if (destroyed || (!worker && pending.size === 0)) return; + console.warn(`[stylesheet-preflight] worker exited unexpectedly (code ${code})`); + failWorker(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.")); + } + + const startWorker = (): Promise => { const location = testWorkerUrl ? { source: false, url: testWorkerUrl, execArgv: [] as string[] } : workerLocation(); - let worker: Worker; + let spawned: Worker; try { - worker = new Worker(location.url, { + spawned = new Worker(location.url, { name: "stylesheet-preflight", - workerData: { input, limits }, resourceLimits: { // The source-only tsx compiler heap is outside the production render budget. maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0), @@ -121,96 +237,99 @@ export function createStylesheetPreflightRunner( ...("env" in location ? { env: location.env } : {}), }); } catch (error) { - resolve(workerFailure(error instanceof Error ? error : new Error("Failed to start PDF preflight worker."))); - return false; + return Promise.reject(error instanceof Error ? error : new Error("Failed to start PDF preflight worker.")); } - activeWorkerCount += 1; - let settled = false; - let renderTimer: ReturnType | undefined; - let startupTimer: ReturnType | undefined; + worker = spawned; + ready = false; + // The idle reused worker must not keep the process (or a test run) alive; active + // requests stay alive through their ref'd readiness/render timers. + spawned.unref(); + spawned.on("message", onMessage); + spawned.once("error", onError); + spawned.once("exit", onExit); - const cleanup = () => { - if (renderTimer) clearTimeout(renderTimer); - if (startupTimer) clearTimeout(startupTimer); - worker.off("message", onMessage); - worker.off("error", onError); - worker.off("exit", onExit); - activeWorkerCount -= 1; - }; - - const finish = async (result: PdfPreflightResult) => { - if (settled) return; - settled = true; - await worker.terminate().catch(() => undefined); - cleanup(); - resolve(result); - drainQueue(); - }; - const fail = async (cause: SerializedPreflightCause) => { - if (settled) return; - settled = true; - await worker.terminate().catch(() => undefined); - cleanup(); - reject(Object.assign(new Error(cause.message), { name: cause.name, issues: cause.issues })); - drainQueue(); - }; - - const onMessage = (message: StylesheetPreflightWorkerMessage) => { - if ("type" in message) { - if (message.type === "preflight_error") { - void fail(message.cause); - return; - } - if (startupTimer) clearTimeout(startupTimer); - renderTimer = setTimeout(() => { - void finish(failure("STYLESHEET_PREFLIGHT_TIMEOUT", "The PDF preflight exceeded its deadline.")); - }, limits.timeoutMs); - return; - } - void finish(message); - }; - const onError = (error: Error) => { - void finish(workerFailure(error)); - }; - const onExit = () => { - if (!settled) { - void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.")); - } - }; - - worker.on("message", onMessage); - worker.once("error", onError); - worker.once("exit", onExit); - startupTimer = setTimeout( - () => { - void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.")); - }, - location.source ? SOURCE_WORKER_STARTUP_TIMEOUT_MS : WORKER_STARTUP_TIMEOUT_MS, - ); - return true; + let resolve!: (value: Worker) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + const timer = setTimeout(() => { + console.warn(`[stylesheet-preflight] worker did not become ready within ${WORKER_READINESS_TIMEOUT_MS}ms`); + teardownWorker(); + }, WORKER_READINESS_TIMEOUT_MS); + readiness = { promise, resolve, reject, timer }; + return promise; }; - function drainQueue() { - while (activeWorkerCount < limits.maxConcurrentWorkers && queue.length > 0) { - const next = queue.shift(); - if (!next) return; - runWorker(next.input, next.resolve, next.reject); + const getReadyWorker = (): Promise => { + if (destroyed) return Promise.reject(new Error("Stylesheet preflight worker was terminated.")); + if (worker && ready) return Promise.resolve(worker); + if (readiness) return readiness.promise; + return startWorker(); + }; + + // One retry: a worker that failed to become ready is torn down; a second + // attempt spawns a fresh worker before the request gives up. + const waitUntilReady = async (): Promise => { + try { + return await getReadyWorker(); + } catch { + return await getReadyWorker(); } + }; + + function dispatch( + input: StylesheetPreflightInput, + resolve: PendingRequest["resolve"], + reject: PendingRequest["reject"], + ) { + const requestId = ++requestSeq; + pending.set(requestId, { resolve, reject }); + void waitUntilReady() + .then((readyWorker) => { + const entry = pending.get(requestId); + if (!entry) return; + entry.renderTimer = setTimeout(() => { + // A stuck render poisons the reused worker: tear it down and respawn — but + // only the worker this request actually ran on, so a stale timer can never + // kill a newer worker already serving other requests. + clearPending(requestId); + if (worker === readyWorker) teardownWorker(); + resolve(failure("STYLESHEET_PREFLIGHT_TIMEOUT", "The PDF preflight exceeded its deadline.")); + drainQueue(); + }, limits.timeoutMs); + readyWorker.postMessage({ type: "preflight", requestId, input, limits }); + }) + .catch((error: unknown) => { + resolveRequest( + requestId, + workerFailure(error instanceof Error ? error : new Error("Failed to start PDF preflight worker.")), + ); + }); } return { get activeWorkerCount() { - return activeWorkerCount; + return pending.size; }, get queuedPreflightCount() { return queue.length; }, + warmup() { + void waitUntilReady().catch(() => undefined); + }, + run(input: StylesheetPreflightInput): Promise { return new Promise((resolve, reject) => { - if (activeWorkerCount < limits.maxConcurrentWorkers) { - runWorker(input, resolve, reject); + if (destroyed) { + resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker was terminated.")); + return; + } + if (pending.size < limits.maxConcurrentWorkers) { + dispatch(input, resolve, reject); return; } if (queue.length >= limits.maxQueuedRequests) { @@ -220,6 +339,23 @@ export function createStylesheetPreflightRunner( queue.push({ input, resolve, reject }); }); }, + + async destroy() { + destroyed = true; + const dead = worker; + teardownWorker(); + await dead?.terminate().catch(() => undefined); + for (const requestId of [...pending.keys()]) { + resolveRequest( + requestId, + failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker was terminated."), + ); + } + while (queue.length > 0) { + const next = queue.shift(); + next?.resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker was terminated.")); + } + }, }; } diff --git a/apps/server/src/workers/stylesheet-preflight.ts b/apps/server/src/workers/stylesheet-preflight.ts index b8f1a4d23..17b2813fe 100644 --- a/apps/server/src/workers/stylesheet-preflight.ts +++ b/apps/server/src/workers/stylesheet-preflight.ts @@ -3,7 +3,7 @@ import type { PdfPreflightResult, StylesheetPreflightInput, } from "@reactive-resume/pdf/preflight"; -import { parentPort, workerData } from "node:worker_threads"; +import { parentPort } from "node:worker_threads"; import * as React from "react"; import { inspectPreflightPdf } from "./stylesheet-preflight-inspection"; @@ -14,7 +14,12 @@ type StylesheetPreflightWorkerLimits = PdfPreflightPageLimits & { maxBytes: number; }; -type StylesheetPreflightWorkerData = { +// The worker is long-lived and reused across requests: the heavy PDF runtime is +// loaded once at startup and each preflight arrives as a message (input can no +// longer come from `workerData`, which is fixed at construction time). +type StylesheetPreflightWorkerRequest = { + type: "preflight"; + requestId: number; input: StylesheetPreflightInput; limits: StylesheetPreflightWorkerLimits; }; @@ -25,10 +30,6 @@ type SerializedPreflightCause = { issues: readonly unknown[]; }; -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); @@ -43,31 +44,47 @@ const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined return { name: cause.name, message: cause.message, issues: cause.issues }; }; +// Load the heavy PDF runtime once. A rejection here (missing/broken dependency in +// a pruned production install) is reported explicitly instead of becoming an +// unhandled rejection that silently kills the worker and surfaces as an opaque +// runner-side failure. 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; -} +void initialization.then( + () => parentPort?.postMessage({ type: "ready" }), + (cause: unknown) => { + console.error("[stylesheet-preflight] worker runtime failed to load", cause); + parentPort?.postMessage({ type: "load_error", message: sanitizeWorkerCause(cause) }); + }, +); -if (parentPort) { - void run() - .then(send) - .catch((cause: unknown) => { - const serializedCause = serializeZodCause(cause); - if (serializedCause) { - parentPort?.postMessage({ type: "preflight_error", cause: serializedCause }); - return; - } - console.error("[stylesheet-preflight]", cause); - send({ +const handle = async (request: StylesheetPreflightWorkerRequest): Promise => { + try { + const { renderPreflightPdf } = await initialization; + const rendered = await renderPreflightPdf(request.input, request.limits); + const result: PdfPreflightResult = rendered.ok ? await inspectPreflightPdf(rendered, request.limits) : rendered; + parentPort?.postMessage({ type: "result", requestId: request.requestId, result }); + } catch (cause) { + const serializedCause = serializeZodCause(cause); + if (serializedCause) { + parentPort?.postMessage({ type: "preflight_error", requestId: request.requestId, cause: serializedCause }); + return; + } + console.error("[stylesheet-preflight]", cause); + parentPort?.postMessage({ + type: "result", + requestId: request.requestId, + result: { ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED", message: sanitizeWorkerCause(cause), diagnostics: [], - }); + }, }); -} + } +}; + +parentPort?.on("message", (message: StylesheetPreflightWorkerRequest) => { + if (message.type !== "preflight") return; + void handle(message); +}); diff --git a/packages/pdf/src/semantic/preflight-reference.ts b/packages/pdf/src/semantic/preflight-reference.ts index 94cee6432..52a349d1b 100644 --- a/packages/pdf/src/semantic/preflight-reference.ts +++ b/packages/pdf/src/semantic/preflight-reference.ts @@ -40,7 +40,10 @@ export const PDF_PREFLIGHT_DIAGNOSTIC_CATALOG = { export type PdfPreflightFailureCode = keyof typeof PDF_PREFLIGHT_DIAGNOSTIC_CATALOG; export const STYLESHEET_PREFLIGHT_LIMITS = Object.freeze({ - timeoutMs: 5_000, + // Render deadline (after the worker is warm). A rich resume on a throttled/shared + // vCPU renders in ~5-18s, so 5s spuriously failed real resumes; the worker is now + // warmed+reused so this ceiling only bounds a genuinely stuck render. + timeoutMs: 30_000, maxPages: 20, maxBytes: 10_000_000, maxPageWidthPt: 2_000,