diff --git a/src/__tests__/browser-pool.test.ts b/src/__tests__/browser-pool.test.ts
index d0a68e5..ecf8627 100644
--- a/src/__tests__/browser-pool.test.ts
+++ b/src/__tests__/browser-pool.test.ts
@@ -218,7 +218,16 @@ describe("browser pool", () => {
expect(stats.pdfCount).toBe(1);
});
+ it("cleans up timeout timer after successful render", async () => {
+ vi.useFakeTimers();
+ await browserModule.initBrowser();
+ await browserModule.renderPdf("
Hello
");
+ expect(vi.getTimerCount()).toBe(0);
+ vi.useRealTimers();
+ });
+
it("rejects with PDF_TIMEOUT after 30s", async () => {
+ vi.useFakeTimers();
await browserModule.initBrowser();
// Make ALL pages' setContent hang so whichever is picked will timeout
for (const b of mockBrowsers) {
@@ -227,11 +236,13 @@ describe("browser pool", () => {
}
}
- vi.useFakeTimers();
const renderPromise = browserModule.renderPdf("slow
");
+ const renderResult = renderPromise.catch((e: Error) => e);
await vi.advanceTimersByTimeAsync(30_001);
- await expect(renderPromise).rejects.toThrow("PDF_TIMEOUT");
+ const err = await renderResult;
+ expect(err).toBeInstanceOf(Error);
+ expect((err as Error).message).toBe("PDF_TIMEOUT");
vi.useRealTimers();
});
});
@@ -322,6 +333,7 @@ describe("browser pool", () => {
});
it("rejects with QUEUE_FULL after 30s timeout when all pages busy", async () => {
+ vi.useFakeTimers();
await browserModule.initBrowser();
// Make all pages hang
@@ -331,20 +343,27 @@ describe("browser pool", () => {
}
}
- // Consume all 4 pages (these will hang)
- browserModule.renderPdf("1
");
- browserModule.renderPdf("2
");
- browserModule.renderPdf("3
");
- browserModule.renderPdf("4
");
+ // Consume all 4 pages (these will hang) — catch their rejections
+ const hanging = [
+ browserModule.renderPdf("1
").catch(() => {}),
+ browserModule.renderPdf("2
").catch(() => {}),
+ browserModule.renderPdf("3
").catch(() => {}),
+ browserModule.renderPdf("4
").catch(() => {}),
+ ];
- vi.useFakeTimers();
- // 5th request should queue
+ // 5th request should queue — attach catch immediately to prevent unhandled rejection
const queued = browserModule.renderPdf("5
");
+ const queuedResult = queued.catch((e: Error) => e);
- // Advance past queue timeout
+ // Advance past all timeouts (queue + PDF_TIMEOUT for hanging renders)
await vi.advanceTimersByTimeAsync(30_001);
- await expect(queued).rejects.toThrow("QUEUE_FULL");
+ const err = await queuedResult;
+ expect(err).toBeInstanceOf(Error);
+ expect((err as Error).message).toBe("QUEUE_FULL");
+
+ // Let hanging PDF_TIMEOUT rejections settle
+ await Promise.allSettled(hanging);
vi.useRealTimers();
});
diff --git a/src/services/browser.ts b/src/services/browser.ts
index 9bd894d..338e8d5 100644
--- a/src/services/browser.ts
+++ b/src/services/browser.ts
@@ -244,6 +244,7 @@ export async function renderPdf(
try {
await page.setJavaScriptEnabled(false);
const startTime = Date.now();
+ let timeoutId: ReturnType;
const result = await Promise.race([
(async () => {
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
@@ -264,10 +265,10 @@ export async function renderPdf(
});
return Buffer.from(pdf);
})(),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)
- ),
- ]);
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000);
+ }),
+ ]).finally(() => clearTimeout(timeoutId));
const durationMs = Date.now() - startTime;
logger.info(`PDF rendered in ${durationMs}ms (html, ${result.length} bytes)`);
return { pdf: result, durationMs };
@@ -320,6 +321,7 @@ export async function renderUrlPdf(
}
}
const startTime = Date.now();
+ let timeoutId: ReturnType;
const result = await Promise.race([
(async () => {
await page.goto(url, {
@@ -342,10 +344,10 @@ export async function renderUrlPdf(
});
return Buffer.from(pdf);
})(),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)
- ),
- ]);
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000);
+ }),
+ ]).finally(() => clearTimeout(timeoutId));
const durationMs = Date.now() - startTime;
logger.info(`PDF rendered in ${durationMs}ms (url, ${result.length} bytes)`);
return { pdf: result, durationMs };