lightweight-browser-load-tester
Version:
A lightweight load testing tool using real browsers for streaming applications with DRM support
775 lines (671 loc) • 26.1 kB
text/typescript
import { Browser, chromium } from 'playwright';
import { EventEmitter } from 'events';
import { BrowserMetrics, BrowserPoolConfig, ManagedBrowserInstance } from '../types';
import { ErrorRecoveryManager } from './error-recovery';
/**
* Browser pool manager that handles browser instance lifecycle and resource monitoring
*/
export class BrowserPool extends EventEmitter {
private instances: Map<string, ManagedBrowserInstance> = new Map();
private availableInstances: Set<string> = new Set();
private config: BrowserPoolConfig;
private resourceMonitorInterval?: NodeJS.Timeout;
private isShuttingDown = false;
private errorRecovery: ErrorRecoveryManager;
private metricsHistory: Map<string, { metrics: BrowserMetrics; disconnectedAt: Date }> = new Map();
constructor(config: BrowserPoolConfig) {
super();
this.config = config;
this.errorRecovery = new ErrorRecoveryManager({
failureThreshold: 3,
recoveryTimeout: 30000,
successThreshold: 2,
monitoringWindow: 300000
});
this.setupErrorRecoveryEvents();
this.startResourceMonitoring();
}
/**
* Initialize the browser pool with minimum instances
*/
async initialize(): Promise<void> {
const promises = [];
for (let i = 0; i < this.config.minInstances; i++) {
promises.push(this.createBrowserInstance());
}
await Promise.all(promises);
this.emit('initialized', { instanceCount: this.instances.size });
}
/**
* Acquire a browser instance from the pool
*/
async acquireInstance(): Promise<ManagedBrowserInstance> {
if (this.isShuttingDown) {
throw new Error('Browser pool is shutting down');
}
// Try to get an available instance first, checking circuit breaker state
for (const availableId of this.availableInstances) {
if (this.errorRecovery.canUseInstance(availableId)) {
const instance = this.instances.get(availableId)!;
this.availableInstances.delete(availableId);
instance.isActive = true;
instance.lastUsed = new Date();
// Record successful acquisition
this.errorRecovery.recordSuccess(availableId);
this.emit('instanceAcquired', { instanceId: availableId });
return instance;
}
}
// Create new instance if under limit
if (this.instances.size < this.config.maxInstances) {
const instance = await this.createBrowserInstance();
instance.isActive = true;
this.emit('instanceAcquired', { instanceId: instance.id });
return instance;
}
// Wait for an instance to become available
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for available browser instance'));
}, 30000); // 30 second timeout
const onInstanceReleased = () => {
clearTimeout(timeout);
this.removeListener('instanceReleased', onInstanceReleased);
// Try to get an available instance directly instead of recursive call
for (const availableId of this.availableInstances) {
if (this.errorRecovery.canUseInstance(availableId)) {
const instance = this.instances.get(availableId)!;
this.availableInstances.delete(availableId);
instance.isActive = true;
instance.lastUsed = new Date();
// Record successful acquisition
this.errorRecovery.recordSuccess(availableId);
this.emit('instanceAcquired', { instanceId: availableId });
resolve(instance);
return;
}
}
// If no available instances, wait for another release
this.on('instanceReleased', onInstanceReleased);
};
this.on('instanceReleased', onInstanceReleased);
});
}
/**
* Release a browser instance back to the pool
*/
async releaseInstance(instanceId: string): Promise<void> {
const instance = this.instances.get(instanceId);
if (!instance) {
throw new Error(`Browser instance ${instanceId} not found`);
}
instance.isActive = false;
instance.lastUsed = new Date();
// Perform comprehensive memory cleanup
try {
await this.performMemoryCleanup(instance);
} catch (error) {
// If cleanup fails, destroy the instance
await this.destroyInstance(instanceId);
return;
}
this.availableInstances.add(instanceId);
this.emit('instanceReleased', { instanceId });
}
/**
* Get current metrics for all browser instances (including recently disconnected)
*/
getMetrics(): BrowserMetrics[] {
const currentMetrics = Array.from(this.instances.values()).map(instance => ({
...instance.metrics,
uptime: (Date.now() - instance.createdAt.getTime()) / 1000
}));
// Include metrics from recently disconnected instances (within last 30 seconds)
const recentDisconnectedMetrics: BrowserMetrics[] = [];
const cutoffTime = Date.now() - 30000; // 30 seconds ago
for (const [instanceId, historyEntry] of this.metricsHistory) {
if (historyEntry.disconnectedAt.getTime() > cutoffTime) {
recentDisconnectedMetrics.push(historyEntry.metrics);
} else {
// Clean up old entries
this.metricsHistory.delete(instanceId);
}
}
return [...currentMetrics, ...recentDisconnectedMetrics];
}
/**
* Get pool status information
*/
getPoolStatus() {
return {
totalInstances: this.instances.size,
availableInstances: this.availableInstances.size,
activeInstances: this.instances.size - this.availableInstances.size,
maxInstances: this.config.maxInstances,
resourceLimits: this.config.resourceLimits
};
}
/**
* Shutdown the browser pool and cleanup all instances
*/
async shutdown(): Promise<void> {
this.isShuttingDown = true;
if (this.resourceMonitorInterval) {
clearInterval(this.resourceMonitorInterval);
}
// Shutdown error recovery manager
this.errorRecovery.shutdown();
const shutdownPromises = Array.from(this.instances.keys()).map(id =>
this.destroyInstance(id)
);
await Promise.all(shutdownPromises);
this.emit('shutdown');
}
/**
* Get error recovery statistics
*/
getErrorRecoveryStats() {
return this.errorRecovery.getRecoveryStats();
}
/**
* Setup error recovery event handlers
*/
private setupErrorRecoveryEvents(): void {
this.errorRecovery.on('circuit-breaker-opened', ({ instanceId }) => {
this.emit('circuitBreakerOpened', { instanceId });
});
this.errorRecovery.on('circuit-breaker-closed', ({ instanceId }) => {
this.emit('circuitBreakerClosed', { instanceId });
});
this.errorRecovery.on('instance-blacklisted', ({ instanceId, reason }) => {
this.emit('instanceBlacklisted', { instanceId, reason });
// Remove blacklisted instance from available pool
this.availableInstances.delete(instanceId);
});
this.errorRecovery.on('restart-attempted', ({ instanceId, success, error }) => {
this.emit('instanceRestartAttempted', { instanceId, success, error });
});
this.errorRecovery.on('error-logged', (errorLog) => {
this.emit('errorLogged', errorLog);
});
}
/**
* Create a new browser instance
*/
private async createBrowserInstance(): Promise<ManagedBrowserInstance> {
const instanceId = `browser-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const browserOptions = {
headless: this.config.browserOptions?.headless ?? true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-features=TranslateUI',
'--disable-ipc-flooding-protection',
'--disable-extensions',
'--disable-plugins',
'--disable-default-apps',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
// Removed --single-process as it makes browsers more fragile, not more stable
// Additional stability flags
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
'--disable-background-networking',
'--disable-client-side-phishing-detection',
'--disable-hang-monitor',
'--disable-popup-blocking',
'--disable-prompt-on-repost',
'--metrics-recording-only',
'--safebrowsing-disable-auto-update',
'--enable-automation',
'--password-store=basic',
'--use-mock-keychain',
...(this.config.browserOptions?.args || [])
]
};
try {
const browser = await chromium.launch(browserOptions);
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true
});
const page = await context.newPage();
// Initialize localStorage if configured
if (this.config.localStorage && this.config.localStorage.length > 0) {
await this.initializeLocalStorage(page);
}
const instance: ManagedBrowserInstance = {
id: instanceId,
browser,
context,
page,
createdAt: new Date(),
lastUsed: new Date(),
isActive: false,
metrics: {
instanceId,
memoryUsage: 0,
cpuUsage: 0,
requestCount: 0,
errorCount: 0,
uptime: 0
}
};
this.instances.set(instanceId, instance);
this.availableInstances.add(instanceId);
// Set up error handling
browser.on('disconnected', () => {
this.handleBrowserDisconnect(instanceId);
});
this.emit('instanceCreated', { instanceId });
return instance;
} catch (error) {
this.emit('instanceCreationFailed', { instanceId, error });
throw error;
}
}
/**
* Destroy a browser instance and clean up resources
*/
private async destroyInstance(instanceId: string): Promise<void> {
const instance = this.instances.get(instanceId);
if (!instance) {
return;
}
try {
await instance.context.close();
await instance.browser.close();
} catch (error) {
// Ignore cleanup errors during shutdown
}
this.instances.delete(instanceId);
this.availableInstances.delete(instanceId);
this.emit('instanceDestroyed', { instanceId });
}
/**
* Handle browser disconnect events
*/
private handleBrowserDisconnect(instanceId: string): void {
const instance = this.instances.get(instanceId);
const uptime = instance ? (Date.now() - instance.createdAt.getTime()) / 1000 : 0;
// Don't treat disconnections during shutdown as errors
if (this.isShuttingDown) {
// This is expected during shutdown - just clean up
if (instance) {
this.metricsHistory.set(instanceId, {
metrics: {
...instance.metrics,
uptime: (Date.now() - instance.createdAt.getTime()) / 1000
},
disconnectedAt: new Date()
});
}
this.instances.delete(instanceId);
this.availableInstances.delete(instanceId);
this.emit('instanceDisconnected', { instanceId });
return;
}
// Only treat as unexpected error if not shutting down
const error = new Error('Browser instance disconnected unexpectedly');
// Record the failure only for unexpected disconnections
this.errorRecovery.recordFailure(instanceId, error, {
event: 'browser-disconnect',
timestamp: new Date(),
uptime
});
// Store metrics in history before removing the instance
if (instance) {
this.metricsHistory.set(instanceId, {
metrics: {
...instance.metrics,
uptime: (Date.now() - instance.createdAt.getTime()) / 1000
},
disconnectedAt: new Date()
});
}
// Clean up the disconnected instance
this.instances.delete(instanceId);
this.availableInstances.delete(instanceId);
this.emit('instanceDisconnected', { instanceId });
// Attempt automatic restart if conditions are met
this.attemptInstanceRestart(instanceId, error);
// Ensure we maintain minimum pool size
this.ensureMinimumPoolSize();
}
/**
* Ensure the pool maintains minimum instance count
*/
private async ensureMinimumPoolSize(): Promise<void> {
if (this.isShuttingDown) {
return;
}
const currentCount = this.instances.size;
const needed = this.config.minInstances - currentCount;
if (needed > 0) {
const promises = [];
for (let i = 0; i < needed; i++) {
promises.push(this.createBrowserInstance().catch(error => {
// Log error but don't fail the entire operation
this.emit('instanceCreationFailed', { instanceId: `recovery-${Date.now()}`, error });
}));
}
await Promise.all(promises);
}
}
/**
* Attempt to restart a failed browser instance
*/
private async attemptInstanceRestart(instanceId: string, originalError: Error): Promise<void> {
// Check if restart should be attempted
if (!this.errorRecovery.shouldRestartInstance(instanceId) || this.isShuttingDown) {
return;
}
try {
// Create a new instance to replace the failed one
const newInstance = await this.createBrowserInstance();
// Record successful restart
this.errorRecovery.recordRestartAttempt(instanceId, true);
this.emit('instanceRestarted', {
originalInstanceId: instanceId,
newInstanceId: newInstance.id
});
} catch (restartError) {
// Record failed restart attempt
this.errorRecovery.recordRestartAttempt(instanceId, false, restartError as Error);
this.emit('instanceRestartFailed', {
instanceId,
originalError,
restartError: restartError as Error
});
}
}
/**
* Start monitoring resource usage of browser instances
*/
private startResourceMonitoring(): void {
this.resourceMonitorInterval = setInterval(async () => {
await this.updateResourceMetrics();
await this.enforceResourceLimits();
}, 5000); // Monitor every 5 seconds
}
/**
* Update resource metrics for all browser instances
*/
private async updateResourceMetrics(): Promise<void> {
for (const [instanceId, instance] of this.instances) {
try {
// Get memory usage from browser process
const memoryInfo = await this.getBrowserMemoryUsage(instance.browser);
instance.metrics.memoryUsage = memoryInfo;
// CPU usage would require additional system monitoring
// For now, we'll estimate based on activity
instance.metrics.cpuUsage = instance.isActive ? 15 : 5; // Rough estimate
// Check if instance exceeds memory limits
if (memoryInfo > this.config.resourceLimits.maxMemoryPerInstance) {
this.emit('resourceLimitExceeded', {
instanceId,
type: 'memory',
usage: memoryInfo,
limit: this.config.resourceLimits.maxMemoryPerInstance
});
}
} catch (error) {
instance.metrics.errorCount++;
this.emit('metricsUpdateFailed', { instanceId, error });
}
}
}
/**
* Get memory usage for a browser instance
*/
private async getBrowserMemoryUsage(browser: Browser): Promise<number> {
try {
// This is a simplified approach - in production you might want to use
// system monitoring tools or browser CDP for more accurate metrics
const contexts = browser.contexts();
let totalMemory = 50; // Base browser memory estimate in MB
for (const context of contexts) {
const pages = context.pages();
totalMemory += pages.length * 20; // Estimate 20MB per page
}
return totalMemory;
} catch {
return 50; // Return base memory instead of 0
}
}
/**
* Enforce resource limits by destroying instances that exceed limits
*/
private async enforceResourceLimits(): Promise<void> {
const instancesToDestroy: string[] = [];
const instancesToCleanup: string[] = [];
for (const [instanceId, instance] of this.instances) {
const { memoryUsage, cpuUsage } = instance.metrics;
if (memoryUsage > this.config.resourceLimits.maxMemoryPerInstance ||
cpuUsage > this.config.resourceLimits.maxCpuPercentage) {
if (!instance.isActive) {
// If memory usage is critically high, destroy the instance
if (memoryUsage > this.config.resourceLimits.maxMemoryPerInstance * 1.5) {
instancesToDestroy.push(instanceId);
} else {
// Otherwise, try aggressive cleanup first
instancesToCleanup.push(instanceId);
}
} else {
// For active instances, emit warning but don't destroy
this.emit('resourceLimitWarning', {
instanceId,
type: memoryUsage > this.config.resourceLimits.maxMemoryPerInstance ? 'memory' : 'cpu',
usage: memoryUsage > this.config.resourceLimits.maxMemoryPerInstance ? memoryUsage : cpuUsage,
limit: memoryUsage > this.config.resourceLimits.maxMemoryPerInstance ?
this.config.resourceLimits.maxMemoryPerInstance : this.config.resourceLimits.maxCpuPercentage,
isActive: true
});
}
}
}
// Try aggressive cleanup first
for (const instanceId of instancesToCleanup) {
try {
const instance = this.instances.get(instanceId);
if (instance) {
await this.performAggressiveMemoryCleanup(instance);
this.emit('instanceCleanedForResourceLimit', { instanceId });
}
} catch (error) {
// If cleanup fails, add to destroy list
instancesToDestroy.push(instanceId);
}
}
// Destroy instances that exceed limits or failed cleanup
for (const instanceId of instancesToDestroy) {
await this.destroyInstance(instanceId);
this.emit('instanceDestroyedForResourceLimit', { instanceId });
}
}
/**
* Perform comprehensive memory cleanup on browser instance
*/
private async performMemoryCleanup(instance: ManagedBrowserInstance): Promise<void> {
// Navigate to blank page to clear current page resources
await instance.page.goto('about:blank');
// Clear browser context data
await instance.context.clearCookies();
await instance.context.clearPermissions();
// Clear storage data
try {
await instance.page.evaluate(() => {
// Clear localStorage
if (typeof (globalThis as any).localStorage !== 'undefined') {
(globalThis as any).localStorage.clear();
}
// Clear sessionStorage
if (typeof (globalThis as any).sessionStorage !== 'undefined') {
(globalThis as any).sessionStorage.clear();
}
// Clear IndexedDB
if (typeof (globalThis as any).indexedDB !== 'undefined') {
(globalThis as any).indexedDB.databases?.().then((databases: any[]) => {
databases.forEach((db: any) => {
if (db.name) {
(globalThis as any).indexedDB.deleteDatabase(db.name);
}
});
});
}
});
} catch (error) {
// Ignore storage cleanup errors
}
// Reset request count for metrics
instance.metrics.requestCount = 0;
}
/**
* Perform aggressive memory cleanup for instances approaching limits
*/
private async performAggressiveMemoryCleanup(instance: ManagedBrowserInstance): Promise<void> {
// Perform standard cleanup first
await this.performMemoryCleanup(instance);
// Force garbage collection if available
try {
await instance.page.evaluate(() => {
// Force garbage collection in browser context
if (typeof (globalThis as any).window !== 'undefined' && ((globalThis as any).window as any).gc) {
((globalThis as any).window as any).gc();
}
});
} catch (error) {
// Ignore GC errors
}
// Close and recreate the page to free up resources
try {
await instance.page.close();
instance.page = await instance.context.newPage();
await instance.page.goto('about:blank');
} catch (error) {
throw new Error(`Failed to recreate page during aggressive cleanup: ${error}`);
}
}
/**
* Get detailed resource usage statistics
*/
getResourceUsageStats() {
const instances = Array.from(this.instances.values());
const totalMemory = instances.reduce((sum, instance) => sum + instance.metrics.memoryUsage, 0);
const averageMemory = instances.length > 0 ? totalMemory / instances.length : 0;
const totalCpu = instances.reduce((sum, instance) => sum + instance.metrics.cpuUsage, 0);
const averageCpu = instances.length > 0 ? totalCpu / instances.length : 0;
const memoryLimit = this.config.resourceLimits.maxMemoryPerInstance;
const cpuLimit = this.config.resourceLimits.maxCpuPercentage;
const instancesNearMemoryLimit = instances.filter(
instance => instance.metrics.memoryUsage > memoryLimit * 0.8
).length;
const instancesNearCpuLimit = instances.filter(
instance => instance.metrics.cpuUsage > cpuLimit * 0.8
).length;
return {
totalInstances: instances.length,
activeInstances: instances.filter(instance => instance.isActive).length,
totalMemoryUsage: totalMemory,
averageMemoryUsage: averageMemory,
totalCpuUsage: totalCpu,
averageCpuUsage: averageCpu,
memoryUtilization: instances.length > 0 ? (totalMemory / (instances.length * memoryLimit)) * 100 : 0,
cpuUtilization: (averageCpu / cpuLimit) * 100,
instancesNearMemoryLimit,
instancesNearCpuLimit,
resourceLimits: this.config.resourceLimits
};
}
/**
* Force cleanup of idle instances to free resources
*/
async cleanupIdleInstances(maxIdleTime: number = 300000): Promise<number> {
const now = Date.now();
const instancesToCleanup: string[] = [];
// Find idle instances that can be cleaned up
for (const [instanceId, instance] of this.instances) {
if (!instance.isActive &&
(now - instance.lastUsed.getTime()) > maxIdleTime) {
instancesToCleanup.push(instanceId);
}
}
// Only cleanup instances if we have more than minimum
const maxToCleanup = Math.max(0, this.instances.size - this.config.minInstances);
const actualCleanupCount = Math.min(instancesToCleanup.length, maxToCleanup);
// Cleanup idle instances (up to the limit)
for (let i = 0; i < actualCleanupCount; i++) {
const instanceId = instancesToCleanup[i];
await this.destroyInstance(instanceId);
this.emit('instanceCleanedForIdle', { instanceId });
}
return actualCleanupCount;
}
/**
* Initialize localStorage for all configured domains
*/
private async initializeLocalStorage(page: any): Promise<void> {
if (!this.config.localStorage || this.config.localStorage.length === 0) {
return;
}
// Import randomization utility
const { RandomizationUtil } = await import('../utils/randomization');
// Create randomization context with browser instance specific data
const instanceId = `browser-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const randomizationUtil = new RandomizationUtil({
instanceId,
timestamp: Date.now().toString(),
sessionId: `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
// Add any arrays that might be used in randomFrom functions
userIds: ['user_001', 'user_002', 'user_003', 'user_004', 'user_005'],
deviceTypes: ['desktop', 'mobile', 'tablet'],
themes: ['light', 'dark', 'auto'],
languages: ['en', 'es', 'fr', 'de', 'ja'],
currencies: ['USD', 'EUR', 'GBP', 'JPY', 'CAD'],
// Additional arrays for streaming/media applications
videoQualities: ['480p', '720p', '1080p', '4K'],
subscriptionTiers: ['free', 'basic', 'premium', 'enterprise'],
booleans: ['true', 'false'],
playbackSpeeds: ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']
});
for (const localStorageEntry of this.config.localStorage) {
try {
// Navigate to the domain to set localStorage
const domainUrl = localStorageEntry.domain.startsWith('http')
? localStorageEntry.domain
: `https://${localStorageEntry.domain}`;
await page.goto(domainUrl, {
waitUntil: 'domcontentloaded',
timeout: 10000
});
// Process localStorage data with randomization
const processedData = randomizationUtil.processLocalStorageData(localStorageEntry.data);
// Set localStorage items for this domain
await page.evaluate((data: Record<string, string>) => {
Object.entries(data).forEach(([key, value]) => {
(globalThis as any).localStorage.setItem(key, value);
});
}, processedData);
this.emit('localStorageInitialized', {
domain: localStorageEntry.domain,
itemCount: Object.keys(processedData).length,
processedData // Include processed data in event for debugging
});
} catch (error) {
this.emit('localStorageInitializationFailed', {
domain: localStorageEntry.domain,
error
});
// Continue with other domains even if one fails
}
}
// Navigate to about:blank after setting up localStorage
await page.goto('about:blank');
}
}