diff --git a/src/automation/BrowserManager.ts b/src/automation/BrowserManager.ts index fbe484fe..0141c267 100644 --- a/src/automation/BrowserManager.ts +++ b/src/automation/BrowserManager.ts @@ -23,10 +23,9 @@ interface BrowserCreationResult { fingerprint: BrowserFingerprintWithHeaders } -type BrowserChannel = 'chrome' | 'msedge' - class BrowserManager { private readonly bot: MicrosoftRewardsBot + private static readonly BROWSER_CLOSE_TIMEOUT_MS = 10_000 private readonly activeBrowsers = new Set() private static readonly BROWSER_ARGS = [ '--no-sandbox', @@ -75,14 +74,35 @@ class BrowserManager { } } + private async closeBrowserWithTimeout(browser: rebrowser.Browser): Promise { + let timeout: NodeJS.Timeout | undefined + try { + await Promise.race([ + browser.close(), + new Promise((_, reject) => { + timeout = setTimeout( + () => + reject( + new Error( + `Browser close timed out after ${BrowserManager.BROWSER_CLOSE_TIMEOUT_MS}ms` + ) + ), + BrowserManager.BROWSER_CLOSE_TIMEOUT_MS + ) + }) + ]) + } finally { + if (timeout) clearTimeout(timeout) + } + } + async createBrowser(account: Account): Promise { let browser: rebrowser.Browser - let channel: BrowserChannel | undefined try { this.bot.logger.info( this.bot.isMobile, 'BROWSER', - 'Initializing browser — detecting available channel (Chromium › Chrome › Edge)...' + 'Initializing browser — verifying Patchright bundled Chromium...' ) const proxyConfig = account.proxy.url @@ -124,8 +144,7 @@ class BrowserManager { ) const fingerprint = - sessionData.fingerprint ?? - (await this.generateFingerprint(this.bot.isMobile, channel === 'msedge' ? 'edge' : 'chrome')) + sessionData.fingerprint ?? (await this.generateFingerprint(this.bot.isMobile, 'chrome')) const context = await newInjectedContext(browser as any, { fingerprint, @@ -134,9 +153,11 @@ class BrowserManager { screen: DESKTOP_BROWSER_VIEWPORT } }) - context.once('close', () => { + context.once('close', async () => { this.activeBrowsers.delete(browser) - void browser.close().catch(() => {}) + if (browser.isConnected()) { + await this.closeBrowserWithTimeout(browser).catch(() => {}) + } }) await context.addInitScript(() => { @@ -205,7 +226,7 @@ class BrowserManager { return { context: context as unknown as BrowserContext, fingerprint } } catch (error) { this.activeBrowsers.delete(browser) - await browser.close().catch(() => {}) + await this.closeBrowserWithTimeout(browser).catch(() => {}) throw error } } @@ -213,7 +234,7 @@ class BrowserManager { async closeAll(): Promise { const browsers = [...this.activeBrowsers] this.activeBrowsers.clear() - await Promise.allSettled(browsers.map(browser => browser.close())) + await Promise.allSettled(browsers.map(browser => this.closeBrowserWithTimeout(browser))) } private formatProxyServer(proxy: AccountProxy): string { diff --git a/src/automation/PageController.ts b/src/automation/PageController.ts index a3fa649a..866041a7 100644 --- a/src/automation/PageController.ts +++ b/src/automation/PageController.ts @@ -14,6 +14,7 @@ import { URLS } from './DashboardSelectors' export default class PageController { private bot: MicrosoftRewardsBot + private static readonly BROWSER_CLOSE_TIMEOUT_MS = 10_000 /** * Once the legacy JSON dashboard API (`/api/getuserinfo`) fails, Microsoft has @@ -75,6 +76,17 @@ export default class PageController { } } + try { + const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage + await page + .goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 30_000 }) + .catch(() => {}) + const html = await page.content() + return this.parseDashboardHtml(html) + } catch (fallbackError) { + this.bot.logger.error(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Failed to get dashboard data') + throw fallbackError + } return this.getDashboardDataFromHtml() } @@ -122,7 +134,11 @@ export default class PageController { private parseDashboardHtml(html: string): DashboardData { const legacyMatch = html.match(/var\s+dashboard\s*=\s*({.*?});/s) if (legacyMatch?.[1]) { - this.bot.logger.debug(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Extracted dashboard data from legacy HTML embed') + this.bot.logger.debug( + this.bot.isMobile, + 'GET-DASHBOARD-DATA', + 'Extracted dashboard data from legacy HTML embed' + ) return JSON.parse(legacyMatch[1]) as DashboardData } @@ -199,9 +215,7 @@ export default class PageController { const parsed = JSON.parse(callArgs) as unknown if (!Array.isArray(parsed)) return null - return parsed - .filter((part): part is string => typeof part === 'string') - .join('\n') + return parsed.filter((part): part is string => typeof part === 'string').join('\n') } catch { const stringValues: string[] = [] const stringPattern = /"((?:\\.|[^"\\])*)"/gs @@ -783,9 +797,9 @@ export default class PageController { } } - async closeBrowser(browser: BrowserContext, email: string) { + async closeBrowser(context: BrowserContext, email: string) { try { - const cookies = await browser.cookies() + const cookies = await context.cookies() // Save cookies this.bot.logger.debug( @@ -798,7 +812,7 @@ export default class PageController { // Save localStorage from all open pages (rewards.bing.com, bing.com) try { const storageOrigins: StorageOrigin[] = [] - const pages = browser.pages() + const pages = context.pages() const seenOrigins = new Set() for (const page of pages) { @@ -847,10 +861,10 @@ export default class PageController { await this.bot.utils.wait(2000) - // Close browser - await browser.close() + await this.closeWithTimeout(() => context.close(), 'context') this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!') } catch (error) { + await this.closeWithTimeout(() => context.close(), 'context').catch(() => {}) this.bot.logger.error( this.bot.isMobile, 'CLOSE-BROWSER', @@ -860,6 +874,26 @@ export default class PageController { } } + private async closeWithTimeout(close: () => Promise, label: string): Promise { + let timeout: NodeJS.Timeout | undefined + try { + await Promise.race([ + close(), + new Promise((_, reject) => { + timeout = setTimeout( + () => + reject( + new Error(`${label} close timed out after ${PageController.BROWSER_CLOSE_TIMEOUT_MS}ms`) + ), + PageController.BROWSER_CLOSE_TIMEOUT_MS + ) + }) + ]) + } finally { + if (timeout) clearTimeout(timeout) + } + } + /** * Report an activity completion via `fetch()` executed inside the browser page. * diff --git a/tests/browser-runtime.test.js b/tests/browser-runtime.test.js index 8fa608fa..fc750783 100644 --- a/tests/browser-runtime.test.js +++ b/tests/browser-runtime.test.js @@ -14,6 +14,10 @@ test('browser runtime requires Patchright Chromium instead of silently using sys test('browser processes are tracked and closed during shutdown paths', () => { assert.match(browserManager, /activeBrowsers = new Set/) + assert.match(browserManager, /BROWSER_CLOSE_TIMEOUT_MS = 10_000/) + assert.match(browserManager, /closeBrowserWithTimeout\(browser\)/) + assert.match(browserManager, /context\.once\('close', async \(\) =>/) assert.match(browserManager, /async closeAll\(\)/) + assert.match(browserManager, /browsers\.map\(browser => this\.closeBrowserWithTimeout\(browser\)\)/) assert.match(main, /await rewardsBot\.closeAllBrowsers\(\)/) })