UX-agent / backend /core /BrowserPool.js
AUXteam's picture
Final cleanup and addressing code review feedback
bb3e610 verified
const puppeteer = require('puppeteer');
const EventEmitter = require('events');
class BrowserInstance {
constructor(browser, options = {}) {
this.browser = browser;
this.id = this.generateId();
this.createdAt = Date.now();
this.lastUsed = Date.now();
this.isIdle = true;
this.usageCount = 0;
this.maxIdleTime = options.maxIdleTime || 300000; // 5 minutes
this.maxLifetime = options.maxLifetime || 1800000; // 30 minutes
this.maxUsageCount = options.maxUsageCount || 100;
this.activeTabs = new Set();
}
generateId() {
return `browser_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
async newPage() {
const page = await this.browser.newPage();
const pageId = `page_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
this.activeTabs.add(pageId);
this.usageCount++;
this.lastUsed = Date.now();
this.isIdle = false;
// Wrap page.close to clean up from our tracking
const originalClose = page.close.bind(page);
page.close = async () => {
this.activeTabs.delete(pageId);
if (this.activeTabs.size === 0) {
this.isIdle = true;
}
return originalClose();
};
return page;
}
isExpired() {
const now = Date.now();
const idleTime = now - this.lastUsed;
const lifetime = now - this.createdAt;
return (
(this.isIdle && idleTime > this.maxIdleTime) ||
lifetime > this.maxLifetime ||
this.usageCount > this.maxUsageCount ||
this.browser.isConnected() === false
);
}
async close() {
try {
// Close all active pages first
for (const page of await this.browser.pages()) {
try {
await page.close();
} catch (error) {
console.warn(`Error closing page: ${error.message}`);
}
}
await this.browser.close();
console.log(`Browser ${this.id} closed successfully`);
} catch (error) {
console.error(`Error closing browser ${this.id}:`, error.message);
}
}
getStatus() {
const now = Date.now();
return {
id: this.id,
isIdle: this.isIdle,
usageCount: this.usageCount,
activeTabs: this.activeTabs.size,
idleTime: now - this.lastUsed,
lifetime: now - this.createdAt,
isExpired: this.isExpired(),
isConnected: this.browser.isConnected()
};
}
}
class BrowserPool extends EventEmitter {
constructor(options = {}) {
super();
this.maxSize = options.maxSize || 3;
this.minSize = options.minSize || 1;
this.launchOptions = options.launchOptions || {
headless: 'new',
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-web-security',
'--single-process'
]
};
this.instanceOptions = {
maxIdleTime: options.maxIdleTime || 300000,
maxLifetime: options.maxLifetime || 1800000,
maxUsageCount: options.maxUsageCount || 100
};
this.instances = new Map();
this.queue = [];
this.isShuttingDown = false;
// Stats
this.stats = {
created: 0,
destroyed: 0,
acquired: 0,
released: 0,
errors: 0,
currentSize: 0,
queueSize: 0
};
// Start maintenance
this.startMaintenance();
}
/**
* Get a browser instance from the pool
*/
async acquire(timeout = 30000) {
if (this.isShuttingDown) {
throw new Error('Browser pool is shutting down');
}
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Browser acquisition timeout after ${timeout}ms`));
}, timeout);
const handleRequest = async () => {
try {
clearTimeout(timeoutId);
const instance = await this.getOrCreateInstance();
this.stats.acquired++;
resolve(instance);
} catch (error) {
clearTimeout(timeoutId);
this.stats.errors++;
reject(error);
}
};
if (this.hasAvailableInstance()) {
handleRequest();
} else if (this.instances.size < this.maxSize) {
handleRequest();
} else {
// Add to queue
this.queue.push(handleRequest);
this.stats.queueSize = this.queue.length;
this.emit('queue', { size: this.queue.length, maxSize: this.maxSize });
}
});
}
/**
* Release a browser instance back to the pool
*/
async release(instance) {
if (!instance || !this.instances.has(instance.id)) {
return;
}
instance.isIdle = true;
instance.lastUsed = Date.now();
this.stats.released++;
this.emit('release', { instanceId: instance.id, stats: instance.getStatus() });
// Process queue if there are waiting requests
if (this.queue.length > 0) {
const handleRequest = this.queue.shift();
this.stats.queueSize = this.queue.length;
setImmediate(handleRequest);
}
}
/**
* Check if there's an available instance
*/
hasAvailableInstance() {
for (const instance of this.instances.values()) {
if (instance.isIdle && !instance.isExpired()) {
return true;
}
}
return false;
}
/**
* Get an existing instance or create a new one
*/
async getOrCreateInstance() {
// Try to find an available instance
for (const instance of this.instances.values()) {
if (instance.isIdle && !instance.isExpired()) {
return instance;
}
}
// Create new instance if under limit
if (this.instances.size < this.maxSize) {
return await this.createInstance();
}
throw new Error('No available browser instances and pool is at maximum capacity');
}
/**
* Create a new browser instance
*/
async createInstance() {
try {
console.log('Creating new browser instance...');
const browser = await puppeteer.launch(this.launchOptions);
const instance = new BrowserInstance(browser, this.instanceOptions);
this.instances.set(instance.id, instance);
this.stats.created++;
this.stats.currentSize = this.instances.size;
this.emit('create', { instanceId: instance.id, poolSize: this.instances.size });
// Set up browser disconnect handler
browser.on('disconnected', () => {
this.handleBrowserDisconnect(instance.id);
});
console.log(`Browser instance ${instance.id} created. Pool size: ${this.instances.size}`);
return instance;
} catch (error) {
this.stats.errors++;
console.error('Failed to create browser instance:', error);
throw error;
}
}
/**
* Handle browser disconnect
*/
handleBrowserDisconnect(instanceId) {
const instance = this.instances.get(instanceId);
if (instance) {
console.warn(`Browser ${instanceId} disconnected unexpectedly`);
this.instances.delete(instanceId);
this.stats.destroyed++;
this.stats.currentSize = this.instances.size;
this.emit('disconnect', { instanceId, poolSize: this.instances.size });
}
}
/**
* Remove expired instances
*/
async cleanupExpiredInstances() {
const expiredInstances = [];
for (const [id, instance] of this.instances.entries()) {
if (instance.isExpired()) {
expiredInstances.push(id);
}
}
for (const id of expiredInstances) {
await this.destroyInstance(id, 'expired');
}
if (expiredInstances.length > 0) {
console.log(`Cleaned up ${expiredInstances.length} expired browser instances`);
}
}
/**
* Destroy a specific instance
*/
async destroyInstance(instanceId, reason = 'manual') {
const instance = this.instances.get(instanceId);
if (!instance) {
return;
}
try {
await instance.close();
this.instances.delete(instanceId);
this.stats.destroyed++;
this.stats.currentSize = this.instances.size;
this.emit('destroy', {
instanceId,
reason,
poolSize: this.instances.size,
stats: instance.getStatus()
});
console.log(`Browser instance ${instanceId} destroyed (reason: ${reason})`);
} catch (error) {
console.error(`Error destroying browser instance ${instanceId}:`, error);
}
}
/**
* Ensure minimum pool size
*/
async ensureMinimumSize() {
const currentSize = this.instances.size;
const needed = this.minSize - currentSize;
if (needed > 0) {
console.log(`Creating ${needed} browser instances to maintain minimum pool size`);
const createPromises = [];
for (let i = 0; i < needed; i++) {
createPromises.push(this.createInstance().catch(error => {
console.error('Failed to create browser instance for minimum pool size:', error);
}));
}
await Promise.allSettled(createPromises);
}
}
/**
* Get pool status
*/
getStatus() {
const instances = Array.from(this.instances.values()).map(instance => instance.getStatus());
return {
poolSize: this.instances.size,
maxSize: this.maxSize,
minSize: this.minSize,
queueSize: this.queue.length,
isShuttingDown: this.isShuttingDown,
stats: { ...this.stats },
instances,
healthy: instances.filter(i => i.isConnected && !i.isExpired).length,
idle: instances.filter(i => i.isIdle).length,
active: instances.filter(i => !i.isIdle).length,
expired: instances.filter(i => i.isExpired).length
};
}
/**
* Start maintenance routine
*/
startMaintenance() {
this.maintenanceInterval = setInterval(async () => {
try {
await this.cleanupExpiredInstances();
await this.ensureMinimumSize();
// Log status periodically
const status = this.getStatus();
if (status.poolSize > 0) {
console.log(`Browser pool status: ${status.poolSize}/${status.maxSize} instances, ${status.active} active, ${status.queueSize} queued`);
}
} catch (error) {
console.error('Browser pool maintenance error:', error);
}
}, 30000); // Run every 30 seconds
}
/**
* Stop maintenance routine
*/
stopMaintenance() {
if (this.maintenanceInterval) {
clearInterval(this.maintenanceInterval);
this.maintenanceInterval = null;
}
}
/**
* Shutdown the browser pool
*/
async shutdown(timeout = 30000) {
console.log('Shutting down browser pool...');
this.isShuttingDown = true;
this.stopMaintenance();
// Reject all queued requests
while (this.queue.length > 0) {
const handleRequest = this.queue.shift();
setImmediate(() => {
handleRequest(new Error('Browser pool is shutting down'));
});
}
// Close all instances
const shutdownPromises = Array.from(this.instances.values()).map(async (instance) => {
try {
await instance.close();
} catch (error) {
console.error(`Error closing browser ${instance.id} during shutdown:`, error);
}
});
// Wait for all instances to close or timeout
const timeoutPromise = new Promise((resolve) => {
setTimeout(resolve, timeout);
});
await Promise.race([
Promise.allSettled(shutdownPromises),
timeoutPromise
]);
this.instances.clear();
this.stats.currentSize = 0;
console.log('Browser pool shutdown complete');
this.emit('shutdown', { totalDestroyed: shutdownPromises.length });
}
/**
* Health check
*/
async healthCheck() {
const status = this.getStatus();
const healthyRatio = status.poolSize > 0 ? status.healthy / status.poolSize : 1;
return {
status: healthyRatio >= 0.5 ? 'healthy' : 'unhealthy',
poolSize: status.poolSize,
healthy: status.healthy,
expired: status.expired,
queueSize: status.queueSize,
healthyRatio,
details: status
};
}
}
module.exports = BrowserPool;