Spaces:
Running
Running
| /** | |
| * 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 }; | |