Repository navigation
Description
Summary
A BrowserUse session is permanently wedged once its Chrome process exits. Every later run() / followUp() / execute() fails with CDP connection is closed. (or Could not connect to CDP endpoint.), session.browser.endpoint still points at the dead browser, and reconnect() — the recovery primitive exposed to the agent — re-dials that same dead endpoint. The only way out is to throw the session away and create a new one.
The browser can exit for reasons outside the caller's control (crash, OOM, taskkill, OS temp cleanup, antivirus), so a long-lived session will hit this eventually, and from the outside it is indistinguishable from a bug in the caller's code.
Environment
@browser_use/pi0.1.0- Node v26.8.1, Windows 11 (10.0.26200), Chrome 152.0.7977.83
Browser.chromium({ headless: true })
Minimal reproduction
execute() never calls the model, so no API key is needed.
import { Browser, BrowserUse } from '@browser_use/pi';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
process.on('uncaughtException', (e) => console.log('[uncaughtException]', e.message));
const session = await BrowserUse.create({
model: 'openai/gpt-5.4', // any built-in model; execute() does not use it
browser: Browser.chromium({ headless: true }),
workspace: await mkdtemp(join(tmpdir(), 'bu-repro-')),
});
try {
await session.execute('await page.info();');
console.log('1. execute with a live browser: ok');
await session.browser.close(); // stands in for the browser exiting on its own
console.log('2. browser closed through the public API');
try {
await session.execute('await page.info();');
console.log('3. execute after the browser exited: unexpectedly ok');
} catch (error) {
console.log(`3. execute after the browser exited: ${error.message}`);
}
console.log('4. endpoint still points at the dead browser:', session.browser.endpoint);
} finally {
try {
await session.close();
console.log('5. session.close() succeeded');
} catch (error) {
console.log(`5. session.close() threw: ${error.message}`);
}
}Output:
1. execute with a live browser: ok
2. browser closed through the public API
3. execute after the browser exited: Error: CDP connection is closed.
at CDP.sendMessage (…/dist/cdp.js:202:35)
4. endpoint still points at the dead browser: ws://127.0.0.1:64863/devtools/browser/59be6885-…
5. session.close() threw: Could not connect to CDP endpoint.
So a dead browser poisons execute(), leaves browser.endpoint pointing at it, and makes close() throw as well — a host has to wrap teardown in try/catch to clean up a wedged session.
Root cause
BrowserUse.create()launches the browser exactly once and stores the endpoint —index.js:116(const browser = await openBrowser(options.browser)),index.js:118(endpoint: browser.endpoint),index.js:136(the instance keepsbrowser).- When the browser dies, the runtime only respawns the JS worker, with the same config:
runtime.js:89-96(Browser worker exited (…)). So the dead endpoint is retried on every call, forever. reconnect(), the helper the agent is expected to use, recreates the CDP client from the same stored endpoint:worker.js:126-139(browser = CDP.lazy(config.endpoint, …)). It cannot recover a browser that is no longer running.- The failures are not diagnosable:
cdp.js:129collapses every socket error/close intoCould not connect to CDP endpoint.— no close code, no reason, no endpoint, nothing about the child process;cdp.js:202throwsCDP connection is closed. close()is not best-effort:runtime.close()connects to the endpoint to close owned targets (runtime.js:233-234) and re-propagates the failure, so tearing down an already-wedged session throws out ofsession.close().
Expected
- Either relaunch the browser on use (
openBrowser()again and swapconfig.endpoint— the SDK already owns the browser lifecycle viaclose(), so it is the natural owner), or - fail with a distinguishable typed error (e.g.
BrowserGoneError, withcause) so hosts can rebuild without string matching. Include the endpoint and the WebSocket close code/reason or the child exit code. reconnect()should relaunch, or state explicitly that the browser is gone and a new session is required.close()should always succeed for a dead browser, e.g. skip the owned-target cleanup when the endpoint is unreachable.
Related, smaller issue
Temp profile leak on Windows: browser.js:182-193 removes the profile with a single rm(profile, { recursive: true, force: true }). If Chrome was killed externally, its child processes can still hold files for a moment, the removal fails, and %TEMP%/browser-use-* is left behind (I saw this once in a handful of kills). The .bu-pi.lock file already contains { pid, createdAt }, which is enough for a safe orphan sweep on the next launch.
Host-side workaround (context)
In the host I maintain, I now probe GET http://127.0.0.1:<port>/json/version before reusing a session and rebuild (close() + create({ historyFile })) when it fails, plus treat a CDP failure as "browser gone" only after the probe confirms it. That works — including a test where Chrome was killed mid-call — but every host has to rediscover this hole on its own, and the error-message and close() problems remain.
Happy to send a PR for whichever direction you prefer.
Activity