Skip to content

Relaunch an exited browser instead of wedging the whole session - #6

Open
lincooln-ai wants to merge 7 commits into
browser-use:mainfrom
lincooln-ai:relaunch-exited-browser
Open

lincooln-ai wants to merge 7 commits into
browser-use:mainfrom
lincooln-ai:relaunch-exited-browser

Conversation

@lincooln-ai

@lincooln-ai lincooln-ai commented Sep 16, 2026

Copy link
Copy Markdown

Fixes #5.

What changes

  1. A session relaunches its own browser when the process exited while idle. openBrowser() now returns a BrowserHandle; the SDK-launched variant (Browser.chromium) exposes alive() and relaunch(). BrowserUse checks it before a run or a manual cell and moves the runtime onto the new endpoint, so a Chrome that died while idle no longer wedges every later call. run()/followUp() report the reset as a warning event; execute() relaunches too but has no event channel.
  2. Caller-owned browsers are left alone. Browser.chrome(...), { cdpUrl } and Browser.cloud(...) expose no relaunch — those processes are not ours to restart, so a dead one keeps failing loudly.
  3. close() no longer throws when the browser is already gone. BrowserRuntime.close() connects to the endpoint to close SDK-owned tabs; that connect failure is now read as "nothing left to clean up" instead of failing teardown.
  4. Connect failures are diagnosable. CDP.connect reports the endpoint host plus the socket error or close code, and the timeout reports its budget. Host only, so devtools paths and cloud session tokens are never echoed.
  5. Temp-profile cleanup retries, so a profile still held by a dying Chrome child is not leaked.

Design note: no relaunch mid-call

Death while idle is cheap to recover: nothing was in flight, and the agent is told the browser was reset. Death during a cell keeps failing loudly on purpose — replaying half-finished browser work automatically is worse than the error. Happy to move that policy (public relaunchBrowser(), an opt-in flag, …) if you would rather keep the decision in the caller's hands.

Verification

  • Repro from Session is permanently wedged after the browser process exits (no relaunch, reconnect() cannot help, close() throws) #5, before/after. execute() after the browser exits: on mainError: CDP connection is closed. and session.close() threw: Could not connect to CDP endpoint.; on this branch → ok, endpoint replaced=true and session.close() succeeded.
  • New tests fail without the fix. test/browser-relaunch.test.mjs on main fails with browser.alive is not a function and Could not connect to CDP endpoint; on this branch all three pass. The new cdp.test.mjs case asserts the message names the host and does not echo the devtools path.
  • Full suite, same machine, same run conditions. npm test on this branch: 157 tests, 125 pass, 30 fail, 2 skipped. On main: 153 tests, 121 pass, 30 fail, 2 skipped. The failing set is identical (comparing failing test names, not just counts), so the four new tests are the only delta. The 30 failures are pre-existing on Windows: POSIX path assertions in browser-options.test.mjs, symlink EPERM in the evidence adapter, examples ESM resolution.
  • Real host cross-check. A host that keeps one long-lived session per chat (a pi extension) was run against this build: killing the headless Chrome mid-execute recovers within that same call, and killing it while idle leaves the next call working instead of requiring a process restart.

Summary by cubic

Fixes issue #5: a session that launched its own browser now relaunches that browser if it exits while idle, so later runs and follow-ups succeed instead of failing with a dead CDP endpoint.

Details

  • SDK-launched browsers now expose alive() and relaunch(), and a session relaunches a dead one before a run, followUp, or execute. A failed relaunch fails the run but leaves the session usable.
  • run/followUp emit a warning event when a relaunch happens; execute silently relaunches. A replacement launch retries once when the dying profile still holds files, keeping the original error.
  • Caller-owned browsers (Browser.chrome, Browser.cloud, { cdpUrl }) have no relaunch — they keep failing loudly. A browser that dies during a call also fails loudly; nothing is replayed automatically.
  • close() no longer throws when the browser is gone; cleanup retries transient connect failures once, and temp-profile cleanup retries to avoid leaks.
  • CDP connect failures now include the endpoint host and the socket error or close code; timeouts report their budget and the host.

Written for commit 784ccc5. Summary will update on new commits.

Review in cubic

