Repository navigation
Description
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:
Line 275 in fae21a5
| cdpBridge.detach(webContentsId); |
driven solely from BrowserPane's effect cleanup:
wmux/src/renderer/components/Browser/BrowserPane.tsx
Lines 100 to 106 in fae21a5
| 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:
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:
Lines 70 to 77 in fae21a5
| // 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:
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:
wmux new-workspace --title probe, add a browser surface, navigate it so CDP attaches.- Kill the guest renderer without unmounting the pane — navigating it to
chrome://crash, or killing that specific Electron renderer PID, should both do it. wmux list-surfaces→ the surface is still listed astype: "browser".- 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:
- Notice the death. Listen for
render-process-gone/destroyedon the guest and callcdpBridge.detach(wcId), so teardown stops depending on React unmounting. - Make the failure self-healing. Have
getDebugger(orresolveTarget) drop the target when it finds it dead, instead of only throwing. Then the next command finds no target for that surface,resolveBrowserWcIdtakes its existingwcId === nullbranch, 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
fix(cdp): drop a browser target when its renderer dies (#155)
amirlehmam commentedon Aug 13, 2026
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:
wcIdForSurfacereturning a non-null id for a corpse is what starvedresolveBrowserWcId's self-healing branch. That branch was already written — its escape hatch iswcId === null— and it was unreachable for the exact case it existed to handle. The other two conditions (no death listener, no re-attach withoutdom-ready) are what let the corpse appear; this is what made it immortal.What shipped
1. Notice it.
BrowserPanenow listens forrender-process-gone/crashed/destroyedon the guest, detaches, and clearswcIdRef— 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-surfacesstill advertises as a browser — the reported symptom half-fixed. Reloading brings the same pane back and its nextdom-readyre-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, andgetDebuggeritself. 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 madedetach()per-wcIdin #27/#62.Tests
tests/unit/cdp-stale-target.test.ts, against a fakewebContentswhoseisDestroyed()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: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.
feat(browser): agent-browser engine in the pane