From 5feeb965223d3e14f87c71d38f3e532d50968086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 22:54:53 -0400 Subject: [PATCH] fix(browser-manager): enforce 5min per-action timeout with cancellation path --- src/main/browser-manager.ts | 56 ++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/src/main/browser-manager.ts b/src/main/browser-manager.ts index 7c1c3596..7c766d7e 100644 --- a/src/main/browser-manager.ts +++ b/src/main/browser-manager.ts @@ -8,6 +8,15 @@ import { getPlatformRules, attachWindowSecurity, assertAllowedUrl, waitForLoad, export type { BrowserAction, BrowserActionResult } from "./browser/browser-types"; +/** + * Hard ceiling for a single browser action end-to-end (navigation, screenshot, + * login verification, scripted DOM interaction). A hung page would otherwise + * block every queued action for the same account forever; the renderer cannot + * be trusted to always call `cancelAction`. Five minutes matches the longest + * legitimate flow (first-time OAuth with 2FA). + */ +const ACTION_TIMEOUT_MS = 5 * 60 * 1000; + export class BrowserManager { private screenshotDir: string; private queues = new Map(); @@ -172,13 +181,7 @@ export class BrowserManager { while (queue.length > 0) { const item = queue.shift()!; try { - const result = await runAction( - item.action, - this.screenshotDir, - this.getSession(item.action.socialAccountId), - this.cancelledActionIds, - this.activeActionWindows, - ); + const result = await this.runActionWithDeadline(item.action); item.resolve(result); } catch (error) { item.reject(error instanceof Error ? error : new Error(String(error))); @@ -187,6 +190,45 @@ export class BrowserManager { this.processing.delete(socialAccountId); } + private runActionWithDeadline(action: BrowserAction): Promise { + // cancelAction reuses the same cancelledActionIds set + closes the active + // window, which runAction observes to return a cancelled result. Mirror + // that path on timeout so queued follow-ups are unblocked. + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + this.cancelledActionIds.add(action.id); + const activeWindow = this.activeActionWindows.get(action.id); + if (activeWindow && !activeWindow.isDestroyed()) { + activeWindow.close(); + } + log.warn(`[browser] action ${action.id} exceeded ${ACTION_TIMEOUT_MS}ms timeout`); + }, ACTION_TIMEOUT_MS); + + runAction( + action, + this.screenshotDir, + this.getSession(action.socialAccountId), + this.cancelledActionIds, + this.activeActionWindows, + ).then( + (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }, + (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); + } + private async verifyLoginSession(socialAccountId: string, platform: string): Promise { const ses = this.getSession(socialAccountId); const rules = getPlatformRules(platform);