/** * BrowserPool — a small, robust pool of reusable Puppeteer browsers. * * Why: launching a fresh Chromium per request is the #1 cause of poor * PDF-export concurrency (each instance ~0.3–1.5s to start + 200–300MB RAM). * This pool keeps `size` browsers warm; a job checks out a browser, does its * work, then returns it. Browsers are recycled after `recycleAfter` jobs so a * long-lived Chromium never accumulates too much memory. * * Design notes (industry best practice, verified by web research): * - Concurrency = pool size. PDF/page rendering is CPU-bound; running more * parallel jobs than the CPU count only degrades latency. * - One job per browser at a time (no shared page juggling) keeps isolation * and error handling trivial. * - Browser crash / disconnect → replaced lazily on next acquire. * - Callers must ALWAYS release in a finally block. */ const puppeteer = require('puppeteer'); const MAX_LAUNCH_FAILURES_PER_SLOT = 3; class BrowserPool { /** * @param {object} opts * @param {string} opts.name label for logs * @param {number} opts.size number of browsers to keep * @param {object} opts.launchOptions puppeteer.launch() options * @param {number} [opts.recycleAfter] jobs per browser before recycle (default 30) * @param {number} [opts.acquireTimeoutMs] how long a waiter waits for a free browser (default 120000) * @param {Function} [opts.log] */ constructor(opts) { this.name = opts.name || 'pool'; this.size = Math.max(1, Math.min(16, Math.floor(opts.size) || 1)); this.launchOptions = opts.launchOptions || {}; this.recycleAfter = opts.recycleAfter || 30; this.acquireTimeoutMs = opts.acquireTimeoutMs || 120000; this.log = opts.log || (() => {}); this._slots = []; this._waiters = []; this.stats = { acquires: 0, launches: 0, recycles: 0, waits: 0, waitTimeouts: 0, errors: 0 }; } _log(msg) { this.log(`[POOL:${this.name}] ${msg}`); } _newSlot() { return { browser: null, jobs: 0, available: true, closed: false, launching: false, launchFailures: 0 }; } async _launch() { this.stats.launches++; this._log(`launching browser (total launches=${this.stats.launches})`); const browser = await puppeteer.launch(this.launchOptions); const slot = this._newSlot(); slot.browser = browser; browser.on('disconnected', () => { slot.closed = true; slot.available = true; this._log('browser disconnected (crash/kill); slot marked closed'); }); return slot; } _pump() { // 1. Hand free browsers to waiting jobs. while (this._waiters.length > 0) { const slot = this._slots.find((s) => s.available && !s.closed && s.browser); if (!slot) break; const waiter = this._waiters.shift(); clearTimeout(waiter.timer); slot.available = false; // IMPORTANT: resolve with the wrapper ({browser, release}), same shape // as the warm-path return in acquire(). Resolving with the raw slot made // callers' `acquired.release` undefined → slots never released → pool // deadlocked (3/4 busy forever). waiter.resolve(this._wrap(slot)); } // 2. Grow/refill the pool (launch one browser per pump pass). // 必须「无论是否有 waiter」都启动空槽位:否则第 size 个槽位被 push 时 // slots.length === size 使 `length < size` 为假,该槽位永远不会被启动, // 池实际并发只有 size-1,第 N 个任务必须等第一个任务释放浏览器后才能执行 // (实测 3 个 widget 的批次因此从 ~2.5s 被串行拖到 ~5s)。 // 浏览器回收(release 中 splice 移除槽位)后也要补位,保证池始终补齐到 size。 if (this._slots.length < this.size) { this._slots.push(this._newSlot()); } const empty = this._slots.find((s) => !s.browser && !s.launching); if (empty) { empty.launching = true; this._launch().then((slot) => { const i = this._slots.indexOf(empty); if (i === -1) { slot.browser.close().catch(() => {}); return; } this._slots[i] = slot; this._pump(); }).catch((err) => { this.stats.errors++; this._log(`browser launch failed: ${err.message}`); empty.launchFailures++; const i = this._slots.indexOf(empty); if (i !== -1) { if (empty.launchFailures >= MAX_LAUNCH_FAILURES_PER_SLOT) { this._slots.splice(i, 1); const waiter = this._waiters.shift(); if (waiter) { clearTimeout(waiter.timer); waiter.reject(new Error(`[POOL:${this.name}] browser launch failed: ${err.message}`)); } } else { empty.launching = false; // allow retry } } this._pump(); }); } } /** * Check out a browser slot for one job. * @returns {Promise<{browser: object, release: Function}>} */ async acquire() { this.stats.acquires++; const warm = this._slots.find((s) => s.available && !s.closed && s.browser); if (warm) { warm.available = false; this._log(`acquire: warm slot (inUse=${this._inUse()}/${this.size})`); return this._wrap(warm); } // Reserve capacity to grow the pool. if (this._slots.length < this.size) { this._slots.push(this._newSlot()); this._pump(); } this.stats.waits++; this._log(`acquire: no free slot, queued (inUse=${this._inUse()}/${this.size})`); return new Promise((resolve, reject) => { const timer = setTimeout(() => { const i = this._waiters.indexOf(waiter); if (i !== -1) this._waiters.splice(i, 1); this.stats.waitTimeouts++; this._log(`acquire timed out after ${this.acquireTimeoutMs}ms`); reject(new Error(`[POOL:${this.name}] no free browser within ${this.acquireTimeoutMs}ms (busy=${this._inUse()}/${this.size})`)); }, this.acquireTimeoutMs); const waiter = { resolve, reject, timer }; this._waiters.push(waiter); this._pump(); }); } _inUse() { return this._slots.filter((s) => !s.available).length; } _wrap(slot) { return { browser: slot.browser, release: async () => { slot.jobs++; if (slot.closed || slot.jobs >= this.recycleAfter) { this.stats.recycles++; this._log(`recycling browser after ${slot.jobs} jobs (recycles=${this.stats.recycles})`); const i = this._slots.indexOf(slot); if (i !== -1) this._slots.splice(i, 1); try { await slot.browser.close(); } catch (e) {} slot.closed = true; } else { slot.available = true; this._log(`released browser (jobs=${slot.jobs}, inUse=${this._inUse()}/${this.size})`); } this._pump(); }, }; } async close() { const slots = this._slots.splice(0); for (const s of slots) { if (s.browser) { try { await s.browser.close(); } catch (e) {} } } for (const w of this._waiters.splice(0)) { clearTimeout(w.timer); w.reject(new Error(`[POOL:${this.name}] pool closed`)); } } } module.exports = { BrowserPool };