From 14d17e6c05caab5337c74edfb013c7b82465feb4 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 30 Jan 2026 15:51:03 +0800 Subject: [PATCH 1/7] feat: implement lightweight reconnect mechanism for WebSocket connections --- src/core/BrowserManager.js | 773 +++++++++++++++++++-------------- src/core/ConnectionRegistry.js | 31 +- src/core/ProxyServerSystem.js | 26 +- 3 files changed, 505 insertions(+), 325 deletions(-) diff --git a/src/core/BrowserManager.js b/src/core/BrowserManager.js index efb0e2f..1a9b0ad 100644 --- a/src/core/BrowserManager.js +++ b/src/core/BrowserManager.js @@ -27,6 +27,10 @@ class BrowserManager { this._currentAuthIndex = -1; this.scriptFileName = "build.js"; + // Flag to distinguish intentional close from unexpected disconnect + // Used by ConnectionRegistry callback to skip unnecessary reconnect attempts + this.isClosingIntentionally = false; + // Added for background wakeup logic from new core this.noButtonCount = 0; @@ -299,6 +303,371 @@ class BrowserManager { throw new Error('Unable to find "Code" button or alternatives (Smart Click Failed)'); } + /** + * Helper: Load and configure build.js script content + * Applies environment-specific configurations (TARGET_DOMAIN, WS_PORT, LOG_LEVEL) + * @returns {string} Configured build.js script content + */ + _loadAndConfigureBuildScript() { + let buildScriptContent = fs.readFileSync( + path.join(__dirname, "..", "..", "scripts", "client", "build.js"), + "utf-8" + ); + + if (process.env.TARGET_DOMAIN) { + const lines = buildScriptContent.split("\n"); + let domainReplaced = false; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("this.targetDomain =")) { + this.logger.info(`[Config] Found targetDomain line: ${lines[i]}`); + lines[i] = ` this.targetDomain = "${process.env.TARGET_DOMAIN}";`; + this.logger.info(`[Config] Replaced with: ${lines[i]}`); + domainReplaced = true; + break; + } + } + if (domainReplaced) { + buildScriptContent = lines.join("\n"); + } else { + this.logger.warn("[Config] Failed to find targetDomain line in build.js, ignoring."); + } + } + + if (process.env.WS_PORT) { + 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")')) { + this.logger.info(`[Config] Found port config line: ${lines[i]}`); + lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}") {`; + this.logger.info(`[Config] Replaced with: ${lines[i]}`); + portReplaced = true; + break; + } + } + if (portReplaced) { + buildScriptContent = lines.join("\n"); + } else { + this.logger.warn("[Config] Failed to find port config line in build.js, using default."); + } + } + + // Inject LOG_LEVEL configuration into build.js + // Read from LoggingService.currentLevel instead of environment variable + // This ensures runtime log level changes are respected when browser restarts + const LoggingService = require("../utils/LoggingService"); + const currentLogLevel = LoggingService.currentLevel; // 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR + const currentLogLevelName = LoggingService.getLevel(); // "DEBUG", "INFO", etc. + + if (currentLogLevel !== 1) { + const lines = buildScriptContent.split("\n"); + let levelReplaced = false; + for (let i = 0; i < lines.length; i++) { + // Match "currentLevel: ," pattern, ignoring comments + // This is more robust than looking for specific comments like "// Default: INFO" + if (/^\s*currentLevel:\s*\d+/.test(lines[i])) { + this.logger.info(`[Config] Found LOG_LEVEL config line: ${lines[i]}`); + lines[i] = ` currentLevel: ${currentLogLevel}, // Injected: ${currentLogLevelName}`; + this.logger.info(`[Config] Replaced with: ${lines[i]}`); + levelReplaced = true; + break; + } + } + if (levelReplaced) { + buildScriptContent = lines.join("\n"); + } else { + this.logger.warn("[Config] Failed to find LOG_LEVEL config line in build.js, using default INFO."); + } + } + + return buildScriptContent; + } + + /** + * Helper: Inject script into editor and activate + * Contains the common UI interaction logic for both launchOrSwitchContext and attemptLightweightReconnect + * @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]") { + this.logger.info(`${logPrefix} Preparing UI interaction, forcefully removing all possible overlay layers...`); + /* eslint-disable no-undef */ + await this.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.`); + overlays.forEach(el => el.remove()); + } + }); + /* eslint-enable no-undef */ + + this.logger.info(`${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...`); + /* eslint-disable no-undef */ + await this.page.evaluate(() => { + document.querySelectorAll("div.cdk-overlay-backdrop").forEach(el => el.remove()); + }); + /* eslint-enable no-undef */ + await this.page.waitForTimeout(500); + + // Use Smart Click instead of hardcoded locator + await this._smartClickCode(this.page); + + this.logger.info(" ✅ Click successful!"); + break; + } catch (error) { + this.logger.warn(` [Attempt ${i}/${maxTimes}] Click failed: ${error.message.split("\n")[0]}`); + if (i === maxTimes) { + throw new Error(`Unable to click "Code" button after multiple attempts, initialization failed.`); + } + } + } + + 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(); + await editorContainerLocator.waitFor({ + state: "visible", + timeout: 60000, + }); + + this.logger.info( + `${logPrefix} (Cleanup #2) Preparing to click editor, forcefully removing all possible overlay layers again...` + ); + /* eslint-disable no-undef */ + await this.page.evaluate(() => { + const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); + if (overlays.length > 0) { + console.log( + `[ProxyClient] (Internal JS) Found and removed ${overlays.length} newly appeared overlay layers.` + ); + overlays.forEach(el => el.remove()); + } + }); + /* eslint-enable no-undef */ + await this.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); + /* eslint-enable no-undef */ + const isMac = os.platform() === "darwin"; + const pasteKey = isMac ? "Meta+V" : "Control+V"; + await this.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(); + 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 () => { + try { + await fetch("https://generativelanguage.googleapis.com/v1beta/models?key=ActiveTrigger", { + headers: { "Content-Type": "application/json" }, + method: "GET", + }); + } catch (e) { + console.log("[ProxyClient] Active trigger sent"); + } + }); + } catch (e) { + /* empty */ + } + + this._startHealthMonitor(); + } + + /** + * Helper: Navigate to target page and wake up the page + * Contains the common navigation and page activation logic + * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") + */ + async _navigateAndWakeUpPage(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, { + timeout: 180000, + waitUntil: "domcontentloaded", + }); + this.logger.info(`${logPrefix} Page loaded.`); + + // Wake up window using JS and Human Movement + try { + await this.page.bringToFront(); + + // Get viewport size for realistic movement range + const vp = this.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); + + // 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(); + + 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); + } + + /** + * Helper: Check page status and detect various error conditions + * Detects: cookie expiration, region restrictions, 403 errors, page load failures + * @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(); + let pageTitle = ""; + try { + pageTitle = await this.page.title(); + } catch (e) { + 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}"`); + + // Check for various error conditions + if ( + currentUrl.includes("accounts.google.com") || + currentUrl.includes("ServiceLogin") || + pageTitle.includes("Sign in") || + pageTitle.includes("登录") + ) { + throw new Error( + "🚨 Cookie expired/invalid! Browser was redirected to Google login page. Please re-extract storageState." + ); + } + + if (pageTitle.includes("Available regions") || pageTitle.includes("not available")) { + throw new Error( + "🚨 Current IP does not support access to Google AI Studio (region restricted). Claw node may be identified as restricted region, try restarting container to get a new IP." + ); + } + + if (pageTitle.includes("403") || pageTitle.includes("Forbidden")) { + throw new Error("🚨 403 Forbidden: Current IP reputation too low, access denied by Google risk control."); + } + + if (currentUrl === "about:blank") { + throw new Error("🚨 Page load failed (about:blank), possibly network timeout or browser crash."); + } + } + + /** + * Helper: Handle various popups with intelligent detection + * Uses short polling instead of long hard-coded timeouts + * @param {string} logPrefix - Log prefix for messages (e.g., "[Browser]" or "[Reconnect]") + */ + async _handlePopups(logPrefix = "[Browser]") { + this.logger.info(`${logPrefix} 🔍 Starting intelligent popup detection (max 6s)...`); + + const popupConfigs = [ + { + logFound: `${logPrefix} ✅ Found Cookie consent banner, clicking "Agree"...`, + name: "Cookie consent", + selector: 'button:text("Agree")', + }, + { + logFound: `${logPrefix} ✅ Found "Got it" popup, clicking...`, + name: "Got it dialog", + selector: 'div.dialog button:text("Got it")', + }, + { + logFound: `${logPrefix} ✅ Found onboarding tutorial popup, clicking close button...`, + name: "Onboarding tutorial", + selector: 'button[aria-label="Close"]', + }, + ]; + + // Polling-based detection with smart exit conditions + // - Initial wait: give popups time to render after page load + // - Consecutive idle tracking: exit after N consecutive iterations with no new popups + const maxIterations = 12; // Max polling iterations + const pollInterval = 500; // Interval between polls (ms) + const minIterations = 6; // Min iterations (3s), ensure slow popups have time to load + const idleThreshold = 4; // Exit after N consecutive iterations with no new popups + const handledPopups = new Set(); + let consecutiveIdleCount = 0; // Counter for consecutive idle iterations + + for (let i = 0; i < maxIterations; i++) { + let foundAny = false; + + for (const popup of popupConfigs) { + if (handledPopups.has(popup.name)) continue; + + try { + const element = this.page.locator(popup.selector).first(); + // Quick visibility check with very short timeout + if (await element.isVisible({ timeout: 200 })) { + this.logger.info(popup.logFound); + await element.click({ force: true }); + handledPopups.add(popup.name); + foundAny = true; + // Short pause after clicking to let next popup appear + await this.page.waitForTimeout(800); + } + } catch (error) { + // Element not visible or doesn't exist is expected here, + // but propagate clearly critical browser/page issues. + if (error && error.message) { + const msg = error.message; + if ( + msg.includes("Execution context was destroyed") || + msg.includes("Target page, context or browser has been closed") || + msg.includes("Protocol error") || + msg.includes("Navigation failed because page was closed") + ) { + throw error; + } + if (this.logger && typeof this.logger.debug === "function") { + this.logger.debug( + `${logPrefix} Ignored error while checking popup "${popup.name}": ${msg}` + ); + } + } + } + } + + // Update consecutive idle counter + if (foundAny) { + consecutiveIdleCount = 0; // Found popup, reset counter + } else { + consecutiveIdleCount++; + } + + // Exit conditions: + // 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( + `${logPrefix} ✅ Popup detection complete (${i + 1} iterations, ${handledPopups.size} popups handled)` + ); + break; + } + + if (i < maxIterations - 1) { + await this.page.waitForTimeout(pollInterval); + } + } + } + /** * Feature: Background Health Monitor (The "Scavenger") * Periodically cleans up popups and keeps the session alive. @@ -696,76 +1065,7 @@ class BrowserManager { throw new Error(`Failed to get or parse auth source for index ${authIndex}.`); } - let buildScriptContent = fs.readFileSync( - path.join(__dirname, "..", "..", "scripts", "client", "build.js"), - "utf-8" - ); - - if (process.env.TARGET_DOMAIN) { - const lines = buildScriptContent.split("\n"); - let domainReplaced = false; - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes("this.targetDomain =")) { - this.logger.info(`[Config] Found targetDomain line: ${lines[i]}`); - lines[i] = ` this.targetDomain = "${process.env.TARGET_DOMAIN}";`; - this.logger.info(`[Config] Replaced with: ${lines[i]}`); - domainReplaced = true; - break; - } - } - if (domainReplaced) { - buildScriptContent = lines.join("\n"); - } else { - this.logger.warn("[Config] Failed to find targetDomain line in build.js, ignoring."); - } - } - - if (process.env.WS_PORT) { - 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")')) { - this.logger.info(`[Config] Found port config line: ${lines[i]}`); - lines[i] = ` constructor(endpoint = "ws://127.0.0.1:${process.env.WS_PORT}") {`; - this.logger.info(`[Config] Replaced with: ${lines[i]}`); - portReplaced = true; - break; - } - } - if (portReplaced) { - buildScriptContent = lines.join("\n"); - } else { - this.logger.warn("[Config] Failed to find port config line in build.js, using default."); - } - } - - // Inject LOG_LEVEL configuration into build.js - // Read from LoggingService.currentLevel instead of environment variable - // This ensures runtime log level changes are respected when browser restarts - const LoggingService = require("../utils/LoggingService"); - const currentLogLevel = LoggingService.currentLevel; // 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR - const currentLogLevelName = LoggingService.getLevel(); // "DEBUG", "INFO", etc. - - if (currentLogLevel !== 1) { - const lines = buildScriptContent.split("\n"); - let levelReplaced = false; - for (let i = 0; i < lines.length; i++) { - // Match "currentLevel: ," pattern, ignoring comments - // This is more robust than looking for specific comments like "// Default: INFO" - if (/^\s*currentLevel:\s*\d+/.test(lines[i])) { - this.logger.info(`[Config] Found LOG_LEVEL config line: ${lines[i]}`); - lines[i] = ` currentLevel: ${currentLogLevel}, // Injected: ${currentLogLevelName}`; - this.logger.info(`[Config] Replaced with: ${lines[i]}`); - levelReplaced = true; - break; - } - } - if (levelReplaced) { - buildScriptContent = lines.join("\n"); - } else { - this.logger.warn("[Config] Failed to find LOG_LEVEL config line in build.js, using default INFO."); - } - } + const buildScriptContent = this._loadAndConfigureBuildScript(); try { // Viewport Randomization @@ -815,282 +1115,104 @@ class BrowserManager { } }); - this.logger.info(`[Browser] 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, { - timeout: 180000, - waitUntil: "domcontentloaded", - }); - this.logger.info("[Browser] Page loaded."); - // Wake up window using JS and Human Movement - try { - await this.page.bringToFront(); + await this._navigateAndWakeUpPage("[Browser]"); - // Get viewport size for realistic movement range - const vp = this.page.viewportSize() || { height: 1080, width: 1920 }; + // Check for cookie expiration, region restrictions, and other errors + await this._checkPageStatusAndErrors("[Browser]"); - // 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); + // Handle various popups (Cookie consent, Got it, Onboarding, etc.) + await this._handlePopups("[Browser]"); - // 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(); - - this.logger.info(`[Browser] ✅ Executed realistic page activation (Random -> 1,1 Click).`); - } catch (e) { - this.logger.warn(`[Browser] Wakeup minor error: ${e.message}`); - } - await this.page.waitForTimeout(2000 + Math.random() * 2000); + await this._injectScriptToEditor(buildScriptContent, "[Browser]"); - const currentUrl = this.page.url(); - let pageTitle = ""; - try { - pageTitle = await this.page.title(); - } catch (e) { - this.logger.warn(`[Browser] Unable to get page title: ${e.message}`); - } - - this.logger.info(`[Browser] [Diagnostic] URL: ${currentUrl}`); - this.logger.info(`[Browser] [Diagnostic] Title: "${pageTitle}"`); - - // Check for various error conditions - if ( - currentUrl.includes("accounts.google.com") || - currentUrl.includes("ServiceLogin") || - pageTitle.includes("Sign in") || - pageTitle.includes("登录") - ) { - throw new Error( - "🚨 Cookie expired/invalid! Browser was redirected to Google login page. Please re-extract storageState." - ); - } - - if (pageTitle.includes("Available regions") || pageTitle.includes("not available")) { - throw new Error( - "🚨 Current IP does not support access to Google AI Studio (region restricted). Claw node may be identified as restricted region, try restarting container to get a new IP." - ); - } - - if (pageTitle.includes("403") || pageTitle.includes("Forbidden")) { - throw new Error( - "🚨 403 Forbidden: Current IP reputation too low, access denied by Google risk control." - ); - } + // Start background wakeup service - only started here during initial browser launch + this._startBackgroundWakeup(); - if (currentUrl === "about:blank") { - throw new Error("🚨 Page load failed (about:blank), possibly network timeout or browser crash."); - } + this._currentAuthIndex = authIndex; - // Handle various popups with intelligent detection - // Use short polling instead of long hard-coded timeouts - this.logger.info(`[Browser] 🔍 Starting intelligent popup detection (max 6s)...`); - - const popupConfigs = [ - { - logFound: `[Browser] ✅ Found Cookie consent banner, clicking "Agree"...`, - name: "Cookie consent", - selector: 'button:text("Agree")', - }, - { - logFound: `[Browser] ✅ Found "Got it" popup, clicking...`, - name: "Got it dialog", - selector: 'div.dialog button:text("Got it")', - }, - { - logFound: `[Browser] ✅ Found onboarding tutorial popup, clicking close button...`, - name: "Onboarding tutorial", - selector: 'button[aria-label="Close"]', - }, - ]; - - // Polling-based detection with smart exit conditions - // - Initial wait: give popups time to render after page load - // - Consecutive idle tracking: exit after N consecutive iterations with no new popups - const maxIterations = 12; // Max polling iterations - const pollInterval = 500; // Interval between polls (ms) - const minIterations = 6; // Min iterations (3s), ensure slow popups have time to load - const idleThreshold = 4; // Exit after N consecutive iterations with no new popups - const handledPopups = new Set(); - let consecutiveIdleCount = 0; // Counter for consecutive idle iterations - - for (let i = 0; i < maxIterations; i++) { - let foundAny = false; - - for (const popup of popupConfigs) { - if (handledPopups.has(popup.name)) continue; + // [Auth Update] Save the refreshed cookies to the auth file immediately + await this._updateAuthFile(authIndex); - try { - const element = this.page.locator(popup.selector).first(); - // Quick visibility check with very short timeout - if (await element.isVisible({ timeout: 200 })) { - this.logger.info(popup.logFound); - await element.click({ force: true }); - handledPopups.add(popup.name); - foundAny = true; - // Short pause after clicking to let next popup appear - await this.page.waitForTimeout(800); - } - } catch (error) { - // Element not visible or doesn't exist is expected here, - // but propagate clearly critical browser/page issues. - if (error && error.message) { - const msg = error.message; - if ( - msg.includes("Execution context was destroyed") || - msg.includes("Target page, context or browser has been closed") || - msg.includes("Protocol error") || - msg.includes("Navigation failed because page was closed") - ) { - throw error; - } - if (this.logger && typeof this.logger.debug === "function") { - this.logger.debug( - `[Browser] Ignored error while checking popup "${popup.name}": ${msg}` - ); - } - } - } - } + this.logger.info("=================================================="); + this.logger.info(`✅ [Browser] Account ${authIndex} context initialized successfully!`); + this.logger.info("✅ [Browser] Browser client is ready."); + this.logger.info("=================================================="); + } catch (error) { + this.logger.error(`❌ [Browser] Account ${authIndex} context initialization failed: ${error.message}`); + await this._saveDebugArtifacts("init_failed"); + await this.closeBrowser(); + this._currentAuthIndex = -1; + throw error; + } + } - // Update consecutive idle counter - if (foundAny) { - consecutiveIdleCount = 0; // Found popup, reset counter - } else { - consecutiveIdleCount++; - } + /** + * Lightweight Reconnect: Refreshes the page and re-injects the script + * without restarting the entire browser instance. + * + * This method is called when WebSocket connection is lost but the browser + * process is still running. It's much faster than a full browser restart. + * + * @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."); + return false; + } - // Exit conditions: - // 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( - `[Browser] ✅ Popup detection complete (${i + 1} iterations, ${handledPopups.size} popups handled)` - ); - break; - } + // Check if page is closed + if (this.page.isClosed()) { + this.logger.warn("[Reconnect] Page is closed, cannot perform lightweight reconnect."); + return false; + } - if (i < maxIterations - 1) { - await this.page.waitForTimeout(pollInterval); - } - } + const authIndex = this._currentAuthIndex; + if (authIndex < 0) { + this.logger.warn("[Reconnect] No current auth index, cannot perform lightweight reconnect."); + return false; + } - this.logger.info("[Browser] Preparing UI interaction, forcefully removing all possible overlay layers..."); - /* eslint-disable no-undef */ - await this.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.`); - overlays.forEach(el => el.remove()); - } - }); - /* eslint-enable no-undef */ + this.logger.info("=================================================="); + this.logger.info(`🔄 [Reconnect] Starting lightweight reconnect for account #${authIndex}...`); + this.logger.info("=================================================="); - this.logger.info('[Browser] (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...`); - /* eslint-disable no-undef */ - await this.page.evaluate(() => { - document.querySelectorAll("div.cdk-overlay-backdrop").forEach(el => el.remove()); - }); - /* eslint-enable no-undef */ - await this.page.waitForTimeout(500); + // Stop existing background tasks + if (this.healthMonitorInterval) { + clearInterval(this.healthMonitorInterval); + this.healthMonitorInterval = null; + this.logger.info("[Reconnect] Stopped background health monitor."); + } - // Use Smart Click instead of hardcoded locator - await this._smartClickCode(this.page); + try { + // Load and configure the build.js script using the shared helper + const buildScriptContent = this._loadAndConfigureBuildScript(); - this.logger.info(" ✅ Click successful!"); - break; - } catch (error) { - this.logger.warn(` [Attempt ${i}/${maxTimes}] Click failed: ${error.message.split("\n")[0]}`); - if (i === maxTimes) { - throw new Error( - `Unable to click "Code" button after multiple attempts, initialization failed.` - ); - } - } - } + // Navigate to target page and wake it up + await this._navigateAndWakeUpPage("[Reconnect]"); - this.logger.info( - '[Browser] (Step 2/5) "Code" button clicked successfully, waiting for editor to become visible...' - ); - const editorContainerLocator = this.page.locator("div.monaco-editor").first(); - await editorContainerLocator.waitFor({ - state: "visible", - timeout: 60000, - }); + // Check for cookie expiration, region restrictions, and other errors + await this._checkPageStatusAndErrors("[Reconnect]"); - this.logger.info( - "[Browser] (Cleanup #2) Preparing to click editor, forcefully removing all possible overlay layers again..." - ); - /* eslint-disable no-undef */ - await this.page.evaluate(() => { - const overlays = document.querySelectorAll("div.cdk-overlay-backdrop"); - if (overlays.length > 0) { - console.log( - `[ProxyClient] (Internal JS) Found and removed ${overlays.length} newly appeared overlay layers.` - ); - overlays.forEach(el => el.remove()); - } - }); - /* eslint-enable no-undef */ - await this.page.waitForTimeout(250); - - this.logger.info("[Browser] (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); - /* eslint-enable no-undef */ - const isMac = os.platform() === "darwin"; - const pasteKey = isMac ? "Meta+V" : "Control+V"; - await this.page.keyboard.press(pasteKey); - this.logger.info("[Browser] (Step 4/5) Script pasted."); - this.logger.info('[Browser] (Step 5/5) Clicking "Preview" button to activate script...'); - await this.page.locator('button:text("Preview")').click(); - this.logger.info("[Browser] ✅ UI interaction complete, script is now running."); - - // Active Trigger (Hack to wake up Google Backend) - this.logger.info("[Browser] ⚡ Sending active trigger request to Launch flow..."); - try { - await this.page.evaluate(async () => { - try { - await fetch("https://generativelanguage.googleapis.com/v1beta/models?key=ActiveTrigger", { - headers: { "Content-Type": "application/json" }, - method: "GET", - }); - } catch (e) { - console.log("[ProxyClient] Active trigger sent"); - } - }); - } catch (e) { - /* empty */ - } + // Handle various popups (Cookie consent, Got it, Onboarding, etc.) + await this._handlePopups("[Reconnect]"); - this._startHealthMonitor(); - this._startBackgroundWakeup(); - this._currentAuthIndex = authIndex; + // Use shared script injection helper with [Reconnect] log prefix + await this._injectScriptToEditor(buildScriptContent, "[Reconnect]"); // [Auth Update] Save the refreshed cookies to the auth file immediately await this._updateAuthFile(authIndex); this.logger.info("=================================================="); - this.logger.info(`✅ [Browser] Account ${authIndex} context initialized successfully!`); - this.logger.info("✅ [Browser] Browser client is ready."); + this.logger.info(`✅ [Reconnect] Lightweight reconnect successful for account #${authIndex}!`); this.logger.info("=================================================="); + + return true; } catch (error) { - this.logger.error(`❌ [Browser] Account ${authIndex} context initialization failed: ${error.message}`); - // Save debug info before closing browser - await this._saveDebugArtifacts("init_failed"); - await this.closeBrowser(); - this._currentAuthIndex = -1; - throw error; + this.logger.error(`❌ [Reconnect] Lightweight reconnect failed: ${error.message}`); + await this._saveDebugArtifacts("reconnect_failed"); + return false; } } @@ -1099,6 +1221,10 @@ class BrowserManager { * Handles intervals, timeouts, and resetting all references. */ async closeBrowser() { + // Set flag to indicate intentional close - prevents ConnectionRegistry from + // attempting lightweight reconnect when WebSocket disconnects + this.isClosingIntentionally = true; + if (this.healthMonitorInterval) { clearInterval(this.healthMonitorInterval); this.healthMonitorInterval = null; @@ -1119,6 +1245,9 @@ class BrowserManager { this._currentAuthIndex = -1; this.logger.info("[Browser] Main browser instance closed, currentAuthIndex reset to -1."); } + + // Reset flag after close is complete + this.isClosingIntentionally = false; } async switchAccount(newAuthIndex) { diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index a8bcd6c..df75ce7 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -13,12 +13,18 @@ const MessageQueue = require("../utils/MessageQueue"); * Responsible for managing WebSocket connections and message queues */ class ConnectionRegistry extends EventEmitter { - constructor(logger) { + /** + * @param {Object} logger - Logger instance + * @param {Function} [onConnectionLostCallback] - Optional callback to invoke when connection is lost after grace period + */ + constructor(logger, onConnectionLostCallback = null) { super(); this.logger = logger; + this.onConnectionLostCallback = onConnectionLostCallback; this.connections = new Set(); this.messageQueues = new Map(); this.reconnectGraceTimer = null; + this.isReconnecting = false; // Flag to prevent multiple simultaneous reconnect attempts } addConnection(websocket, clientInfo) { @@ -42,13 +48,34 @@ class ConnectionRegistry extends EventEmitter { this.connections.delete(websocket); this.logger.warn("[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); + } + this.logger.info("[Server] Starting 5-second reconnect grace period..."); - this.reconnectGraceTimer = setTimeout(() => { + this.reconnectGraceTimer = setTimeout(async () => { this.logger.error( "[Server] Grace period ended, no reconnection detected. Connection lost confirmed, cleaning up all pending requests..." ); 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; + this.logger.info("[Server] Attempting lightweight reconnect..."); + try { + await this.onConnectionLostCallback(); + this.logger.info("[Server] Lightweight reconnect callback completed."); + } catch (error) { + this.logger.error(`[Server] Lightweight reconnect failed: ${error.message}`); + } finally { + this.isReconnecting = false; + } + } + this.emit("connectionLost"); }, 5000); diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index cc25d2a..41e83dd 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -40,7 +40,31 @@ class ProxyServerSystem extends EventEmitter { this.authSource = new AuthSource(this.logger); this.browserManager = new BrowserManager(this.logger, this.config, this.authSource); - this.connectionRegistry = new ConnectionRegistry(this.logger); + + // 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; + } + + if (this.browserManager.browser && this.browserManager.page && !this.browserManager.page.isClosed()) { + this.logger.info( + "[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." + ); + } + } else { + this.logger.info("[System] Browser not available, skipping lightweight reconnect."); + } + }); this.requestHandler = new RequestHandler( this, this.connectionRegistry, From 328506ad094949a121faabcaefbaeea121c9042d Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 30 Jan 2026 16:20:31 +0800 Subject: [PATCH 2/7] fix: clear reconnectGraceTimer on connection loss in ConnectionRegistry --- src/core/ConnectionRegistry.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index df75ce7..3943b08 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -77,6 +77,8 @@ class ConnectionRegistry extends EventEmitter { } this.emit("connectionLost"); + + this.reconnectGraceTimer = null; }, 5000); this.emit("connectionRemoved", websocket); From f505969be8bece306bae4a403ff7993b1bfeb6e3 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 30 Jan 2026 18:51:30 +0800 Subject: [PATCH 3/7] feat: enhance WebSocket connection handling with extended grace period and improved error management --- src/core/ConnectionRegistry.js | 11 +++- src/core/RequestHandler.js | 103 ++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 3943b08..4303b31 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -31,7 +31,8 @@ class ConnectionRegistry extends EventEmitter { if (this.reconnectGraceTimer) { clearTimeout(this.reconnectGraceTimer); this.reconnectGraceTimer = null; - this.logger.info("[Server] New connection detected during grace period, canceling disconnect handling."); + this.messageQueues.forEach(queue => queue.close()); + this.messageQueues.clear(); } this.connections.add(websocket); @@ -54,7 +55,7 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimer); } - this.logger.info("[Server] Starting 5-second reconnect grace period..."); + this.logger.info("[Server] Starting 60-second reconnect grace period..."); this.reconnectGraceTimer = setTimeout(async () => { this.logger.error( "[Server] Grace period ended, no reconnection detected. Connection lost confirmed, cleaning up all pending requests..." @@ -79,7 +80,7 @@ class ConnectionRegistry extends EventEmitter { this.emit("connectionLost"); this.reconnectGraceTimer = null; - }, 5000); + }, 60000); this.emit("connectionRemoved", websocket); } @@ -123,6 +124,10 @@ class ConnectionRegistry extends EventEmitter { return this.connections.size > 0; } + isInGracePeriod() { + return !!this.reconnectGraceTimer; + } + getFirstConnection() { return this.connections.values().next().value; } diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index d9d1361..da05625 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -56,6 +56,29 @@ class RequestHandler { return this.authSwitcher.switchToSpecificAuth(targetIndex); } + async _waitForGraceReconnect(timeoutMs = 60000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!this.connectionRegistry.isInGracePeriod()) { + return this.connectionRegistry.hasActiveConnections(); + } + if (this.connectionRegistry.hasActiveConnections()) { + return true; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return this.connectionRegistry.hasActiveConnections(); + } + + _isConnectionResetError(error) { + if (!error || !error.message) return false; + return ( + error.message.includes("Queue closed") || + error.message.includes("Queue is closed") || + error.message.includes("Connection lost") + ); + } + /** * Wait for WebSocket connection to be established * @param {number} timeoutMs - Maximum time to wait in milliseconds @@ -113,6 +136,21 @@ class RequestHandler { * @returns {boolean} true if recovery successful, false otherwise */ async _handleBrowserRecovery(res) { + // If ConnectionRegistry is still within its reconnect grace period, + // wait a short time to allow the browser client to auto-reconnect + // and avoid unnecessary restart/switch. + if (this.connectionRegistry.isInGracePeriod()) { + this.logger.info( + "[System] WebSocket disconnected, awaiting grace-period reconnection before triggering recovery..." + ); + const reconnected = await this._waitForGraceReconnect(); + if (reconnected) { + this.logger.info("[System] Connection restored during grace period, skipping recovery."); + return true; + } + this.logger.warn("[System] Grace period elapsed without reconnection, proceeding to recovery workflow."); + } + // Wait for system to become ready if it's busy (someone else is starting/switching browser) if (this.authSwitcher.isSystemBusy) { const ready = await this._waitForSystemReady(); @@ -498,8 +536,14 @@ class RequestHandler { // Send standard HTTP error response this._sendErrorResponse(res, initialMessage.status || 500, initialMessage.message); - // Handle account switch without sending callback to client (response is closed) - await this.authSwitcher.handleRequestFailureAndSwitch(initialMessage, null); + // Avoid switching account if the error is just a connection reset + if (!this._isConnectionResetError(initialMessage)) { + await this.authSwitcher.handleRequestFailureAndSwitch(initialMessage, null); + } else { + this.logger.info( + "[Request] Failure due to connection reset (Real Stream), skipping account switch." + ); + } return; } @@ -549,8 +593,14 @@ class RequestHandler { if (connectionMaintainer) clearTimeout(connectionMaintainer); this._sendErrorResponse(res, result.error.status || 500, result.error.message); - // Handle account switch without sending callback to client - await this.authSwitcher.handleRequestFailureAndSwitch(result.error, null); + // Avoid switching account if the error is just a connection reset + if (!this._isConnectionResetError(result.error)) { + await this.authSwitcher.handleRequestFailureAndSwitch(result.error, null); + } else { + this.logger.info( + "[Request] Failure due to connection reset (OpenAI), skipping account switch." + ); + } return; } @@ -663,8 +713,14 @@ class RequestHandler { // Send standard HTTP error response this._sendErrorResponse(res, result.error.status || 500, result.error.message); - // Handle account switch without sending callback to client - await this.authSwitcher.handleRequestFailureAndSwitch(result.error, null); + // Avoid switching account if the error is just a connection reset + if (!this._isConnectionResetError(result.error)) { + await this.authSwitcher.handleRequestFailureAndSwitch(result.error, null); + } else { + this.logger.info( + "[Request] Failure due to connection reset (Gemini Non-Stream), skipping account switch." + ); + } } return; } @@ -808,7 +864,14 @@ class RequestHandler { ); } else { this.logger.error(`[Request] Request failed, will be counted in failure statistics.`); - await this.authSwitcher.handleRequestFailureAndSwitch(headerMessage, null); + // Avoid switching account if the error is just a connection reset + if (!this._isConnectionResetError(headerMessage)) { + await this.authSwitcher.handleRequestFailureAndSwitch(headerMessage, null); + } else { + this.logger.info( + "[Request] Failure due to connection reset (Gemini Real Stream), skipping account switch." + ); + } return this._sendErrorResponse(res, headerMessage.status, headerMessage.message); } if (!res.writableEnded) res.end(); @@ -880,7 +943,14 @@ class RequestHandler { this.logger.info(`[Request] Request #${proxyRequest.request_id} was properly cancelled by user.`); } else { this.logger.error(`[Request] Browser returned error after retries: ${result.error.message}`); - await this.authSwitcher.handleRequestFailureAndSwitch(result.error, null); + // Avoid switching account if the error is just a connection reset + if (!this._isConnectionResetError(result.error)) { + await this.authSwitcher.handleRequestFailureAndSwitch(result.error, null); + } else { + this.logger.info( + "[Request] Failure due to connection reset (Gemini Non-Stream), skipping account switch." + ); + } } return this._sendErrorResponse(res, result.error.status || 500, result.error.message); } @@ -1006,6 +1076,15 @@ class RequestHandler { errorPayload = { message: error.message, status: 500 }; } + // Stop retrying immediately if the queue is closed (connection reset) + if (this._isConnectionResetError(errorPayload)) { + this.logger.warn( + `[Request] Message queue closed unexpectedly (likely due to connection reset), aborting retries.` + ); + lastError = { message: "Connection lost (Queue closed)", status: 503 }; + break; + } + lastError = errorPayload; // Check if we should stop retrying immediately based on status code @@ -1147,7 +1226,13 @@ class RequestHandler { if (!res.writableEnded) res.end(); } else { this.logger.error(`[Request] Request processing error: ${error.message}`); - const status = error.message.toLowerCase().includes("timeout") ? 504 : 500; + let status = 500; + if (error.message.toLowerCase().includes("timeout")) { + status = 504; + } else if (this._isConnectionResetError(error)) { + status = 503; + this.logger.info("[Request] Mapping connection reset error to 503 Service Unavailable."); + } this._sendErrorResponse(res, status, `Proxy error: ${error.message}`); } } From 4543264b23109c192f94a0bcc2ced362a9ddb79c Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 30 Jan 2026 19:44:38 +0800 Subject: [PATCH 4/7] fix: reduce WebSocket reconnect grace period to 5 seconds and enhance reconnect handling --- src/core/ConnectionRegistry.js | 27 +++++++++++++++++++++++---- src/core/RequestHandler.js | 22 +++++++++------------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index 4303b31..f34c825 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -55,7 +55,9 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimer); } - this.logger.info("[Server] Starting 60-second reconnect grace period..."); + const reconnectTimeoutMs = 55000; + + this.logger.info("[Server] Starting 5-second reconnect grace period..."); this.reconnectGraceTimer = setTimeout(async () => { this.logger.error( "[Server] Grace period ended, no reconnection detected. Connection lost confirmed, cleaning up all pending requests..." @@ -66,13 +68,26 @@ class ConnectionRegistry extends EventEmitter { // Attempt lightweight reconnect if callback is provided and not already reconnecting if (this.onConnectionLostCallback && !this.isReconnecting) { this.isReconnecting = true; - this.logger.info("[Server] Attempting lightweight reconnect..."); + this.logger.info( + `[Server] Attempting lightweight reconnect (timeout ${reconnectTimeoutMs / 1000}s)...` + ); + let timeoutId; try { - await this.onConnectionLostCallback(); + const callbackPromise = this.onConnectionLostCallback(); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("Lightweight reconnect timed out")), + reconnectTimeoutMs + ); + }); + await Promise.race([callbackPromise, timeoutPromise]); this.logger.info("[Server] Lightweight reconnect callback completed."); } catch (error) { this.logger.error(`[Server] Lightweight reconnect failed: ${error.message}`); } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } this.isReconnecting = false; } } @@ -80,7 +95,7 @@ class ConnectionRegistry extends EventEmitter { this.emit("connectionLost"); this.reconnectGraceTimer = null; - }, 60000); + }, 5000); this.emit("connectionRemoved", websocket); } @@ -124,6 +139,10 @@ class ConnectionRegistry extends EventEmitter { return this.connections.size > 0; } + isReconnectingInProgress() { + return this.isReconnecting; + } + isInGracePeriod() { return !!this.reconnectGraceTimer; } diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index da05625..d38c737 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -59,11 +59,9 @@ class RequestHandler { async _waitForGraceReconnect(timeoutMs = 60000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { - if (!this.connectionRegistry.isInGracePeriod()) { - return this.connectionRegistry.hasActiveConnections(); - } - if (this.connectionRegistry.hasActiveConnections()) { - return true; + if (!this.connectionRegistry.isInGracePeriod() && !this.connectionRegistry.isReconnectingInProgress()) { + const connectionReady = await this._waitForConnection(10000); + return connectionReady; } await new Promise(resolve => setTimeout(resolve, 100)); } @@ -136,19 +134,17 @@ class RequestHandler { * @returns {boolean} true if recovery successful, false otherwise */ async _handleBrowserRecovery(res) { - // If ConnectionRegistry is still within its reconnect grace period, - // wait a short time to allow the browser client to auto-reconnect - // and avoid unnecessary restart/switch. - if (this.connectionRegistry.isInGracePeriod()) { + // If within grace period or lightweight reconnect is running, wait up to 60s for WS恢复 + if (this.connectionRegistry.isInGracePeriod() || this.connectionRegistry.isReconnectingInProgress()) { this.logger.info( - "[System] WebSocket disconnected, awaiting grace-period reconnection before triggering recovery..." + "[System] Waiting up to 60s for WebSocket reconnection (grace/reconnect in progress) before full recovery..." ); - const reconnected = await this._waitForGraceReconnect(); + const reconnected = await this._waitForGraceReconnect(60000); if (reconnected) { - this.logger.info("[System] Connection restored during grace period, skipping recovery."); + this.logger.info("[System] Connection restored, skipping recovery."); return true; } - this.logger.warn("[System] Grace period elapsed without reconnection, proceeding to recovery workflow."); + this.logger.warn("[System] Reconnection wait expired, proceeding to recovery workflow."); } // Wait for system to become ready if it's busy (someone else is starting/switching browser) From 1380314c5524ebd54cdd1d4a384b911fafa0926a Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 30 Jan 2026 22:16:00 +0800 Subject: [PATCH 5/7] fix: skip lightweight reconnect attempt when system is busy switching or recovering --- src/core/ProxyServerSystem.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/ProxyServerSystem.js b/src/core/ProxyServerSystem.js index 41e83dd..572ef38 100644 --- a/src/core/ProxyServerSystem.js +++ b/src/core/ProxyServerSystem.js @@ -50,6 +50,13 @@ class ProxyServerSystem extends EventEmitter { 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; + } if (this.browserManager.browser && this.browserManager.page && !this.browserManager.page.isClosed()) { this.logger.info( From 2a36b38d0770e977758cb3795816d9f947dc3195 Mon Sep 17 00:00:00 2001 From: bbbugg <80089841+bbbugg@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:24:13 -0600 Subject: [PATCH 6/7] Update src/core/RequestHandler.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/core/RequestHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/RequestHandler.js b/src/core/RequestHandler.js index d38c737..68f2231 100644 --- a/src/core/RequestHandler.js +++ b/src/core/RequestHandler.js @@ -134,7 +134,7 @@ class RequestHandler { * @returns {boolean} true if recovery successful, false otherwise */ async _handleBrowserRecovery(res) { - // If within grace period or lightweight reconnect is running, wait up to 60s for WS恢复 + // If within grace period or lightweight reconnect is running, wait up to 60s for WebSocket reconnection if (this.connectionRegistry.isInGracePeriod() || this.connectionRegistry.isReconnectingInProgress()) { this.logger.info( "[System] Waiting up to 60s for WebSocket reconnection (grace/reconnect in progress) before full recovery..." From 2f27150aa6f7ec263f31d4a198c036dc4e06d9bf Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 30 Jan 2026 23:03:55 +0800 Subject: [PATCH 7/7] chore --- src/core/ConnectionRegistry.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/core/ConnectionRegistry.js b/src/core/ConnectionRegistry.js index f34c825..e627bcd 100644 --- a/src/core/ConnectionRegistry.js +++ b/src/core/ConnectionRegistry.js @@ -55,8 +55,6 @@ class ConnectionRegistry extends EventEmitter { clearTimeout(this.reconnectGraceTimer); } - const reconnectTimeoutMs = 55000; - this.logger.info("[Server] Starting 5-second reconnect grace period..."); this.reconnectGraceTimer = setTimeout(async () => { this.logger.error( @@ -68,8 +66,9 @@ class ConnectionRegistry extends EventEmitter { // Attempt lightweight reconnect if callback is provided and not already reconnecting if (this.onConnectionLostCallback && !this.isReconnecting) { this.isReconnecting = true; + const lightweightReconnectTimeoutMs = 55000; this.logger.info( - `[Server] Attempting lightweight reconnect (timeout ${reconnectTimeoutMs / 1000}s)...` + `[Server] Attempting lightweight reconnect (timeout ${lightweightReconnectTimeoutMs / 1000}s)...` ); let timeoutId; try { @@ -77,7 +76,7 @@ class ConnectionRegistry extends EventEmitter { const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout( () => reject(new Error("Lightweight reconnect timed out")), - reconnectTimeoutMs + lightweightReconnectTimeoutMs ); }); await Promise.race([callbackPromise, timeoutPromise]);