/** * Browser 温池:按 profile:sessionKey 复用 Browser,降低冷启动与 CF 触发 * * 同一 key 的 acquire 在 inUse 时排队等待,禁止销毁正在使用的 Browser(修复 A2C 并发误杀)。 */ const { POOL_IDLE_MS, MAX_POOLED_BROWSERS, POOL_ENABLED, QUEUE_TIMEOUT_MS } = require('./constants'); class BrowserPool { constructor() { /** @type {Map} */ this.entries = new Map(); /** @type {Map void, reject: (err: Error) => void, timer: NodeJS.Timeout|null }>>} */ this._waitQueues = new Map(); this.hits = 0; this.misses = 0; this.evictions = 0; this._sweepTimer = setInterval(() => this.evictIdle(), 30000); this._sweepTimer.unref?.(); } buildKey(sessionKey, profile) { return `${profile}:${sessionKey}`; } async isBrowserAlive(browser) { if (!browser) return false; try { if (typeof browser.isConnected === 'function' && !browser.isConnected()) { return false; } await browser.version(); return true; } catch (_) { return false; } } async destroyEntry(key) { const entry = this.entries.get(key); if (!entry) return; if (entry.inUse) { console.warn(`[BrowserPool] 跳过销毁 inUse 条目 key=${key}`); return; } this.entries.delete(key); try { await entry.browser.close(); } catch (_) { /* ignore */ } this.evictions++; } enforceCapacity(excludeKey) { if (this.entries.size < MAX_POOLED_BROWSERS) return; const candidates = [...this.entries.entries()] .filter(([k, e]) => k !== excludeKey && !e.inUse) .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt); if (candidates.length === 0) return; const [oldestKey] = candidates[0]; this.destroyEntry(oldestKey); } waitForRelease(key, timeoutMs = QUEUE_TIMEOUT_MS) { const entry = this.entries.get(key); if (!entry || !entry.inUse) { return Promise.resolve(); } return new Promise((resolve, reject) => { const waiter = { resolve: () => { if (waiter.timer) clearTimeout(waiter.timer); resolve(); }, reject, timer: timeoutMs > 0 ? setTimeout(() => { const queue = this._waitQueues.get(key) || []; const idx = queue.indexOf(waiter); if (idx >= 0) queue.splice(idx, 1); reject(new Error('POOL_ACQUIRE_TIMEOUT')); }, timeoutMs) : null, }; if (!this._waitQueues.has(key)) { this._waitQueues.set(key, []); } this._waitQueues.get(key).push(waiter); console.log(`[BrowserPool] key=${key} 正在使用,排队等待 (队列 ${this._waitQueues.get(key).length})`); }); } _wakeNextWaiter(key) { const queue = this._waitQueues.get(key); if (!queue || queue.length === 0) return; const next = queue.shift(); if (next?.timer) clearTimeout(next.timer); next?.resolve(); } async acquire(sessionKey, profile, launcher) { if (!POOL_ENABLED || !sessionKey) { const launched = await launcher(); return { browser: launched.browser, page: launched.page, profile, pooled: false, reused: false, release: async () => { await launched.cleanup(true); }, }; } const key = this.buildKey(sessionKey, profile); while (true) { const existing = this.entries.get(key); if (!existing) { break; } if (existing.inUse) { await this.waitForRelease(key); continue; } if (await this.isBrowserAlive(existing.browser)) { existing.inUse = true; existing.lastUsedAt = Date.now(); this.hits++; return { browser: existing.browser, page: null, profile, pooled: true, reused: true, release: async (destroy = false) => this.release(key, destroy), }; } await this.destroyEntry(key); break; } this.enforceCapacity(key); this.misses++; const launched = await launcher(); this.entries.set(key, { browser: launched.browser, page: null, profile, sessionKey, lastUsedAt: Date.now(), inUse: true, }); return { browser: launched.browser, page: launched.page, profile, pooled: true, reused: false, release: async (destroy = false) => this.release(key, destroy), }; } async release(key, destroy = false) { const entry = this.entries.get(key); if (!entry) { this._wakeNextWaiter(key); return; } entry.inUse = false; entry.lastUsedAt = Date.now(); entry.page = null; if (destroy || !(await this.isBrowserAlive(entry.browser))) { await this.destroyEntry(key); } this._wakeNextWaiter(key); } evictIdle() { const now = Date.now(); for (const [key, entry] of this.entries.entries()) { if (!entry.inUse && now - entry.lastUsedAt > POOL_IDLE_MS) { this.destroyEntry(key); } } } async drain() { clearInterval(this._sweepTimer); const keys = [...this.entries.keys()]; for (const key of keys) { const entry = this.entries.get(key); if (entry && !entry.inUse) { await this.destroyEntry(key); } } } getStats() { let idle = 0; let active = 0; let waiting = 0; for (const entry of this.entries.values()) { if (entry.inUse) active++; else idle++; } for (const queue of this._waitQueues.values()) { waiting += queue.length; } return { enabled: POOL_ENABLED, max: MAX_POOLED_BROWSERS, idleMs: POOL_IDLE_MS, size: this.entries.size, idle, active, waiting, hits: this.hits, misses: this.misses, evictions: this.evictions, }; } } const browserPool = new BrowserPool(); module.exports = { BrowserPool, browserPool, };