8000 A dead browser webContents leaves a permanently stale CDP target: list-surfaces still reports it live, every command returns browser_not_open · Issue #155 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content
8000

A dead browser webContents leaves a permanently stale CDP target: list-surfaces still reports it live, every command returns browser_not_open #155

Closed

Description

@capad-xyz

Summary

A browser surface whose guest webContents dies without its React component unmounting leaves a permanently stale CDP target. The surface still exists in the store, so list-surfaces keeps reporting it as a live browser, but every browser command against it returns browser_not_open — and there is no recovery path short of closing the pane, because nothing ever prunes the dead target and the per-caller binding keeps resolving to it.

Reported to me as: "a surface's browser view can die while list-surfaces still reports it as a live browser; every command then returns browser_not_open with no way to revive it." I could not force a renderer crash on the running instance to confirm it end to end (another session was working in it — see the note at the bottom), so what follows is the mechanism traced through the code rather than a captured repro. Every link below is master at fae21a5.

Mechanism

1. detach has exactly one production trigger, and it is a React unmount.

The only call is the cdp:detach IPC handler:

cdpBridge.detach(webContentsId);

driven solely from BrowserPane's effect cleanup:

wv.addEventListener('dom-ready', onDomReady);
return () => {
wv.removeEventListener('dom-ready', onDomReady);
// Only detach if this pane still owns the connection — closing a split-tree
// browser pane must not kill another open pane's CDP (issue #27).
if (wcIdRef.current !== null) window.wmux?.cdp?.detach?.(wcIdRef.current);
};

There is no render-process-gone, crashed, destroyed or did-fail-load listener on the webview, and none in src/main/. I grepped for all of them. So a guest that dies while the component stays mounted never reaches detach, and its entry stays in targets.

2. Attach only re-runs on dom-ready. claimCdp is wired to dom-ready, and an Electron <webview> whose renderer dies does not reload itself — so no second dom-ready, no re-attach.

3. The stale entry is still a match, so the binding never heals. wcIdForSurface scans targets by surfaceId and has no liveness check:

wmux/src/main/cdp-bridge.ts

Lines 184 to 189 in fae21a5

wcIdForSurface(surfaceId: string): number | null {
for (const target of this.targets.values()) {
if (target.surfaceId === surfaceId) return target.wcId;
}
return null;
}

resolveBrowserWcId is written to self-heal, but its escape hatch is wcId === null:

// Reuse this caller's already-bound browser if it's still live.
const bound = callerBrowserSurface.get(caller);
if (bound) {
const wcId = cdpBridge.wcIdForSurface(bound);
if (wcId !== null) return wcId;
callerBrowserSurface.delete(caller);
boundBrowserSurfaces.delete(bound);
}

A dead target still returns a non-null id, so the callerBrowserSurface / boundBrowserSurfaces entries are never cleared, no new browser is adopted or created, and the caller stays bound to a corpse.

4. The one place that notices is the one place that cannot fix it. getDebugger correctly detects the death — and throws without pruning:

wmux/src/main/cdp-bridge.ts

Lines 201 to 206 in fae21a5

private getDebugger(target: CDPTarget) {
let wc;
try { wc = webContents.fromId(target.wcId); } catch { throw new Error('browser_not_open'); }
if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) throw new Error('browser_not_open');
return wc.debugger;
}

if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) throw new Error('browser_not_open');

So every subsequent command repeats the identical detection and the identical throw, forever. That is the "no way to revive it" in the report: the system has the information required to recover on the very first failure and discards it.

Meanwhile surface.list reads the renderer store, which knows nothing about CDP, so the surface keeps advertising itself as a working browser.

Suggested repro for whoever picks this up

In a scratch workspace, so nothing else is disturbed:

  1. wmux new-workspace --title probe, add a browser surface, navigate it so CDP attaches.
  2. Kill the guest renderer without unmounting the pane — navigating it to chrome://crash, or killing that specific Electron renderer PID, should both do it.
  3. wmux list-surfaces → the surface is still listed as type: "browser".
  4. Any wmux browser … against it → browser_not_open, repeatably, with no recovery.

Fix directions

Two independent halves, and I think the second is worth doing regardless of the first:

  1. Notice the death. Listen for render-process-gone / destroyed on the guest and call cdpBridge.detach(wcId), so teardown stops depending on React unmounting.
  2. Make the failure self-healing. Have getDebugger (or resolveTarget) drop the target when it finds it dead, instead of only throwing. Then the next command finds no target for that surface, resolveBrowserWcId takes its existing wcId === null branch, clears the binding and adopts or creates a working browser. That turns a permanent wedge into one failed command, and it needs no new event plumbing.

(2) also covers deaths that arrive by routes nobody thought to listen for, which given this class of bug seems like the more durable half.

Happy to implement either or both with tests if you tell me which shape you want.


Note on verification: I stopped short of crashing a live instance because a second session was actively driving the shared browser pane at the time. Everything above is traced statically; I did not want to present a reasoned-from-source finding as a captured repro.

Activity

  1. amirlehmam commented on Aug 13, 2026

    @amirlehmam
    Owner

    Fixed in 0.50.0 — both halves, and you were right that the second is worth doing regardless of the first.

    Your trace is accurate to the line. The part I want to underline is the third step, because it is the one that turns a transient failure into a permanent one: wcIdForSurface returning a non-null id for a corpse is what starved resolveBrowserWcId's self-healing branch. That branch was already written — its escape hatch is wcId === null — and it was unreachable for the exact case it existed to handle. The other two conditions (no death listener, no re-attach without dom-ready) are what let the corpse appear; this is what made it immortal.

    What shipped

    1. Notice it. BrowserPane now listens for render-process-gone / crashed / destroyed on the guest, detaches, and clears wcIdRef — so teardown no longer depends on React unmounting.

    It also reloads the webview once, rate-limited to one automatic recovery per 10 s so a page that crashes on load cannot be reloaded in a tight loop. That is deliberate and it's the piece your write-up didn't call for: without it, the bridge heals by adopting or creating a different browser, and the original surface stays a dead rectangle that list-surfaces still advertises as a browser — the reported symptom half-fixed. Reloading brings the same pane back and its next dom-ready re-attaches it, so the caller keeps the browser it was bound to.

    2. Heal anyway. A dead target is now dropped wherever it is found rather than only thrown about — wcIdForSurface, wcIdForWorkspace, isAttached, resolveTarget, navigate, and getDebugger itself. One failed command instead of a wedge, and it covers deaths arriving by routes nobody listens for, which as you say is the more durable half.

    One deliberate asymmetry inside getDebugger: a destroyed webContents is pruned, but a live one whose debugger merely came unattached (DevTools taking the session and giving it back) is re-attached instead. Pruning that would replace a working pane with a new one — a worse bug than the one being fixed, and the same reasoning that made detach() per-wcId in #27/#62.

    Tests

    tests/unit/cdp-stale-target.test.ts, against a fake webContents whose isDestroyed() can flip mid-test — i.e. a guest dying with no unmount and no detach, which is precisely the state you could not force on a live instance:

    • stops matching by surface id, so the binding can heal
    • drops on the first failed command, not on every one forever
    • a second live browser is untouched when one dies
    • the default target falls back to a live browser once the dead one is gone
    • a live guest whose debugger came loose is re-attached, not pruned

    On stopping short of the repro: that was the right call, and saying so explicitly is why the report was actionable. A reasoned-from-source finding presented as one is worth more than a captured repro presented vaguely — the mechanism is what got fixed, and it was correct.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0