From bfb817079ae5de9d18ce381115ef7f863c8793ed Mon Sep 17 00:00:00 2001 From: wrdo-dev <244725171+wrdo-dev@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:51:11 +0200 Subject: [PATCH] fix(browser): close orphaned browser before relaunch to stop headless_shell leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchBrowser() assigned `_browser = await chromium.launch()` without closing any existing browser first. When the connection state desyncs from the OS process — the `disconnected` handler nulls `_browser` while the headless_shell process can still be alive, or a post-launch step (newContext/newPage) throws and the catch-block launches a *second* browser — the previous browser process was abandoned. ensureBrowser() then relaunches on the next tool call, so idle headless_shell processes accumulate (~6 per relaunch cycle) until the host OOMs. Fix: - Add discardBrowser(): best-effort close that never blocks a relaunch. - launchBrowser() closes the current browser before launching a new one. - The fallback (system-Chrome) path discards the partially-launched primary browser before its second launch attempt. - The `disconnected` handler now closes the browser (deterministic Playwright handle cleanup) and only clears shared state if it still owns `_browser`, so a stale event from a prior browser can't null the current one. Verified on a host with chromium installed: two forced disconnect→relaunch cycles hold the headless_shell process count flat instead of growing by ~6 each cycle. --- src/browser/manager.ts | 117 +++++++++++++++++++++++++---------------- 1 file changed, 73 insertions(+), 44 deletions(-) diff --git a/src/browser/manager.ts b/src/browser/manager.ts index c819ae9..3a10fb1 100644 --- a/src/browser/manager.ts +++ b/src/browser/manager.ts @@ -15,39 +15,55 @@ let _config: BrowserConfig; const MAX_CONSOLE = 500; const MAX_NETWORK = 500; -export async function launchBrowser(config: BrowserConfig): Promise { - _config = config; - +/** + * Close whatever browser is currently referenced, swallowing any error. + * Used to guarantee we never abandon a live browser process before + * (re)launching — otherwise the old `headless_shell` leaks. Best-effort: + * a close failure must not block the relaunch. + */ +async function discardBrowser(b: Browser | null): Promise { + if (!b) return; try { - const launchOptions: Parameters[0] = { - headless: config.headless, - args: [ - '--no-first-run', - '--no-default-browser-check', - `--window-size=${config.viewport.width},${config.viewport.height + 100}`, - ], - }; - - if (config.channel) { - launchOptions.channel = config.channel as any; - } + await b.close(); + } catch (err) { + console.error('[glance] Error closing previous browser (ignored):', err); + } +} - _browser = await chromium.launch(launchOptions); +export async function launchBrowser(config: BrowserConfig): Promise { + _config = config; - _browser.on('disconnected', () => { + // Never abandon an existing browser process: close it before launching a + // new one. ensureBrowser() relaunches when `_browser` is null/disconnected, + // and the `disconnected` handler nulls `_browser` while the OS process may + // still be alive — so without this guard each relaunch orphaned a live + // headless_shell, accumulating until OOM. + await discardBrowser(_browser); + _browser = null; + _context = null; + _pages.clear(); + _activePageId = null; + + const attach = async (browser: Browser, label: string): Promise => { + _browser = browser; + browser.on('disconnected', () => { console.error('[glance] Browser disconnected, will relaunch on next tool call'); - _browser = null; - _context = null; - _pages.clear(); - _activePageId = null; + // Fully release the (now-dead) browser; close() is a no-op once the + // process is gone but cleans up Playwright-side handles deterministically. + void discardBrowser(browser); + if (_browser === browser) { + _browser = null; + _context = null; + _pages.clear(); + _activePageId = null; + } }); - _context = await _browser.newContext({ + _context = await browser.newContext({ viewport: config.viewport, ignoreHTTPSErrors: config.securityProfile === 'local-dev', }); - // Create initial page const page = await _context.newPage(); const pageId = randomUUID(); _pages.set(pageId, page); @@ -55,32 +71,45 @@ export async function launchBrowser(config: BrowserConfig): Promise { _setupPageListeners(page, pageId); _readyResolve?.(); - console.error('[glance] Browser launched and ready'); + console.error(`[glance] Browser launched${label} and ready`); + }; + + try { + const launchOptions: Parameters[0] = { + headless: config.headless, + args: [ + '--no-first-run', + '--no-default-browser-check', + `--window-size=${config.viewport.width},${config.viewport.height + 100}`, + ], + }; + + if (config.channel) { + launchOptions.channel = config.channel as any; + } + + await attach(await chromium.launch(launchOptions), ''); } catch (err) { console.error('[glance] Failed to launch browser:', err); - // Try with bundled chromium path detection disabled, fallback to system chrome + // A browser may have launched on the first attempt before a later step + // (newContext/newPage) threw — discard it so the fallback launch can't + // orphan it. + await discardBrowser(_browser); + _browser = null; + // Try the system Chrome channel as a fallback. try { - _browser = await chromium.launch({ - headless: config.headless, - channel: 'chrome', - args: ['--no-first-run', '--no-default-browser-check'], - }); - - _context = await _browser.newContext({ - viewport: config.viewport, - ignoreHTTPSErrors: config.securityProfile === 'local-dev', - }); - - const page = await _context.newPage(); - const pageId = randomUUID(); - _pages.set(pageId, page); - _activePageId = pageId; - _setupPageListeners(page, pageId); - - _readyResolve?.(); - console.error('[glance] Browser launched (system Chrome) and ready'); + await attach( + await chromium.launch({ + headless: config.headless, + channel: 'chrome', + args: ['--no-first-run', '--no-default-browser-check'], + }), + ' (system Chrome)', + ); } catch (err2) { console.error('[glance] Failed to launch system Chrome:', err2); + await discardBrowser(_browser); + _browser = null; _readyResolve?.(); } }