A BrowserUse session launches its browser once and keeps that endpoint for its
whole life. If the Chrome process exits (crash, taskkill, OS cleanup of temp
files), the runtime only ever respawns the JS worker, so every later run,
followUp and execute fails with "CDP connection is closed." or "Could not
connect to CDP endpoint." and the session can never recover. reconnect(), the
recovery helper the agent is told to use, re-dials the same dead endpoint.

Browsers this SDK launched now report alive() and can relaunch(), and a session
checks that before a run or a manual cell so an idle browser that died is
replaced instead of poisoning every later call. run() and followUp() surface the
reset as a warning event; execute() relaunches without an event channel.
Caller-owned browsers (Browser.chrome, {cdpUrl}, Browser.cloud) expose no
relaunch: those processes are not ours to restart, so a dead one still fails
loudly.

A browser that dies mid-call keeps failing loudly on purpose: nothing is
replayed automatically, because half-finished browser work is not safe to redo.

Closing a session no longer throws when the browser is already gone: the
SDK-owned tab cleanup skips itself when the endpoint cannot be reached. Temp
profile cleanup also retries, so a profile still held by a dying Chrome child is
not leaked.
"Could not connect to CDP endpoint." gave no way to tell a dead browser from a
bad port, a blocked socket or a cloud session that expired, and it forced hosts
to match that exact string to react.

Connect failures now include the endpoint host and the socket error or close
code, while the timeout reports its budget. Only protocol and host are echoed,
so devtools paths and cloud session tokens never reach logs or error messages.
Copilot AI lite review requested due to automatic review settings September 16, 2026 07:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/index.ts">

<violation number="1" location="src/index.ts:219">
P2: When a run is cancelled while a dead browser is being relaunched, the cancellation cannot interrupt `reviveBrowser()`, so `cancel()` and an aborted run signal can wait through the replacement launch before the run observes cancellation. Make browser recovery cancellation-aware and check the signal before starting it.</violation>
</file>

<file name="src/runtime.ts">

