From fb5f5b39ff1968322d3d48e4b480eaf674880191 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 13 Feb 2026 23:41:07 +0800 Subject: [PATCH 01/47] feat: implement multi-context support with authIndex for WebSocket connections --- scripts/client/build.js | 13 +- src/core/BrowserManager.js | 555 +++++++++++++++++++++++---------- src/core/ConnectionRegistry.js | 139 ++++++--- src/core/ProxyServerSystem.js | 115 ++++--- src/core/RequestHandler.js | 65 ++-- 5 files changed, 609 insertions(+), 278 deletions(-) diff --git a/scripts/client/build.js b/scripts/client/build.js index b1f887f..8e282d0 100644 --- a/scripts/client/build.js +++ b/scripts/client/build.js @@ -99,9 +99,10 @@ const Logger = { class ConnectionManager extends EventTarget { // [BrowserManager Injection Point] Do not modify the line below. // This line is dynamically replaced by BrowserManager.js based on WS_PORT environment variable. - constructor(endpoint = "ws://127.0.0.1:9998") { + constructor(endpoint = "ws://127.0.0.1:9998", authIndex = -1) { super(); this.endpoint = endpoint; + this.authIndex = authIndex; this.socket = null; this.isConnected = false; this.reconnectDelay = 5000; @@ -110,10 +111,12 @@ class ConnectionManager extends EventTarget { async establish() { if (this.isConnected) return Promise.resolve(); - Logger.output("Connecting to server:", this.endpoint); + // Add authIndex to WebSocket URL for server-side identification + const wsUrl = this.authIndex >= 0 ? `${this.endpoint}?authIndex=${this.authIndex}` : this.endpoint; + Logger.output("Connecting to server:", wsUrl); return new Promise((resolve, reject) => { try { - this.socket = new WebSocket(this.endpoint); + this.socket = new WebSocket(wsUrl); this.socket.addEventListener("open", () => { this.isConnected = true; this.reconnectAttempts = 0; @@ -504,9 +507,9 @@ class RequestProcessor { } class ProxySystem extends EventTarget { - constructor(websocketEndpoint) { + constructor(websocketEndpoint, authIndex = -1) { super(); - this.connectionManager = new ConnectionManager(websocketEndpoint); + this.connectionManager = new ConnectionManager(websocketEndpoint, authIndex); this.requestProcessor = new RequestProcessor(); this._setupEventHandlers(); } diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index e436f5c..9fa749f 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -22,8 +22,15 @@ class BrowserManager { this.config = config; this.authSource = authSource; this.browser = null; + + // Multi-context architecture: Store all initialized contexts + // Map: authIndex -> {context, page, healthMonitorInterval, backgroundWakeupRunning} + this.contexts = new Map(); + + // Legacy single context references (for backward compatibility) this.context = null; this.page = null; + // currentAuthIndex is the single source of truth for current account, accessed via getter/setter // -1 means no account is currently active (invalid/error state) this._currentAuthIndex = -1; @@ -107,7 +114,9 @@ class BrowserManager { * @param {number} authIndex - The auth index to update */ async _updateAuthFile(authIndex) { - if (!this.context) return; + // 从多上下文 Map 获取目标账号的 context,避免使用 this.context 导致认证数据串线 + const contextData = this.contexts.get(authIndex); + if (!contextData || !contextData.context) return; // Check availability of auto-update feature from config if (!this.config.enableAuthUpdate) { @@ -128,7 +137,7 @@ class BrowserManager { return; } - const storageState = await this.context.storageState(); + const storageState = await contextData.context.storageState(); // Merge new credentials into existing data authData.cookies = storageState.cookies; @@ -322,10 +331,11 @@ class BrowserManager { /** * Helper: Load and configure build.js script content - * Applies environment-specific configurations (TARGET_DOMAIN, WS_PORT, LOG_LEVEL) + * Applies environment-specific configurations (TARGET_DOMAIN, WS_PORT, LOG_LEVEL, AUTH_INDEX) + * @param {number} authIndex - The auth index to inject into the script * @returns {string} Configured build.js script content */ - _loadAndConfigureBuildScript() { + _loadAndConfigureBuildScript(authIndex = -1) { let buildScriptContent = fs.readFileSync( path.join(__dirname, "..", "..", "scripts", "client", "build.js"), "utf-8" @@ -354,9 +364,9 @@ class BrowserManager { const lines = buildScriptContent.split("\n"); let portReplaced = false; for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('constructor(endpoint = "ws://127.0.0.1:9998")')) { + if (lines[i].includes('constructor(endpoint = "ws://127.0.0.1:9998"')) { this.logger.info(`[Config] Found port config line: ${lines[i]}`); - lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}") {`; + lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}", authIndex = -1) {`; this.logger.info(`[Config] Replaced with: ${lines[i]}`); portReplaced = true; break; @@ -397,6 +407,17 @@ class BrowserManager { } } + // Inject authIndex into ProxySystem initialization + const lines = buildScriptContent.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("const proxySystem = new ProxySystem()")) { + lines[i] = ` const proxySystem = new ProxySystem(undefined, ${authIndex});`; + this.logger.debug(`[Config] Injected authIndex ${authIndex} into ProxySystem initialization`); + buildScriptContent = lines.join("\n"); + break; + } + } + return buildScriptContent; } @@ -406,10 +427,10 @@ class BrowserManager { * @param {string} buildScriptContent - The script content to inject * @param {string} logPrefix - Log prefix for step messages (e.g., "[Browser]" or "[Reconnect]") */ - async _injectScriptToEditor(buildScriptContent, logPrefix = "[Browser]") { + async _injectScriptToEditor(page, buildScriptContent, logPrefix = "[Browser]") { this.logger.info(`${logPrefix} Preparing UI interaction, forcefully removing all possible overlay layers...`); /* eslint-disable no-undef */ - await this.page.evaluate(() => { + await page.evaluate(() => { const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); if (overlays.length > 0) { console.log(`[ProxyClient] (Internal JS) Found and removed ${overlays.length} overlay layers.`); @@ -424,14 +445,14 @@ class BrowserManager { try { this.logger.info(` [Attempt ${i}/${maxTimes}] Cleaning overlay layers and clicking...`); /* eslint-disable no-undef */ - await this.page.evaluate(() => { + await page.evaluate(() => { document.querySelectorAll("div.cdk-overlay-backdrop").forEach(el => el.remove()); }); /* eslint-enable no-undef */ - await this.page.waitForTimeout(500); + await page.waitForTimeout(500); // Use Smart Click instead of hardcoded locator - await this._smartClickCode(this.page); + await this._smartClickCode(page); this.logger.info(" ✅ Click successful!"); break; @@ -446,7 +467,7 @@ class BrowserManager { this.logger.info( `${logPrefix} (Step 2/5) "Code" button clicked successfully, waiting for editor to become visible...` ); - const editorContainerLocator = this.page.locator("div.monaco-editor").first(); + const editorContainerLocator = page.locator("div.monaco-editor").first(); await editorContainerLocator.waitFor({ state: "visible", timeout: 60000, @@ -456,7 +477,7 @@ class BrowserManager { `${logPrefix} (Cleanup #2) Preparing to click editor, forcefully removing all possible overlay layers again...` ); /* eslint-disable no-undef */ - await this.page.evaluate(() => { + await page.evaluate(() => { const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); if (overlays.length > 0) { console.log( @@ -466,26 +487,26 @@ class BrowserManager { } }); /* eslint-enable no-undef */ - await this.page.waitForTimeout(250); + await page.waitForTimeout(250); this.logger.info(`${logPrefix} (Step 3/5) Editor displayed, focusing and pasting script...`); await editorContainerLocator.click({ timeout: 30000 }); /* eslint-disable no-undef */ - await this.page.evaluate(text => navigator.clipboard.writeText(text), buildScriptContent); + await page.evaluate(text => navigator.clipboard.writeText(text), buildScriptContent); /* eslint-enable no-undef */ const isMac = os.platform() === "darwin"; const pasteKey = isMac ? "Meta+V" : "Control+V"; - await this.page.keyboard.press(pasteKey); + await page.keyboard.press(pasteKey); this.logger.info(`${logPrefix} (Step 4/5) Script pasted.`); this.logger.info(`${logPrefix} (Step 5/5) Clicking "Preview" button to activate script...`); - await this.page.locator('button:text("Preview")').click(); + await page.locator('button:text("Preview")').click(); this.logger.info(`${logPrefix} ✅ UI interaction complete, script is now running.`); // Active Trigger (Hack to wake up Google Backend) this.logger.info(`${logPrefix} ⚡ Sending active trigger request to Launch flow...`); try { - await this.page.evaluate(async () => { + await page.evaluate(async () => { try { await fetch("https://generativelanguage.googleapis.com/v1beta/models?key=ActiveTrigger", { headers: { "Content-Type": "application/json" }, @@ -498,20 +519,19 @@ class BrowserManager { } catch (e) { /* empty */ } - - this._startHealthMonitor(); } /** * Helper: Navigate to target page and wake up the page * Contains the common navigation and page activation logic + * @param {Page} page - The page object to navigate * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") */ - async _navigateAndWakeUpPage(logPrefix = "[Browser]") { + async _navigateAndWakeUpPage(page, logPrefix = "[Browser]") { this.logger.info(`${logPrefix} Navigating to target page...`); const targetUrl = "https://aistudio.google.com/u/0/apps/bundled/blank?showPreview=true&showCode=true&showAssistant=true"; - await this.page.goto(targetUrl, { + await page.goto(targetUrl, { timeout: 180000, waitUntil: "domcontentloaded", }); @@ -519,40 +539,41 @@ class BrowserManager { // Wake up window using JS and Human Movement try { - await this.page.bringToFront(); + await page.bringToFront(); // Get viewport size for realistic movement range - const vp = this.page.viewportSize() || { height: 1080, width: 1920 }; + const vp = page.viewportSize() || { height: 1080, width: 1920 }; // 1. Move to a random point to simulate activity const randomX = Math.floor(Math.random() * (vp.width * 0.7)); const randomY = Math.floor(Math.random() * (vp.height * 0.7)); - await this._simulateHumanMovement(this.page, randomX, randomY); + await this._simulateHumanMovement(page, randomX, randomY); // 2. Move to (1,1) specifically for a safe click, using human simulation - await this._simulateHumanMovement(this.page, 1, 1); - await this.page.mouse.down(); - await this.page.waitForTimeout(50 + Math.random() * 100); - await this.page.mouse.up(); + await this._simulateHumanMovement(page, 1, 1); + await page.mouse.down(); + await page.waitForTimeout(50 + Math.random() * 100); + await page.mouse.up(); this.logger.info(`${logPrefix} ✅ Executed realistic page activation (Random -> 1,1 Click).`); } catch (e) { this.logger.warn(`${logPrefix} Wakeup minor error: ${e.message}`); } - await this.page.waitForTimeout(2000 + Math.random() * 2000); + await page.waitForTimeout(2000 + Math.random() * 2000); } /** * Helper: Check page status and detect various error conditions * Detects: cookie expiration, region restrictions, 403 errors, page load failures + * @param {Page} page - The page object to check * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") * @throws {Error} If any error condition is detected */ - async _checkPageStatusAndErrors(logPrefix = "[Browser]") { - const currentUrl = this.page.url(); + async _checkPageStatusAndErrors(page, logPrefix = "[Browser]") { + const currentUrl = page.url(); let pageTitle = ""; try { - pageTitle = await this.page.title(); + pageTitle = await page.title(); } catch (e) { this.logger.warn(`${logPrefix} Unable to get page title: ${e.message}`); } @@ -590,9 +611,10 @@ class BrowserManager { /** * Helper: Handle various popups with intelligent detection * Uses short polling instead of long hard-coded timeouts + * @param {Page} page - The page object to check for popups * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") */ - async _handlePopups(logPrefix = "[Browser]") { + async _handlePopups(page, logPrefix = "[Browser]") { this.logger.info(`${logPrefix} 🔍 Starting intelligent popup detection (max 6s)...`); const popupConfigs = [ @@ -630,7 +652,7 @@ class BrowserManager { if (handledPopups.has(popup.name)) continue; try { - const element = this.page.locator(popup.selector).first(); + const element = page.locator(popup.selector).first(); // Quick visibility check with very short timeout if (await element.isVisible({ timeout: 200 })) { this.logger.info(popup.logFound); @@ -638,7 +660,7 @@ class BrowserManager { handledPopups.add(popup.name); foundAny = true; // Short pause after clicking to let next popup appear - await this.page.waitForTimeout(800); + await page.waitForTimeout(800); } } catch (error) { // Element not visible or doesn't exist is expected here, @@ -680,7 +702,7 @@ class BrowserManager { } if (i < maxIterations - 1) { - await this.page.waitForTimeout(pollInterval); + await page.waitForTimeout(pollInterval); } } } @@ -688,20 +710,37 @@ class BrowserManager { /** * Feature: Background Health Monitor (The "Scavenger") * Periodically cleans up popups and keeps the session alive. + * In multi-context mode, stores the interval in the context data. */ _startHealthMonitor() { + const authIndex = this._currentAuthIndex; + if (authIndex < 0) { + this.logger.warn("[Browser] Cannot start health monitor: no active auth index"); + return; + } + + // Get context data + const contextData = this.contexts.get(authIndex); + if (!contextData) { + this.logger.warn(`[Browser] Cannot start health monitor: context #${authIndex} not found`); + return; + } + // Clear existing interval if any - if (this.healthMonitorInterval) clearInterval(this.healthMonitorInterval); + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + } - this.logger.info("[Browser] 🛡️ Background health monitor service (Scavenger) started..."); + this.logger.info(`[Context#${authIndex}] 🛡️ Background health monitor service (Scavenger) started...`); let tickCount = 0; // Run every 4 seconds - this.healthMonitorInterval = setInterval(async () => { - const page = this.page; + contextData.healthMonitorInterval = setInterval(async () => { + const page = contextData.page; if (!page || page.isClosed()) { - clearInterval(this.healthMonitorInterval); + clearInterval(contextData.healthMonitorInterval); + contextData.healthMonitorInterval = null; return; } @@ -740,13 +779,13 @@ class BrowserManager { // 3. Auto-Save Auth: Every ~24 hours (21600 ticks * 4s = 86400s) if (tickCount % 21600 === 0) { - if (this._currentAuthIndex >= 0) { - try { - this.logger.info("[HealthMonitor] 💾 Triggering daily periodic auth file update..."); - await this._updateAuthFile(this._currentAuthIndex); - } catch (e) { - this.logger.warn(`[HealthMonitor] Auth update failed: ${e.message}`); - } + try { + this.logger.info( + `[HealthMonitor#${authIndex}] 💾 Triggering daily periodic auth file update...` + ); + await this._updateAuthFile(authIndex); + } catch (e) { + this.logger.warn(`[HealthMonitor#${authIndex}] Auth update failed: ${e.message}`); } } @@ -793,20 +832,30 @@ class BrowserManager { /** * Helper: Save debug information (screenshot and HTML) to root directory + * @param {string} suffix - Suffix for the debug file names + * @param {number} [authIndex] - Optional auth index to get the correct page from contexts Map */ - async _saveDebugArtifacts(suffix = "final") { - if (!this.page || this.page.isClosed()) return; + async _saveDebugArtifacts(suffix = "final", authIndex = null) { + // 优先从 contexts Map 获取指定账号的 page,回退到 this.page + let targetPage = this.page; + if (authIndex !== null && this.contexts.has(authIndex)) { + const ctxData = this.contexts.get(authIndex); + if (ctxData && ctxData.page) { + targetPage = ctxData.page; + } + } + if (!targetPage || targetPage.isClosed()) return; try { const timestamp = new Date().toISOString().replace(/[:.]/g, "-").substring(0, 19); const screenshotPath = path.join(process.cwd(), `debug_screenshot_${suffix}_${timestamp}.png`); - await this.page.screenshot({ + await targetPage.screenshot({ fullPage: true, path: screenshotPath, }); this.logger.info(`[Debug] Failure screenshot saved to: ${screenshotPath}`); const htmlPath = path.join(process.cwd(), `debug_page_source_${suffix}_${timestamp}.html`); - const htmlContent = await this.page.content(); + const htmlContent = await targetPage.content(); fs.writeFileSync(htmlPath, htmlContent); this.logger.info(`[Debug] Failure page source saved to: ${htmlPath}`); } catch (e) { @@ -1024,146 +1073,266 @@ class BrowserManager { return { browser: vncBrowser, context }; } - async launchOrSwitchContext(authIndex) { - if (typeof authIndex !== "number" || authIndex < 0) { - this.logger.error(`[Browser] Invalid authIndex: ${authIndex}. authIndex must be >= 0.`); - this._currentAuthIndex = -1; - throw new Error(`Invalid authIndex: ${authIndex}. Must be >= 0.`); - } - - // [Auth Switch] Save current auth data before switching - if (this.browser && this._currentAuthIndex >= 0) { - try { - await this._updateAuthFile(this._currentAuthIndex); - } catch (e) { - this.logger.warn(`[Browser] Failed to save current auth during switch: ${e.message}`); - } - } + /** + * Preload all available auth contexts at startup + * This method initializes all contexts in parallel and keeps them ready for instant switching + * @returns {Promise<{successful: number[], failed: Array<{index: number, error: string}>}>} + */ + async preloadAllContexts() { + this.logger.info("=================================================="); + this.logger.info("🚀 [MultiContext] Starting preload of all auth contexts..."); + this.logger.info("=================================================="); + // Launch browser if not already running const proxyConfig = parseProxyFromEnv(); - if (proxyConfig) { - this.logger.info(`[Browser] 🌐 Using proxy: ${proxyConfig.server}`); - } - if (!this.browser) { - this.logger.info("🚀 [Browser] Main browser instance not running, performing first-time launch..."); + this.logger.info("🚀 [Browser] Launching main browser instance..."); if (!fs.existsSync(this.browserExecutablePath)) { - this._currentAuthIndex = -1; throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); } this.browser = await firefox.launch({ args: this.launchArgs, executablePath: this.browserExecutablePath, firefoxUserPrefs: this.firefoxUserPrefs, - headless: true, // Main browser is always headless + headless: true, ...(proxyConfig ? { proxy: proxyConfig } : {}), }); this.browser.on("disconnected", () => { this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); this.browser = null; + this.contexts.clear(); this.context = null; this.page = null; this._currentAuthIndex = -1; - this.logger.warn("[Browser] Reset currentAuthIndex to -1 due to unexpected disconnect."); }); - this.logger.info("✅ [Browser] Main browser instance successfully launched."); + this.logger.info("✅ [Browser] Main browser instance launched successfully."); } - if (this.healthMonitorInterval) { - clearInterval(this.healthMonitorInterval); - this.healthMonitorInterval = null; - this.logger.info("[Browser] Stopped background tasks (Scavenger) for old page."); + // Get all available auth indices + const allAuthIndices = this.authSource.availableIndices; + if (allAuthIndices.length === 0) { + this.logger.warn("[MultiContext] No auth files found, skipping preload."); + return { failed: [], successful: [] }; } - if (this.context) { - this.logger.info("[Browser] Closing old API browser context..."); - const closePromise = this.context.close(); - const timeoutPromise = new Promise(r => setTimeout(r, 5000)); // 5秒超时 - await Promise.race([closePromise, timeoutPromise]); - this.context = null; - this.page = null; - this.logger.info("[Browser] Old API context closed."); + this.logger.info( + `[MultiContext] Found ${allAuthIndices.length} auth files to preload: [${allAuthIndices.join(", ")}]` + ); + + const successful = []; + const failed = []; + + // Preload contexts sequentially to avoid overwhelming the system + for (const authIndex of allAuthIndices) { + try { + this.logger.info(`[MultiContext] Preloading context for account #${authIndex}...`); + await this._initializeContext(authIndex); + successful.push(authIndex); + this.logger.info(`✅ [MultiContext] Account #${authIndex} context preloaded successfully.`); + } catch (error) { + this.logger.error(`❌ [MultiContext] Failed to preload account #${authIndex}: ${error.message}`); + failed.push({ error: error.message, index: authIndex }); + } } - const sourceDescription = `File auth-${authIndex}.json`; this.logger.info("=================================================="); - this.logger.info(`🔄 [Browser] Creating new API browser context for account #${authIndex}`); - this.logger.info(` • Auth source: ${sourceDescription}`); + this.logger.info( + `✅ [MultiContext] Preload complete: ${successful.length} successful, ${failed.length} failed` + ); + if (successful.length > 0) { + this.logger.info(` • Successful: [${successful.join(", ")}]`); + } + if (failed.length > 0) { + this.logger.info(` • Failed: [${failed.map(f => f.index).join(", ")}]`); + } this.logger.info("=================================================="); + return { failed, successful }; + } + + /** + * Initialize a single context for the given auth index + * This is a helper method used by both preloadAllContexts and launchOrSwitchContext + * @param {number} authIndex - The auth index to initialize + * @returns {Promise<{context, page}>} + */ + async _initializeContext(authIndex) { + const proxyConfig = parseProxyFromEnv(); const storageStateObject = this.authSource.getAuth(authIndex); if (!storageStateObject) { throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); } - const buildScriptContent = this._loadAndConfigureBuildScript(); + const buildScriptContent = this._loadAndConfigureBuildScript(authIndex); + + // Viewport Randomization + const randomWidth = 1920 + Math.floor(Math.random() * 50); + const randomHeight = 1080 + Math.floor(Math.random() * 50); + + const context = await this.browser.newContext({ + deviceScaleFactor: 1, + storageState: storageStateObject, + viewport: { height: randomHeight, width: randomWidth }, + ...(proxyConfig ? { proxy: proxyConfig } : {}), + }); + + // Inject Privacy Script immediately after context creation + const privacyScript = this._getPrivacyProtectionScript(authIndex); + await context.addInitScript(privacyScript); + const page = await context.newPage(); + + // Pure JS Wakeup (Focus & Click) try { - // Viewport Randomization - const randomWidth = 1920 + Math.floor(Math.random() * 50); - const randomHeight = 1080 + Math.floor(Math.random() * 50); - - this.context = await this.browser.newContext({ - deviceScaleFactor: 1, - storageState: storageStateObject, - viewport: { height: randomHeight, width: randomWidth }, - ...(proxyConfig ? { proxy: proxyConfig } : {}), - }); + await page.bringToFront(); + // eslint-disable-next-line no-undef + await page.evaluate(() => window.focus()); + const vp = page.viewportSize() || { height: 1080, width: 1920 }; + const startX = Math.floor(Math.random() * (vp.width * 0.5)); + const startY = Math.floor(Math.random() * (vp.height * 0.5)); + await this._simulateHumanMovement(page, startX, startY); + await page.mouse.down(); + await page.waitForTimeout(100); + await page.mouse.up(); + } catch (e) { + this.logger.warn(`[Context#${authIndex}] Wakeup minor error: ${e.message}`); + } + + page.on("console", msg => { + const msgText = msg.text(); + if (msgText.includes("Content-Security-Policy")) { + return; + } + if (msgText.includes("[ProxyClient]")) { + this.logger.info(`[Context#${authIndex}] ${msgText.replace("[ProxyClient] ", "")}`); + } else if (msg.type() === "error") { + this.logger.error(`[Context#${authIndex} Page Error] ${msgText}`); + } + }); - // Inject Privacy Script immediately after context creation - const privacyScript = this._getPrivacyProtectionScript(authIndex); - await this.context.addInitScript(privacyScript); + await this._navigateAndWakeUpPage(page, `[Context#${authIndex}]`); + await this._checkPageStatusAndErrors(page, `[Context#${authIndex}]`); + await this._handlePopups(page, `[Context#${authIndex}]`); + await this._injectScriptToEditor(page, buildScriptContent, `[Context#${authIndex}]`); + + // Save to contexts map + this.contexts.set(authIndex, { + backgroundWakeupRunning: false, + context, + healthMonitorInterval: null, + page, + }); + + // Update auth file + await this._updateAuthFile(authIndex); - this.page = await this.context.newPage(); + return { context, page }; + } - // Pure JS Wakeup (Focus & Click) + async launchOrSwitchContext(authIndex) { + if (typeof authIndex !== "number" || authIndex < 0) { + this.logger.error(`[Browser] Invalid authIndex: ${authIndex}. authIndex must be >= 0.`); + this._currentAuthIndex = -1; + throw new Error(`Invalid authIndex: ${authIndex}. Must be >= 0.`); + } + + // [Auth Switch] Save current auth data before switching + if (this.browser && this._currentAuthIndex >= 0 && this._currentAuthIndex !== authIndex) { try { - await this.page.bringToFront(); - // eslint-disable-next-line no-undef - await this.page.evaluate(() => window.focus()); - // Get viewport size for realistic movement range - const vp = this.page.viewportSize() || { height: 1080, width: 1920 }; - const startX = Math.floor(Math.random() * (vp.width * 0.5)); - const startY = Math.floor(Math.random() * (vp.height * 0.5)); - await this._simulateHumanMovement(this.page, startX, startY); - await this.page.mouse.down(); - await this.page.waitForTimeout(100); - await this.page.mouse.up(); - this.logger.info("[Browser] ⚡ Forced window wake-up via JS focus."); + await this._updateAuthFile(this._currentAuthIndex); } catch (e) { - this.logger.warn(`[Browser] Wakeup minor error: ${e.message}`); + this.logger.warn(`[Browser] Failed to save current auth during switch: ${e.message}`); } + } - this.page.on("console", msg => { - const msgText = msg.text(); - if (msgText.includes("Content-Security-Policy")) { - return; - } + // Check if browser is running, launch if needed + if (!this.browser) { + const proxyConfig = parseProxyFromEnv(); + this.logger.info("🚀 [Browser] Main browser instance not running, performing first-time launch..."); + if (!fs.existsSync(this.browserExecutablePath)) { + this._currentAuthIndex = -1; + throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); + } + this.browser = await firefox.launch({ + args: this.launchArgs, + executablePath: this.browserExecutablePath, + firefoxUserPrefs: this.firefoxUserPrefs, + headless: true, + ...(proxyConfig ? { proxy: proxyConfig } : {}), + }); + this.browser.on("disconnected", () => { + this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); + this.browser = null; + this.contexts.clear(); + this.context = null; + this.page = null; + this._currentAuthIndex = -1; + }); + this.logger.info("✅ [Browser] Main browser instance successfully launched."); + } + + // Check if context already exists (fast switch path) + if (this.contexts.has(authIndex)) { + this.logger.info("=================================================="); + this.logger.info(`⚡ [FastSwitch] Switching to pre-loaded context for account #${authIndex}`); + this.logger.info("=================================================="); - if (msgText.includes("[ProxyClient]")) { - this.logger.info(`[Browser] ${msgText.replace("[ProxyClient] ", "")}`); - } else if (msg.type() === "error") { - this.logger.error(`[Browser Page Error] ${msgText}`); + // Stop background tasks for old context + if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { + const oldContextData = this.contexts.get(this._currentAuthIndex); + if (oldContextData.healthMonitorInterval) { + clearInterval(oldContextData.healthMonitorInterval); + oldContextData.healthMonitorInterval = null; } - }); + } - await this._navigateAndWakeUpPage("[Browser]"); + // Switch to new context + const contextData = this.contexts.get(authIndex); + this.context = contextData.context; + this.page = contextData.page; + this._currentAuthIndex = authIndex; - // Check for cookie expiration, region restrictions, and other errors - await this._checkPageStatusAndErrors("[Browser]"); + // Start background tasks for new context + this._startHealthMonitor(); + if (!contextData.backgroundWakeupRunning) { + this._startBackgroundWakeup(); + contextData.backgroundWakeupRunning = true; + } - // Handle various popups (Cookie consent, Got it, Onboarding, etc.) - await this._handlePopups("[Browser]"); + this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); + return; + } - await this._injectScriptToEditor(buildScriptContent, "[Browser]"); + // Context doesn't exist, need to initialize it (slow path) + this.logger.info("=================================================="); + this.logger.info(`🔄 [Browser] Context for account #${authIndex} not found, initializing...`); + this.logger.info("=================================================="); - // Start background wakeup service - only started here during initial browser launch - this._startBackgroundWakeup(); + try { + // Stop background tasks for old context + if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { + const oldContextData = this.contexts.get(this._currentAuthIndex); + if (oldContextData.healthMonitorInterval) { + clearInterval(oldContextData.healthMonitorInterval); + oldContextData.healthMonitorInterval = null; + } + } + + // Initialize new context + const { context, page } = await this._initializeContext(authIndex); + // Update current references + this.context = context; + this.page = page; this._currentAuthIndex = authIndex; - // [Auth Update] Save the refreshed cookies to the auth file immediately - await this._updateAuthFile(authIndex); + // Start background tasks + this._startHealthMonitor(); + this._startBackgroundWakeup(); + const contextData = this.contexts.get(authIndex); + if (contextData) { + contextData.backgroundWakeupRunning = true; + } this.logger.info("=================================================="); this.logger.info(`✅ [Browser] Account ${authIndex} context initialized successfully!`); @@ -1171,8 +1340,7 @@ class BrowserManager { this.logger.info("=================================================="); } catch (error) { this.logger.error(`❌ [Browser] Account ${authIndex} context initialization failed: ${error.message}`); - await this._saveDebugArtifacts("init_failed"); - await this.closeBrowser(); + await this._saveDebugArtifacts("init_failed", authIndex); this._currentAuthIndex = -1; throw error; } @@ -1187,63 +1355,97 @@ class BrowserManager { * * @returns {Promise} true if reconnect was successful, false otherwise */ - async attemptLightweightReconnect() { - // Verify browser and page are still valid - if (!this.browser || !this.page) { - this.logger.warn("[Reconnect] Browser or page is not available, cannot perform lightweight reconnect."); + /** + * Attempt lightweight reconnect for a specific account + * Refreshes the page and re-injects the proxy script without restarting the browser + * @param {number} authIndex - The auth index to reconnect (defaults to current if not specified) + * @returns {Promise} true if reconnect was successful, false otherwise + */ + async attemptLightweightReconnect(authIndex = null) { + // Use provided authIndex or fall back to current + const targetAuthIndex = authIndex !== null ? authIndex : this._currentAuthIndex; + + if (targetAuthIndex < 0) { + this.logger.warn("[Reconnect] Invalid auth index, cannot perform lightweight reconnect."); return false; } - // Check if page is closed - if (this.page.isClosed()) { - this.logger.warn("[Reconnect] Page is closed, cannot perform lightweight reconnect."); + // Get the context data for this account + const contextData = this.contexts.get(targetAuthIndex); + if (!contextData || !contextData.page) { + this.logger.warn( + `[Reconnect] No context found for account #${targetAuthIndex}, cannot perform lightweight reconnect.` + ); return false; } - const authIndex = this._currentAuthIndex; - if (authIndex < 0) { - this.logger.warn("[Reconnect] No current auth index, cannot perform lightweight reconnect."); + const page = contextData.page; + + // Verify browser and page are still valid + if (!this.browser || !page) { + this.logger.warn( + `[Reconnect] Browser or page is not available for account #${targetAuthIndex}, cannot perform lightweight reconnect.` + ); + return false; + } + + // Check if page is closed + if (page.isClosed()) { + this.logger.warn( + `[Reconnect] Page is closed for account #${targetAuthIndex}, cannot perform lightweight reconnect.` + ); return false; } this.logger.info("=================================================="); - this.logger.info(`🔄 [Reconnect] Starting lightweight reconnect for account #${authIndex}...`); + this.logger.info(`🔄 [Reconnect] Starting lightweight reconnect for account #${targetAuthIndex}...`); this.logger.info("=================================================="); - // Stop existing background tasks - if (this.healthMonitorInterval) { - clearInterval(this.healthMonitorInterval); - this.healthMonitorInterval = null; - this.logger.info("[Reconnect] Stopped background health monitor."); + // Stop existing background tasks only if this is the current account + const isCurrentAccount = targetAuthIndex === this._currentAuthIndex; + if (isCurrentAccount) { + const ctxData = this.contexts.get(targetAuthIndex); + if (ctxData && ctxData.healthMonitorInterval) { + clearInterval(ctxData.healthMonitorInterval); + ctxData.healthMonitorInterval = null; + this.logger.info("[Reconnect] Stopped background health monitor."); + } } try { // Load and configure the build.js script using the shared helper - const buildScriptContent = this._loadAndConfigureBuildScript(); + const buildScriptContent = this._loadAndConfigureBuildScript(targetAuthIndex); // Navigate to target page and wake it up - await this._navigateAndWakeUpPage("[Reconnect]"); + await this._navigateAndWakeUpPage(page, "[Reconnect]"); // Check for cookie expiration, region restrictions, and other errors - await this._checkPageStatusAndErrors("[Reconnect]"); + await this._checkPageStatusAndErrors(page, "[Reconnect]"); // Handle various popups (Cookie consent, Got it, Onboarding, etc.) - await this._handlePopups("[Reconnect]"); + await this._handlePopups(page, "[Reconnect]"); // Use shared script injection helper with [Reconnect] log prefix - await this._injectScriptToEditor(buildScriptContent, "[Reconnect]"); + await this._injectScriptToEditor(page, buildScriptContent, "[Reconnect]"); // [Auth Update] Save the refreshed cookies to the auth file immediately - await this._updateAuthFile(authIndex); + await this._updateAuthFile(targetAuthIndex); this.logger.info("=================================================="); - this.logger.info(`✅ [Reconnect] Lightweight reconnect successful for account #${authIndex}!`); + this.logger.info(`✅ [Reconnect] Lightweight reconnect successful for account #${targetAuthIndex}!`); this.logger.info("=================================================="); + // Restart background tasks only if this is the current account + if (isCurrentAccount) { + this._startHealthMonitor(); + } + return true; } catch (error) { - this.logger.error(`❌ [Reconnect] Lightweight reconnect failed: ${error.message}`); - await this._saveDebugArtifacts("reconnect_failed"); + this.logger.error( + `❌ [Reconnect] Lightweight reconnect failed for account #${targetAuthIndex}: ${error.message}` + ); + await this._saveDebugArtifacts("reconnect_failed", targetAuthIndex); return false; } } @@ -1251,18 +1453,30 @@ class BrowserManager { /** * Unified cleanup method for the main browser instance. * Handles intervals, timeouts, and resetting all references. + * In multi-context mode, cleans up all contexts. */ async closeBrowser() { // Set flag to indicate intentional close - prevents ConnectionRegistry from // attempting lightweight reconnect when WebSocket disconnects this.isClosingIntentionally = true; + // Clean up all context health monitors + for (const [authIndex, contextData] of this.contexts.entries()) { + if (contextData.healthMonitorInterval) { + clearInterval(contextData.healthMonitorInterval); + contextData.healthMonitorInterval = null; + this.logger.info(`[Browser] Stopped health monitor for context #${authIndex}`); + } + } + + // Legacy single health monitor cleanup (for backward compatibility) if (this.healthMonitorInterval) { clearInterval(this.healthMonitorInterval); this.healthMonitorInterval = null; } + if (this.browser) { - this.logger.info("[Browser] Closing main browser instance..."); + this.logger.info("[Browser] Closing main browser instance and all contexts..."); try { // Give close() 5 seconds, otherwise force proceed await Promise.race([this.browser.close(), new Promise(resolve => setTimeout(resolve, 5000))]); @@ -1272,10 +1486,11 @@ class BrowserManager { // Reset all references this.browser = null; + this.contexts.clear(); this.context = null; this.page = null; this._currentAuthIndex = -1; - this.logger.info("[Browser] Main browser instance closed, currentAuthIndex reset to -1."); + this.logger.info("[Browser] Main browser instance and all contexts closed, currentAuthIndex reset to -1."); } // Reset flag after close is complete diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 4aa95e5..9fa160c 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -16,27 +16,49 @@ class ConnectionRegistry extends EventEmitter { /** * @param {Object} logger - Logger instance * @param {Function} [onConnectionLostCallback] - Optional callback to invoke when connection is lost after grace period + * @param {Function} [getCurrentAuthIndex] - Function to get current auth index */ - constructor(logger, onConnectionLostCallback = null) { + constructor(logger, onConnectionLostCallback = null, getCurrentAuthIndex = null) { super(); this.logger = logger; this.onConnectionLostCallback = onConnectionLostCallback; - this.connections = new Set(); + this.getCurrentAuthIndex = getCurrentAuthIndex; + // Map: authIndex -> WebSocket connection + this.connectionsByAuth = new Map(); this.messageQueues = new Map(); - this.reconnectGraceTimer = null; - this.isReconnecting = false; // Flag to prevent multiple simultaneous reconnect attempts + // Map: authIndex -> timerId, supports independent grace period for each account + this.reconnectGraceTimers = new Map(); + // Map: authIndex -> boolean, supports independent reconnect status for each account + this.reconnectingAccounts = new Map(); } addConnection(websocket, clientInfo) { - if (this.reconnectGraceTimer) { - clearTimeout(this.reconnectGraceTimer); - this.reconnectGraceTimer = null; - this.messageQueues.forEach(queue => queue.close()); - this.messageQueues.clear(); + // Clear grace timer for this specific authIndex only, without affecting other accounts + const incomingAuthIndex = clientInfo.authIndex; + if ( + incomingAuthIndex !== undefined && + incomingAuthIndex >= 0 && + this.reconnectGraceTimers.has(incomingAuthIndex) + ) { + clearTimeout(this.reconnectGraceTimers.get(incomingAuthIndex)); + this.reconnectGraceTimers.delete(incomingAuthIndex); + this.logger.info(`[Server] Grace timer cleared for reconnected authIndex=${incomingAuthIndex}`); } - this.connections.add(websocket); - this.logger.info(`[Server] Internal WebSocket client connected (from: ${clientInfo.address})`); + // Store connection by authIndex if provided + const authIndex = clientInfo.authIndex; + if (authIndex !== undefined && authIndex >= 0) { + this.connectionsByAuth.set(authIndex, websocket); + this.logger.info( + `[Server] Internal WebSocket client connected (from: ${clientInfo.address}, authIndex: ${authIndex})` + ); + } else { + this.logger.info(`[Server] Internal WebSocket client connected (from: ${clientInfo.address})`); + } + + // Store authIndex on websocket for cleanup + websocket._authIndex = authIndex; + websocket.on("message", data => this._handleIncomingMessage(data.toString())); websocket.on("close", () => this._removeConnection(websocket)); websocket.on("error", error => @@ -46,33 +68,57 @@ class ConnectionRegistry extends EventEmitter { } _removeConnection(websocket) { - this.connections.delete(websocket); - this.logger.info("[Server] Internal WebSocket client disconnected."); + const disconnectedAuthIndex = websocket._authIndex; + + // Remove from connectionsByAuth if it has an authIndex + if (disconnectedAuthIndex !== undefined && disconnectedAuthIndex >= 0) { + this.connectionsByAuth.delete(disconnectedAuthIndex); + this.logger.info(`[Server] Internal WebSocket client disconnected (authIndex: ${disconnectedAuthIndex}).`); + } else { + this.logger.info("[Server] Internal WebSocket client disconnected."); + } - // Clear any existing grace timer before starting a new one - // This prevents multiple timers from running if connections disconnect in quick succession - if (this.reconnectGraceTimer) { - clearTimeout(this.reconnectGraceTimer); + // Clear any existing grace timer for THIS account before starting a new one + if ( + disconnectedAuthIndex !== undefined && + disconnectedAuthIndex >= 0 && + this.reconnectGraceTimers.has(disconnectedAuthIndex) + ) { + clearTimeout(this.reconnectGraceTimers.get(disconnectedAuthIndex)); } - this.logger.info("[Server] Starting 5-second reconnect grace period..."); - this.reconnectGraceTimer = setTimeout(async () => { + this.logger.info(`[Server] Starting 5-second reconnect grace period for account #${disconnectedAuthIndex}...`); + const graceTimerId = setTimeout(async () => { this.logger.info( - "[Server] Grace period ended, no reconnection detected. Connection lost confirmed, cleaning up all pending requests..." + `[Server] Grace period ended for account #${disconnectedAuthIndex}, no reconnection detected.` ); - this.messageQueues.forEach(queue => queue.close()); - this.messageQueues.clear(); - // Attempt lightweight reconnect if callback is provided and not already reconnecting - if (this.onConnectionLostCallback && !this.isReconnecting) { - this.isReconnecting = true; + // Re-check if this is the current account at the time of grace period expiry + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + const isCurrentAccount = disconnectedAuthIndex === currentAuthIndex; + + // Only clear message queues if this is the current account + if (isCurrentAccount) { + this.logger.info("[Server] Current account disconnected, cleaning up all pending requests..."); + this.messageQueues.forEach(queue => queue.close()); + this.messageQueues.clear(); + } else { + this.logger.info( + `[Server] Non-current account #${disconnectedAuthIndex} disconnected, keeping message queues intact.` + ); + } + + // Attempt lightweight reconnect if callback is provided and this account is not already reconnecting + const isAccountReconnecting = this.reconnectingAccounts.get(disconnectedAuthIndex) || false; + if (this.onConnectionLostCallback && !isAccountReconnecting) { + this.reconnectingAccounts.set(disconnectedAuthIndex, true); const lightweightReconnectTimeoutMs = 55000; this.logger.info( - `[Server] Attempting lightweight reconnect (timeout ${lightweightReconnectTimeoutMs / 1000}s)...` + `[Server] Attempting lightweight reconnect for account #${disconnectedAuthIndex} (timeout ${lightweightReconnectTimeoutMs / 1000}s)...` ); let timeoutId; try { - const callbackPromise = this.onConnectionLostCallback(); + const callbackPromise = this.onConnectionLostCallback(disconnectedAuthIndex); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error("Lightweight reconnect timed out")), @@ -80,22 +126,30 @@ class ConnectionRegistry extends EventEmitter { ); }); await Promise.race([callbackPromise, timeoutPromise]); - this.logger.info("[Server] Lightweight reconnect callback completed."); + this.logger.info( + `[Server] Lightweight reconnect callback completed for account #${disconnectedAuthIndex}.` + ); } catch (error) { - this.logger.error(`[Server] Lightweight reconnect failed: ${error.message}`); + this.logger.error( + `[Server] Lightweight reconnect failed for account #${disconnectedAuthIndex}: ${error.message}` + ); } finally { if (timeoutId) { clearTimeout(timeoutId); } - this.isReconnecting = false; + this.reconnectingAccounts.delete(disconnectedAuthIndex); } } this.emit("connectionLost"); - this.reconnectGraceTimer = null; + this.reconnectGraceTimers.delete(disconnectedAuthIndex); }, 5000); + if (disconnectedAuthIndex !== undefined && disconnectedAuthIndex >= 0) { + this.reconnectGraceTimers.set(disconnectedAuthIndex, graceTimerId); + } + this.emit("connectionRemoved", websocket); } @@ -134,20 +188,27 @@ class ConnectionRegistry extends EventEmitter { } } - hasActiveConnections() { - return this.connections.size > 0; - } - isReconnectingInProgress() { - return this.isReconnecting; + // Check if any account is currently reconnecting + return this.reconnectingAccounts.size > 0; } isInGracePeriod() { - return !!this.reconnectGraceTimer; + // Only check if current account is in grace period, to avoid non-current account disconnection affecting current account's request handling + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + return currentAuthIndex >= 0 && this.reconnectGraceTimers.has(currentAuthIndex); } - getFirstConnection() { - return this.connections.values().next().value; + getConnectionByAuth(authIndex) { + const connection = this.connectionsByAuth.get(authIndex); + if (connection) { + this.logger.debug(`[Registry] Found WebSocket connection for authIndex=${authIndex}`); + } else { + this.logger.warn( + `[Registry] No WebSocket connection found for authIndex=${authIndex}. Available: [${Array.from(this.connectionsByAuth.keys()).join(", ")}]` + ); + } + return connection; } createMessageQueue(requestId) { diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index eef87d7..327a186 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -44,34 +44,52 @@ class ProxyServerSystem extends EventEmitter { // Create ConnectionRegistry with lightweight reconnect callback // When WebSocket connection is lost but browser is still running, // this callback attempts to refresh the page and re-inject the script - this.connectionRegistry = new ConnectionRegistry(this.logger, async () => { - // Skip if browser is being intentionally closed (not an unexpected disconnect) - if (this.browserManager.isClosingIntentionally) { - this.logger.info("[System] Browser is closing intentionally, skipping reconnect attempt."); - return; - } - // Skip if the system is busy switching/recovering to avoid conflicting refreshes - if (this.requestHandler?.isSystemBusy) { - this.logger.info( - "[System] System is busy (switching/recovering), skipping lightweight reconnect attempt." - ); - return; - } + this.connectionRegistry = new ConnectionRegistry( + this.logger, + async authIndex => { + // Skip if browser is being intentionally closed (not an unexpected disconnect) + if (this.browserManager.isClosingIntentionally) { + this.logger.info("[System] Browser is closing intentionally, skipping reconnect attempt."); + return; + } - if (this.browserManager.browser && this.browserManager.page && !this.browserManager.page.isClosed()) { - this.logger.error( - "[System] WebSocket lost but browser still running, attempting lightweight reconnect..." - ); - const success = await this.browserManager.attemptLightweightReconnect(); - if (!success) { - this.logger.warn( - "[System] Lightweight reconnect failed. Will attempt full recovery on next request." + // Check if this is the current account + const currentAuthIndex = this.browserManager.currentAuthIndex; + const isCurrentAccount = authIndex === currentAuthIndex; + + // Only check isSystemBusy if this is the current account + if (isCurrentAccount && this.requestHandler?.isSystemBusy) { + this.logger.info( + `[System] Current account #${authIndex} is busy (switching/recovering), skipping lightweight reconnect attempt.` ); + return; } - } else { - this.logger.info("[System] Browser not available, skipping lightweight reconnect."); - } - }); + + // Get the context and page for this specific account + const contextData = this.browserManager.contexts.get(authIndex); + if (!contextData || !contextData.page || contextData.page.isClosed()) { + this.logger.info( + `[System] Account #${authIndex} page not available or closed, skipping lightweight reconnect.` + ); + return; + } + + if (this.browserManager.browser) { + this.logger.error( + `[System] WebSocket lost for account #${authIndex} but browser still running, attempting lightweight reconnect...` + ); + const success = await this.browserManager.attemptLightweightReconnect(authIndex); + if (!success) { + this.logger.warn( + `[System] Lightweight reconnect failed for account #${authIndex}. Will attempt full recovery on next request.` + ); + } + } else { + this.logger.info("[System] Browser not available, skipping lightweight reconnect."); + } + }, + () => this.browserManager.currentAuthIndex + ); this.requestHandler = new RequestHandler( this, this.connectionRegistry, @@ -101,6 +119,28 @@ class ProxyServerSystem extends EventEmitter { return; // Exit early } + // Preload all contexts at startup + this.logger.info("[System] Starting multi-context preload..."); + try { + this.requestHandler.authSwitcher.isSystemBusy = true; // ← 防止 WebUI 状态检查干扰 + const preloadResult = await this.browserManager.preloadAllContexts(); + + if (preloadResult.successful.length === 0) { + this.logger.error("[System] Failed to preload any contexts!"); + this.emit("started"); + return; + } + + this.logger.info(`[System] ✅ Preloaded ${preloadResult.successful.length} contexts successfully.`); + } catch (error) { + this.logger.error(`[System] ❌ Context preload failed: ${error.message}`); + this.emit("started"); + return; + } finally { + this.requestHandler.authSwitcher.isSystemBusy = false; // ← 预加载完成,恢复正常 + } + + // Determine which context to activate first let startupOrder = allRotationIndices.length > 0 ? [...allRotationIndices] : [...allAvailableIndices]; const hasInitialAuthIndex = Number.isInteger(initialAuthIndex); if (hasInitialAuthIndex) { @@ -123,22 +163,23 @@ class ProxyServerSystem extends EventEmitter { } } else { this.logger.info( - `[System] No valid startup index specified, will try in default order [${startupOrder.join(", ")}].` + `[System] No valid startup index specified, will activate first available context [${startupOrder[0]}].` ); } + // Activate the first context (fast switch since already preloaded) let isStarted = false; for (const index of startupOrder) { try { - this.logger.info(`[System] Attempting to start service with account #${index}...`); + this.logger.info(`[System] Activating pre-loaded context for account #${index}...`); this.requestHandler.authSwitcher.isSystemBusy = true; await this.browserManager.launchOrSwitchContext(index); isStarted = true; - this.logger.info(`[System] ✅ Successfully started with account #${index}!`); + this.logger.info(`[System] ✅ Successfully activated account #${index}!`); break; } catch (error) { - this.logger.error(`[System] ❌ Failed to start with account #${index}. Reason: ${error.message}`); + this.logger.error(`[System] ❌ Failed to activate account #${index}. Reason: ${error.message}`); } finally { this.requestHandler.authSwitcher.isSystemBusy = false; } @@ -146,9 +187,8 @@ class ProxyServerSystem extends EventEmitter { if (!isStarted) { this.logger.warn( - "[System] All authentication sources failed to initialize. Starting in account binding mode without an active account." + "[System] All authentication sources failed to activate. Starting in account binding mode without an active account." ); - // Don't throw an error, just proceed to start servers } this.emit("started"); @@ -499,19 +539,20 @@ class ProxyServerSystem extends EventEmitter { this.wsServer.on("error", err => { if (!isListening) { - this.logger.error( - `[System] WebSocket server failed to start: ${err.message}` - ); + this.logger.error(`[System] WebSocket server failed to start: ${err.message}`); reject(err); } else { - this.logger.error( - `[System] WebSocket server runtime error: ${err.message}` - ); + this.logger.error(`[System] WebSocket server runtime error: ${err.message}`); } }); this.wsServer.on("connection", (ws, req) => { + // Parse authIndex from query parameter + const url = new URL(req.url, `http://${req.headers.host}`); + const authIndex = parseInt(url.searchParams.get("authIndex")) || -1; + this.connectionRegistry.addConnection(ws, { address: req.socket.remoteAddress, + authIndex, }); }); }); diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 5238318..32bf386 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -65,7 +65,7 @@ class RequestHandler { } await new Promise(resolve => setTimeout(resolve, 100)); } - return this.connectionRegistry.hasActiveConnections(); + return !!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); } _isConnectionResetError(error) { @@ -78,7 +78,7 @@ class RequestHandler { } /** - * Wait for WebSocket connection to be established + * Wait for WebSocket connection to be established for current account * @param {number} timeoutMs - Maximum time to wait in milliseconds * @returns {Promise} true if connection established, false if timeout */ @@ -87,12 +87,13 @@ class RequestHandler { const checkInterval = 200; // Check every 200ms while (Date.now() - startTime < timeoutMs) { - if (this.connectionRegistry.hasActiveConnections()) { + if (this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { return true; } await new Promise(resolve => setTimeout(resolve, checkInterval)); } + this.logger.warn(`[Request] Timeout waiting for WebSocket connection for account #${this.currentAuthIndex}`); return false; } @@ -158,13 +159,13 @@ class RequestHandler { ); return false; } - // After waiting, also wait for WebSocket connection to be established - if (!this.connectionRegistry.hasActiveConnections()) { + // After waiting, also wait for WebSocket connection to be established for current account + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { // The other process failed to establish connection, return error this.logger.error( - "[System] WebSocket connection not established after system ready, browser startup may have failed." + `[System] WebSocket connection not established for account #${this.currentAuthIndex} after system ready, browser startup may have failed.` ); await this._sendErrorResponse( res, @@ -281,8 +282,9 @@ class RequestHandler { async processRequest(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -297,8 +299,8 @@ class RequestHandler { "Server undergoing internal maintenance (account switching/recovery), please try again later." ); } - // After system ready, ensure connection is available - if (!this.connectionRegistry.hasActiveConnections()) { + // After system ready, ensure connection is available for current account + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendErrorResponse( @@ -382,8 +384,9 @@ class RequestHandler { const requestId = this._generateRequestId(); this.logger.info(`[Upload] Processing upload request ${req.method} ${req.path} (ID: ${requestId})`); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Upload] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -398,7 +401,7 @@ class RequestHandler { "Server undergoing internal maintenance (account switching/recovery), please try again later." ); } - if (!this.connectionRegistry.hasActiveConnections()) { + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendErrorResponse( @@ -448,8 +451,9 @@ class RequestHandler { async processOpenAIRequest(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -464,8 +468,8 @@ class RequestHandler { "Server undergoing internal maintenance (account switching/recovery), please try again later." ); } - // After system ready, ensure connection is available - if (!this.connectionRegistry.hasActiveConnections()) { + // After system ready, ensure connection is available for current account + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendErrorResponse( @@ -688,8 +692,9 @@ class RequestHandler { async processClaudeRequest(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -705,7 +710,7 @@ class RequestHandler { "Server undergoing internal maintenance, please try again later." ); } - if (!this.connectionRegistry.hasActiveConnections()) { + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendClaudeErrorResponse( @@ -926,8 +931,9 @@ class RequestHandler { async processClaudeCountTokens(req, res) { const requestId = this._generateRequestId(); - // Check browser connection - if (!this.connectionRegistry.hasActiveConnections()) { + // Check current account's browser connection + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + this.logger.warn(`[Request] No WebSocket connection for current account #${this.currentAuthIndex}`); const recovered = await this._handleBrowserRecovery(res); if (!recovered) return; } @@ -943,7 +949,7 @@ class RequestHandler { "Server undergoing internal maintenance, please try again later." ); } - if (!this.connectionRegistry.hasActiveConnections()) { + if (!this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { const connectionReady = await this._waitForConnection(10000); if (!connectionReady) { return this._sendClaudeErrorResponse( @@ -1807,7 +1813,7 @@ class RequestHandler { } _cancelBrowserRequest(requestId) { - const connection = this.connectionRegistry.getFirstConnection(); + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); if (connection) { this.logger.info(`[Request] Cancelling request #${requestId}`); connection.send( @@ -1834,7 +1840,7 @@ class RequestHandler { return false; } - const connection = this.connectionRegistry.getFirstConnection(); + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); if (connection) { connection.send( JSON.stringify({ @@ -2001,8 +2007,11 @@ class RequestHandler { } _forwardRequest(proxyRequest) { - const connection = this.connectionRegistry.getFirstConnection(); + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); if (connection) { + this.logger.debug( + `[Request] Forwarding request #${proxyRequest.request_id} via connection for authIndex=${this.currentAuthIndex}` + ); connection.send( JSON.stringify({ event_type: "proxy_request", @@ -2010,7 +2019,9 @@ class RequestHandler { }) ); } else { - throw new Error("Unable to forward request: No available WebSocket connection."); + throw new Error( + `Unable to forward request: No WebSocket connection found for authIndex=${this.currentAuthIndex}` + ); } } From 8a00aa82223c57333f551050599d4ad8dc9584b0 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Sat, 14 Feb 2026 00:08:55 +0800 Subject: [PATCH 02/47] feat: add fast switch account feature in StatusPage and update translations --- src/routes/StatusRoutes.js | 4 +++- ui/app/pages/StatusPage.vue | 20 ++++++++++++++++++-- ui/locales/en.json | 1 + ui/locales/zh.json | 1 + 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 5a40f18..f065904 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -611,7 +611,9 @@ class StatusRoutes { const isDuplicate = canonicalIndex !== null && canonicalIndex !== index; const isRotation = rotationIndices.includes(index); - return { canonicalIndex, index, isDuplicate, isInvalid, isRotation, name }; + const hasContext = browserManager.contexts.has(index); + + return { canonicalIndex, hasContext, index, isDuplicate, isInvalid, isRotation, name }; }); const currentAuthIndex = requestHandler.currentAuthIndex; diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 04684ae..e7303b0 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -710,12 +710,17 @@ @@ -1526,6 +1526,18 @@ const dedupedAvailableCount = computed(() => { const isBusy = computed(() => state.isSwitchingAccount || state.isSystemBusy); +const formattedLogs = computed(() => { + if (!state.logs) return ""; + // Escape HTML first to prevent XSS (though logs should be safe, better safe than sorry) + let safeLogs = escapeHtml(state.logs); + + // Highlight [WARN] and [ERROR] at the start of lines with inline styles + safeLogs = safeLogs.replace(/(^|\n)(\[WARN\])/g, '$1$2'); + safeLogs = safeLogs.replace(/(^|\n)(\[ERROR\])/g, '$1$2'); + + return safeLogs; +}); + // Computed properties for batch selection const selectedCount = computed(() => state.selectedAccounts.size); const hasSelection = computed(() => state.selectedAccounts.size > 0); From adcb161a26e0f5c541f0e0924713fb5493e93748 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 00:40:46 +0800 Subject: [PATCH 09/47] refactor: improve context switching logic and reset BackgroundWakeup state --- src/core/BrowserManager.js | 52 +++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 8b9265e..005f351 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1356,27 +1356,40 @@ class BrowserManager { this.logger.info(`⚡ [FastSwitch] Switching to pre-loaded context for account #${authIndex}`); this.logger.info("=================================================="); - // Stop background tasks for old context - if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { - const oldContextData = this.contexts.get(this._currentAuthIndex); - if (oldContextData.healthMonitorInterval) { - clearInterval(oldContextData.healthMonitorInterval); - oldContextData.healthMonitorInterval = null; + // Validate that the page is still alive before switching + const contextData = this.contexts.get(authIndex); + if (contextData.page.isClosed()) { + this.logger.warn( + `[FastSwitch] Page for account #${authIndex} is closed, cleaning up and re-initializing...` + ); + // Clean up the dead context + await this.closeContext(authIndex); + // Fall through to slow path to re-initialize + } else { + // Stop background tasks for old context + if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { + const oldContextData = this.contexts.get(this._currentAuthIndex); + if (oldContextData.healthMonitorInterval) { + clearInterval(oldContextData.healthMonitorInterval); + oldContextData.healthMonitorInterval = null; + } } - } - // Switch to new context - const contextData = this.contexts.get(authIndex); - this.context = contextData.context; - this.page = contextData.page; - this._currentAuthIndex = authIndex; + // Switch to new context + this.context = contextData.context; + this.page = contextData.page; + this._currentAuthIndex = authIndex; - // Start background tasks for new context - this._startHealthMonitor(); - this._startBackgroundWakeup(); // Internal check prevents duplicate instances + // Reset BackgroundWakeup state for new context + this.noButtonCount = 0; - this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); - return; + // Start background tasks for new context + this._startHealthMonitor(); + this._startBackgroundWakeup(); // Internal check prevents duplicate instances + + this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); + return; + } } // Context doesn't exist, need to initialize it (slow path) @@ -1402,6 +1415,9 @@ class BrowserManager { this.page = page; this._currentAuthIndex = authIndex; + // Reset BackgroundWakeup state for new context + this.noButtonCount = 0; + // Start background tasks this._startHealthMonitor(); this._startBackgroundWakeup(); // Internal check prevents duplicate instances @@ -1526,6 +1542,8 @@ class BrowserManager { // Restart background tasks only if this is the current account if (isCurrentAccount) { + // Reset BackgroundWakeup state after reconnect + this.noButtonCount = 0; this._startHealthMonitor(); this._startBackgroundWakeup(); // Internal check prevents duplicate instances } From e5c89dccb91b2ca0af837233ae32558b27755a9c Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 09:23:14 +0800 Subject: [PATCH 10/47] refactor: enhance fast context switching by checking auth status and cleaning up expired contexts --- src/core/BrowserManager.js | 67 +++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 005f351..0e26d93 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1366,29 +1366,58 @@ class BrowserManager { await this.closeContext(authIndex); // Fall through to slow path to re-initialize } else { - // Stop background tasks for old context - if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { - const oldContextData = this.contexts.get(this._currentAuthIndex); - if (oldContextData.healthMonitorInterval) { - clearInterval(oldContextData.healthMonitorInterval); - oldContextData.healthMonitorInterval = null; - } - } + // Quick auth status check without navigation + try { + const currentUrl = contextData.page.url(); + const pageTitle = await contextData.page.title(); + + // Check if redirected to login page (auth expired) + if ( + currentUrl.includes("accounts.google.com") || + currentUrl.includes("ServiceLogin") || + pageTitle.includes("Sign in") || + pageTitle.includes("登录") + ) { + this.logger.warn( + `[FastSwitch] Account #${authIndex} auth expired (redirected to login), cleaning up and re-initializing...` + ); + // Clean up the expired context + await this.closeContext(authIndex); + // Fall through to slow path to re-initialize + } else { + // Page is alive and auth is valid, proceed with fast switch + // Stop background tasks for old context + if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { + const oldContextData = this.contexts.get(this._currentAuthIndex); + if (oldContextData.healthMonitorInterval) { + clearInterval(oldContextData.healthMonitorInterval); + oldContextData.healthMonitorInterval = null; + } + } - // Switch to new context - this.context = contextData.context; - this.page = contextData.page; - this._currentAuthIndex = authIndex; + // Switch to new context + this.context = contextData.context; + this.page = contextData.page; + this._currentAuthIndex = authIndex; - // Reset BackgroundWakeup state for new context - this.noButtonCount = 0; + // Reset BackgroundWakeup state for new context + this.noButtonCount = 0; - // Start background tasks for new context - this._startHealthMonitor(); - this._startBackgroundWakeup(); // Internal check prevents duplicate instances + // Start background tasks for new context + this._startHealthMonitor(); + this._startBackgroundWakeup(); // Internal check prevents duplicate instances - this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); - return; + this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); + return; + } + } catch (error) { + this.logger.warn( + `[FastSwitch] Failed to check auth status for account #${authIndex}: ${error.message}, cleaning up and re-initializing...` + ); + // Clean up the problematic context + await this.closeContext(authIndex); + // Fall through to slow path to re-initialize + } } } From 96de9be99d30c032ca20d1818425f53334ad7166 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 10:14:11 +0800 Subject: [PATCH 11/47] refactor: improve browser preload logic with error handling and resource cleanup --- src/core/BrowserManager.js | 137 +++++++++++++++++++--------------- src/core/ProxyServerSystem.js | 4 +- 2 files changed, 79 insertions(+), 62 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 0e26d93..62f375f 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1135,73 +1135,89 @@ class BrowserManager { this.logger.info("🚀 [MultiContext] Starting preload of all auth contexts..."); this.logger.info("=================================================="); - // Launch browser if not already running - const proxyConfig = parseProxyFromEnv(); - if (!this.browser) { - this.logger.info("🚀 [Browser] Launching main browser instance..."); - if (!fs.existsSync(this.browserExecutablePath)) { - throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); - } - this.browser = await firefox.launch({ - args: this.launchArgs, - executablePath: this.browserExecutablePath, - firefoxUserPrefs: this.firefoxUserPrefs, - headless: true, - ...(proxyConfig ? { proxy: proxyConfig } : {}), - }); - this.browser.on("disconnected", () => { - if (!this.isClosingIntentionally) { - this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); - } else { - this.logger.info("[Browser] Main browser closed intentionally."); + try { + // Launch browser if not already running + const proxyConfig = parseProxyFromEnv(); + if (!this.browser) { + this.logger.info("🚀 [Browser] Launching main browser instance..."); + if (!fs.existsSync(this.browserExecutablePath)) { + throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); } + this.browser = await firefox.launch({ + args: this.launchArgs, + executablePath: this.browserExecutablePath, + firefoxUserPrefs: this.firefoxUserPrefs, + headless: true, + ...(proxyConfig ? { proxy: proxyConfig } : {}), + }); + this.browser.on("disconnected", () => { + if (!this.isClosingIntentionally) { + this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); + } else { + this.logger.info("[Browser] Main browser closed intentionally."); + } - this.browser = null; - this._cleanupAllContexts(); - }); - this.logger.info("✅ [Browser] Main browser instance launched successfully."); - } + this.browser = null; + this._cleanupAllContexts(); + }); + this.logger.info("✅ [Browser] Main browser instance launched successfully."); + } - // Get all available auth indices - const allAuthIndices = this.authSource.availableIndices; - if (allAuthIndices.length === 0) { - this.logger.warn("[MultiContext] No auth files found, skipping preload."); - return { failed: [], successful: [] }; - } + // Get all available auth indices + const allAuthIndices = this.authSource.availableIndices; + if (allAuthIndices.length === 0) { + this.logger.warn("[MultiContext] No auth files found, skipping preload."); + return { failed: [], successful: [] }; + } - this.logger.info( - `[MultiContext] Found ${allAuthIndices.length} auth files to preload: [${allAuthIndices.join(", ")}]` - ); + this.logger.info( + `[MultiContext] Found ${allAuthIndices.length} auth files to preload: [${allAuthIndices.join(", ")}]` + ); - const successful = []; - const failed = []; + const successful = []; + const failed = []; - // Preload contexts sequentially to avoid overwhelming the system - for (const authIndex of allAuthIndices) { - try { - this.logger.info(`[MultiContext] Preloading context for account #${authIndex}...`); - await this._initializeContext(authIndex); - successful.push(authIndex); - this.logger.info(`✅ [MultiContext] Account #${authIndex} context preloaded successfully.`); - } catch (error) { - this.logger.error(`❌ [MultiContext] Failed to preload account #${authIndex}: ${error.message}`); - failed.push({ error: error.message, index: authIndex }); + // Preload contexts sequentially to avoid overwhelming the system + for (const authIndex of allAuthIndices) { + try { + this.logger.info(`[MultiContext] Preloading context for account #${authIndex}...`); + await this._initializeContext(authIndex); + successful.push(authIndex); + this.logger.info(`✅ [MultiContext] Account #${authIndex} context preloaded successfully.`); + } catch (error) { + this.logger.error(`❌ [MultiContext] Failed to preload account #${authIndex}: ${error.message}`); + failed.push({ error: error.message, index: authIndex }); + } } - } - this.logger.info("=================================================="); - this.logger.info( - `✅ [MultiContext] Preload complete: ${successful.length} successful, ${failed.length} failed` - ); - if (successful.length > 0) { - this.logger.info(` • Successful: [${successful.join(", ")}]`); - } - if (failed.length > 0) { - this.logger.info(` • Failed: [${failed.map(f => f.index).join(", ")}]`); - } - this.logger.info("=================================================="); + this.logger.info("=================================================="); + this.logger.info( + `✅ [MultiContext] Preload complete: ${successful.length} successful, ${failed.length} failed` + ); + if (successful.length > 0) { + this.logger.info(` • Successful: [${successful.join(", ")}]`); + } + if (failed.length > 0) { + this.logger.info(` • Failed: [${failed.map(f => f.index).join(", ")}]`); + } + this.logger.info("=================================================="); - return { failed, successful }; + // Clean up browser if all contexts failed to preload + if (successful.length === 0 && this.browser) { + this.logger.warn("[MultiContext] All contexts failed to preload, closing browser to free resources..."); + await this.closeBrowser(); + } + + return { failed, successful }; + } catch (error) { + // Catastrophic failure during preload - clean up any resources + this.logger.error(`❌ [MultiContext] Catastrophic failure during preload: ${error.message}`); + if (this.browser) { + this.logger.warn("[MultiContext] Cleaning up browser due to catastrophic failure..."); + await this.closeBrowser(); + } + throw error; + } } /** @@ -1358,7 +1374,7 @@ class BrowserManager { // Validate that the page is still alive before switching const contextData = this.contexts.get(authIndex); - if (contextData.page.isClosed()) { + if (!contextData || !contextData.page || contextData.page.isClosed()) { this.logger.warn( `[FastSwitch] Page for account #${authIndex} is closed, cleaning up and re-initializing...` ); @@ -1618,7 +1634,8 @@ class BrowserManager { this._currentAuthIndex = -1; // DO NOT reset backgroundWakeupRunning here! // If a BackgroundWakeup was running, it will detect this.page === null and exit on its own. - // Resetting the flag here could allow a new instance to start before the old one exits. this.logger.info(`[Browser] Current context was closed, currentAuthIndex reset to -1.`); + // Resetting the flag here could allow a new instance to start before the old one exits. + this.logger.info(`[Browser] Current context was closed, currentAuthIndex reset to -1.`); } // Close the context AFTER removing from map diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index 9e4e1ec..beab43a 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -123,7 +123,7 @@ class ProxyServerSystem extends EventEmitter { // Preload all contexts at startup this.logger.info("[System] Starting multi-context preload..."); try { - this.requestHandler.authSwitcher.isSystemBusy = true; // ← 防止 WebUI 状态检查干扰 + this.requestHandler.authSwitcher.isSystemBusy = true; const preloadResult = await this.browserManager.preloadAllContexts(); if (preloadResult.successful.length === 0) { @@ -138,7 +138,7 @@ class ProxyServerSystem extends EventEmitter { this.emit("started"); return; } finally { - this.requestHandler.authSwitcher.isSystemBusy = false; // ← 预加载完成,恢复正常 + this.requestHandler.authSwitcher.isSystemBusy = false; } // Determine which context to activate first From 8c44d28a495782f94d43fdc1ae0029e9599df510 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 10:38:41 +0800 Subject: [PATCH 12/47] refactor: enhance authIndex validation and injection in BrowserManager and ProxyServerSystem --- scripts/client/build.js | 3 +-- src/core/BrowserManager.js | 7 +++++++ src/core/ConnectionRegistry.js | 23 +++++++++++++++++------ src/core/ProxyServerSystem.js | 9 +++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/scripts/client/build.js b/scripts/client/build.js index 01cb47e..30f2ca2 100644 --- a/scripts/client/build.js +++ b/scripts/client/build.js @@ -103,10 +103,9 @@ class ConnectionManager extends EventTarget { super(); // Validate authIndex: must be >= 0 for multi-context architecture - if (typeof authIndex !== "number" || authIndex < 0) { + if (!Number.isInteger(authIndex) || authIndex < 0) { const errorMsg = `❌ FATAL: Invalid authIndex (${authIndex}). BrowserManager failed to inject authIndex correctly. This is a configuration error.`; console.error(errorMsg); - alert(errorMsg); throw new Error(errorMsg); } diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 62f375f..dc929fe 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -412,15 +412,22 @@ class BrowserManager { } // Inject authIndex into ProxySystem initialization + let authIndexInjected = false; const lines = buildScriptContent.split("\n"); for (let i = 0; i < lines.length; i++) { if (lines[i].includes("const proxySystem = new ProxySystem()")) { lines[i] = ` const proxySystem = new ProxySystem(undefined, ${authIndex});`; this.logger.debug(`[Config] Injected authIndex ${authIndex} into ProxySystem initialization`); buildScriptContent = lines.join("\n"); + authIndexInjected = true; break; } } + if (!authIndexInjected) { + const message = "[Config] Failed to inject authIndex into ProxySystem initialization in build.js"; + this.logger.error("[Config] Failed to inject authIndex into ProxySystem initialization in build.js"); + throw new Error(message); + } return buildScriptContent; } diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 2577b12..5fc1d42 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -44,6 +44,17 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimers.get(incomingAuthIndex)); this.reconnectGraceTimers.delete(incomingAuthIndex); this.logger.info(`[Server] Grace timer cleared for reconnected authIndex=${incomingAuthIndex}`); + + // Clear message queues for reconnected current account + // When WebSocket disconnects, browser aborts all in-flight requests + // Keeping these queues would cause them to hang until timeout + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + if (incomingAuthIndex === currentAuthIndex && this.messageQueues.size > 0) { + this.logger.info( + `[Server] Reconnected current account #${incomingAuthIndex}, clearing ${this.messageQueues.size} stale message queues...` + ); + this.closeAllMessageQueues(); + } } // Store connection by authIndex if provided @@ -77,11 +88,14 @@ class ConnectionRegistry extends EventEmitter { this.logger.info(`[Server] Internal WebSocket client disconnected (authIndex: ${disconnectedAuthIndex}).`); } else { this.logger.info("[Server] Internal WebSocket client disconnected."); + // Early return for invalid authIndex - no reconnect logic needed + this.emit("connectionRemoved", websocket); + return; } // Check if the page still exists for this account // If page is closed/missing, it means the context was intentionally closed, skip reconnect - if (disconnectedAuthIndex !== undefined && disconnectedAuthIndex >= 0 && this.browserManager) { + if (this.browserManager) { const contextData = this.browserManager.contexts.get(disconnectedAuthIndex); if (!contextData || !contextData.page || contextData.page.isClosed()) { this.logger.info( @@ -92,16 +106,13 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimers.get(disconnectedAuthIndex)); this.reconnectGraceTimers.delete(disconnectedAuthIndex); } + this.emit("connectionRemoved", websocket); return; } } // Clear any existing grace timer for THIS account before starting a new one - if ( - disconnectedAuthIndex !== undefined && - disconnectedAuthIndex >= 0 && - this.reconnectGraceTimers.has(disconnectedAuthIndex) - ) { + if (this.reconnectGraceTimers.has(disconnectedAuthIndex)) { clearTimeout(this.reconnectGraceTimers.get(disconnectedAuthIndex)); } diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index beab43a..4ae6456 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -552,6 +552,15 @@ class ProxyServerSystem extends EventEmitter { const authIndexParam = url.searchParams.get("authIndex"); const authIndex = authIndexParam !== null ? parseInt(authIndexParam, 10) : -1; + // Validate authIndex: must be a valid non-negative integer + if (Number.isNaN(authIndex) || authIndex < 0) { + this.logger.error( + `[System] Rejecting WebSocket connection with invalid authIndex: ${authIndexParam} (parsed as ${authIndex})` + ); + ws.close(1008, "Invalid authIndex: must be a non-negative integer"); + return; + } + this.connectionRegistry.addConnection(ws, { address: req.socket.remoteAddress, authIndex, From 15f07176a2affb73fec92bb64127ed967719b87f Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 13:24:35 +0800 Subject: [PATCH 13/47] refactor: update preloadAllContexts to initialize contexts sequentially and enhance authIndex validation in addConnection --- src/core/BrowserManager.js | 2 +- src/core/ConnectionRegistry.js | 64 ++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index dc929fe..92c8ff7 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1134,7 +1134,7 @@ class BrowserManager { /** * Preload all available auth contexts at startup - * This method initializes all contexts in parallel and keeps them ready for instant switching + * This method initializes all contexts in sequentially (one by one) and keeps them ready for instant switching * @returns {Promise<{successful: number[], failed: Array<{index: number, error: string}>}>} */ async preloadAllContexts() { diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 5fc1d42..8b24725 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -34,39 +34,59 @@ class ConnectionRegistry extends EventEmitter { } addConnection(websocket, clientInfo) { - // Clear grace timer for this specific authIndex only, without affecting other accounts - const incomingAuthIndex = clientInfo.authIndex; - if ( - incomingAuthIndex !== undefined && - incomingAuthIndex >= 0 && - this.reconnectGraceTimers.has(incomingAuthIndex) - ) { - clearTimeout(this.reconnectGraceTimers.get(incomingAuthIndex)); - this.reconnectGraceTimers.delete(incomingAuthIndex); - this.logger.info(`[Server] Grace timer cleared for reconnected authIndex=${incomingAuthIndex}`); + const authIndex = clientInfo.authIndex; + + // Validate authIndex: must be a valid non-negative integer + if (authIndex === undefined || authIndex < 0 || !Number.isInteger(authIndex)) { + this.logger.error( + `[Server] Rejecting connection with invalid authIndex: ${authIndex}. Connection will be closed.` + ); + try { + websocket.close(1008, "Invalid authIndex"); + } catch (e) { + /* ignore */ + } + return; + } + + // Check if there's already a connection for this authIndex + const existingConnection = this.connectionsByAuth.get(authIndex); + if (existingConnection && existingConnection !== websocket) { + this.logger.warn( + `[Server] Duplicate connection detected for authIndex=${authIndex}, closing old connection...` + ); + try { + // Remove event listeners to prevent them from firing during close + existingConnection.removeAllListeners(); + existingConnection.close(1000, "Replaced by new connection"); + } catch (e) { + this.logger.warn(`[Server] Error closing old connection: ${e.message}`); + } + } + + // Clear grace timer for this authIndex + if (this.reconnectGraceTimers.has(authIndex)) { + clearTimeout(this.reconnectGraceTimers.get(authIndex)); + this.reconnectGraceTimers.delete(authIndex); + this.logger.info(`[Server] Grace timer cleared for reconnected authIndex=${authIndex}`); // Clear message queues for reconnected current account // When WebSocket disconnects, browser aborts all in-flight requests // Keeping these queues would cause them to hang until timeout const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; - if (incomingAuthIndex === currentAuthIndex && this.messageQueues.size > 0) { + if (authIndex === currentAuthIndex && this.messageQueues.size > 0) { this.logger.info( - `[Server] Reconnected current account #${incomingAuthIndex}, clearing ${this.messageQueues.size} stale message queues...` + `[Server] Reconnected current account #${authIndex}, clearing ${this.messageQueues.size} stale message queues...` ); this.closeAllMessageQueues(); } } - // Store connection by authIndex if provided - const authIndex = clientInfo.authIndex; - if (authIndex !== undefined && authIndex >= 0) { - this.connectionsByAuth.set(authIndex, websocket); - this.logger.info( - `[Server] Internal WebSocket client connected (from: ${clientInfo.address}, authIndex: ${authIndex})` - ); - } else { - this.logger.info(`[Server] Internal WebSocket client connected (from: ${clientInfo.address})`); - } + // Store connection by authIndex + this.connectionsByAuth.set(authIndex, websocket); + this.logger.info( + `[Server] Internal WebSocket client connected (from: ${clientInfo.address}, authIndex: ${authIndex})` + ); // Store authIndex on websocket for cleanup websocket._authIndex = authIndex; From fa63bdfd4e05931e105a53f224e67caa2fa71ee5 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 16:49:00 +0800 Subject: [PATCH 14/47] refactor: enhance BrowserManager with improved health monitoring and background wakeup logic --- src/core/BrowserManager.js | 39 +++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 92c8ff7..dc9ae8d 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -425,7 +425,6 @@ class BrowserManager { } if (!authIndexInjected) { const message = "[Config] Failed to inject authIndex into ProxySystem initialization in build.js"; - this.logger.error("[Config] Failed to inject authIndex into ProxySystem initialization in build.js"); throw new Error(message); } @@ -749,6 +748,13 @@ class BrowserManager { // Run every 4 seconds contextData.healthMonitorInterval = setInterval(async () => { try { + // Check if this is still the current active account + // This prevents background contexts from running healthMonitor unnecessarily + if (this._currentAuthIndex !== authIndex) { + // Silently skip - this context is not active + return; + } + const page = contextData.page; // Double check page status if (!page || page.isClosed()) { @@ -902,19 +908,28 @@ class BrowserManager { async _startBackgroundWakeup() { // Prevent multiple instances from running simultaneously if (this.backgroundWakeupRunning) { - this.logger.debug("[Browser] BackgroundWakeup already running, skipping duplicate start."); + this.logger.info("[Browser] BackgroundWakeup already running, skipping duplicate start."); return; } + this.logger.info("[Browser] Starting BackgroundWakeup initialization..."); this.backgroundWakeupRunning = true; // Initial buffer - wait before starting the main loop to let page stabilize await new Promise(r => setTimeout(r, 1500)); // Verify page is still valid after the initial delay - if (!this.page || this.page.isClosed()) { + try { + if (!this.page || this.page.isClosed()) { + this.backgroundWakeupRunning = false; + this.logger.info( + "[Browser] BackgroundWakeup stopped: page became null or closed during startup delay." + ); + return; + } + } catch (error) { this.backgroundWakeupRunning = false; - this.logger.info("[Browser] BackgroundWakeup stopped: page became null or closed during startup delay."); + this.logger.warn(`[Browser] BackgroundWakeup stopped: error checking page status: ${error.message}`); return; } @@ -1046,7 +1061,14 @@ class BrowserManager { await new Promise(r => setTimeout(r, 2000)); } else { this.logger.info(`[Browser] ✅ Click successful, button disappeared.`); - await new Promise(r => setTimeout(r, 60000)); // Long sleep on success + // Long sleep on success, but check for context switches every second + for (let i = 0; i < 60; i++) { + if (this.noButtonCount === 0) { + this.logger.info(`[Browser] ⚡ Woken up early due to user activity or context switch.`); + break; // Wake up early if user activity detected + } + await new Promise(r => setTimeout(r, 1000)); + } } } else { this.noButtonCount++; @@ -1430,6 +1452,13 @@ class BrowserManager { this._startHealthMonitor(); this._startBackgroundWakeup(); // Internal check prevents duplicate instances + // Optimize UX: Force page to front immediately + try { + await this.page.bringToFront(); + } catch (ignored) { + /* ignore error if page is busy */ + } + this.logger.info(`✅ [FastSwitch] Switched to account #${authIndex} instantly!`); return; } From e26be1e046c65fb1ca9d104526ded98574947ef1 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 17:26:03 +0800 Subject: [PATCH 15/47] refactor: improve WebSocket connection handling with proactive context closure on timeout --- src/core/RequestHandler.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 32bf386..af7d749 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -87,13 +87,25 @@ class RequestHandler { const checkInterval = 200; // Check every 200ms while (Date.now() - startTime < timeoutMs) { - if (this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex)) { + const connection = this.connectionRegistry.getConnectionByAuth(this.currentAuthIndex); + // Check both existence and readyState (1 = OPEN) + if (connection && connection.readyState === 1) { return true; } await new Promise(resolve => setTimeout(resolve, checkInterval)); } - this.logger.warn(`[Request] Timeout waiting for WebSocket connection for account #${this.currentAuthIndex}`); + this.logger.warn( + `[Request] Timeout waiting for WebSocket connection for account #${this.currentAuthIndex}. Closing unresponsive context...` + ); + // Proactively close the unresponsive context so subsequent attempts re-initialize it + if (this.browserManager) { + try { + await this.browserManager.closeContext(this.currentAuthIndex); + } catch (e) { + /* ignore */ + } + } return false; } From 0020bfca7b9d1235a393f658b0906e0b545df605 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Mon, 16 Feb 2026 18:43:12 +0800 Subject: [PATCH 16/47] refactor: enhance log highlighting in StatusPage with improved regex for WARN and ERROR tags --- ui/app/pages/StatusPage.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 9395268..d49dc63 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1532,8 +1532,14 @@ const formattedLogs = computed(() => { let safeLogs = escapeHtml(state.logs); // Highlight [WARN] and [ERROR] at the start of lines with inline styles - safeLogs = safeLogs.replace(/(^|\n)(\[WARN\])/g, '$1$2'); - safeLogs = safeLogs.replace(/(^|\n)(\[ERROR\])/g, '$1$2'); + safeLogs = safeLogs.replace( + /(^|\r?\n)(\[WARN\])(?=\s)/g, + '$1$2' + ); + safeLogs = safeLogs.replace( + /(^|\r?\n)(\[ERROR\])(?=\s)/g, + '$1$2' + ); return safeLogs; }); From 85cdb15de4a835bb852f18937b82736e026361a7 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Tue, 17 Feb 2026 00:02:43 +0800 Subject: [PATCH 17/47] refactor: improve connection management by clearing reconnecting status for authIndex on reconnection --- src/core/ConnectionRegistry.js | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 8b24725..aee84a0 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -82,6 +82,12 @@ class ConnectionRegistry extends EventEmitter { } } + // Clear reconnecting status for this authIndex when connection is re-established + if (this.reconnectingAccounts.has(authIndex)) { + this.reconnectingAccounts.delete(authIndex); + this.logger.info(`[Server] Cleared reconnecting status for reconnected authIndex=${authIndex}`); + } + // Store connection by authIndex this.connectionsByAuth.set(authIndex, websocket); this.logger.info( @@ -126,6 +132,10 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimers.get(disconnectedAuthIndex)); this.reconnectGraceTimers.delete(disconnectedAuthIndex); } + // Clear reconnecting status + if (this.reconnectingAccounts.has(disconnectedAuthIndex)) { + this.reconnectingAccounts.delete(disconnectedAuthIndex); + } this.emit("connectionRemoved", websocket); return; } @@ -188,8 +198,6 @@ class ConnectionRegistry extends EventEmitter { } } - this.emit("connectionLost"); - this.reconnectGraceTimers.delete(disconnectedAuthIndex); }, 5000); @@ -236,8 +244,9 @@ class ConnectionRegistry extends EventEmitter { } isReconnectingInProgress() { - // Check if any account is currently reconnecting - return this.reconnectingAccounts.size > 0; + // Only check if current account is reconnecting, to avoid non-current account reconnection affecting current account's request handling + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + return currentAuthIndex >= 0 && (this.reconnectingAccounts.get(currentAuthIndex) || false); } isInGracePeriod() { @@ -279,6 +288,12 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimers.get(authIndex)); this.reconnectGraceTimers.delete(authIndex); } + + // Clear reconnecting status for this account + if (this.reconnectingAccounts.has(authIndex)) { + this.reconnectingAccounts.delete(authIndex); + this.logger.info(`[Registry] Cleared reconnecting status for authIndex=${authIndex}`); + } } else { this.logger.debug(`[Registry] No WebSocket connection to close for authIndex=${authIndex}`); } From 603fdfd4fa6986c0e314bbb71377013a8599bb22 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Tue, 17 Feb 2026 00:34:27 +0800 Subject: [PATCH 18/47] refactor: enhance context and connection closure logic to prevent unnecessary reconnect attempts --- src/core/BrowserManager.js | 10 ++++++++++ src/core/ConnectionRegistry.js | 34 +++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index dc9ae8d..c515d1a 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1641,6 +1641,16 @@ class BrowserManager { /** * Close a single context for a specific account + * + * IMPORTANT: When deleting an account, always call this method BEFORE closeConnectionByAuth() + * Calling order: closeContext() -> closeConnectionByAuth() + * + * Reason: This method removes the context from the contexts Map BEFORE closing it. + * When context.close() triggers WebSocket disconnect, ConnectionRegistry._removeConnection() + * will check if the context still exists. If not found, it skips reconnect logic. + * If you call closeConnectionByAuth() first, _removeConnection() will see the context + * still exists and may trigger unnecessary reconnect attempts. + * * @param {number} authIndex - The auth index to close */ async closeContext(authIndex) { diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index aee84a0..f9e055c 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -69,17 +69,6 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimers.get(authIndex)); this.reconnectGraceTimers.delete(authIndex); this.logger.info(`[Server] Grace timer cleared for reconnected authIndex=${authIndex}`); - - // Clear message queues for reconnected current account - // When WebSocket disconnects, browser aborts all in-flight requests - // Keeping these queues would cause them to hang until timeout - const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; - if (authIndex === currentAuthIndex && this.messageQueues.size > 0) { - this.logger.info( - `[Server] Reconnected current account #${authIndex}, clearing ${this.messageQueues.size} stale message queues...` - ); - this.closeAllMessageQueues(); - } } // Clear reconnecting status for this authIndex when connection is re-established @@ -88,6 +77,20 @@ class ConnectionRegistry extends EventEmitter { this.logger.info(`[Server] Cleared reconnecting status for reconnected authIndex=${authIndex}`); } + // Clear message queues for reconnected current account + // IMPORTANT: This must be done regardless of whether grace timer existed + // Scenario: If WebSocket reconnects after grace period timeout (>5s), + // grace timer is already deleted, but new message queues may have been created + // When WebSocket disconnects, browser aborts all in-flight requests + // Keeping these queues would cause them to hang until timeout + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + if (authIndex === currentAuthIndex && this.messageQueues.size > 0) { + this.logger.info( + `[Server] Reconnected current account #${authIndex}, clearing ${this.messageQueues.size} stale message queues...` + ); + this.closeAllMessageQueues(); + } + // Store connection by authIndex this.connectionsByAuth.set(authIndex, websocket); this.logger.info( @@ -269,6 +272,15 @@ class ConnectionRegistry extends EventEmitter { /** * Close WebSocket connection for a specific account + * + * IMPORTANT: When deleting an account, always call BrowserManager.closeContext() BEFORE this method + * Calling order: closeContext() -> closeConnectionByAuth() + * + * Reason: closeContext() removes the context from the contexts Map before closing it. + * When this method closes the WebSocket, _removeConnection() will check if the context exists. + * If context is already removed, _removeConnection() skips reconnect logic (which is desired for deletion). + * If you call this method first, _removeConnection() may trigger unnecessary reconnect attempts. + * * @param {number} authIndex - The auth index to close connection for */ closeConnectionByAuth(authIndex) { From 32bce05de513378cc45838557131c9b53350a428 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Tue, 17 Feb 2026 00:51:12 +0800 Subject: [PATCH 19/47] refactor: improve WebSocket disconnection handling with enhanced logging for first-time startup and recovery scenarios --- src/core/RequestHandler.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index af7d749..b29624c 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -190,11 +190,20 @@ class RequestHandler { return true; } - this.logger.error( - "❌ [System] Browser WebSocket connection disconnected! Possible process crash. Attempting recovery..." - ); - + // Determine if this is first-time startup or actual crash recovery const recoveryAuthIndex = this.currentAuthIndex; + const isFirstTimeStartup = recoveryAuthIndex < 0 && !this.browserManager.browser; + + if (isFirstTimeStartup) { + this.logger.info( + "🚀 [System] Browser not yet started. Initializing browser with first available account..." + ); + } else { + this.logger.error( + "❌ [System] Browser WebSocket connection disconnected! Possible process crash. Attempting recovery..." + ); + } + let wasDirectRecovery = false; let recoverySuccess = false; @@ -217,7 +226,6 @@ class RequestHandler { this.logger.info("✅ [System] WebSocket connection is ready!"); recoverySuccess = true; } else if (this.authSource.getRotationIndices().length > 0) { - this.logger.warn("⚠️ [System] No current account, attempting to switch to first available account..."); // Don't set isSystemBusy here - let switchToNextAuth manage it const result = await this.authSwitcher.switchToNextAuth(); if (!result.success) { From 89a28356231dc8c927c78d8cc1d63949c3aaa59e Mon Sep 17 00:00:00 2001 From: bbbugg Date: Tue, 17 Feb 2026 16:52:02 +0800 Subject: [PATCH 20/47] refactor: enhance logging for missing WebSocket connections based on debug level --- src/core/ConnectionRegistry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index f9e055c..3e5880c 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -226,7 +226,7 @@ class ConnectionRegistry extends EventEmitter { this.logger.warn(`[Server] Received message for unknown or outdated request ID: ${requestId}`); } } catch (error) { - this.logger.error("[Server] Failed to parse internal WebSocket message"); + this.logger.error(`[Server] Failed to parse internal WebSocket message: ${error.message}`); } } @@ -262,7 +262,7 @@ class ConnectionRegistry extends EventEmitter { const connection = this.connectionsByAuth.get(authIndex); if (connection) { this.logger.debug(`[Registry] Found WebSocket connection for authIndex=${authIndex}`); - } else { + } else if (this.logger.getLevel?.() === "DEBUG") { this.logger.debug( `[Registry] No WebSocket connection found for authIndex=${authIndex}. Available: [${Array.from(this.connectionsByAuth.keys()).join(", ")}]` ); From 1a93a2255ce41b80d2aee17d641236bada832d30 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Tue, 17 Feb 2026 17:08:32 +0800 Subject: [PATCH 21/47] refactor: close pending message queues on account disconnection to prevent hanging requests --- src/core/ConnectionRegistry.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 3e5880c..9c45916 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -139,6 +139,12 @@ class ConnectionRegistry extends EventEmitter { if (this.reconnectingAccounts.has(disconnectedAuthIndex)) { this.reconnectingAccounts.delete(disconnectedAuthIndex); } + // Close pending message queues if this is the current account + // to prevent in-flight requests from hanging until timeout + const currentAuthIndex = this.getCurrentAuthIndex ? this.getCurrentAuthIndex() : -1; + if (disconnectedAuthIndex === currentAuthIndex) { + this.closeAllMessageQueues(); + } this.emit("connectionRemoved", websocket); return; } From 30f0bbafc4a22826e9cbf1e18cf024a217ced82a Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 00:28:07 +0800 Subject: [PATCH 22/47] refactor: enhance context pool management with background initialization and rebalancing logic --- src/auth/AuthSource.js | 2 + src/auth/AuthSwitcher.js | 4 + src/core/BrowserManager.js | 296 ++++++++++++++++++++++------------ src/core/ProxyServerSystem.js | 62 +++---- src/routes/StatusRoutes.js | 28 +++- src/utils/ConfigLoader.js | 4 + 6 files changed, 252 insertions(+), 144 deletions(-) diff --git a/src/auth/AuthSource.js b/src/auth/AuthSource.js index f2ab9ee..a7412bb 100644 --- a/src/auth/AuthSource.js +++ b/src/auth/AuthSource.js @@ -53,7 +53,9 @@ class AuthSource { `[Auth] Reload complete. ${this.availableIndices.length} valid sources available: [${this.availableIndices.join(", ")}]` ); this.lastScannedIndices = newIndices; + return true; // Changes detected } + return false; // No changes } removeAuth(index) { diff --git a/src/auth/AuthSwitcher.js b/src/auth/AuthSwitcher.js index e4ba8d7..a5b28a8 100644 --- a/src/auth/AuthSwitcher.js +++ b/src/auth/AuthSwitcher.js @@ -77,6 +77,7 @@ class AuthSwitcher { try { await this.browserManager.launchOrSwitchContext(singleIndex); this.resetCounters(); + this.browserManager.rebalanceContextPool(); this.logger.info( `✅ [Auth] Single account #${singleIndex} restart/refresh successful, usage count reset.` @@ -129,6 +130,7 @@ class AuthSwitcher { try { await this.browserManager.switchAccount(accountIndex); this.resetCounters(); + this.browserManager.rebalanceContextPool(); if (failedAccounts.length > 0) { this.logger.info( @@ -159,6 +161,7 @@ class AuthSwitcher { try { await this.browserManager.switchAccount(originalStartAccount); this.resetCounters(); + this.browserManager.rebalanceContextPool(); this.logger.info( `✅ [Auth] Final attempt succeeded! Switched to account #${originalStartAccount}.` ); @@ -215,6 +218,7 @@ class AuthSwitcher { this.logger.info(`🔄 [Auth] Starting switch to specified account #${targetIndex}...`); await this.browserManager.switchAccount(targetIndex); this.resetCounters(); + this.browserManager.rebalanceContextPool(); this.logger.info(`✅ [Auth] Successfully switched to account #${targetIndex}, counters reset.`); return { newIndex: targetIndex, success: true }; } catch (error) { diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index c515d1a..331a38c 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -27,6 +27,10 @@ class BrowserManager { // Map: authIndex -> {context, page, healthMonitorInterval} this.contexts = new Map(); + // Context pool state tracking + this.initializingContexts = new Set(); // Indices currently being initialized in background + this.abortedContexts = new Set(); // Indices that should be aborted during background init + // Legacy single context references (for backward compatibility) this.context = null; this.page = null; @@ -159,6 +163,28 @@ class BrowserManager { } } + /** + * Get pool target indices based on current account and rotation order + * @param {number} maxContexts - Max pool size (0 = unlimited) + * @returns {number[]} Target indices for the pool + */ + // _getPoolTargetIndices(maxContexts) { + // const rotation = this.authSource.getRotationIndices(); + // if (rotation.length === 0) return []; + // if (maxContexts === 0 || maxContexts >= rotation.length) return [...rotation]; + // + // const currentCanonical = + // this._currentAuthIndex >= 0 ? this.authSource.getCanonicalIndex(this._currentAuthIndex) : null; + // const startPos = currentCanonical !== null ? rotation.indexOf(currentCanonical) : -1; + // const start = startPos >= 0 ? startPos : 0; + // + // const result = []; + // for (let i = 0; i < maxContexts && i < rotation.length; i++) { + // result.push(rotation[(start + i) % rotation.length]); + // } + // return result; + // } + /** * Interface: Notify user activity * Used to force wake up the Launch detection when a request comes in @@ -1155,97 +1181,173 @@ class BrowserManager { } /** - * Preload all available auth contexts at startup - * This method initializes all contexts in sequentially (one by one) and keeps them ready for instant switching - * @returns {Promise<{successful: number[], failed: Array<{index: number, error: string}>}>} + * Preload a pool of contexts at startup + * Synchronously initializes the first context, then starts remaining in background + * @param {number[]} startupOrder - Ordered list of auth indices to try + * @param {number} maxContexts - Max pool size (0 = unlimited) + * @returns {Promise<{firstReady: number|null}>} */ - async preloadAllContexts() { - this.logger.info("=================================================="); - this.logger.info("🚀 [MultiContext] Starting preload of all auth contexts..."); - this.logger.info("=================================================="); + async preloadContextPool(startupOrder, maxContexts) { + const poolSize = maxContexts === 0 ? startupOrder.length : Math.min(maxContexts, startupOrder.length); + this.logger.info( + `🚀 [ContextPool] Starting pool preload (pool=${poolSize}, order=[${startupOrder.join(", ")}])...` + ); - try { - // Launch browser if not already running - const proxyConfig = parseProxyFromEnv(); - if (!this.browser) { - this.logger.info("🚀 [Browser] Launching main browser instance..."); - if (!fs.existsSync(this.browserExecutablePath)) { - throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); - } - this.browser = await firefox.launch({ - args: this.launchArgs, - executablePath: this.browserExecutablePath, - firefoxUserPrefs: this.firefoxUserPrefs, - headless: true, - ...(proxyConfig ? { proxy: proxyConfig } : {}), - }); - this.browser.on("disconnected", () => { - if (!this.isClosingIntentionally) { - this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); - } else { - this.logger.info("[Browser] Main browser closed intentionally."); - } + // Launch browser if not already running + if (!this.browser) { + await this._ensureBrowser(); + } - this.browser = null; - this._cleanupAllContexts(); - }); - this.logger.info("✅ [Browser] Main browser instance launched successfully."); - } + // Synchronously try ALL indices until one succeeds (fallback beyond poolSize) + let firstReady = null; + let syncEndIdx = 0; - // Get all available auth indices - const allAuthIndices = this.authSource.availableIndices; - if (allAuthIndices.length === 0) { - this.logger.warn("[MultiContext] No auth files found, skipping preload."); - return { failed: [], successful: [] }; + for (let i = 0; i < startupOrder.length; i++) { + try { + this.logger.info(`[ContextPool] Initializing context #${startupOrder[i]}...`); + await this._initializeContext(startupOrder[i]); + firstReady = startupOrder[i]; + syncEndIdx = i + 1; + this.logger.info(`✅ [ContextPool] First context #${startupOrder[i]} ready.`); + break; + } catch (error) { + this.logger.error(`❌ [ContextPool] Context #${startupOrder[i]} failed: ${error.message}`); + syncEndIdx = i + 1; } + } - this.logger.info( - `[MultiContext] Found ${allAuthIndices.length} auth files to preload: [${allAuthIndices.join(", ")}]` - ); + if (firstReady === null) { + if (this.browser) await this.closeBrowser(); + return { firstReady: null }; + } - const successful = []; - const failed = []; + // Background: fill up to poolSize from remaining candidates (fire-and-forget) + const remaining = startupOrder.slice(syncEndIdx); + if (remaining.length > 0 && this.contexts.size < poolSize) { + this._preloadBackgroundContexts(remaining, poolSize); + } - // Preload contexts sequentially to avoid overwhelming the system - for (const authIndex of allAuthIndices) { - try { - this.logger.info(`[MultiContext] Preloading context for account #${authIndex}...`); - await this._initializeContext(authIndex); - successful.push(authIndex); - this.logger.info(`✅ [MultiContext] Account #${authIndex} context preloaded successfully.`); - } catch (error) { - this.logger.error(`❌ [MultiContext] Failed to preload account #${authIndex}: ${error.message}`); - failed.push({ error: error.message, index: authIndex }); - } - } + return { firstReady }; + } - this.logger.info("=================================================="); - this.logger.info( - `✅ [MultiContext] Preload complete: ${successful.length} successful, ${failed.length} failed` - ); - if (successful.length > 0) { - this.logger.info(` • Successful: [${successful.join(", ")}]`); + /** + * Launch browser instance if not already running + */ + async _ensureBrowser() { + if (this.browser) return; + + const proxyConfig = parseProxyFromEnv(); + this.logger.info("🚀 [Browser] Launching main browser instance..."); + if (!fs.existsSync(this.browserExecutablePath)) { + this._currentAuthIndex = -1; + throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); + } + this.browser = await firefox.launch({ + args: this.launchArgs, + executablePath: this.browserExecutablePath, + firefoxUserPrefs: this.firefoxUserPrefs, + headless: true, + ...(proxyConfig ? { proxy: proxyConfig } : {}), + }); + this.browser.on("disconnected", () => { + if (!this.isClosingIntentionally) { + this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); + } else { + this.logger.info("[Browser] Main browser closed intentionally."); } - if (failed.length > 0) { - this.logger.info(` • Failed: [${failed.map(f => f.index).join(", ")}]`); + this.browser = null; + this._cleanupAllContexts(); + }); + this.logger.info("✅ [Browser] Main browser instance launched successfully."); + } + + /** + * Background sequential initialization of contexts (fire-and-forget) + * @param {number[]} indices - Auth indices to initialize (candidates, may exceed pool size) + * @param {number} maxPoolSize - Stop when this.contexts.size reaches this limit (0 = no limit) + */ + async _preloadBackgroundContexts(indices, maxPoolSize = 0) { + this.logger.info( + `[ContextPool] Background preload starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` + ); + for (const authIndex of indices) { + if (maxPoolSize > 0 && this.contexts.size >= maxPoolSize) break; + if (this.contexts.has(authIndex)) continue; + + this.initializingContexts.add(authIndex); + try { + this.logger.info(`[ContextPool] Background init context #${authIndex}...`); + await this._initializeContext(authIndex); + this.logger.info(`✅ [ContextPool] Background context #${authIndex} ready.`); + } catch (error) { + this.logger.error(`❌ [ContextPool] Background context #${authIndex} failed: ${error.message}`); + } finally { + this.initializingContexts.delete(authIndex); } - this.logger.info("=================================================="); + } + this.logger.info(`[ContextPool] Background preload complete.`); + } - // Clean up browser if all contexts failed to preload - if (successful.length === 0 && this.browser) { - this.logger.warn("[MultiContext] All contexts failed to preload, closing browser to free resources..."); - await this.closeBrowser(); + /** + * Rebalance context pool after account changes + * Removes excess contexts and starts missing ones in background + */ + async rebalanceContextPool() { + const maxContexts = this.config.maxContexts; + const poolSize = maxContexts === 0 ? 0 : maxContexts; + + // Build full rotation ordered from current account + const rotation = this.authSource.getRotationIndices(); + const currentCanonical = + this._currentAuthIndex >= 0 ? this.authSource.getCanonicalIndex(this._currentAuthIndex) : null; + const startPos = currentCanonical !== null ? Math.max(rotation.indexOf(currentCanonical), 0) : 0; + const ordered = []; + for (let i = 0; i < rotation.length; i++) { + ordered.push(rotation[(startPos + i) % rotation.length]); + } + + // Targets = first poolSize from ordered (or all if unlimited) + const targets = new Set(poolSize === 0 ? ordered : ordered.slice(0, poolSize)); + + // Remove contexts not in targets (except current) + const toRemove = []; + for (const idx of this.contexts.keys()) { + if (!targets.has(idx) && idx !== this._currentAuthIndex) { + toRemove.push(idx); } + } - return { failed, successful }; - } catch (error) { - // Catastrophic failure during preload - clean up any resources - this.logger.error(`❌ [MultiContext] Catastrophic failure during preload: ${error.message}`); - if (this.browser) { - this.logger.warn("[MultiContext] Cleaning up browser due to catastrophic failure..."); - await this.closeBrowser(); + // Candidates: contexts that will be active after toRemove is processed + // This ensures accounts being removed are considered "free slots" for candidates + const activeContexts = new Set([...this.contexts.keys()].filter(idx => !toRemove.includes(idx))); + const candidates = ordered.filter(idx => !activeContexts.has(idx) && !this.initializingContexts.has(idx)); + + this.logger.info( + `[ContextPool] Rebalance: targets=[${[...targets]}], remove=[${toRemove}], candidates=[${candidates}]` + ); + + for (const idx of toRemove) { + await this.closeContext(idx); + } + + // Preload candidates if we have room in the pool + if (candidates.length > 0 && (poolSize === 0 || this.contexts.size < poolSize)) { + this._preloadBackgroundContexts(candidates, poolSize); + } + } + + /** + * Wait for a background context initialization to complete + * @param {number} authIndex - The auth index to wait for + * @param {number} timeoutMs - Timeout in milliseconds + */ + async _waitForContextInit(authIndex, timeoutMs = 120000) { + const start = Date.now(); + while (this.initializingContexts.has(authIndex)) { + if (Date.now() - start > timeoutMs) { + throw new Error(`Timeout waiting for context #${authIndex} initialization`); } - throw error; + await new Promise(r => setTimeout(r, 500)); } } @@ -1367,32 +1469,15 @@ class BrowserManager { } } + // Wait for background initialization if in progress + if (this.initializingContexts.has(authIndex)) { + this.logger.info(`[Browser] Context #${authIndex} is being initialized in background, waiting...`); + await this._waitForContextInit(authIndex); + } + // Check if browser is running, launch if needed if (!this.browser) { - const proxyConfig = parseProxyFromEnv(); - this.logger.info("🚀 [Browser] Main browser instance not running, performing first-time launch..."); - if (!fs.existsSync(this.browserExecutablePath)) { - this._currentAuthIndex = -1; - throw new Error(`Browser executable not found at path: ${this.browserExecutablePath}`); - } - this.browser = await firefox.launch({ - args: this.launchArgs, - executablePath: this.browserExecutablePath, - firefoxUserPrefs: this.firefoxUserPrefs, - headless: true, - ...(proxyConfig ? { proxy: proxyConfig } : {}), - }); - this.browser.on("disconnected", () => { - if (!this.isClosingIntentionally) { - this.logger.error("❌ [Browser] Main browser unexpectedly disconnected!"); - } else { - this.logger.info("[Browser] Main browser closed intentionally."); - } - - this.browser = null; - this._cleanupAllContexts(); - }); - this.logger.info("✅ [Browser] Main browser instance successfully launched."); + await this._ensureBrowser(); } // Check if context already exists (fast switch path) @@ -1654,8 +1739,15 @@ class BrowserManager { * @param {number} authIndex - The auth index to close */ async closeContext(authIndex) { + // If context is being initialized in background, signal abort and wait + if (this.initializingContexts.has(authIndex)) { + this.logger.info(`[Browser] Context #${authIndex} is being initialized, marking for abort and waiting...`); + this.abortedContexts.add(authIndex); + await this._waitForContextInit(authIndex); + this.abortedContexts.delete(authIndex); + } + if (!this.contexts.has(authIndex)) { - this.logger.warn(`[Browser] Context #${authIndex} not found, nothing to close.`); return; } @@ -1718,6 +1810,8 @@ class BrowserManager { // Reset all references this.contexts.clear(); + this.initializingContexts.clear(); + this.abortedContexts.clear(); this.context = null; this.page = null; this._currentAuthIndex = -1; diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index 4ae6456..be8e5ea 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -120,28 +120,7 @@ class ProxyServerSystem extends EventEmitter { return; // Exit early } - // Preload all contexts at startup - this.logger.info("[System] Starting multi-context preload..."); - try { - this.requestHandler.authSwitcher.isSystemBusy = true; - const preloadResult = await this.browserManager.preloadAllContexts(); - - if (preloadResult.successful.length === 0) { - this.logger.error("[System] Failed to preload any contexts!"); - this.emit("started"); - return; - } - - this.logger.info(`[System] ✅ Preloaded ${preloadResult.successful.length} contexts successfully.`); - } catch (error) { - this.logger.error(`[System] ❌ Context preload failed: ${error.message}`); - this.emit("started"); - return; - } finally { - this.requestHandler.authSwitcher.isSystemBusy = false; - } - - // Determine which context to activate first + // Determine startup order let startupOrder = allRotationIndices.length > 0 ? [...allRotationIndices] : [...allAvailableIndices]; const hasInitialAuthIndex = Number.isInteger(initialAuthIndex); if (hasInitialAuthIndex) { @@ -149,7 +128,7 @@ class ProxyServerSystem extends EventEmitter { if (canonicalInitialIndex !== null && startupOrder.includes(canonicalInitialIndex)) { if (canonicalInitialIndex !== initialAuthIndex) { this.logger.warn( - `[System] Specified startup index #${initialAuthIndex} is a duplicate for the same email, using latest auth index #${canonicalInitialIndex} instead.` + `[System] Specified startup index #${initialAuthIndex} is a duplicate, using latest auth index #${canonicalInitialIndex} instead.` ); } else { this.logger.info( @@ -168,28 +147,27 @@ class ProxyServerSystem extends EventEmitter { ); } - // Activate the first context (fast switch since already preloaded) - let isStarted = false; - for (const index of startupOrder) { - try { - this.logger.info(`[System] Activating pre-loaded context for account #${index}...`); - this.requestHandler.authSwitcher.isSystemBusy = true; - await this.browserManager.launchOrSwitchContext(index); + // Context pool startup + const maxContexts = this.config.maxContexts; + this.logger.info(`[System] Starting context pool (maxContexts=${maxContexts})...`); - isStarted = true; - this.logger.info(`[System] ✅ Successfully activated account #${index}!`); - break; - } catch (error) { - this.logger.error(`[System] ❌ Failed to activate account #${index}. Reason: ${error.message}`); - } finally { - this.requestHandler.authSwitcher.isSystemBusy = false; + try { + this.requestHandler.authSwitcher.isSystemBusy = true; + const { firstReady } = await this.browserManager.preloadContextPool(startupOrder, maxContexts); + + if (firstReady === null) { + this.logger.error("[System] Failed to initialize any context!"); + this.emit("started"); + return; } - } - if (!isStarted) { - this.logger.warn( - "[System] All authentication sources failed to activate. Starting in account binding mode without an active account." - ); + // Activate first ready context (fast switch since already preloaded) + await this.browserManager.launchOrSwitchContext(firstReady); + this.logger.info(`[System] ✅ Successfully activated account #${firstReady}!`); + } catch (error) { + this.logger.error(`[System] ❌ Startup failed: ${error.message}`); + } finally { + this.requestHandler.authSwitcher.isSystemBusy = false; } this.emit("started"); diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index cb467d2..c7de6bc 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -107,12 +107,16 @@ class StatusRoutes { app.get("/api/status", isAuthenticated, async (req, res) => { // Force a reload of auth sources on each status check for real-time accuracy - this.serverSystem.authSource.reloadAuthSources(); + const hasChanges = this.serverSystem.authSource.reloadAuthSources(); const { authSource, browserManager, requestHandler } = this.serverSystem; // If the system is busy switching accounts, skip the validity check to prevent race conditions if (requestHandler.isSystemBusy) { + // Rebalance context pool if auth files changed + if (hasChanges) { + this.serverSystem.browserManager.rebalanceContextPool(); + } return res.json(this._getStatusData()); } @@ -133,6 +137,12 @@ class StatusRoutes { } } + // Rebalance context pool if auth files changed (e.g., user manually added/removed files) + if (hasChanges) { + this.logger.info("[System] Auth file changes detected, rebalancing context pool..."); + this.serverSystem.browserManager.rebalanceContextPool(); + } + res.json(this._getStatusData()); }); @@ -232,6 +242,11 @@ class StatusRoutes { }); } + // Rebalance context pool after dedup + if (removedIndices.length > 0) { + this.serverSystem.browserManager.rebalanceContextPool(); + } + return res.status(200).json({ message: "accountDedupSuccess", removedIndices, @@ -325,6 +340,11 @@ class StatusRoutes { } } + // Rebalance context pool after batch delete + if (successIndices.length > 0) { + this.serverSystem.browserManager.rebalanceContextPool(); + } + if (failedIndices.length > 0) { return res.status(207).json({ failedIndices, @@ -472,6 +492,9 @@ class StatusRoutes { // Then close WebSocket connection this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); + // Rebalance context pool after delete + this.serverSystem.browserManager.rebalanceContextPool(); + this.logger.info( `[WebUI] Account #${targetIndex} deleted via web interface. Previous current account: #${currentAuthIndex}` ); @@ -584,6 +607,9 @@ class StatusRoutes { // Reload auth sources to pick up changes this.serverSystem.authSource.reloadAuthSources(); + // Rebalance context pool to pick up new account + this.serverSystem.browserManager.rebalanceContextPool(); + this.logger.info(`[WebUI] File uploaded via API: generated ${newFilename}`); res.status(200).json({ filename: newFilename, message: "File uploaded successfully" }); } catch (error) { diff --git a/src/utils/ConfigLoader.js b/src/utils/ConfigLoader.js index 24ebf0f..f7b886b 100644 --- a/src/utils/ConfigLoader.js +++ b/src/utils/ConfigLoader.js @@ -30,6 +30,7 @@ class ConfigLoader { host: "0.0.0.0", httpPort: 7860, immediateSwitchStatusCodes: [429, 503], + maxContexts: 1, maxRetries: 3, retryDelay: 2000, streamingMode: "real", @@ -51,6 +52,8 @@ class ConfigLoader { if (process.env.RETRY_DELAY) config.retryDelay = Math.max(50, parseInt(process.env.RETRY_DELAY, 10)) || config.retryDelay; if (process.env.WS_PORT) config.wsPort = parseInt(process.env.WS_PORT, 10) || config.wsPort; + if (process.env.MAX_CONTEXTS !== undefined) + config.maxContexts = Math.max(0, parseInt(process.env.MAX_CONTEXTS, 10)) || config.maxContexts; if (process.env.CAMOUFOX_EXECUTABLE_PATH) config.browserExecutablePath = process.env.CAMOUFOX_EXECUTABLE_PATH; if (process.env.API_KEYS) { config.apiKeys = process.env.API_KEYS.split(","); @@ -138,6 +141,7 @@ class ConfigLoader { this.logger.info(` Force Web Search: ${config.forceWebSearch}`); this.logger.info(` Force URL Context: ${config.forceUrlContext}`); this.logger.info(` Auto Update Auth: ${config.enableAuthUpdate}`); + this.logger.info(` Max Contexts: ${config.maxContexts === 0 ? "Unlimited" : config.maxContexts}`); this.logger.info( ` Usage-based Switch Threshold: ${ config.switchOnUses > 0 ? `Switch after every ${config.switchOnUses} requests` : "Disabled" From f248a77a46057370e77505f259e8e02a41b9a5f8 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 00:39:00 +0800 Subject: [PATCH 23/47] chore: update .gitignore and add CLAUDE.md for project documentation --- .gitignore | 1 + CLAUDE.md | 212 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 749e642..15e9330 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea/ .cursor/ .DS_Store +.claude/ # Environment variables (keep .env.example) .env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c48f6e9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,212 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +AIStudioToAPI is a proxy server that wraps Google AI Studio's web interface and exposes it as API endpoints compatible with OpenAI, Gemini, and Anthropic API formats. The system uses browser automation (Playwright with Camoufox/Firefox) to interact with AI Studio's web interface and translates API requests into browser interactions. + +## Common Commands + +### Development + +```bash +npm run dev # Start dev server with hot reload (server + UI) +npm run dev:server # Start only the server in dev mode +npm run dev:ui # Build UI in watch mode +``` + +### Production + +```bash +npm start # Build UI and start production server +``` + +### Authentication Setup + +```bash +npm run setup-auth # Interactive auth setup (launches browser) +npm run save-auth # Save authentication credentials +``` + +### Code Quality + +```bash +npm run lint # Lint JavaScript and CSS +npm run lint:fix # Auto-fix linting issues +npm run lint:js # Lint only JavaScript files +npm run lint:css # Lint only CSS/Less files +npm run format # Format all files with Prettier +npm run format:check # Check formatting without changes +``` + +### UI Development + +```bash +npm run build:ui # Build Vue.js UI for production +npm run preview:ui # Preview built UI +``` + +## Architecture + +### Core System Components + +The system follows a modular architecture with clear separation of concerns: + +**ProxyServerSystem** (`src/core/ProxyServerSystem.js`) + +- Main orchestrator that integrates all modules +- Manages HTTP/WebSocket servers +- Coordinates between authentication, browser management, and request handling +- Entry point: `main.js` instantiates and starts this system + +**BrowserManager** (`src/core/BrowserManager.js`) + +- Manages headless Firefox/Camoufox browser instances +- Implements multi-context architecture: maintains a pool of browser contexts (Map: authIndex -> {context, page, healthMonitorInterval}) +- Handles context switching between different Google accounts +- Injects and manages the client-side script (`build.js`) that communicates with AI Studio +- Supports background context initialization and rebalancing + +**ConnectionRegistry** (`src/core/ConnectionRegistry.js`) + +- Manages WebSocket connections from browser contexts +- Routes messages to appropriate MessageQueue instances +- Implements grace period for reconnection attempts +- Supports multiple concurrent connections (one per auth context) + +**RequestHandler** (`src/core/RequestHandler.js`) + +- Processes incoming API requests +- Coordinates retry logic and account switching +- Delegates to AuthSwitcher for account management +- Delegates to FormatConverter for API format translation + +**AuthSwitcher** (`src/auth/AuthSwitcher.js`) + +- Handles automatic account switching based on: + - Usage count (SWITCH_ON_USES) + - Failure threshold (FAILURE_THRESHOLD) + - Immediate status codes (IMMEDIATE_SWITCH_STATUS_CODES: 429, 503) +- Manages system busy state during switches + +**FormatConverter** (`src/core/FormatConverter.js`) + +- Converts between API formats (OpenAI ↔ Gemini ↔ Anthropic) +- Handles streaming and non-streaming responses + +**AuthSource** (`src/auth/AuthSource.js`) + +- Loads authentication data from `configs/auth/auth-N.json` files +- Validates and deduplicates accounts by email +- Maintains rotation indices for account switching + +### Request Flow + +1. Client sends API request (OpenAI/Gemini/Anthropic format) → Express routes +2. RequestHandler receives request → FormatConverter normalizes to Gemini format +3. RequestHandler checks ConnectionRegistry for active WebSocket +4. If no connection: BrowserManager initializes/switches browser context +5. Request sent via WebSocket to browser context → injected script interacts with AI Studio +6. Response streams back via WebSocket → FormatConverter translates to requested format +7. On failure: AuthSwitcher may trigger account switch based on configured thresholds + +### Multi-Context Architecture + +The system maintains multiple browser contexts simultaneously: + +- Each Google account gets its own browser context and page +- Contexts are initialized on-demand or in background +- Current account tracked via `browserManager.currentAuthIndex` +- Background initialization prevents request delays when switching accounts +- Context pool rebalancing ensures optimal resource usage + +### UI Structure + +- **Frontend**: Vue.js 3 + Element Plus + Vite +- **Location**: `ui/` directory +- **Build output**: `ui/dist/` (served by Express) +- **Features**: Account management, VNC login, status monitoring, auth file upload/download + +## Configuration + +### Environment Variables + +Key variables (see `.env.example` for full list): + +- `PORT`: API server port (default: 7860) +- `WS_PORT`: WebSocket port for browser communication (default: 9998) +- `API_KEYS`: Comma-separated API keys for client authentication +- `INITIAL_AUTH_INDEX`: Starting account index (default: 0) +- `STREAMING_MODE`: "real" or "fake" streaming +- `SWITCH_ON_USES`: Auto-switch after N requests (default: 40) +- `FAILURE_THRESHOLD`: Switch after N consecutive failures (default: 3) +- `IMMEDIATE_SWITCH_STATUS_CODES`: Status codes triggering immediate switch (default: 429,503) +- `HTTP_PROXY`/`HTTPS_PROXY`: Proxy configuration for Google services +- `CAMOUFOX_EXECUTABLE_PATH`: Custom browser executable path +- `LOG_LEVEL`: Set to "DEBUG" for verbose logging + +### Model Configuration + +Edit `configs/models.json` to customize available models and their settings. + +### Authentication Files + +- Location: `configs/auth/auth-N.json` (N = 0, 1, 2, ...) +- Format: Playwright browser context state (cookies, localStorage, etc.) +- Generated by: `npm run setup-auth` or VNC login in Docker + +## Key Technical Details + +### Browser Automation + +- Uses Playwright with Camoufox (privacy-focused Firefox fork) +- Injects `build.js` script into AI Studio page for WebSocket communication +- Script location: `public/build.js` (built from `ui/app/`) +- Health monitoring via periodic checks and reconnection logic + +### WebSocket Communication + +- Browser contexts connect to WebSocket server on WS_PORT +- Each connection identified by authIndex +- MessageQueue pattern for request/response correlation +- Grace period (60s) for reconnection before triggering callback + +### Account Switching + +- Automatic switching based on usage/failures +- Supports immediate switching on specific HTTP status codes +- System busy flag prevents concurrent switches +- Lightweight reconnect attempts before full context switch + +### Streaming Modes + +- **Real streaming**: True SSE streaming from AI Studio +- **Fake streaming**: Buffer complete response, then stream to client + +## Development Notes + +### Testing + +- Test files in `test/` directory +- Client test scripts in `scripts/client/` +- Auth test scripts in `scripts/auth/` + +### Linting & Formatting + +- ESLint for JavaScript (includes Vue plugin) +- Stylelint for CSS/Less +- Prettier for code formatting +- Pre-commit hooks via Husky + lint-staged + +### Docker + +- Dockerfile supports VNC for browser interaction +- Auth files mounted via volume: `/app/configs/auth` +- Environment variables for configuration + +### Git Workflow + +- Main branch: `release-workflow` +- Current branch: `performance/muli-context` (multi-context optimization) +- Recent work focuses on context pool management and connection handling From 4a6197312ddf0141dda7844157ab54faa41d2e63 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 10:57:42 +0800 Subject: [PATCH 24/47] refactor: improve context initialization with abort checks and logging enhancements --- src/core/BrowserManager.js | 56 +++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 331a38c..eab082e 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1294,7 +1294,8 @@ class BrowserManager { */ async rebalanceContextPool() { const maxContexts = this.config.maxContexts; - const poolSize = maxContexts === 0 ? 0 : maxContexts; + // maxContexts === 0 means unlimited pool size + const isUnlimited = maxContexts === 0; // Build full rotation ordered from current account const rotation = this.authSource.getRotationIndices(); @@ -1306,8 +1307,8 @@ class BrowserManager { ordered.push(rotation[(startPos + i) % rotation.length]); } - // Targets = first poolSize from ordered (or all if unlimited) - const targets = new Set(poolSize === 0 ? ordered : ordered.slice(0, poolSize)); + // Targets = first maxContexts from ordered (or all if unlimited) + const targets = new Set(isUnlimited ? ordered : ordered.slice(0, maxContexts)); // Remove contexts not in targets (except current) const toRemove = []; @@ -1331,8 +1332,8 @@ class BrowserManager { } // Preload candidates if we have room in the pool - if (candidates.length > 0 && (poolSize === 0 || this.contexts.size < poolSize)) { - this._preloadBackgroundContexts(candidates, poolSize); + if (candidates.length > 0 && (isUnlimited || this.contexts.size < maxContexts)) { + this._preloadBackgroundContexts(candidates, isUnlimited ? 0 : maxContexts); } } @@ -1358,6 +1359,11 @@ class BrowserManager { * @returns {Promise<{context, page}>} */ async _initializeContext(authIndex) { + // Check if this context has been marked for abort before starting + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + const proxyConfig = parseProxyFromEnv(); const storageStateObject = this.authSource.getAuth(authIndex); if (!storageStateObject) { @@ -1374,6 +1380,11 @@ class BrowserManager { let page = null; try { + // Check abort status before expensive operations + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + context = await this.browser.newContext({ deviceScaleFactor: 1, storageState: storageStateObject, @@ -1381,6 +1392,11 @@ class BrowserManager { ...(proxyConfig ? { proxy: proxyConfig } : {}), }); + // Check abort status after context creation + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + // Inject Privacy Script immediately after context creation const privacyScript = this._getPrivacyProtectionScript(authIndex); await context.addInitScript(privacyScript); @@ -1415,11 +1431,27 @@ class BrowserManager { } }); + // Check abort status before navigation (most time-consuming part) + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + await this._navigateAndWakeUpPage(page, `[Context#${authIndex}]`); + + // Check abort status after navigation + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + await this._checkPageStatusAndErrors(page, `[Context#${authIndex}]`); await this._handlePopups(page, `[Context#${authIndex}]`); await this._injectScriptToEditor(page, buildScriptContent, `[Context#${authIndex}]`); + // Final check before adding to contexts map + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + // Save to contexts map this.contexts.set(authIndex, { context, @@ -1432,7 +1464,13 @@ class BrowserManager { return { context, page }; } catch (error) { - this.logger.error(`❌ [Browser] Context initialization failed for index ${authIndex}, cleaning up...`); + // Check if this is an abort error + const isAbortError = error.message && error.message.includes("aborted for index"); + if (isAbortError) { + this.logger.info(`[Browser] Context #${authIndex} initialization aborted as requested.`); + } else { + this.logger.error(`❌ [Browser] Context initialization failed for index ${authIndex}, cleaning up...`); + } // Remove from contexts map if it was added if (this.contexts.has(authIndex)) { @@ -1444,7 +1482,11 @@ class BrowserManager { if (context) { try { await context.close(); - this.logger.info(`[Browser] Cleaned up leaked context for index ${authIndex}`); + if (isAbortError) { + this.logger.info(`[Browser] Cleaned up aborted context for index ${authIndex}`); + } else { + this.logger.info(`[Browser] Cleaned up leaked context for index ${authIndex}`); + } } catch (closeError) { this.logger.warn(`[Browser] Failed to close context during cleanup: ${closeError.message}`); } From 032e0ac6faa0cf24c1f463522d0bb4315a78c0d8 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 14:34:44 +0800 Subject: [PATCH 25/47] refactor: optimize background context preloading with generation control and improved logging --- src/core/BrowserManager.js | 125 ++++++++++++++++++++++++++++++++----- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index eab082e..243c863 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -30,6 +30,7 @@ class BrowserManager { // Context pool state tracking this.initializingContexts = new Set(); // Indices currently being initialized in background this.abortedContexts = new Set(); // Indices that should be aborted during background init + this._preloadGeneration = 0; // Generation counter to ensure only one preload task is active // Legacy single context references (for backward compatibility) this.context = null; @@ -1200,19 +1201,16 @@ class BrowserManager { // Synchronously try ALL indices until one succeeds (fallback beyond poolSize) let firstReady = null; - let syncEndIdx = 0; for (let i = 0; i < startupOrder.length; i++) { try { this.logger.info(`[ContextPool] Initializing context #${startupOrder[i]}...`); await this._initializeContext(startupOrder[i]); firstReady = startupOrder[i]; - syncEndIdx = i + 1; this.logger.info(`✅ [ContextPool] First context #${startupOrder[i]} ready.`); break; } catch (error) { this.logger.error(`❌ [ContextPool] Context #${startupOrder[i]} failed: ${error.message}`); - syncEndIdx = i + 1; } } @@ -1221,10 +1219,36 @@ class BrowserManager { return { firstReady: null }; } - // Background: fill up to poolSize from remaining candidates (fire-and-forget) - const remaining = startupOrder.slice(syncEndIdx); - if (remaining.length > 0 && this.contexts.size < poolSize) { - this._preloadBackgroundContexts(remaining, poolSize); + // Background: calculate remaining contexts using rotation order (same logic as rebalanceContextPool) + // This ensures startup pool matches the rotation order used during account switching + const rotation = this.authSource.getRotationIndices(); + const currentCanonical = this.authSource.getCanonicalIndex(firstReady); + const startPos = currentCanonical !== null ? Math.max(rotation.indexOf(currentCanonical), 0) : 0; + const ordered = []; + for (let i = 0; i < rotation.length; i++) { + ordered.push(rotation[(startPos + i) % rotation.length]); + } + + // Calculate how many more contexts we need to reach poolSize + const needCount = poolSize - this.contexts.size; + if (needCount > 0) { + // Get candidates from ordered list (excluding already initialized contexts) + // Convert existing contexts to canonical indices to handle duplicate accounts + const existingCanonical = new Set( + [...this.contexts.keys()].map(idx => this.authSource.getCanonicalIndex(idx) ?? idx) + ); + const candidates = ordered.filter( + idx => !existingCanonical.has(idx) && !this.initializingContexts.has(idx) + ); + + if (candidates.length > 0) { + this.logger.info( + `[ContextPool] Background preload will try [${candidates.join(", ")}] to reach pool size ${poolSize} (need ${needCount} more)` + ); + // Pass all candidates, not just the first needCount + // This allows the background task to try subsequent accounts if earlier ones fail + this._preloadBackgroundContexts(candidates, poolSize); + } } return { firstReady }; @@ -1263,29 +1287,74 @@ class BrowserManager { /** * Background sequential initialization of contexts (fire-and-forget) + * Only one instance should be active at a time - new calls supersede old ones * @param {number[]} indices - Auth indices to initialize (candidates, may exceed pool size) * @param {number} maxPoolSize - Stop when this.contexts.size reaches this limit (0 = no limit) */ async _preloadBackgroundContexts(indices, maxPoolSize = 0) { + // Increment generation to signal old tasks to stop + const generation = ++this._preloadGeneration; + this.logger.info( - `[ContextPool] Background preload starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` + `[ContextPool] Background preload #${generation} starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` ); + for (const authIndex of indices) { - if (maxPoolSize > 0 && this.contexts.size >= maxPoolSize) break; - if (this.contexts.has(authIndex)) continue; + // Check if browser is still available + if (!this.browser) { + this.logger.info(`[ContextPool] Background preload #${generation} stopped: browser is not available`); + break; + } + + // Check pool size limit + if (maxPoolSize > 0 && this.contexts.size >= maxPoolSize) { + this.logger.info(`[ContextPool] Pool size limit reached, stopping preload #${generation}`); + break; + } + + // Skip if already exists or being initialized by another task + if (this.contexts.has(authIndex)) { + this.logger.debug(`[ContextPool] Context #${authIndex} already exists, skipping`); + continue; + } + if (this.initializingContexts.has(authIndex)) { + this.logger.info( + `[ContextPool] Context #${authIndex} already being initialized by another task, skipping` + ); + continue; + } + + // Check if this task has been superseded BEFORE starting a new initialization + // This allows the current initialization to complete even if superseded + if (this._preloadGeneration !== generation) { + this.logger.info( + `[ContextPool] Background preload #${generation} superseded by newer task #${this._preloadGeneration}, stopping` + ); + break; + } this.initializingContexts.add(authIndex); try { - this.logger.info(`[ContextPool] Background init context #${authIndex}...`); + this.logger.info(`[ContextPool] Background preload #${generation} init context #${authIndex}...`); await this._initializeContext(authIndex); this.logger.info(`✅ [ContextPool] Background context #${authIndex} ready.`); } catch (error) { - this.logger.error(`❌ [ContextPool] Background context #${authIndex} failed: ${error.message}`); + // Check if this is an abort error (user deleted the account during initialization) + const isAbortError = error.message && error.message.includes("aborted for index"); + if (isAbortError) { + this.logger.info(`[ContextPool] Background context #${authIndex} aborted (account deleted)`); + } else { + this.logger.error(`❌ [ContextPool] Background context #${authIndex} failed: ${error.message}`); + } } finally { this.initializingContexts.delete(authIndex); } } - this.logger.info(`[ContextPool] Background preload complete.`); + + // Only log completion if this task wasn't superseded + if (this._preloadGeneration === generation) { + this.logger.info(`[ContextPool] Background preload #${generation} complete.`); + } } /** @@ -1311,16 +1380,38 @@ class BrowserManager { const targets = new Set(isUnlimited ? ordered : ordered.slice(0, maxContexts)); // Remove contexts not in targets (except current) + // Special handling: if current account is a duplicate (old version), also remove its canonical version const toRemove = []; + const currentCanonicalIndex = currentCanonical; // Already calculated above + const isDuplicateAccount = + this._currentAuthIndex >= 0 && + currentCanonicalIndex !== null && + currentCanonicalIndex !== this._currentAuthIndex; + for (const idx of this.contexts.keys()) { - if (!targets.has(idx) && idx !== this._currentAuthIndex) { + // Skip current account + if (idx === this._currentAuthIndex) continue; + + // If current is a duplicate, also remove the canonical version (we're using the old one) + if (isDuplicateAccount && idx === currentCanonicalIndex) { + toRemove.push(idx); + continue; + } + + // Remove if not in targets + if (!targets.has(idx)) { toRemove.push(idx); } } - // Candidates: contexts that will be active after toRemove is processed - // This ensures accounts being removed are considered "free slots" for candidates - const activeContexts = new Set([...this.contexts.keys()].filter(idx => !toRemove.includes(idx))); + // Candidates: all accounts from ordered that are not yet initialized + // Pass the full ordered list to allow fallback if target accounts fail + // The background task will stop when poolSize is reached + // Convert activeContexts to canonical indices to handle duplicate accounts + const activeContextsRaw = new Set([...this.contexts.keys()].filter(idx => !toRemove.includes(idx))); + const activeContexts = new Set( + [...activeContextsRaw].map(idx => this.authSource.getCanonicalIndex(idx) ?? idx) + ); const candidates = ordered.filter(idx => !activeContexts.has(idx) && !this.initializingContexts.has(idx)); this.logger.info( From c47b2d64ba551fb14a5abf54d86338ae961d9e4d Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 16:17:32 +0800 Subject: [PATCH 26/47] refactor: enhance context management with single context mode handling and active contexts display --- src/core/BrowserManager.js | 38 +++++++++++++++++++++++++++---------- src/routes/StatusRoutes.js | 15 +++++++++++++++ src/utils/ConfigLoader.js | 2 +- ui/app/pages/StatusPage.vue | 32 +++++++++++++++++++++++++++++++ ui/locales/en.json | 1 + ui/locales/zh.json | 1 + 6 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 243c863..b02eb7a 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1219,6 +1219,12 @@ class BrowserManager { return { firstReady: null }; } + // Early return if pool size is 1 (single context mode) - no need for background preload + if (poolSize === 1) { + this.logger.info(`[ContextPool] Single context mode (maxContexts=1), skipping background preload.`); + return { firstReady }; + } + // Background: calculate remaining contexts using rotation order (same logic as rebalanceContextPool) // This ensures startup pool matches the rotation order used during account switching const rotation = this.authSource.getRotationIndices(); @@ -1376,11 +1382,18 @@ class BrowserManager { ordered.push(rotation[(startPos + i) % rotation.length]); } - // Targets = first maxContexts from ordered (or all if unlimited) - const targets = new Set(isUnlimited ? ordered : ordered.slice(0, maxContexts)); + // Targets = first maxContexts from ordered (or all available if unlimited) + // In unlimited mode, include all valid accounts (rotation + duplicates) + let targets; + if (isUnlimited) { + targets = new Set(this.authSource.availableIndices); + } else { + targets = new Set(ordered.slice(0, maxContexts)); + } // Remove contexts not in targets (except current) // Special handling: if current account is a duplicate (old version), also remove its canonical version + // BUT only in limited mode - in unlimited mode, keep all contexts const toRemove = []; const currentCanonicalIndex = currentCanonical; // Already calculated above const isDuplicateAccount = @@ -1392,8 +1405,8 @@ class BrowserManager { // Skip current account if (idx === this._currentAuthIndex) continue; - // If current is a duplicate, also remove the canonical version (we're using the old one) - if (isDuplicateAccount && idx === currentCanonicalIndex) { + // If current is a duplicate AND we're in limited mode, remove the canonical version (we're using the old one) + if (!isUnlimited && isDuplicateAccount && idx === currentCanonicalIndex) { toRemove.push(idx); continue; } @@ -1543,12 +1556,17 @@ class BrowserManager { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } - // Save to contexts map - this.contexts.set(authIndex, { - context, - healthMonitorInterval: null, - page, - }); + // Save to contexts map - with atomic abort check to prevent race condition + // between the check above and actually adding to the map + if (!this.abortedContexts.has(authIndex)) { + this.contexts.set(authIndex, { + context, + healthMonitorInterval: null, + page, + }); + } else { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } // Update auth file await this._updateAuthFile(authIndex); diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index c7de6bc..50ec86c 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -242,6 +242,11 @@ class StatusRoutes { }); } + // Reload auth sources to update internal state immediately after dedup deletions + if (removedIndices.length > 0) { + authSource.reloadAuthSources(); + } + // Rebalance context pool after dedup if (removedIndices.length > 0) { this.serverSystem.browserManager.rebalanceContextPool(); @@ -316,6 +321,11 @@ class StatusRoutes { } } + // Reload auth sources to update internal state immediately after deletions + if (successIndices.length > 0) { + authSource.reloadAuthSources(); + } + // If current active account was deleted, close context first, then connection if (includesCurrent && successIndices.includes(currentAuthIndex)) { this.logger.warn( @@ -479,6 +489,9 @@ class StatusRoutes { try { authSource.removeAuth(targetIndex); + // Reload auth sources to update internal state immediately + authSource.reloadAuthSources(); + // Always close context first, then connection this.logger.info(`[WebUI] Account #${targetIndex} deleted. Closing context and connection...`); @@ -673,6 +686,7 @@ class StatusRoutes { logs: displayLogs.join("\n"), status: { accountDetails, + activeContextsCount: browserManager.contexts.size, apiKeySource: config.apiKeySource, browserConnected: !!this.serverSystem.connectionRegistry.getConnectionByAuth(currentAuthIndex), currentAccountName, @@ -691,6 +705,7 @@ class StatusRoutes { invalidIndicesRaw: invalidIndices, isSystemBusy: requestHandler.isSystemBusy, logMaxCount: limit, + maxContexts: config.maxContexts, rotationIndicesRaw: rotationIndices, streamingMode: this.serverSystem.streamingMode, usageCount, diff --git a/src/utils/ConfigLoader.js b/src/utils/ConfigLoader.js index f7b886b..40bd4e1 100644 --- a/src/utils/ConfigLoader.js +++ b/src/utils/ConfigLoader.js @@ -53,7 +53,7 @@ class ConfigLoader { config.retryDelay = Math.max(50, parseInt(process.env.RETRY_DELAY, 10)) || config.retryDelay; if (process.env.WS_PORT) config.wsPort = parseInt(process.env.WS_PORT, 10) || config.wsPort; if (process.env.MAX_CONTEXTS !== undefined) - config.maxContexts = Math.max(0, parseInt(process.env.MAX_CONTEXTS, 10)) || config.maxContexts; + config.maxContexts = Math.max(0, parseInt(process.env.MAX_CONTEXTS, 10)) ?? config.maxContexts; if (process.env.CAMOUFOX_EXECUTABLE_PATH) config.browserExecutablePath = process.env.CAMOUFOX_EXECUTABLE_PATH; if (process.env.API_KEYS) { config.apiKeys = process.env.API_KEYS.split(","); diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index d49dc63..5378100 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -347,6 +347,27 @@ {{ dedupedAvailableCount }} +
+ + + + + + {{ t("activeContexts") }} + + {{ activeContextsDisplay }} +
@@ -1475,6 +1496,7 @@ const { theme, setTheme } = useTheme(); const state = reactive({ accountDetails: [], + activeContextsCount: 0, apiKeySource: "", browserConnected: false, currentAuthIndex: -1, @@ -1494,6 +1516,7 @@ const state = reactive({ logMaxCount: 100, logs: t("loading"), logScrollTop: 0, + maxContexts: 1, releaseUrl: null, selectedAccounts: new Set(), // Selected account indices serviceConnected: false, @@ -1524,6 +1547,13 @@ const dedupedAvailableCount = computed(() => { return state.accountDetails.filter(acc => !acc.isDuplicate && !acc.isInvalid).length; }); +// Active contexts display (e.g., "1 / 3" or "1 / ∞") +const activeContextsDisplay = computed(() => { + const active = state.activeContextsCount; + const max = state.maxContexts; + return max === 0 ? `${active} / ∞` : `${active} / ${max}`; +}); + const isBusy = computed(() => state.isSwitchingAccount || state.isSystemBusy); const formattedLogs = computed(() => { @@ -2137,6 +2167,8 @@ const updateStatus = data => { state.debugModeEnabled = isEnabled(data.status.debugMode); state.currentAuthIndex = data.status.currentAuthIndex; state.accountDetails = data.status.accountDetails || []; + state.activeContextsCount = data.status.activeContextsCount || 0; + state.maxContexts = data.status.maxContexts ?? 1; const validIndices = new Set(state.accountDetails.map(acc => acc.index)); for (const idx of state.selectedAccounts) { diff --git a/ui/locales/en.json b/ui/locales/en.json index 9cd49eb..a04d8ea 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -16,6 +16,7 @@ "accountSwitchSuccess": "Switch successful! Account #{newIndex} activated.", "accountSwitchSuccessNext": "Switch successful! Switched to account #{newIndex}.", "actionsPanel": "Settings", + "activeContexts": "Concurrent Logins", "alreadyCurrentAccount": "This is already the current active account.", "apiKey": "API Key", "apiKeyPlaceholder": "API Key", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 18213bf..cb25126 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -16,6 +16,7 @@ "accountSwitchSuccess": "切换成功!账号 {newIndex} 已激活。", "accountSwitchSuccessNext": "切换成功!已切换到账号 {newIndex}。", "actionsPanel": "设置", + "activeContexts": "同时登录的账号", "alreadyCurrentAccount": "当前已是该账号,无需切换。", "apiKey": "API 密钥", "apiKeyPlaceholder": "API 密钥", From 1b3177e581ae9ffedfee6dbdf52bc5f7a72371e6 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 16:51:06 +0800 Subject: [PATCH 27/47] chore: change logging level from info to debug for improved verbosity in BrowserManager and RequestHandler --- scripts/client/build.js | 4 +-- src/core/BrowserManager.js | 56 ++++++++++++++++++++------------------ src/core/RequestHandler.js | 4 +-- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/scripts/client/build.js b/scripts/client/build.js index 30f2ca2..0237960 100644 --- a/scripts/client/build.js +++ b/scripts/client/build.js @@ -210,7 +210,7 @@ class RequestProcessor { const attemptPromise = (async () => { try { - Logger.output(`Executing request:`, requestSpec.method, requestSpec.path); + Logger.debug(`Executing request:`, requestSpec.method, requestSpec.path); const requestUrl = this._constructUrl(requestSpec); const requestConfig = this._buildRequestConfig(requestSpec, abortController.signal); @@ -583,7 +583,7 @@ class ProxySystem extends EventTarget { async _processProxyRequest(requestSpec) { const operationId = requestSpec.request_id; const mode = requestSpec.streaming_mode || "fake"; - Logger.output(`Browser received request`); + Logger.debug(`Browser received request`); let cancelTimeout; try { diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index b02eb7a..b0f0583 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -340,14 +340,14 @@ class BrowserManager { 'button span:has-text("Code")', ]; - this.logger.info('[Browser] Trying to locate "Code" entry point using smart selectors...'); + this.logger.debug('[Browser] Trying to locate "Code" entry point using smart selectors...'); for (const selector of selectors) { try { // Use a short timeout for quick fail-over const element = page.locator(selector).first(); if (await element.isVisible({ timeout: 2000 })) { - this.logger.info(`[Browser] ✅ Smart match: "${selector}", clicking...`); + this.logger.debug(`[Browser] ✅ Smart match: "${selector}", clicking...`); // Direct click with force as per new logic await element.click({ force: true, timeout: 10000 }); return true; @@ -465,7 +465,7 @@ class BrowserManager { * @param {string} logPrefix - Log prefix for step messages (e.g., "[Browser]" or "[Reconnect]") */ async _injectScriptToEditor(page, buildScriptContent, logPrefix = "[Browser]") { - this.logger.info(`${logPrefix} Preparing UI interaction, forcefully removing all possible overlay layers...`); + this.logger.debug(`${logPrefix} Preparing UI interaction, forcefully removing all possible overlay layers...`); /* eslint-disable no-undef */ await page.evaluate(() => { const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); @@ -476,11 +476,11 @@ class BrowserManager { }); /* eslint-enable no-undef */ - this.logger.info(`${logPrefix} (Step 1/5) Preparing to click "Code" button...`); + this.logger.debug(`${logPrefix} (Step 1/5) Preparing to click "Code" button...`); const maxTimes = 15; for (let i = 1; i <= maxTimes; i++) { try { - this.logger.info(` [Attempt ${i}/${maxTimes}] Cleaning overlay layers and clicking...`); + this.logger.debug(` [Attempt ${i}/${maxTimes}] Cleaning overlay layers and clicking...`); /* eslint-disable no-undef */ await page.evaluate(() => { document.querySelectorAll("div.cdk-overlay-backdrop").forEach(el => el.remove()); @@ -491,7 +491,7 @@ class BrowserManager { // Use Smart Click instead of hardcoded locator await this._smartClickCode(page); - this.logger.info(" ✅ Click successful!"); + this.logger.debug(" ✅ Click successful!"); break; } catch (error) { this.logger.warn(` [Attempt ${i}/${maxTimes}] Click failed: ${error.message.split("\n")[0]}`); @@ -501,7 +501,7 @@ class BrowserManager { } } - this.logger.info( + this.logger.debug( `${logPrefix} (Step 2/5) "Code" button clicked successfully, waiting for editor to become visible...` ); const editorContainerLocator = page.locator("div.monaco-editor").first(); @@ -510,7 +510,7 @@ class BrowserManager { timeout: 60000, }); - this.logger.info( + this.logger.debug( `${logPrefix} (Cleanup #2) Preparing to click editor, forcefully removing all possible overlay layers again...` ); /* eslint-disable no-undef */ @@ -526,7 +526,7 @@ class BrowserManager { /* eslint-enable no-undef */ await page.waitForTimeout(250); - this.logger.info(`${logPrefix} (Step 3/5) Editor displayed, focusing and pasting script...`); + this.logger.debug(`${logPrefix} (Step 3/5) Editor displayed, focusing and pasting script...`); await editorContainerLocator.click({ timeout: 30000 }); /* eslint-disable no-undef */ @@ -535,13 +535,13 @@ class BrowserManager { const isMac = os.platform() === "darwin"; const pasteKey = isMac ? "Meta+V" : "Control+V"; await page.keyboard.press(pasteKey); - this.logger.info(`${logPrefix} (Step 4/5) Script pasted.`); - this.logger.info(`${logPrefix} (Step 5/5) Clicking "Preview" button to activate script...`); + this.logger.debug(`${logPrefix} (Step 4/5) Script pasted.`); + this.logger.debug(`${logPrefix} (Step 5/5) Clicking "Preview" button to activate script...`); await page.locator('button:text("Preview")').click(); - this.logger.info(`${logPrefix} ✅ UI interaction complete, script is now running.`); + this.logger.debug(`${logPrefix} ✅ UI interaction complete, script is now running.`); // Active Trigger (Hack to wake up Google Backend) - this.logger.info(`${logPrefix} ⚡ Sending active trigger request to Launch flow...`); + this.logger.debug(`${logPrefix} ⚡ Sending active trigger request to Launch flow...`); try { await page.evaluate(async () => { try { @@ -565,14 +565,14 @@ class BrowserManager { * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") */ async _navigateAndWakeUpPage(page, logPrefix = "[Browser]") { - this.logger.info(`${logPrefix} Navigating to target page...`); + this.logger.debug(`${logPrefix} Navigating to target page...`); const targetUrl = "https://aistudio.google.com/u/0/apps/bundled/blank?showPreview=true&showCode=true&showAssistant=true"; await page.goto(targetUrl, { timeout: 180000, waitUntil: "domcontentloaded", }); - this.logger.info(`${logPrefix} Page loaded.`); + this.logger.debug(`${logPrefix} Page loaded.`); // Wake up window using JS and Human Movement try { @@ -592,7 +592,7 @@ class BrowserManager { await page.waitForTimeout(50 + Math.random() * 100); await page.mouse.up(); - this.logger.info(`${logPrefix} ✅ Executed realistic page activation (Random -> 1,1 Click).`); + this.logger.debug(`${logPrefix} ✅ Executed realistic page activation (Random -> 1,1 Click).`); } catch (e) { this.logger.warn(`${logPrefix} Wakeup minor error: ${e.message}`); } @@ -615,8 +615,8 @@ class BrowserManager { this.logger.warn(`${logPrefix} Unable to get page title: ${e.message}`); } - this.logger.info(`${logPrefix} [Diagnostic] URL: ${currentUrl}`); - this.logger.info(`${logPrefix} [Diagnostic] Title: "${pageTitle}"`); + this.logger.debug(`${logPrefix} [Diagnostic] URL: ${currentUrl}`); + this.logger.debug(`${logPrefix} [Diagnostic] Title: "${pageTitle}"`); // Check for various error conditions if ( @@ -652,7 +652,7 @@ class BrowserManager { * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") */ async _handlePopups(page, logPrefix = "[Browser]") { - this.logger.info(`${logPrefix} 🔍 Starting intelligent popup detection (max 6s)...`); + this.logger.debug(`${logPrefix} 🔍 Starting intelligent popup detection (max 6s)...`); const popupConfigs = [ { @@ -692,7 +692,7 @@ class BrowserManager { const element = page.locator(popup.selector).first(); // Quick visibility check with very short timeout if (await element.isVisible({ timeout: 200 })) { - this.logger.info(popup.logFound); + this.logger.debug(popup.logFound); await element.click({ force: true }); handledPopups.add(popup.name); foundAny = true; @@ -732,7 +732,7 @@ class BrowserManager { // 1. Must have completed minimum iterations (ensure slow popups have time to load) // 2. Consecutive idle count exceeds threshold (no new popups appearing) if (i >= minIterations - 1 && consecutiveIdleCount >= idleThreshold) { - this.logger.info( + this.logger.debug( `${logPrefix} ✅ Popup detection complete (${i + 1} iterations, ${handledPopups.size} popups handled)` ); break; @@ -1302,19 +1302,21 @@ class BrowserManager { const generation = ++this._preloadGeneration; this.logger.info( - `[ContextPool] Background preload #${generation} starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` + `[ContextPool] Background preload (gen=${generation}) starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` ); for (const authIndex of indices) { // Check if browser is still available if (!this.browser) { - this.logger.info(`[ContextPool] Background preload #${generation} stopped: browser is not available`); + this.logger.info( + `[ContextPool] Background preload (gen=${generation}) stopped: browser is not available` + ); break; } // Check pool size limit if (maxPoolSize > 0 && this.contexts.size >= maxPoolSize) { - this.logger.info(`[ContextPool] Pool size limit reached, stopping preload #${generation}`); + this.logger.info(`[ContextPool] Pool size limit reached, stopping preload (gen=${generation})`); break; } @@ -1334,14 +1336,14 @@ class BrowserManager { // This allows the current initialization to complete even if superseded if (this._preloadGeneration !== generation) { this.logger.info( - `[ContextPool] Background preload #${generation} superseded by newer task #${this._preloadGeneration}, stopping` + `[ContextPool] Background preload (gen=${generation}) superseded by newer task (gen=${this._preloadGeneration}), stopping` ); break; } this.initializingContexts.add(authIndex); try { - this.logger.info(`[ContextPool] Background preload #${generation} init context #${authIndex}...`); + this.logger.info(`[ContextPool] Background preload (gen=${generation}) init context #${authIndex}...`); await this._initializeContext(authIndex); this.logger.info(`✅ [ContextPool] Background context #${authIndex} ready.`); } catch (error) { @@ -1359,7 +1361,7 @@ class BrowserManager { // Only log completion if this task wasn't superseded if (this._preloadGeneration === generation) { - this.logger.info(`[ContextPool] Background preload #${generation} complete.`); + this.logger.info(`[ContextPool] Background preload (gen=${generation}) complete.`); } } diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index b29624c..37ee69a 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -697,7 +697,7 @@ class RequestHandler { this.connectionRegistry.removeMessageQueue(requestId); if (this.needsSwitchingAfterRequest) { this.logger.info( - `[Auth] Rotation count reached switching threshold, will automatically switch account in background...` + `[Auth] Rotation count reached switching threshold (${this.authSwitcher.usageCount}/${this.config.switchOnUses}), will automatically switch account in background...` ); this.authSwitcher.switchToNextAuth().catch(err => { this.logger.error(`[Auth] Background account switching task failed: ${err.message}`); @@ -936,7 +936,7 @@ class RequestHandler { this.connectionRegistry.removeMessageQueue(requestId); if (this.needsSwitchingAfterRequest) { this.logger.info( - `[Auth] Rotation count reached switching threshold, will automatically switch account in background...` + `[Auth] Rotation count reached switching threshold (${this.authSwitcher.usageCount}/${this.config.switchOnUses}), will automatically switch account in background...` ); this.authSwitcher.switchToNextAuth().catch(err => { this.logger.error(`[Auth] Background account switching task failed: ${err.message}`); From f4a4a167e7d7b578b2ddd2b1749faf4ef917c8a3 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 18:11:18 +0800 Subject: [PATCH 28/47] refactor: implement pre-cleanup for context management before account switch to prevent exceeding maxContexts --- src/auth/AuthSwitcher.js | 6 +++ src/core/BrowserManager.js | 85 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/auth/AuthSwitcher.js b/src/auth/AuthSwitcher.js index a5b28a8..f5d5cea 100644 --- a/src/auth/AuthSwitcher.js +++ b/src/auth/AuthSwitcher.js @@ -128,6 +128,8 @@ class AuthSwitcher { ); try { + // Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts + await this.browserManager.preCleanupForSwitch(accountIndex); await this.browserManager.switchAccount(accountIndex); this.resetCounters(); this.browserManager.rebalanceContextPool(); @@ -159,6 +161,8 @@ class AuthSwitcher { this.logger.warn("=================================================="); try { + // Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts + await this.browserManager.preCleanupForSwitch(originalStartAccount); await this.browserManager.switchAccount(originalStartAccount); this.resetCounters(); this.browserManager.rebalanceContextPool(); @@ -216,6 +220,8 @@ class AuthSwitcher { this.isSystemBusy = true; try { this.logger.info(`🔄 [Auth] Starting switch to specified account #${targetIndex}...`); + // Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts + await this.browserManager.preCleanupForSwitch(targetIndex); await this.browserManager.switchAccount(targetIndex); this.resetCounters(); this.browserManager.rebalanceContextPool(); diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index b0f0583..9a65841 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1365,6 +1365,91 @@ class BrowserManager { } } + /** + * Pre-cleanup before switching to a new account + * Removes contexts that will be excess after the switch to avoid exceeding maxContexts + * @param {number} targetAuthIndex - The account index we're about to switch to + */ + async preCleanupForSwitch(targetAuthIndex) { + const maxContexts = this.config.maxContexts; + const isUnlimited = maxContexts === 0; + + // In unlimited mode, no need to pre-cleanup + if (isUnlimited) { + this.logger.debug(`[ContextPool] Pre-cleanup skipped: unlimited mode`); + return; + } + + // If target context already exists, no new context will be created, so no cleanup needed + if (this.contexts.has(targetAuthIndex)) { + this.logger.debug(`[ContextPool] Pre-cleanup skipped: target context #${targetAuthIndex} already exists`); + return; + } + + // Calculate how many contexts we'll have after adding the new one + const currentSize = this.contexts.size; + const futureSize = currentSize + 1; + + // If we won't exceed the limit, no cleanup needed + if (futureSize <= maxContexts) { + this.logger.debug( + `[ContextPool] Pre-cleanup skipped: future size ${futureSize} <= maxContexts ${maxContexts}` + ); + return; + } + + // We need to remove (futureSize - maxContexts) contexts + const removeCount = futureSize - maxContexts; + + // Build priority list for removal (from lowest to highest priority to keep): + // 1. Duplicate accounts not in rotation (old versions) - remove first + // 2. Accounts in rotation but far from target - remove by reverse rotation order + + const rotation = this.authSource.getRotationIndices(); + const rotationSet = new Set(rotation); + const targetCanonical = this.authSource.getCanonicalIndex(targetAuthIndex); + + // Build rotation order starting from target (accounts closer to target have higher priority) + const startPos = Math.max(rotation.indexOf(targetCanonical), 0); + const orderedFromTarget = []; + for (let i = 0; i < rotation.length; i++) { + orderedFromTarget.push(rotation[(startPos + i) % rotation.length]); + } + + // Build removal priority list (reverse order = lowest priority first) + const removalPriority = []; + + // Priority 1: Duplicate accounts not in rotation (lowest priority to keep) + for (const idx of this.contexts.keys()) { + const canonical = this.authSource.getCanonicalIndex(idx); + if (!rotationSet.has(canonical)) { + removalPriority.push(idx); + } + } + + // Priority 2: Accounts in rotation, from farthest to closest (reverse rotation order) + for (let i = orderedFromTarget.length - 1; i >= 0; i--) { + const canonical = orderedFromTarget[i]; + // Find all contexts with this canonical index (including duplicates in rotation) + for (const idx of this.contexts.keys()) { + if (this.authSource.getCanonicalIndex(idx) === canonical && !removalPriority.includes(idx)) { + removalPriority.push(idx); + } + } + } + + // Remove contexts according to priority until we have enough space + const toRemove = removalPriority.slice(0, removeCount); + + this.logger.info( + `[ContextPool] Pre-cleanup: removing ${toRemove.length} contexts before switch to #${targetAuthIndex}: [${toRemove}]` + ); + + for (const idx of toRemove) { + await this.closeContext(idx); + } + } + /** * Rebalance context pool after account changes * Removes excess contexts and starts missing ones in background From c521fa12a0530bc3a7d880cc1513a85c8a79ca96 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 20:58:52 +0800 Subject: [PATCH 29/47] refactor: enhance context pre-cleanup logic to account for initializing contexts and improve removal priority handling --- src/core/BrowserManager.js | 61 ++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 9a65841..44074ab 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1380,20 +1380,28 @@ class BrowserManager { return; } - // If target context already exists, no new context will be created, so no cleanup needed + // If target context already exists or is being initialized, no new context will be created if (this.contexts.has(targetAuthIndex)) { this.logger.debug(`[ContextPool] Pre-cleanup skipped: target context #${targetAuthIndex} already exists`); return; } + if (this.initializingContexts.has(targetAuthIndex)) { + this.logger.debug( + `[ContextPool] Pre-cleanup skipped: target context #${targetAuthIndex} is being initialized` + ); + return; + } + // Calculate how many contexts we'll have after adding the new one - const currentSize = this.contexts.size; + // Include contexts that are currently being initialized in background + const currentSize = this.contexts.size + this.initializingContexts.size; const futureSize = currentSize + 1; // If we won't exceed the limit, no cleanup needed if (futureSize <= maxContexts) { this.logger.debug( - `[ContextPool] Pre-cleanup skipped: future size ${futureSize} <= maxContexts ${maxContexts}` + `[ContextPool] Pre-cleanup skipped: future size ${futureSize} (${this.contexts.size} ready + ${this.initializingContexts.size} initializing + 1 new) <= maxContexts ${maxContexts}` ); return; } @@ -1401,13 +1409,21 @@ class BrowserManager { // We need to remove (futureSize - maxContexts) contexts const removeCount = futureSize - maxContexts; - // Build priority list for removal (from lowest to highest priority to keep): - // 1. Duplicate accounts not in rotation (old versions) - remove first - // 2. Accounts in rotation but far from target - remove by reverse rotation order + // Build removal priority list (from lowest to highest priority to keep): + // Priority 1: Old duplicate accounts (removedIndices from duplicateGroups) + // Priority 2: Accounts in rotation, ordered by distance from target (farthest first) const rotation = this.authSource.getRotationIndices(); - const rotationSet = new Set(rotation); const targetCanonical = this.authSource.getCanonicalIndex(targetAuthIndex); + const duplicateGroups = this.authSource.getDuplicateGroups(); + + // Get all old duplicate indices (not in rotation) + const oldDuplicates = new Set(); + for (const group of duplicateGroups) { + for (const idx of group.removedIndices) { + oldDuplicates.add(idx); + } + } // Build rotation order starting from target (accounts closer to target have higher priority) const startPos = Math.max(rotation.indexOf(targetCanonical), 0); @@ -1416,13 +1432,28 @@ class BrowserManager { orderedFromTarget.push(rotation[(startPos + i) % rotation.length]); } - // Build removal priority list (reverse order = lowest priority first) + // Collect all context indices (existing + initializing) + const allContextIndices = new Set([...this.contexts.keys(), ...this.initializingContexts]); + + // Build removal priority list const removalPriority = []; - // Priority 1: Duplicate accounts not in rotation (lowest priority to keep) - for (const idx of this.contexts.keys()) { - const canonical = this.authSource.getCanonicalIndex(idx); - if (!rotationSet.has(canonical)) { + // Special case: If target is an old duplicate, prioritize removing its canonical version + // Because we're about to create the old duplicate, and they're the same account + const isTargetOldDuplicate = oldDuplicates.has(targetAuthIndex); + if (isTargetOldDuplicate) { + // Find the canonical version of target in existing contexts + for (const idx of allContextIndices) { + if (this.authSource.getCanonicalIndex(idx) === targetCanonical && idx === targetCanonical) { + removalPriority.push(idx); + break; + } + } + } + + // Priority 1: Old duplicate accounts (lowest priority to keep) + for (const idx of allContextIndices) { + if (oldDuplicates.has(idx) && !removalPriority.includes(idx)) { removalPriority.push(idx); } } @@ -1430,8 +1461,8 @@ class BrowserManager { // Priority 2: Accounts in rotation, from farthest to closest (reverse rotation order) for (let i = orderedFromTarget.length - 1; i >= 0; i--) { const canonical = orderedFromTarget[i]; - // Find all contexts with this canonical index (including duplicates in rotation) - for (const idx of this.contexts.keys()) { + // Find all contexts with this canonical index + for (const idx of allContextIndices) { if (this.authSource.getCanonicalIndex(idx) === canonical && !removalPriority.includes(idx)) { removalPriority.push(idx); } @@ -1442,7 +1473,7 @@ class BrowserManager { const toRemove = removalPriority.slice(0, removeCount); this.logger.info( - `[ContextPool] Pre-cleanup: removing ${toRemove.length} contexts before switch to #${targetAuthIndex}: [${toRemove}]` + `[ContextPool] Pre-cleanup: removing ${toRemove.length} contexts before switch to #${targetAuthIndex}: [${toRemove}] (${this.contexts.size} ready + ${this.initializingContexts.size} initializing)` ); for (const idx of toRemove) { From 38993348e064d2c39da344f64d02adbed0d3a389 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 22:10:42 +0800 Subject: [PATCH 30/47] refactor: improve background context preload handling with abort logic and task management --- src/core/BrowserManager.js | 136 +++++++++++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 30 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 44074ab..df2d7ae 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -30,7 +30,8 @@ class BrowserManager { // Context pool state tracking this.initializingContexts = new Set(); // Indices currently being initialized in background this.abortedContexts = new Set(); // Indices that should be aborted during background init - this._preloadGeneration = 0; // Generation counter to ensure only one preload task is active + this._backgroundPreloadTask = null; // Current background preload task promise (only one at a time) + this._backgroundPreloadAbort = false; // Flag to signal background task to abort // Legacy single context references (for backward compatibility) this.context = null; @@ -1293,30 +1294,71 @@ class BrowserManager { /** * Background sequential initialization of contexts (fire-and-forget) - * Only one instance should be active at a time - new calls supersede old ones + * Only one instance should be active at a time - new calls abort old ones * @param {number[]} indices - Auth indices to initialize (candidates, may exceed pool size) * @param {number} maxPoolSize - Stop when this.contexts.size reaches this limit (0 = no limit) */ async _preloadBackgroundContexts(indices, maxPoolSize = 0) { - // Increment generation to signal old tasks to stop - const generation = ++this._preloadGeneration; + // If there's an existing background task, abort it and wait for it to finish + if (this._backgroundPreloadTask) { + this.logger.info(`[ContextPool] Aborting previous background preload task...`); + this._backgroundPreloadAbort = true; + try { + await this._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[ContextPool] Previous background preload task aborted: ${error.message}`); + } + this.logger.info(`[ContextPool] Previous background preload task aborted successfully`); + } + + // Reset abort flag and create new background task + this._backgroundPreloadAbort = false; + const currentTask = this._executePreloadTask(indices, maxPoolSize); + this._backgroundPreloadTask = currentTask; + + // Don't await here - this is fire-and-forget + // But ensure we clean up the task reference when done + currentTask + .catch(error => { + this.logger.error(`[ContextPool] Background preload task failed: ${error.message}`); + }) + .finally(() => { + // Only clear if this is still the current task + if (this._backgroundPreloadTask === currentTask) { + this._backgroundPreloadTask = null; + } + }); + } + /** + * Internal method to execute the actual preload task + * @private + */ + async _executePreloadTask(indices, maxPoolSize) { this.logger.info( - `[ContextPool] Background preload (gen=${generation}) starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` + `[ContextPool] Background preload starting for [${indices.join(", ")}] (poolCap=${maxPoolSize || "unlimited"})...` ); + let aborted = false; + for (const authIndex of indices) { + // Check if abort was requested + if (this._backgroundPreloadAbort) { + this.logger.info(`[ContextPool] Background preload aborted by request`); + aborted = true; + break; + } + // Check if browser is still available if (!this.browser) { - this.logger.info( - `[ContextPool] Background preload (gen=${generation}) stopped: browser is not available` - ); + this.logger.info(`[ContextPool] Background preload stopped: browser is not available`); break; } // Check pool size limit if (maxPoolSize > 0 && this.contexts.size >= maxPoolSize) { - this.logger.info(`[ContextPool] Pool size limit reached, stopping preload (gen=${generation})`); + this.logger.info(`[ContextPool] Pool size limit reached, stopping preload`); break; } @@ -1332,25 +1374,18 @@ class BrowserManager { continue; } - // Check if this task has been superseded BEFORE starting a new initialization - // This allows the current initialization to complete even if superseded - if (this._preloadGeneration !== generation) { - this.logger.info( - `[ContextPool] Background preload (gen=${generation}) superseded by newer task (gen=${this._preloadGeneration}), stopping` - ); - break; - } - this.initializingContexts.add(authIndex); try { - this.logger.info(`[ContextPool] Background preload (gen=${generation}) init context #${authIndex}...`); - await this._initializeContext(authIndex); + this.logger.info(`[ContextPool] Background preload init context #${authIndex}...`); + await this._initializeContext(authIndex, true); // Mark as background task this.logger.info(`✅ [ContextPool] Background context #${authIndex} ready.`); } catch (error) { // Check if this is an abort error (user deleted the account during initialization) const isAbortError = error.message && error.message.includes("aborted for index"); if (isAbortError) { this.logger.info(`[ContextPool] Background context #${authIndex} aborted (account deleted)`); + // If aborted due to background preload abort, mark as aborted + aborted = true; } else { this.logger.error(`❌ [ContextPool] Background context #${authIndex} failed: ${error.message}`); } @@ -1359,9 +1394,8 @@ class BrowserManager { } } - // Only log completion if this task wasn't superseded - if (this._preloadGeneration === generation) { - this.logger.info(`[ContextPool] Background preload (gen=${generation}) complete.`); + if (!aborted) { + this.logger.info(`[ContextPool] Background preload complete.`); } } @@ -1374,6 +1408,32 @@ class BrowserManager { const maxContexts = this.config.maxContexts; const isUnlimited = maxContexts === 0; + // Abort any ongoing background preload task before cleanup + // This prevents race conditions where background tasks continue initializing contexts + // that will be immediately removed by the new rebalance after switch + if (this._backgroundPreloadTask) { + this.logger.info(`[ContextPool] Pre-cleanup: aborting background preload...`); + this._backgroundPreloadAbort = true; + try { + await this._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[ContextPool] Background preload aborted: ${error.message}`); + } + this.logger.info(`[ContextPool] Pre-cleanup: background preload aborted, proceeding with cleanup`); + } + + // Test: Check if initializingContexts is empty after aborting background task + if (this.initializingContexts.size > 0) { + const initializingList = [...this.initializingContexts].join(", "); + this.logger.error( + `[ContextPool] Pre-cleanup ERROR: initializingContexts not empty after aborting background task! Contexts still initializing: [${initializingList}]` + ); + throw new Error( + `Pre-cleanup failed: initializingContexts not empty (${initializingList}). This should not happen after aborting background task.` + ); + } + // In unlimited mode, no need to pre-cleanup if (isUnlimited) { this.logger.debug(`[ContextPool] Pre-cleanup skipped: unlimited mode`); @@ -1578,14 +1638,20 @@ class BrowserManager { * Initialize a single context for the given auth index * This is a helper method used by both preloadAllContexts and launchOrSwitchContext * @param {number} authIndex - The auth index to initialize + * @param {boolean} isBackgroundTask - Whether this is a background preload task (can be aborted by _backgroundPreloadAbort) * @returns {Promise<{context, page}>} */ - async _initializeContext(authIndex) { + async _initializeContext(authIndex, isBackgroundTask = false) { // Check if this context has been marked for abort before starting if (this.abortedContexts.has(authIndex)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } + // Check if background preload was aborted (only for background tasks) + if (isBackgroundTask && this._backgroundPreloadAbort) { + throw new Error(`Context initialization aborted for index ${authIndex} (background preload aborted)`); + } + const proxyConfig = parseProxyFromEnv(); const storageStateObject = this.authSource.getAuth(authIndex); if (!storageStateObject) { @@ -1603,7 +1669,7 @@ class BrowserManager { try { // Check abort status before expensive operations - if (this.abortedContexts.has(authIndex)) { + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } @@ -1615,7 +1681,7 @@ class BrowserManager { }); // Check abort status after context creation - if (this.abortedContexts.has(authIndex)) { + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } @@ -1654,29 +1720,39 @@ class BrowserManager { }); // Check abort status before navigation (most time-consuming part) - if (this.abortedContexts.has(authIndex)) { + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } await this._navigateAndWakeUpPage(page, `[Context#${authIndex}]`); // Check abort status after navigation - if (this.abortedContexts.has(authIndex)) { + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } await this._checkPageStatusAndErrors(page, `[Context#${authIndex}]`); + + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + await this._handlePopups(page, `[Context#${authIndex}]`); + + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } + await this._injectScriptToEditor(page, buildScriptContent, `[Context#${authIndex}]`); // Final check before adding to contexts map - if (this.abortedContexts.has(authIndex)) { + if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); } // Save to contexts map - with atomic abort check to prevent race condition // between the check above and actually adding to the map - if (!this.abortedContexts.has(authIndex)) { + if (!this.abortedContexts.has(authIndex) && !(isBackgroundTask && this._backgroundPreloadAbort)) { this.contexts.set(authIndex, { context, healthMonitorInterval: null, From 0b4488cb83a79cf78cb8a46e66b1aaf76a64a776 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 23:13:54 +0800 Subject: [PATCH 31/47] refactor: enhance context initialization handling to prevent race conditions during concurrent initialization --- src/core/BrowserManager.js | 25 ++++++++++++++++++++----- src/core/RequestHandler.js | 3 +++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index df2d7ae..a3fd32b 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1389,9 +1389,8 @@ class BrowserManager { } else { this.logger.error(`❌ [ContextPool] Background context #${authIndex} failed: ${error.message}`); } - } finally { - this.initializingContexts.delete(authIndex); } + // Note: initializingContexts and abortedContexts cleanup is handled in _initializeContext's finally block } if (!aborted) { @@ -1636,7 +1635,7 @@ class BrowserManager { /** * Initialize a single context for the given auth index - * This is a helper method used by both preloadAllContexts and launchOrSwitchContext + * This is a helper method used by both preloadContextPool and launchOrSwitchContext * @param {number} authIndex - The auth index to initialize * @param {boolean} isBackgroundTask - Whether this is a background preload task (can be aborted by _backgroundPreloadAbort) * @returns {Promise<{context, page}>} @@ -1795,6 +1794,10 @@ class BrowserManager { } } throw error; + } finally { + // Ensure cleanup of tracking sets even if error is thrown + this.initializingContexts.delete(authIndex); + this.abortedContexts.delete(authIndex); } } @@ -1908,6 +1911,18 @@ class BrowserManager { this.logger.info(`🔄 [Browser] Context for account #${authIndex} not found, initializing...`); this.logger.info("=================================================="); + // Check again if another caller started initializing while we were checking + // This protects against race condition where multiple callers finish waiting + // at the same time and all try to initialize the same context + if (this.initializingContexts.has(authIndex)) { + this.logger.info(`[Browser] Another caller is initializing context #${authIndex}, waiting...`); + await this._waitForContextInit(authIndex); + // After waiting, recursively call to use the fast path or retry + return await this.launchOrSwitchContext(authIndex); + } + + this.initializingContexts.add(authIndex); + try { // Stop background tasks for old context if (this._currentAuthIndex >= 0 && this.contexts.has(this._currentAuthIndex)) { @@ -1918,8 +1933,8 @@ class BrowserManager { } } - // Initialize new context - const { context, page } = await this._initializeContext(authIndex); + // Initialize new context (isBackgroundTask=false for foreground initialization) + const { context, page } = await this._initializeContext(authIndex, false); // Update current references this.context = context; diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 37ee69a..5ac878f 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -254,6 +254,9 @@ class RequestHandler { if (wasDirectRecovery && this.authSource.getRotationIndices().length > 1) { this.logger.warn("⚠️ [System] Attempting to switch to alternative account..."); + // Reset isSystemBusy before calling switchToNextAuth to avoid "already in progress" rejection + this.authSwitcher.isSystemBusy = false; + wasDirectRecovery = false; // Prevent finally block from resetting again try { const result = await this.authSwitcher.switchToNextAuth(); if (!result.success) { From 3dee6a26fa9e688d8ff879266653b43b6822497c Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 23:40:53 +0800 Subject: [PATCH 32/47] refactor: improve environment variable parsing for configuration with finite checks --- src/utils/ConfigLoader.js | 41 ++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/utils/ConfigLoader.js b/src/utils/ConfigLoader.js index 40bd4e1..8e5f16d 100644 --- a/src/utils/ConfigLoader.js +++ b/src/utils/ConfigLoader.js @@ -39,21 +39,36 @@ class ConfigLoader { }; // Environment variable overrides - if (process.env.PORT) config.httpPort = parseInt(process.env.PORT, 10) || config.httpPort; + if (process.env.PORT) { + const parsed = parseInt(process.env.PORT, 10); + config.httpPort = Number.isFinite(parsed) ? parsed : config.httpPort; + } if (process.env.HOST) config.host = process.env.HOST; if (process.env.STREAMING_MODE) config.streamingMode = process.env.STREAMING_MODE; - if (process.env.FAILURE_THRESHOLD) - config.failureThreshold = - Math.max(0, parseInt(process.env.FAILURE_THRESHOLD, 10)) ?? config.failureThreshold; - if (process.env.SWITCH_ON_USES) - config.switchOnUses = Math.max(0, parseInt(process.env.SWITCH_ON_USES, 10)) ?? config.switchOnUses; - if (process.env.MAX_RETRIES) - config.maxRetries = Math.max(1, parseInt(process.env.MAX_RETRIES, 10)) || config.maxRetries; - if (process.env.RETRY_DELAY) - config.retryDelay = Math.max(50, parseInt(process.env.RETRY_DELAY, 10)) || config.retryDelay; - if (process.env.WS_PORT) config.wsPort = parseInt(process.env.WS_PORT, 10) || config.wsPort; - if (process.env.MAX_CONTEXTS !== undefined) - config.maxContexts = Math.max(0, parseInt(process.env.MAX_CONTEXTS, 10)) ?? config.maxContexts; + if (process.env.FAILURE_THRESHOLD) { + const parsed = parseInt(process.env.FAILURE_THRESHOLD, 10); + config.failureThreshold = Number.isFinite(parsed) ? Math.max(0, parsed) : config.failureThreshold; + } + if (process.env.SWITCH_ON_USES) { + const parsed = parseInt(process.env.SWITCH_ON_USES, 10); + config.switchOnUses = Number.isFinite(parsed) ? Math.max(0, parsed) : config.switchOnUses; + } + if (process.env.MAX_RETRIES) { + const parsed = parseInt(process.env.MAX_RETRIES, 10); + config.maxRetries = Number.isFinite(parsed) ? Math.max(1, parsed) : config.maxRetries; + } + if (process.env.RETRY_DELAY) { + const parsed = parseInt(process.env.RETRY_DELAY, 10); + config.retryDelay = Number.isFinite(parsed) ? Math.max(50, parsed) : config.retryDelay; + } + if (process.env.WS_PORT) { + const parsed = parseInt(process.env.WS_PORT, 10); + config.wsPort = Number.isFinite(parsed) ? parsed : config.wsPort; + } + if (process.env.MAX_CONTEXTS !== undefined) { + const parsed = parseInt(process.env.MAX_CONTEXTS, 10); + config.maxContexts = Number.isFinite(parsed) ? Math.max(0, parsed) : config.maxContexts; + } if (process.env.CAMOUFOX_EXECUTABLE_PATH) config.browserExecutablePath = process.env.CAMOUFOX_EXECUTABLE_PATH; if (process.env.API_KEYS) { config.apiKeys = process.env.API_KEYS.split(","); From c94aa19de401f276847f9cae22f491358e5110da Mon Sep 17 00:00:00 2001 From: bbbugg Date: Wed, 18 Feb 2026 23:54:53 +0800 Subject: [PATCH 33/47] refactor: add error handling for context pool rebalancing to improve stability during account operations --- src/auth/AuthSwitcher.js | 16 ++++++++++++---- src/core/BrowserManager.js | 22 +++++++++++++++++----- src/routes/StatusRoutes.js | 24 ++++++++++++++++++------ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/auth/AuthSwitcher.js b/src/auth/AuthSwitcher.js index f5d5cea..eaf392c 100644 --- a/src/auth/AuthSwitcher.js +++ b/src/auth/AuthSwitcher.js @@ -77,7 +77,9 @@ class AuthSwitcher { try { await this.browserManager.launchOrSwitchContext(singleIndex); this.resetCounters(); - this.browserManager.rebalanceContextPool(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info( `✅ [Auth] Single account #${singleIndex} restart/refresh successful, usage count reset.` @@ -132,7 +134,9 @@ class AuthSwitcher { await this.browserManager.preCleanupForSwitch(accountIndex); await this.browserManager.switchAccount(accountIndex); this.resetCounters(); - this.browserManager.rebalanceContextPool(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); if (failedAccounts.length > 0) { this.logger.info( @@ -165,7 +169,9 @@ class AuthSwitcher { await this.browserManager.preCleanupForSwitch(originalStartAccount); await this.browserManager.switchAccount(originalStartAccount); this.resetCounters(); - this.browserManager.rebalanceContextPool(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info( `✅ [Auth] Final attempt succeeded! Switched to account #${originalStartAccount}.` ); @@ -224,7 +230,9 @@ class AuthSwitcher { await this.browserManager.preCleanupForSwitch(targetIndex); await this.browserManager.switchAccount(targetIndex); this.resetCounters(); - this.browserManager.rebalanceContextPool(); + this.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info(`✅ [Auth] Successfully switched to account #${targetIndex}, counters reset.`); return { newIndex: targetIndex, success: true }; } catch (error) { diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index a3fd32b..2ab7759 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1204,14 +1204,26 @@ class BrowserManager { let firstReady = null; for (let i = 0; i < startupOrder.length; i++) { + const authIndex = startupOrder[i]; + + // Skip if already initialized or being initialized + if (this.contexts.has(authIndex) || this.initializingContexts.has(authIndex)) { + this.logger.info(`[ContextPool] Context #${authIndex} already exists or being initialized, skipping`); + firstReady = authIndex; + break; + } + + this.initializingContexts.add(authIndex); try { - this.logger.info(`[ContextPool] Initializing context #${startupOrder[i]}...`); - await this._initializeContext(startupOrder[i]); - firstReady = startupOrder[i]; - this.logger.info(`✅ [ContextPool] First context #${startupOrder[i]} ready.`); + this.logger.info(`[ContextPool] Initializing context #${authIndex}...`); + await this._initializeContext(authIndex); + firstReady = authIndex; + this.logger.info(`✅ [ContextPool] First context #${authIndex} ready.`); break; } catch (error) { - this.logger.error(`❌ [ContextPool] Context #${startupOrder[i]} failed: ${error.message}`); + this.logger.error(`❌ [ContextPool] Context #${authIndex} failed: ${error.message}`); + } finally { + // Note: _initializeContext already removes from initializingContexts in its finally block } } diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 50ec86c..f7453f3 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -115,7 +115,9 @@ class StatusRoutes { if (requestHandler.isSystemBusy) { // Rebalance context pool if auth files changed if (hasChanges) { - this.serverSystem.browserManager.rebalanceContextPool(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[System] Background rebalance failed: ${err.message}`); + }); } return res.json(this._getStatusData()); } @@ -140,7 +142,9 @@ class StatusRoutes { // Rebalance context pool if auth files changed (e.g., user manually added/removed files) if (hasChanges) { this.logger.info("[System] Auth file changes detected, rebalancing context pool..."); - this.serverSystem.browserManager.rebalanceContextPool(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[System] Background rebalance failed: ${err.message}`); + }); } res.json(this._getStatusData()); @@ -249,7 +253,9 @@ class StatusRoutes { // Rebalance context pool after dedup if (removedIndices.length > 0) { - this.serverSystem.browserManager.rebalanceContextPool(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); } return res.status(200).json({ @@ -352,7 +358,9 @@ class StatusRoutes { // Rebalance context pool after batch delete if (successIndices.length > 0) { - this.serverSystem.browserManager.rebalanceContextPool(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); } if (failedIndices.length > 0) { @@ -506,7 +514,9 @@ class StatusRoutes { this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); // Rebalance context pool after delete - this.serverSystem.browserManager.rebalanceContextPool(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info( `[WebUI] Account #${targetIndex} deleted via web interface. Previous current account: #${currentAuthIndex}` @@ -621,7 +631,9 @@ class StatusRoutes { this.serverSystem.authSource.reloadAuthSources(); // Rebalance context pool to pick up new account - this.serverSystem.browserManager.rebalanceContextPool(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); this.logger.info(`[WebUI] File uploaded via API: generated ${newFilename}`); res.status(200).json({ filename: newFilename, message: "File uploaded successfully" }); From 6d8d89301f3bb6627d60dcc4c9d659a82bcace03 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 00:22:32 +0800 Subject: [PATCH 34/47] refactor: adjust candidate filtering in context rebalancing to improve task handling during preload --- src/core/BrowserManager.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 2ab7759..2b61479 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1614,7 +1614,10 @@ class BrowserManager { const activeContexts = new Set( [...activeContextsRaw].map(idx => this.authSource.getCanonicalIndex(idx) ?? idx) ); - const candidates = ordered.filter(idx => !activeContexts.has(idx) && !this.initializingContexts.has(idx)); + // Don't filter out initializingContexts here - let _executePreloadTask handle it + // This ensures that if a background task is aborted, the account will be retried + // If a foreground task is running, _executePreloadTask will skip it (line 1382) + const candidates = ordered.filter(idx => !activeContexts.has(idx)); this.logger.info( `[ContextPool] Rebalance: targets=[${[...targets]}], remove=[${toRemove}], candidates=[${candidates}]` From 7cb12dfb582f0c8224ecf148036664f3be1a431b Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 10:21:24 +0800 Subject: [PATCH 35/47] refactor: enhance environment variable parsing and improve cleanup handling for account deletion --- src/routes/AuthRoutes.js | 8 ++++-- src/routes/StatusRoutes.js | 55 +++++++++++++++++++++++++++---------- src/utils/ConfigLoader.js | 2 +- src/utils/LoggingService.js | 2 +- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/routes/AuthRoutes.js b/src/routes/AuthRoutes.js index 5214875..a2b489f 100644 --- a/src/routes/AuthRoutes.js +++ b/src/routes/AuthRoutes.js @@ -24,8 +24,12 @@ class AuthRoutes { // Rate limiting configuration from environment variables this.rateLimitEnabled = process.env.RATE_LIMIT_MAX_ATTEMPTS !== "0"; - this.rateLimitWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 15; // minutes - this.rateLimitMaxAttempts = parseInt(process.env.RATE_LIMIT_MAX_ATTEMPTS) || 5; + + const parsedWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 10); + this.rateLimitWindow = Number.isFinite(parsedWindow) && parsedWindow > 0 ? parsedWindow : 15; // minutes + + const parsedMaxAttempts = parseInt(process.env.RATE_LIMIT_MAX_ATTEMPTS, 10); + this.rateLimitMaxAttempts = Number.isFinite(parsedMaxAttempts) && parsedMaxAttempts > 0 ? parsedMaxAttempts : 5; if (this.rateLimitEnabled) { this.logger.info( diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index f7453f3..46eed18 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -337,12 +337,24 @@ class StatusRoutes { this.logger.warn( `[WebUI] Current active account #${currentAuthIndex} was deleted. Closing context and connection...` ); - // 1. Terminate all pending requests immediately - this.serverSystem.connectionRegistry.closeAllMessageQueues(); - // 2. Close context first so page is gone when _removeConnection checks - await this.serverSystem.browserManager.closeContext(currentAuthIndex); - // 3. Then close WebSocket connection - this.serverSystem.connectionRegistry.closeConnectionByAuth(currentAuthIndex); + // Set system busy flag to prevent new requests during cleanup + const previousBusy = this.serverSystem.isSystemBusy === true; + if (!previousBusy) { + this.serverSystem.isSystemBusy = true; + } + try { + // 1. Terminate all pending requests immediately + this.serverSystem.connectionRegistry.closeAllMessageQueues(); + // 2. Close context first so page is gone when _removeConnection checks + await this.serverSystem.browserManager.closeContext(currentAuthIndex); + // 3. Then close WebSocket connection + this.serverSystem.connectionRegistry.closeConnectionByAuth(currentAuthIndex); + } finally { + // Reset system busy flag after cleanup completes + if (!previousBusy) { + this.serverSystem.isSystemBusy = false; + } + } } // Close contexts and connections for all successfully deleted accounts (except current, already handled) @@ -504,15 +516,30 @@ class StatusRoutes { this.logger.info(`[WebUI] Account #${targetIndex} deleted. Closing context and connection...`); if (targetIndex === currentAuthIndex) { - // If deleting the current account, terminate pending requests first - this.serverSystem.connectionRegistry.closeAllMessageQueues(); + // Set system busy flag to prevent new requests during cleanup + const previousBusy = this.serverSystem.isSystemBusy === true; + if (!previousBusy) { + this.serverSystem.isSystemBusy = true; + } + try { + // If deleting the current account, terminate pending requests first + this.serverSystem.connectionRegistry.closeAllMessageQueues(); + // Close context first so page is gone when _removeConnection checks + await this.serverSystem.browserManager.closeContext(targetIndex); + // Then close WebSocket connection + this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); + } finally { + // Reset system busy flag after cleanup completes + if (!previousBusy) { + this.serverSystem.isSystemBusy = false; + } + } + } else { + // Non-current account: no need for system busy flag + await this.serverSystem.browserManager.closeContext(targetIndex); + this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); } - // Close context first so page is gone when _removeConnection checks - await this.serverSystem.browserManager.closeContext(targetIndex); - // Then close WebSocket connection - this.serverSystem.connectionRegistry.closeConnectionByAuth(targetIndex); - // Rebalance context pool after delete this.serverSystem.browserManager.rebalanceContextPool().catch(err => { this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); @@ -590,7 +617,7 @@ class StatusRoutes { const { count } = req.body; const newCount = parseInt(count, 10); - if (!isNaN(newCount) && newCount > 0) { + if (Number.isFinite(newCount) && newCount > 0) { this.logger.setDisplayLimit(newCount); this.logger.info(`[WebUI] Log display limit updated to: ${newCount}`); res.status(200).json({ message: "settingUpdateSuccess", setting: "logMaxCount", value: newCount }); diff --git a/src/utils/ConfigLoader.js b/src/utils/ConfigLoader.js index 8e5f16d..18e71d1 100644 --- a/src/utils/ConfigLoader.js +++ b/src/utils/ConfigLoader.js @@ -65,7 +65,7 @@ class ConfigLoader { const parsed = parseInt(process.env.WS_PORT, 10); config.wsPort = Number.isFinite(parsed) ? parsed : config.wsPort; } - if (process.env.MAX_CONTEXTS !== undefined) { + if (process.env.MAX_CONTEXTS) { const parsed = parseInt(process.env.MAX_CONTEXTS, 10); config.maxContexts = Number.isFinite(parsed) ? Math.max(0, parsed) : config.maxContexts; } diff --git a/src/utils/LoggingService.js b/src/utils/LoggingService.js index 0ae9a0d..21138c4 100644 --- a/src/utils/LoggingService.js +++ b/src/utils/LoggingService.js @@ -57,7 +57,7 @@ class LoggingService { */ setDisplayLimit(limit) { const newLimit = parseInt(limit, 10); - if (!isNaN(newLimit) && newLimit > 0) { + if (Number.isFinite(newLimit) && newLimit > 0) { this.displayLimit = newLimit; } } From 03d8f807532a062a896df9b494c34c60c3d6369d Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 10:32:22 +0800 Subject: [PATCH 36/47] refactor: add defensive WebSocket close handling with readyState validation - Add _safeCloseWebSocket() helper method to both ConnectionRegistry and ProxyServerSystem - Check WebSocket readyState before calling close() to avoid errors on already closing/closed connections - Replace silent error ignoring with proper logging (warn level for failures, debug for already-closed) - Apply safe close to all WebSocket termination points: invalid authIndex validation and duplicate connection handling - Improves robustness and debuggability by logging close failures instead of silently ignoring them Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/core/ConnectionRegistry.js | 38 +++++++++++++++++++++++++++------- src/core/ProxyServerSystem.js | 30 ++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 9c45916..f77775e 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -41,11 +41,7 @@ class ConnectionRegistry extends EventEmitter { this.logger.error( `[Server] Rejecting connection with invalid authIndex: ${authIndex}. Connection will be closed.` ); - try { - websocket.close(1008, "Invalid authIndex"); - } catch (e) { - /* ignore */ - } + this._safeCloseWebSocket(websocket, 1008, "Invalid authIndex"); return; } @@ -58,10 +54,10 @@ class ConnectionRegistry extends EventEmitter { try { // Remove event listeners to prevent them from firing during close existingConnection.removeAllListeners(); - existingConnection.close(1000, "Replaced by new connection"); } catch (e) { - this.logger.warn(`[Server] Error closing old connection: ${e.message}`); + this.logger.warn(`[Server] Error removing listeners from old connection: ${e.message}`); } + this._safeCloseWebSocket(existingConnection, 1000, "Replaced by new connection"); } // Clear grace timer for this authIndex @@ -348,6 +344,34 @@ class ConnectionRegistry extends EventEmitter { this.messageQueues.clear(); } } + + /** + * Safely close a WebSocket connection with readyState check + * @param {WebSocket} ws - The WebSocket to close + * @param {number} code - Close code (e.g., 1000, 1008) + * @param {string} reason - Close reason + */ + _safeCloseWebSocket(ws, code, reason) { + if (!ws) { + return; + } + + // WebSocket readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + // Only attempt to close if not already closing or closed + if (ws.readyState === 0 || ws.readyState === 1) { + try { + ws.close(code, reason); + } catch (error) { + this.logger.warn( + `[Registry] Failed to close WebSocket (code=${code}, reason="${reason}"): ${error.message}` + ); + } + } else { + this.logger.debug( + `[Registry] WebSocket already closing/closed (readyState=${ws.readyState}), skipping close()` + ); + } + } } module.exports = ConnectionRegistry; diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index be8e5ea..6c58e8a 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -535,7 +535,7 @@ class ProxyServerSystem extends EventEmitter { this.logger.error( `[System] Rejecting WebSocket connection with invalid authIndex: ${authIndexParam} (parsed as ${authIndex})` ); - ws.close(1008, "Invalid authIndex: must be a non-negative integer"); + this._safeCloseWebSocket(ws, 1008, "Invalid authIndex: must be a non-negative integer"); return; } @@ -546,6 +546,34 @@ class ProxyServerSystem extends EventEmitter { }); }); } + + /** + * Safely close a WebSocket connection with readyState check + * @param {WebSocket} ws - The WebSocket to close + * @param {number} code - Close code (e.g., 1000, 1008) + * @param {string} reason - Close reason + */ + _safeCloseWebSocket(ws, code, reason) { + if (!ws) { + return; + } + + // WebSocket readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED + // Only attempt to close if not already closing or closed + if (ws.readyState === 0 || ws.readyState === 1) { + try { + ws.close(code, reason); + } catch (error) { + this.logger.warn( + `[System] Failed to close WebSocket (code=${code}, reason="${reason}"): ${error.message}` + ); + } + } else { + this.logger.debug( + `[System] WebSocket already closing/closed (readyState=${ws.readyState}), skipping close()` + ); + } + } } module.exports = ProxyServerSystem; From 8abbc32fb3ba27a36c2b614ec147d0ac02513fe9 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 10:37:58 +0800 Subject: [PATCH 37/47] refactor: replace silent error ignoring with proper logging in cleanup operations - Replace /* ignore */ comments with descriptive warning logs in ConnectionRegistry and RequestHandler - Log failed message queue closures with request ID for better debugging - Log failed context closures with account index and error message - Improves observability and debugging capabilities for cleanup operations - Maintains non-blocking behavior while providing visibility into failures Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/core/ConnectionRegistry.js | 4 ++-- src/core/RequestHandler.js | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index f77775e..cdf4855 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -334,11 +334,11 @@ class ConnectionRegistry extends EventEmitter { closeAllMessageQueues() { if (this.messageQueues.size > 0) { this.logger.info(`[Registry] Force closing ${this.messageQueues.size} pending message queues...`); - this.messageQueues.forEach(queue => { + this.messageQueues.forEach((queue, requestId) => { try { queue.close(); } catch (e) { - /* ignore */ + this.logger.warn(`[Registry] Failed to close message queue for request ${requestId}: ${e.message}`); } }); this.messageQueues.clear(); diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index 5ac878f..0651894 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -103,7 +103,9 @@ class RequestHandler { try { await this.browserManager.closeContext(this.currentAuthIndex); } catch (e) { - /* ignore */ + this.logger.warn( + `[System] Failed to close unresponsive context for account #${this.currentAuthIndex}: ${e.message}` + ); } } return false; From d162370c75c4fda87dcbede7b73b61a9da622237 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 11:09:12 +0800 Subject: [PATCH 38/47] refactor: improve background preload handling and context closure during account deletions --- src/core/BrowserManager.js | 40 +++++++++++------------ src/routes/StatusRoutes.js | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 2b61479..2e228c5 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1656,32 +1656,32 @@ class BrowserManager { * @returns {Promise<{context, page}>} */ async _initializeContext(authIndex, isBackgroundTask = false) { - // Check if this context has been marked for abort before starting - if (this.abortedContexts.has(authIndex)) { - throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); - } + let context = null; + let page = null; - // Check if background preload was aborted (only for background tasks) - if (isBackgroundTask && this._backgroundPreloadAbort) { - throw new Error(`Context initialization aborted for index ${authIndex} (background preload aborted)`); - } + try { + // Check if this context has been marked for abort before starting + if (this.abortedContexts.has(authIndex)) { + throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); + } - const proxyConfig = parseProxyFromEnv(); - const storageStateObject = this.authSource.getAuth(authIndex); - if (!storageStateObject) { - throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); - } + // Check if background preload was aborted (only for background tasks) + if (isBackgroundTask && this._backgroundPreloadAbort) { + throw new Error(`Context initialization aborted for index ${authIndex} (background preload aborted)`); + } - const buildScriptContent = this._loadAndConfigureBuildScript(authIndex); + const proxyConfig = parseProxyFromEnv(); + const storageStateObject = this.authSource.getAuth(authIndex); + if (!storageStateObject) { + throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); + } - // Viewport Randomization - const randomWidth = 1920 + Math.floor(Math.random() * 50); - const randomHeight = 1080 + Math.floor(Math.random() * 50); + const buildScriptContent = this._loadAndConfigureBuildScript(authIndex); - let context = null; - let page = null; + // Viewport Randomization + const randomWidth = 1920 + Math.floor(Math.random() * 50); + const randomHeight = 1080 + Math.floor(Math.random() * 50); - try { // Check abort status before expensive operations if (this.abortedContexts.has(authIndex) || (isBackgroundTask && this._backgroundPreloadAbort)) { throw new Error(`Context initialization aborted for index ${authIndex} (marked for deletion)`); diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 46eed18..cbcf79e 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -217,6 +217,23 @@ class StatusRoutes { const removedIndices = []; const failed = []; + // Abort any ongoing background preload task before deletion + // This prevents race conditions where background tasks continue initializing contexts + // that are about to be deleted + const browserManager = this.serverSystem.browserManager; + if (browserManager._backgroundPreloadTask) { + this.logger.info(`[Auth] Aborting background preload before dedup cleanup...`); + browserManager._backgroundPreloadAbort = true; + try { + await browserManager._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[Auth] Background preload aborted: ${error.message}`); + } + this.logger.info(`[Auth] Background preload aborted, proceeding with dedup cleanup`); + } + + // Delete duplicate auth files for (const group of duplicateGroups) { const removed = Array.isArray(group.removedIndices) ? group.removedIndices : []; if (removed.length === 0) continue; @@ -251,6 +268,20 @@ class StatusRoutes { authSource.reloadAuthSources(); } + // Close contexts for removed duplicate accounts + if (removedIndices.length > 0) { + for (const idx of removedIndices) { + try { + await browserManager.closeContext(idx); + this.serverSystem.connectionRegistry.closeConnectionByAuth(idx); + } catch (error) { + this.logger.warn( + `[Auth] Failed to close context for removed duplicate #${idx}: ${error.message}` + ); + } + } + } + // Rebalance context pool after dedup if (removedIndices.length > 0) { this.serverSystem.browserManager.rebalanceContextPool().catch(err => { @@ -316,6 +347,23 @@ class StatusRoutes { }); } + // Abort any ongoing background preload task before deletion + // This prevents race conditions where background tasks continue initializing contexts + // that are about to be deleted + const browserManager = this.serverSystem.browserManager; + if (browserManager._backgroundPreloadTask) { + this.logger.info(`[WebUI] Aborting background preload before batch delete...`); + browserManager._backgroundPreloadAbort = true; + try { + await browserManager._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); + } + this.logger.info(`[WebUI] Background preload aborted, proceeding with batch delete`); + } + + // Delete auth files for (const targetIndex of validIndices) { try { authSource.removeAuth(targetIndex); @@ -506,7 +554,24 @@ class StatusRoutes { }); } + // Abort any ongoing background preload task before deletion + // This prevents race conditions where background tasks continue initializing contexts + // that are about to be deleted + const browserManager = this.serverSystem.browserManager; + if (browserManager._backgroundPreloadTask) { + this.logger.info(`[WebUI] Aborting background preload before deleting account #${targetIndex}...`); + browserManager._backgroundPreloadAbort = true; + try { + await browserManager._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); + } + this.logger.info(`[WebUI] Background preload aborted, proceeding with account deletion`); + } + try { + // Delete auth file authSource.removeAuth(targetIndex); // Reload auth sources to update internal state immediately From 0a5c62d935da71abc86e649e878ef298d29e795e Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 12:13:06 +0800 Subject: [PATCH 39/47] refactor: implement batch file upload API and enhance error handling for uploads --- src/routes/StatusRoutes.js | 94 +++++++++++++++++++++++++++++++++++++ ui/app/pages/StatusPage.vue | 88 +++++++++++++++++++++++++++++++--- 2 files changed, 176 insertions(+), 6 deletions(-) diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index cbcf79e..706bcb4 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -735,6 +735,100 @@ class StatusRoutes { } }); + // Batch upload files + app.post("/api/files/batch", isAuthenticated, async (req, res) => { + const { files } = req.body; + + if (!Array.isArray(files) || files.length === 0) { + return res.status(400).json({ error: "Missing files array" }); + } + + try { + // Abort any ongoing background preload task before batch upload + // This prevents race conditions where background tasks continue initializing contexts + // while we're adding multiple new accounts + const browserManager = this.serverSystem.browserManager; + if (browserManager._backgroundPreloadTask) { + this.logger.info(`[WebUI] Aborting background preload before batch upload...`); + browserManager._backgroundPreloadAbort = true; + try { + await browserManager._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); + } + this.logger.info(`[WebUI] Background preload aborted, proceeding with batch upload`); + } + + // Ensure directory exists + const configDir = path.join(process.cwd(), "configs", "auth"); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const results = []; + + // Get starting index + const existingIndices = this.serverSystem.authSource.availableIndices || []; + let nextAuthIndex = existingIndices.length > 0 ? Math.max(...existingIndices) + 1 : 0; + + // Write all files first, track each file's result + for (let i = 0; i < files.length; i++) { + const content = files[i]; + + if (!content) { + results.push({ error: "Missing content", index: i, success: false }); + continue; + } + + try { + // If content is object, stringify it + const fileContent = typeof content === "object" ? JSON.stringify(content, null, 2) : content; + + const newFilename = `auth-${nextAuthIndex}.json`; + const filePath = path.join(configDir, newFilename); + + fs.writeFileSync(filePath, fileContent); + + results.push({ filename: newFilename, index: i, success: true }); + this.logger.info(`[WebUI] Batch upload: generated ${newFilename}`); + + nextAuthIndex++; + } catch (error) { + results.push({ error: error.message, index: i, success: false }); + this.logger.error(`[WebUI] Batch upload failed for file ${i}: ${error.message}`); + } + } + + // Only reload and rebalance once after all files are written + const successCount = results.filter(r => r.success).length; + if (successCount > 0) { + this.serverSystem.authSource.reloadAuthSources(); + this.serverSystem.browserManager.rebalanceContextPool().catch(err => { + this.logger.error(`[Auth] Background rebalance failed: ${err.message}`); + }); + } + + const failureCount = results.length - successCount; + if (failureCount > 0) { + return res.status(207).json({ + message: "Batch upload partially successful", + results, + successCount, + }); + } + + res.status(200).json({ + message: "Batch upload successful", + results, + successCount, + }); + } catch (error) { + this.logger.error(`[WebUI] Batch upload failed: ${error.message}`); + res.status(500).json({ error: "Failed to upload files" }); + } + }); + app.get("/api/files/:filename", isAuthenticated, (req, res) => { const filename = req.params.filename; // Security check diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 5378100..de9350e 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -2371,12 +2371,88 @@ const handleFileUpload = async event => { const successFiles = []; const failedFiles = [...extractErrors]; - for (const fileData of jsonFilesToUpload) { - const result = await uploadFile(fileData); - if (result.success) { - successFiles.push({ local: fileData.name, saved: result.filename }); - } else { - failedFiles.push({ local: fileData.name, reason: result.error }); + // Use batch upload API if multiple files, otherwise use single file upload + if (jsonFilesToUpload.length > 1) { + // Batch upload + const parsedFiles = []; + const parseErrors = []; + + // Parse all files first and track original indices + for (let i = 0; i < jsonFilesToUpload.length; i++) { + const fileData = jsonFilesToUpload[i]; + try { + const parsed = JSON.parse(fileData.content); + parsedFiles.push({ content: parsed, name: fileData.name, originalIndex: i }); + } catch (err) { + parseErrors.push({ local: fileData.name, reason: t("invalidJson") }); + } + } + + failedFiles.push(...parseErrors); + + // Upload all valid files in one batch + if (parsedFiles.length > 0) { + try { + const res = await fetch("/api/files/batch", { + body: JSON.stringify({ files: parsedFiles.map(f => f.content) }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + + if (res.ok || res.status === 207) { + const data = await res.json(); + // Process results array with proper index mapping + if (data.results && Array.isArray(data.results)) { + for (const result of data.results) { + const originalFile = parsedFiles[result.index]; + if (result.success) { + successFiles.push({ + local: originalFile?.name || `file-${result.index}`, + saved: result.filename || originalFile?.name || `file-${result.index}`, + }); + } else { + failedFiles.push({ + local: originalFile?.name || `file-${result.index}`, + reason: result.error || t("unknownError"), + }); + } + } + } + } else { + // Batch upload failed completely + let errorMsg = t("unknownError"); + try { + const data = await res.json(); + if (data.error) errorMsg = data.error; + } catch (e) { + if (res.statusText) { + errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; + } else { + errorMsg = `HTTP Error ${res.status}`; + } + } + // Mark all parsed files as failed + for (const fileData of parsedFiles) { + failedFiles.push({ local: fileData.name, reason: errorMsg }); + } + } + } catch (error) { + // Network or other error - mark all parsed files as failed + // (parseErrors are already in failedFiles) + for (const fileData of parsedFiles) { + failedFiles.push({ local: fileData.name, reason: error.message || t("networkError") }); + } + } + } + } else { + // Single file upload (use existing logic) + for (const fileData of jsonFilesToUpload) { + const result = await uploadFile(fileData); + if (result.success) { + successFiles.push({ local: fileData.name, saved: result.filename }); + } else { + failedFiles.push({ local: fileData.name, reason: result.error }); + } } } From cbc8119534e29f911b476a6ecd51be01ef0c200a Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 14:49:16 +0800 Subject: [PATCH 40/47] refactor: abort ongoing background preload tasks before file uploads to prevent race conditions --- CLAUDE.md | 4 +--- src/routes/StatusRoutes.js | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c48f6e9..82da520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,4 @@ Edit `configs/models.json` to customize available models and their settings. ### Git Workflow -- Main branch: `release-workflow` -- Current branch: `performance/muli-context` (multi-context optimization) -- Recent work focuses on context pool management and connection handling +- Main branch: `main` diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 706bcb4..ce4c5dd 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -691,7 +691,7 @@ class StatusRoutes { } }); - app.post("/api/files", isAuthenticated, (req, res) => { + app.post("/api/files", isAuthenticated, async (req, res) => { const { content } = req.body; // Ignore req.body.filename - auto rename @@ -700,6 +700,22 @@ class StatusRoutes { } try { + // Abort any ongoing background preload task before upload + // This prevents race conditions where background tasks continue initializing contexts + // while we're adding a new account + const browserManager = this.serverSystem.browserManager; + if (browserManager._backgroundPreloadTask) { + this.logger.info(`[WebUI] Aborting background preload before file upload...`); + browserManager._backgroundPreloadAbort = true; + try { + await browserManager._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); + } + this.logger.info(`[WebUI] Background preload aborted, proceeding with file upload`); + } + // Ensure directory exists const configDir = path.join(process.cwd(), "configs", "auth"); if (!fs.existsSync(configDir)) { From 514d5bf2f185bf34fff260e721f99b53669a503a Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 15:02:35 +0800 Subject: [PATCH 41/47] style: add loading notifications for batch delete and file upload operations --- ui/app/pages/StatusPage.vue | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index de9350e..36b65dd 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1620,6 +1620,12 @@ const batchDeleteAccounts = async () => { // Helper to perform batch delete const performBatchDelete = async (forceDelete = false) => { + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); state.isSwitchingAccount = true; try { const res = await fetch("/api/accounts/batch", { @@ -1631,6 +1637,7 @@ const batchDeleteAccounts = async () => { if (res.status === 409 && data.requiresConfirmation) { state.isSwitchingAccount = false; + notification.close(); ElMessageBox.confirm(t("warningDeleteCurrentAccount"), t("warningTitle"), { cancelButtonText: t("cancel"), confirmButtonText: t("ok"), @@ -1667,6 +1674,7 @@ const batchDeleteAccounts = async () => { ElMessage.error(t("batchDeleteFailed", { error: err.message || err })); } finally { state.isSwitchingAccount = false; + notification.close(); updateContent(); } }; @@ -1843,6 +1851,12 @@ const deleteAccountByIndex = async targetIndex => { // Helper function to perform the actual deletion const performDelete = async (forceDelete = false) => { + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); state.isSwitchingAccount = true; let shouldUpdate = true; try { @@ -1855,6 +1869,7 @@ const deleteAccountByIndex = async targetIndex => { if (res.status === 409 && data.requiresConfirmation) { shouldUpdate = false; state.isSwitchingAccount = false; + notification.close(); ElMessageBox.confirm(t("warningDeleteCurrentAccount"), t("warningTitle"), { cancelButtonText: t("cancel"), confirmButtonText: t("ok"), @@ -1881,6 +1896,7 @@ const deleteAccountByIndex = async targetIndex => { } finally { if (shouldUpdate) { state.isSwitchingAccount = false; + notification.close(); updateContent(); } } @@ -2244,6 +2260,14 @@ const handleFileUpload = async event => { // Reset input so same files can be selected again event.target.value = ""; + // Show notification immediately + const notification = ElNotification({ + duration: 0, + message: t("operationInProgress"), + title: t("warningTitle"), + type: "warning", + }); + // Helper function to read file as ArrayBuffer (for zip) const readFileAsArrayBuffer = file => new Promise((resolve, reject) => { @@ -2363,6 +2387,7 @@ const handleFileUpload = async event => { // Check if we have anything to process if (jsonFilesToUpload.length === 0 && extractErrors.length === 0) { + notification.close(); ElMessage.warning(t("noSupportedFiles")); return; } @@ -2456,6 +2481,9 @@ const handleFileUpload = async event => { } } + // Close the waiting notification + notification.close(); + // Build notification message with file details (scrollable container) let messageHtml = '
'; From d254c4d903452930b0dec77df6af2724792fc6f2 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 15:38:42 +0800 Subject: [PATCH 42/47] refactor: enhance browser management during background preloading and context closure --- src/core/BrowserManager.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 2e228c5..5e40eb9 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1362,10 +1362,18 @@ class BrowserManager { break; } - // Check if browser is still available + // Check if browser is available, launch if needed if (!this.browser) { - this.logger.info(`[ContextPool] Background preload stopped: browser is not available`); - break; + this.logger.info(`[ContextPool] Browser not available, launching browser for background preload...`); + try { + await this._ensureBrowser(); + this.logger.info(`[ContextPool] Browser launched successfully for background preload`); + } catch (error) { + this.logger.error( + `[ContextPool] Failed to launch browser for background preload: ${error.message}` + ); + break; + } } // Check pool size limit @@ -2123,6 +2131,13 @@ class BrowserManager { } if (!this.contexts.has(authIndex)) { + // Context doesn't exist (was never initialized or was aborted) + // Still check if we need to close the browser + // Only close if there are no contexts AND no contexts being initialized + if (this.contexts.size === 0 && this.initializingContexts.size === 0 && this.browser) { + this.logger.info(`[Browser] All contexts closed, closing browser instance...`); + await this.closeBrowser(); + } return; } @@ -2163,7 +2178,8 @@ class BrowserManager { // If this was the last context, close the browser to free resources // This ensures a clean state when all accounts are deleted - if (this.contexts.size === 0 && this.browser) { + // Only close if there are no contexts AND no contexts being initialized + if (this.contexts.size === 0 && this.initializingContexts.size === 0 && this.browser) { this.logger.info(`[Browser] All contexts closed, closing browser instance...`); await this.closeBrowser(); } From 667cab5766eb896aa2a019485dc73a2dd141f64b Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 16:06:50 +0800 Subject: [PATCH 43/47] refactor: improve file upload handling and notification messaging during batch operations --- ui/app/pages/StatusPage.vue | 450 ++++++++++++++++++------------------ 1 file changed, 231 insertions(+), 219 deletions(-) diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 36b65dd..8495242 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1627,6 +1627,7 @@ const batchDeleteAccounts = async () => { type: "warning", }); state.isSwitchingAccount = true; + let shouldUpdate = true; try { const res = await fetch("/api/accounts/batch", { body: JSON.stringify({ force: forceDelete, indices }), @@ -1636,6 +1637,7 @@ const batchDeleteAccounts = async () => { const data = await res.json(); if (res.status === 409 && data.requiresConfirmation) { + shouldUpdate = false; state.isSwitchingAccount = false; notification.close(); ElMessageBox.confirm(t("warningDeleteCurrentAccount"), t("warningTitle"), { @@ -1673,9 +1675,11 @@ const batchDeleteAccounts = async () => { } catch (err) { ElMessage.error(t("batchDeleteFailed", { error: err.message || err })); } finally { - state.isSwitchingAccount = false; - notification.close(); - updateContent(); + if (shouldUpdate) { + state.isSwitchingAccount = false; + notification.close(); + updateContent(); + } } }; @@ -2268,272 +2272,280 @@ const handleFileUpload = async event => { type: "warning", }); - // Helper function to read file as ArrayBuffer (for zip) - const readFileAsArrayBuffer = file => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = e => resolve(e.target.result); - reader.onerror = () => reject(new Error(t("fileReadFailed"))); - reader.readAsArrayBuffer(file); - }); + // Set busy flag to disable UI during upload and rebalance + state.isSwitchingAccount = true; - // Helper function to read file as text (for json) - const readFileAsText = file => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = e => resolve({ content: e.target.result, name: file.name }); - reader.onerror = () => reject(new Error(t("fileReadFailed"))); - reader.readAsText(file); - }); - - // Helper function to upload a single file - const uploadFile = async fileData => { - let parsed; - try { - parsed = JSON.parse(fileData.content); - } catch (err) { - return { error: t("invalidJson"), filename: fileData.name, success: false }; - } - - try { - const res = await fetch("/api/files", { - body: JSON.stringify({ content: parsed }), - headers: { "Content-Type": "application/json" }, - method: "POST", + try { + // Helper function to read file as ArrayBuffer (for zip) + const readFileAsArrayBuffer = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve(e.target.result); + reader.onerror = () => reject(new Error(t("fileReadFailed"))); + reader.readAsArrayBuffer(file); }); - if (res.ok) { - const data = await res.json(); - return { filename: data.filename || fileData.name, success: true }; - } + // Helper function to read file as text (for json) + const readFileAsText = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve({ content: e.target.result, name: file.name }); + reader.onerror = () => reject(new Error(t("fileReadFailed"))); + reader.readAsText(file); + }); - let errorMsg = t("unknownError"); + // Helper function to upload a single file + const uploadFile = async fileData => { + let parsed; try { - const data = await res.json(); - if (data.error) errorMsg = data.error; - } catch (e) { - // Response is not JSON or cannot be parsed, fallback to status text or unknown error - if (res.statusText) { - errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; - } else { - errorMsg = `HTTP Error ${res.status}`; - } + parsed = JSON.parse(fileData.content); + } catch (err) { + return { error: t("invalidJson"), filename: fileData.name, success: false }; } - return { error: errorMsg, filename: fileData.name, success: false }; - } catch (err) { - // Network or other fetch errors - return { error: err.message || t("networkError"), filename: fileData.name, success: false }; - } - }; - // Collect all JSON files to upload (including extracted from zip) - const jsonFilesToUpload = []; - const extractErrors = []; + try { + const res = await fetch("/api/files", { + body: JSON.stringify({ content: parsed }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); - for (const file of files) { - const lowerName = file.name.toLowerCase(); + if (res.ok) { + const data = await res.json(); + return { filename: data.filename || fileData.name, success: true }; + } - if (lowerName.endsWith(".zip")) { - // Extract JSON files from zip - try { - let arrayBuffer; + let errorMsg = t("unknownError"); try { - arrayBuffer = await readFileAsArrayBuffer(file); - } catch (readErr) { - extractErrors.push({ local: file.name, reason: readErr.message || t("fileReadFailed") }); - continue; // Skip zip processing if read failed + const data = await res.json(); + if (data.error) errorMsg = data.error; + } catch (e) { + // Response is not JSON or cannot be parsed, fallback to status text or unknown error + if (res.statusText) { + errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; + } else { + errorMsg = `HTTP Error ${res.status}`; + } } + return { error: errorMsg, filename: fileData.name, success: false }; + } catch (err) { + // Network or other fetch errors + return { error: err.message || t("networkError"), filename: fileData.name, success: false }; + } + }; - const zip = await JSZip.loadAsync(arrayBuffer); - const zipEntries = Object.keys(zip.files); + // Collect all JSON files to upload (including extracted from zip) + const jsonFilesToUpload = []; + const extractErrors = []; - let foundJsonInZip = false; - for (const entryName of zipEntries) { - const entry = zip.files[entryName]; - // Skip directories and non-json files - if (entry.dir || !entryName.toLowerCase().endsWith(".json")) continue; + for (const file of files) { + const lowerName = file.name.toLowerCase(); - foundJsonInZip = true; + if (lowerName.endsWith(".zip")) { + // Extract JSON files from zip + try { + let arrayBuffer; try { - const content = await entry.async("string"); - // Use format: zipName/entryName for display - const displayName = `${file.name}/${entryName}`; - jsonFilesToUpload.push({ content, name: displayName }); - } catch (err) { - extractErrors.push({ - local: `${file.name}/${entryName}`, - reason: t("zipExtractFailed"), // Prefer localized generic error for extraction issues - }); + arrayBuffer = await readFileAsArrayBuffer(file); + } catch (readErr) { + extractErrors.push({ local: file.name, reason: readErr.message || t("fileReadFailed") }); + continue; // Skip zip processing if read failed } - } - if (!foundJsonInZip) { - extractErrors.push({ local: file.name, reason: t("zipNoJsonFiles") }); + const zip = await JSZip.loadAsync(arrayBuffer); + const zipEntries = Object.keys(zip.files); + + let foundJsonInZip = false; + for (const entryName of zipEntries) { + const entry = zip.files[entryName]; + // Skip directories and non-json files + if (entry.dir || !entryName.toLowerCase().endsWith(".json")) continue; + + foundJsonInZip = true; + try { + const content = await entry.async("string"); + // Use format: zipName/entryName for display + const displayName = `${file.name}/${entryName}`; + jsonFilesToUpload.push({ content, name: displayName }); + } catch (err) { + extractErrors.push({ + local: `${file.name}/${entryName}`, + reason: t("zipExtractFailed"), // Prefer localized generic error for extraction issues + }); + } + } + + if (!foundJsonInZip) { + extractErrors.push({ local: file.name, reason: t("zipNoJsonFiles") }); + } + } catch (err) { + // Catch any other errors during zip processing (e.g. invalid zip format) + extractErrors.push({ local: file.name, reason: t("zipExtractFailed") }); + } + } else if (lowerName.endsWith(".json")) { + // Regular JSON file + try { + const fileData = await readFileAsText(file); + jsonFilesToUpload.push(fileData); + } catch (err) { + extractErrors.push({ local: file.name, reason: err.message || t("fileReadFailed") }); } - } catch (err) { - // Catch any other errors during zip processing (e.g. invalid zip format) - extractErrors.push({ local: file.name, reason: t("zipExtractFailed") }); - } - } else if (lowerName.endsWith(".json")) { - // Regular JSON file - try { - const fileData = await readFileAsText(file); - jsonFilesToUpload.push(fileData); - } catch (err) { - extractErrors.push({ local: file.name, reason: err.message || t("fileReadFailed") }); } } - } - // Check if we have anything to process - if (jsonFilesToUpload.length === 0 && extractErrors.length === 0) { - notification.close(); - ElMessage.warning(t("noSupportedFiles")); - return; - } + // Check if we have anything to process + if (jsonFilesToUpload.length === 0 && extractErrors.length === 0) { + notification.close(); + ElMessage.warning(t("noSupportedFiles")); + return; + } - // Upload all collected JSON files - const successFiles = []; - const failedFiles = [...extractErrors]; + // Upload all collected JSON files + const successFiles = []; + const failedFiles = [...extractErrors]; - // Use batch upload API if multiple files, otherwise use single file upload - if (jsonFilesToUpload.length > 1) { - // Batch upload - const parsedFiles = []; - const parseErrors = []; + // Use batch upload API if multiple files, otherwise use single file upload + if (jsonFilesToUpload.length > 1) { + // Batch upload + const parsedFiles = []; + const parseErrors = []; - // Parse all files first and track original indices - for (let i = 0; i < jsonFilesToUpload.length; i++) { - const fileData = jsonFilesToUpload[i]; - try { - const parsed = JSON.parse(fileData.content); - parsedFiles.push({ content: parsed, name: fileData.name, originalIndex: i }); - } catch (err) { - parseErrors.push({ local: fileData.name, reason: t("invalidJson") }); + // Parse all files first and track original indices + for (let i = 0; i < jsonFilesToUpload.length; i++) { + const fileData = jsonFilesToUpload[i]; + try { + const parsed = JSON.parse(fileData.content); + parsedFiles.push({ content: parsed, name: fileData.name, originalIndex: i }); + } catch (err) { + parseErrors.push({ local: fileData.name, reason: t("invalidJson") }); + } } - } - failedFiles.push(...parseErrors); + failedFiles.push(...parseErrors); - // Upload all valid files in one batch - if (parsedFiles.length > 0) { - try { - const res = await fetch("/api/files/batch", { - body: JSON.stringify({ files: parsedFiles.map(f => f.content) }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); + // Upload all valid files in one batch + if (parsedFiles.length > 0) { + try { + const res = await fetch("/api/files/batch", { + body: JSON.stringify({ files: parsedFiles.map(f => f.content) }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); - if (res.ok || res.status === 207) { - const data = await res.json(); - // Process results array with proper index mapping - if (data.results && Array.isArray(data.results)) { - for (const result of data.results) { - const originalFile = parsedFiles[result.index]; - if (result.success) { - successFiles.push({ - local: originalFile?.name || `file-${result.index}`, - saved: result.filename || originalFile?.name || `file-${result.index}`, - }); + if (res.ok || res.status === 207) { + const data = await res.json(); + // Process results array with proper index mapping + if (data.results && Array.isArray(data.results)) { + for (const result of data.results) { + const originalFile = parsedFiles[result.index]; + if (result.success) { + successFiles.push({ + local: originalFile?.name || `file-${result.index}`, + saved: result.filename || originalFile?.name || `file-${result.index}`, + }); + } else { + failedFiles.push({ + local: originalFile?.name || `file-${result.index}`, + reason: result.error || t("unknownError"), + }); + } + } + } + } else { + // Batch upload failed completely + let errorMsg = t("unknownError"); + try { + const data = await res.json(); + if (data.error) errorMsg = data.error; + } catch (e) { + if (res.statusText) { + errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; } else { - failedFiles.push({ - local: originalFile?.name || `file-${result.index}`, - reason: result.error || t("unknownError"), - }); + errorMsg = `HTTP Error ${res.status}`; } } - } - } else { - // Batch upload failed completely - let errorMsg = t("unknownError"); - try { - const data = await res.json(); - if (data.error) errorMsg = data.error; - } catch (e) { - if (res.statusText) { - errorMsg = `HTTP Error ${res.status}: ${res.statusText}`; - } else { - errorMsg = `HTTP Error ${res.status}`; + // Mark all parsed files as failed + for (const fileData of parsedFiles) { + failedFiles.push({ local: fileData.name, reason: errorMsg }); } } - // Mark all parsed files as failed + } catch (error) { + // Network or other error - mark all parsed files as failed + // (parseErrors are already in failedFiles) for (const fileData of parsedFiles) { - failedFiles.push({ local: fileData.name, reason: errorMsg }); + failedFiles.push({ local: fileData.name, reason: error.message || t("networkError") }); } } - } catch (error) { - // Network or other error - mark all parsed files as failed - // (parseErrors are already in failedFiles) - for (const fileData of parsedFiles) { - failedFiles.push({ local: fileData.name, reason: error.message || t("networkError") }); - } } - } - } else { - // Single file upload (use existing logic) - for (const fileData of jsonFilesToUpload) { - const result = await uploadFile(fileData); - if (result.success) { - successFiles.push({ local: fileData.name, saved: result.filename }); - } else { - failedFiles.push({ local: fileData.name, reason: result.error }); + } else { + // Single file upload (use existing logic) + for (const fileData of jsonFilesToUpload) { + const result = await uploadFile(fileData); + if (result.success) { + successFiles.push({ local: fileData.name, saved: result.filename }); + } else { + failedFiles.push({ local: fileData.name, reason: result.error }); + } } } - } - // Close the waiting notification - notification.close(); + // Close the waiting notification + notification.close(); - // Build notification message with file details (scrollable container) - let messageHtml = '
'; + // Build notification message with file details (scrollable container) + let messageHtml = '
'; - if (successFiles.length > 0) { - messageHtml += `
${t("fileUploadBatchSuccess")} (${successFiles.length}):
`; - messageHtml += '
    '; - for (const f of successFiles) { - messageHtml += `
  • ${escapeHtml(f.local)} → ${escapeHtml(f.saved)}
  • `; + if (successFiles.length > 0) { + messageHtml += `
    ${t("fileUploadBatchSuccess")} (${successFiles.length}):
    `; + messageHtml += '
      '; + for (const f of successFiles) { + messageHtml += `
    • ${escapeHtml(f.local)} → ${escapeHtml(f.saved)}
    • `; + } + messageHtml += "
    "; } - messageHtml += "
"; - } - if (failedFiles.length > 0) { - messageHtml += `
${t("fileUploadBatchFailed")} (${failedFiles.length}):
`; - messageHtml += '
    '; - for (const f of failedFiles) { - messageHtml += `
  • ${escapeHtml(f.local)}: ${escapeHtml(f.reason)}
  • `; + if (failedFiles.length > 0) { + messageHtml += `
    ${t("fileUploadBatchFailed")} (${failedFiles.length}):
    `; + messageHtml += '
      '; + for (const f of failedFiles) { + messageHtml += `
    • ${escapeHtml(f.local)}: ${escapeHtml(f.reason)}
    • `; + } + messageHtml += "
    "; } - messageHtml += "
"; - } - messageHtml += "
"; + messageHtml += "
"; - // Determine notification type - let notifyType = "success"; - if (failedFiles.length > 0 && successFiles.length === 0) { - notifyType = "error"; - } else if (failedFiles.length > 0) { - notifyType = "warning"; - } + // Determine notification type + let notifyType = "success"; + if (failedFiles.length > 0 && successFiles.length === 0) { + notifyType = "error"; + } else if (failedFiles.length > 0) { + notifyType = "warning"; + } - // Build title with counts - const totalProcessed = successFiles.length + failedFiles.length; - let notifyTitle; - if (totalProcessed === 1) { - notifyTitle = t("fileUploadComplete"); - } else { - notifyTitle = `${t("fileUploadBatchResult")} (✓${successFiles.length} ✗${failedFiles.length})`; - } + // Build title with counts + const totalProcessed = successFiles.length + failedFiles.length; + let notifyTitle; + if (totalProcessed === 1) { + notifyTitle = t("fileUploadComplete"); + } else { + notifyTitle = `${t("fileUploadBatchResult")} (✓${successFiles.length} ✗${failedFiles.length})`; + } - ElNotification({ - dangerouslyUseHTMLString: true, - duration: 0, - message: messageHtml, - position: "top-right", - title: notifyTitle, - type: notifyType, - }); + ElNotification({ + dangerouslyUseHTMLString: true, + duration: 0, + message: messageHtml, + position: "top-right", + title: notifyTitle, + type: notifyType, + }); - updateContent(); + updateContent(); + } finally { + // Always reset busy flag, even if an error occurs + state.isSwitchingAccount = false; + } }; // Download account by index From bcd36f5a0b416068cec97c19178e26abb6229c02 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 16:12:18 +0800 Subject: [PATCH 44/47] refactor: encapsulate background preload abortion logic in a dedicated method --- src/core/BrowserManager.js | 47 +++++++++++++------------- src/routes/StatusRoutes.js | 67 ++++---------------------------------- 2 files changed, 31 insertions(+), 83 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 5e40eb9..8d4ab6d 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -1304,6 +1304,29 @@ class BrowserManager { this.logger.info("✅ [Browser] Main browser instance launched successfully."); } + /** + * Abort any ongoing background preload task and wait for it to complete + * This is a public method that encapsulates access to internal preload state + * @returns {Promise} Resolves when the background task has been aborted and cleaned up + */ + async abortBackgroundPreload() { + if (!this._backgroundPreloadTask) { + return; // No task to abort + } + + this.logger.info(`[ContextPool] Aborting background preload task...`); + this._backgroundPreloadAbort = true; + + try { + await this._backgroundPreloadTask; + } catch (error) { + // Ignore errors from aborted task + this.logger.debug(`[ContextPool] Background preload aborted: ${error.message}`); + } + + this.logger.info(`[ContextPool] Background preload aborted successfully`); + } + /** * Background sequential initialization of contexts (fire-and-forget) * Only one instance should be active at a time - new calls abort old ones @@ -1312,17 +1335,7 @@ class BrowserManager { */ async _preloadBackgroundContexts(indices, maxPoolSize = 0) { // If there's an existing background task, abort it and wait for it to finish - if (this._backgroundPreloadTask) { - this.logger.info(`[ContextPool] Aborting previous background preload task...`); - this._backgroundPreloadAbort = true; - try { - await this._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[ContextPool] Previous background preload task aborted: ${error.message}`); - } - this.logger.info(`[ContextPool] Previous background preload task aborted successfully`); - } + await this.abortBackgroundPreload(); // Reset abort flag and create new background task this._backgroundPreloadAbort = false; @@ -1430,17 +1443,7 @@ class BrowserManager { // Abort any ongoing background preload task before cleanup // This prevents race conditions where background tasks continue initializing contexts // that will be immediately removed by the new rebalance after switch - if (this._backgroundPreloadTask) { - this.logger.info(`[ContextPool] Pre-cleanup: aborting background preload...`); - this._backgroundPreloadAbort = true; - try { - await this._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[ContextPool] Background preload aborted: ${error.message}`); - } - this.logger.info(`[ContextPool] Pre-cleanup: background preload aborted, proceeding with cleanup`); - } + await this.abortBackgroundPreload(); // Test: Check if initializingContexts is empty after aborting background task if (this.initializingContexts.size > 0) { diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index ce4c5dd..6d47d95 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -220,18 +220,7 @@ class StatusRoutes { // Abort any ongoing background preload task before deletion // This prevents race conditions where background tasks continue initializing contexts // that are about to be deleted - const browserManager = this.serverSystem.browserManager; - if (browserManager._backgroundPreloadTask) { - this.logger.info(`[Auth] Aborting background preload before dedup cleanup...`); - browserManager._backgroundPreloadAbort = true; - try { - await browserManager._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[Auth] Background preload aborted: ${error.message}`); - } - this.logger.info(`[Auth] Background preload aborted, proceeding with dedup cleanup`); - } + await this.serverSystem.browserManager.abortBackgroundPreload(); // Delete duplicate auth files for (const group of duplicateGroups) { @@ -272,7 +261,7 @@ class StatusRoutes { if (removedIndices.length > 0) { for (const idx of removedIndices) { try { - await browserManager.closeContext(idx); + await this.serverSystem.browserManager.closeContext(idx); this.serverSystem.connectionRegistry.closeConnectionByAuth(idx); } catch (error) { this.logger.warn( @@ -350,18 +339,7 @@ class StatusRoutes { // Abort any ongoing background preload task before deletion // This prevents race conditions where background tasks continue initializing contexts // that are about to be deleted - const browserManager = this.serverSystem.browserManager; - if (browserManager._backgroundPreloadTask) { - this.logger.info(`[WebUI] Aborting background preload before batch delete...`); - browserManager._backgroundPreloadAbort = true; - try { - await browserManager._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); - } - this.logger.info(`[WebUI] Background preload aborted, proceeding with batch delete`); - } + await this.serverSystem.browserManager.abortBackgroundPreload(); // Delete auth files for (const targetIndex of validIndices) { @@ -557,18 +535,7 @@ class StatusRoutes { // Abort any ongoing background preload task before deletion // This prevents race conditions where background tasks continue initializing contexts // that are about to be deleted - const browserManager = this.serverSystem.browserManager; - if (browserManager._backgroundPreloadTask) { - this.logger.info(`[WebUI] Aborting background preload before deleting account #${targetIndex}...`); - browserManager._backgroundPreloadAbort = true; - try { - await browserManager._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); - } - this.logger.info(`[WebUI] Background preload aborted, proceeding with account deletion`); - } + await this.serverSystem.browserManager.abortBackgroundPreload(); try { // Delete auth file @@ -703,18 +670,7 @@ class StatusRoutes { // Abort any ongoing background preload task before upload // This prevents race conditions where background tasks continue initializing contexts // while we're adding a new account - const browserManager = this.serverSystem.browserManager; - if (browserManager._backgroundPreloadTask) { - this.logger.info(`[WebUI] Aborting background preload before file upload...`); - browserManager._backgroundPreloadAbort = true; - try { - await browserManager._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); - } - this.logger.info(`[WebUI] Background preload aborted, proceeding with file upload`); - } + await this.serverSystem.browserManager.abortBackgroundPreload(); // Ensure directory exists const configDir = path.join(process.cwd(), "configs", "auth"); @@ -763,18 +719,7 @@ class StatusRoutes { // Abort any ongoing background preload task before batch upload // This prevents race conditions where background tasks continue initializing contexts // while we're adding multiple new accounts - const browserManager = this.serverSystem.browserManager; - if (browserManager._backgroundPreloadTask) { - this.logger.info(`[WebUI] Aborting background preload before batch upload...`); - browserManager._backgroundPreloadAbort = true; - try { - await browserManager._backgroundPreloadTask; - } catch (error) { - // Ignore errors from aborted task - this.logger.debug(`[WebUI] Background preload aborted: ${error.message}`); - } - this.logger.info(`[WebUI] Background preload aborted, proceeding with batch upload`); - } + await this.serverSystem.browserManager.abortBackgroundPreload(); // Ensure directory exists const configDir = path.join(process.cwd(), "configs", "auth"); From 3995fd3e9af5cb568c83f9bfba03d95177a93f2a Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 19 Feb 2026 16:32:23 +0800 Subject: [PATCH 45/47] style: adjust notification handling during file upload processes --- ui/app/pages/StatusPage.vue | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 8495242..d5520d2 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -2395,7 +2395,6 @@ const handleFileUpload = async event => { // Check if we have anything to process if (jsonFilesToUpload.length === 0 && extractErrors.length === 0) { - notification.close(); ElMessage.warning(t("noSupportedFiles")); return; } @@ -2489,9 +2488,6 @@ const handleFileUpload = async event => { } } - // Close the waiting notification - notification.close(); - // Build notification message with file details (scrollable container) let messageHtml = '
'; @@ -2532,6 +2528,7 @@ const handleFileUpload = async event => { notifyTitle = `${t("fileUploadBatchResult")} (✓${successFiles.length} ✗${failedFiles.length})`; } + // Show result notification (keep open) ElNotification({ dangerouslyUseHTMLString: true, duration: 0, @@ -2543,7 +2540,8 @@ const handleFileUpload = async event => { updateContent(); } finally { - // Always reset busy flag, even if an error occurs + // Always close notification and reset busy flag + notification.close(); state.isSwitchingAccount = false; } }; From e311a8c17aa5cacd0e3840099e7e3c0cda94a70c Mon Sep 17 00:00:00 2001 From: bbbugg Date: Sun, 22 Feb 2026 00:25:28 +0800 Subject: [PATCH 46/47] refactor: simplify file parsing logic in StatusPage component --- ui/app/pages/StatusPage.vue | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index d5520d2..3bb306a 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -2409,12 +2409,11 @@ const handleFileUpload = async event => { const parsedFiles = []; const parseErrors = []; - // Parse all files first and track original indices - for (let i = 0; i < jsonFilesToUpload.length; i++) { - const fileData = jsonFilesToUpload[i]; + // Parse all files first + for (const fileData of jsonFilesToUpload) { try { const parsed = JSON.parse(fileData.content); - parsedFiles.push({ content: parsed, name: fileData.name, originalIndex: i }); + parsedFiles.push({ content: parsed, name: fileData.name }); } catch (err) { parseErrors.push({ local: fileData.name, reason: t("invalidJson") }); } From 3ed11e8c2c35890ad4256c4a48d6dcec75965db4 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Sun, 22 Feb 2026 00:51:30 +0800 Subject: [PATCH 47/47] feat: inject authIndex into BrowserManager and update ProxySystem initialization for multi-context support --- scripts/client/build.js | 17 ++++++++++------- src/core/BrowserManager.js | 35 ++++++++++++----------------------- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/scripts/client/build.js b/scripts/client/build.js index 0237960..22f1b6f 100644 --- a/scripts/client/build.js +++ b/scripts/client/build.js @@ -99,18 +99,21 @@ const Logger = { class ConnectionManager extends EventTarget { // [BrowserManager Injection Point] Do not modify the line below. // This line is dynamically replaced by BrowserManager.js based on WS_PORT environment variable. - constructor(endpoint = "ws://127.0.0.1:9998", authIndex = -1) { + constructor(endpoint = "ws://127.0.0.1:9998") { super(); - // Validate authIndex: must be >= 0 for multi-context architecture - if (!Number.isInteger(authIndex) || authIndex < 0) { - const errorMsg = `❌ FATAL: Invalid authIndex (${authIndex}). BrowserManager failed to inject authIndex correctly. This is a configuration error.`; + // Read authIndex from window.chrome._contextId (injected by BrowserManager via addInitScript) + const contextId = window.chrome?._contextId; + this.authIndex = contextId !== undefined ? contextId : -1; + + // Validate authIndex: must be >= 0 and not NaN for multi-context architecture + if (Number.isNaN(this.authIndex) || !Number.isInteger(this.authIndex) || this.authIndex < 0) { + const errorMsg = `❌ FATAL: Invalid authIndex (${this.authIndex}). Missing window.chrome._contextId. This is a configuration error.`; console.error(errorMsg); throw new Error(errorMsg); } this.endpoint = endpoint; - this.authIndex = authIndex; this.socket = null; this.isConnected = false; this.reconnectDelay = 5000; @@ -515,9 +518,9 @@ class RequestProcessor { } class ProxySystem extends EventTarget { - constructor(websocketEndpoint, authIndex = -1) { + constructor(websocketEndpoint) { super(); - this.connectionManager = new ConnectionManager(websocketEndpoint, authIndex); + this.connectionManager = new ConnectionManager(websocketEndpoint); this.requestProcessor = new RequestProcessor(); this._setupEventHandlers(); } diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index 8d4ab6d..b621c92 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -264,6 +264,12 @@ class BrowserManager { window._privacyProtectionInjected = true; try { + // 0. Inject authIndex for multi-account identification (disguised as Chrome internal property) + if (!window.chrome) { + window.chrome = {}; + } + window.chrome._contextId = ${authIndex}; + // 1. Mask WebDriver property Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); @@ -363,11 +369,11 @@ class BrowserManager { /** * Helper: Load and configure build.js script content - * Applies environment-specific configurations (TARGET_DOMAIN, WS_PORT, LOG_LEVEL, AUTH_INDEX) - * @param {number} authIndex - The auth index to inject into the script + * Applies environment-specific configurations (TARGET_DOMAIN, WS_PORT, LOG_LEVEL) + * Note: authIndex is now injected via window.chrome._contextId in addInitScript, not here * @returns {string} Configured build.js script content */ - _loadAndConfigureBuildScript(authIndex = -1) { + _loadAndConfigureBuildScript() { let buildScriptContent = fs.readFileSync( path.join(__dirname, "..", "..", "scripts", "client", "build.js"), "utf-8" @@ -398,7 +404,7 @@ class BrowserManager { for (let i = 0; i < lines.length; i++) { if (lines[i].includes('constructor(endpoint = "ws://127.0.0.1:9998"')) { this.logger.info(`[Config] Found port config line: ${lines[i]}`); - lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}", authIndex = -1) {`; + lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}") {`; this.logger.info(`[Config] Replaced with: ${lines[i]}`); portReplaced = true; break; @@ -439,23 +445,6 @@ class BrowserManager { } } - // Inject authIndex into ProxySystem initialization - let authIndexInjected = false; - const lines = buildScriptContent.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes("const proxySystem = new ProxySystem()")) { - lines[i] = ` const proxySystem = new ProxySystem(undefined, ${authIndex});`; - this.logger.debug(`[Config] Injected authIndex ${authIndex} into ProxySystem initialization`); - buildScriptContent = lines.join("\n"); - authIndexInjected = true; - break; - } - } - if (!authIndexInjected) { - const message = "[Config] Failed to inject authIndex into ProxySystem initialization in build.js"; - throw new Error(message); - } - return buildScriptContent; } @@ -1687,7 +1676,7 @@ class BrowserManager { throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); } - const buildScriptContent = this._loadAndConfigureBuildScript(authIndex); + const buildScriptContent = this._loadAndConfigureBuildScript(); // Viewport Randomization const randomWidth = 1920 + Math.floor(Math.random() * 50); @@ -2071,7 +2060,7 @@ class BrowserManager { try { // Load and configure the build.js script using the shared helper - const buildScriptContent = this._loadAndConfigureBuildScript(targetAuthIndex); + const buildScriptContent = this._loadAndConfigureBuildScript(); // Navigate to target page and wake it up await this._navigateAndWakeUpPage(page, "[Reconnect]");