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 };