<violation number="1" location="src/runtime.ts:247">
P2: If `BrowserUse.close()` runs while a direct `execute()` is waiting for `browser.relaunch()`, this guard discards the replacement after closing the old handle. Coordinate relaunch with session shutdown or close the replacement when the runtime is already closed.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/index.ts
Comment thread src/runtime.ts Outdated
Comment thread src/runtime.ts
* reconnects, and targets owned by the previous browser are forgotten with it.
*/
async adoptEndpoint(endpoint: string): Promise<void> {
if (this.closed) return;

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If BrowserUse.close() runs while a direct execute() is waiting for browser.relaunch(), this guard discards the replacement after closing the old handle. Coordinate relaunch with session shutdown or close the replacement when the runtime is already closed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/runtime.ts, line 247:

<comment>If `BrowserUse.close()` runs while a direct `execute()` is waiting for `browser.relaunch()`, this guard discards the replacement after closing the old handle. Coordinate relaunch with session shutdown or close the replacement when the runtime is already closed.</comment>

<file context>
@@ -239,6 +239,18 @@ export class BrowserRuntime {
+   * reconnects, and targets owned by the previous browser are forgotten with it.
+   */
+  async adoptEndpoint(endpoint: string): Promise<void> {
+    if (this.closed) return;
+    this.config.endpoint = endpoint;
+    this.owned.clear();
</file context>
Fix with cubic

Comment thread src/index.ts Outdated
this.control = new RunControl((paused) => this.emit({ type: paused ? 'paused' : 'resumed' }));
this.activeRun = this.performRun(task, options, followUp);
this.activeRun = (async () => {
await this.reviveBrowser();

@cubic-dev-ai cubic-dev-ai Bot Sep 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a run is cancelled while a dead browser is being relaunched, the cancellation cannot interrupt reviveBrowser(), so cancel() and an aborted run signal can wait through the replacement launch before the run observes cancellation. Make browser recovery cancellation-aware and check the signal before starting it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/index.ts, line 219:

<comment>When a run is cancelled while a dead browser is being relaunched, the cancellation cannot interrupt `reviveBrowser()`, so `cancel()` and an aborted run signal can wait through the replacement launch before the run observes cancellation. Make browser recovery cancellation-aware and check the signal before starting it.</comment>

<file context>
@@ -214,7 +215,10 @@ export class BrowserUse {
     this.control = new RunControl((paused) => this.emit({ type: paused ? 'paused' : 'resumed' }));
-    this.activeRun = this.performRun(task, options, followUp);
+    this.activeRun = (async () => {
+      await this.reviveBrowser();
+      return await this.performRun(task, options, followUp);
+    })();
</file context>
Fix with cubic

Comment thread src/browser.ts Outdated
Comment thread docs/sessions.md Outdated
Comment thread test/cdp.test.mjs
A relaunch that failed threw before performRun started, so its cleanup never
ran and every later call reported "This session is busy." Recovery failure now
mirrors performRun's cleanup before propagating the error.

Recovery also stops racing session teardown: a replacement that finishes
launching after close() is closed instead of being leaked, a run that finds the
session closed afterwards rejects instead of continuing, and an already aborted
signal skips the launch entirely. A launch already in flight still completes:
spawning Chrome is not cancellable without plumbing a signal through
openBrowser, and the run observes cancellation at its usual checkpoints.
Closing a session connected to the endpoint to close SDK-owned tabs; a
transient failure now retries once after a quick refusal, so the cleanup is not
dropped just because the browser was slow to answer. A slow failure is not
retried, because close() must not spend a second full timeout on an endpoint
that is not answering at all, and the cleanup is still dropped rather than
thrown when the browser really is gone.

A replacement launch is retried once as well, for a profile the dying Chrome
still holds, and the first error is kept because it describes the original
problem.
The timeout branch now reports the same protocol and host as the socket branch,
so both are equally diagnosable, and the test accepts either branch instead of
depending on the OS refusing the connection to port 1.
@lincooln-ai

Copy link
Copy Markdown
Author

Thanks — the P1 was real, and it is now covered by a regression test.

Fixed in 5fbd7c1, 4f12a5a, 75217e1, 784ccc5:

  • src/index.ts (P1, run state): a failed relaunch threw before performRun() ran, so its cleanup never executed and every later call reported This session is busy. Recovery failure now mirrors that cleanup before propagating. Regression test a failing relaunch reports the failure and leaves the session usable fails on the previous revision with exactly that busy error.
  • src/runtime.ts (P2, close race): a replacement that finishes launching after close() is now closed instead of leaked, and a run that finds the session closed after recovery rejects instead of continuing into performRun(). Regression test a replacement that arrives after close() is disposed instead of leaked.
  • src/index.ts (P2, cancellation): recovery is skipped when the signal is already aborted, so a cancelled run does not start a launch. A launch already in flight still completes — spawning Chrome is not cancellable without plumbing a signal through openBrowser() — and the run then observes cancellation at its usual checkpoints.
  • src/runtime.ts (P2, transient connect failure): cleanup now retries once after a quick refusal, so a transient failure no longer drops the owned-tab cleanup. A slow failure is not retried: close() must not spend a second full budget on an endpoint that is not answering at all. If both attempts fail the cleanup is still dropped rather than thrown, because tabs cannot be closed on a browser that is unreachable. Finding the right distinction here needs the connect failure to be typed; that felt out of scope for this PR, happy to add it if you want it.
  • src/browser.ts (P2, profile still held): the replacement launch is retried once after a short pause, and the first error is kept because it describes the original problem. Both retry paths are defensive failure paths with no dedicated test — forcing a deterministic first-attempt failure with a real Chrome is not practical — so they are covered only by the host-side kill test described below.
  • src/cdp.ts + test/cdp.test.mjs (P3): the assertion no longer depends on ECONNREFUSED; it accepts either the socket branch or the timeout branch. The timeout message now names the endpoint as well (CDP connection to ws://… timed out after N ms.), so both branches are equally diagnosable.
  • docs/sessions.md (P3): "handles report" → "handles expose".

Re-verified after these changes:

  • npm test on this branch: 159 tests, 127 pass, 30 fail, 2 skipped.
  • npm test on pristine main (same machine, same conditions): 153 tests, 121 pass, 30 fail, 2 skipped. The failing test names are identical (empty diff), so the six added tests are the only delta. The 30 failures are pre-existing on Windows: POSIX path assertions, symlink EPERM, ffmpeg/esbuild absence, examples ESM resolution.
  • The two new regression tests fail on the pre-fix revision with This session is busy. and /BrowserUse is closed/ respectively.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session is permanently wedged after the browser process exits (no relaunch, reconnect() cannot help, close() throws)

2 participants