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:
|
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:
|
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 as type: "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 / destroyed on the guest and call cdpBridge.detach(wcId), so teardown stops depending on React unmounting.
- 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.
Summary
A browser surface whose guest
webContentsdies without its React component unmounting leaves a permanently stale CDP target. The surface still exists in the store, solist-surfaceskeeps reporting it as a livebrowser, but every browser command against it returnsbrowser_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-surfacesstill reports it as a live browser; every command then returnsbrowser_not_openwith 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 ismasteratfae21a5.Mechanism
1.
detachhas exactly one production trigger, and it is a React unmount.The only call is the
cdp:detachIPC handler:wmux/src/main/ipc-handlers.ts
Line 275 in fae21a5
driven solely from
BrowserPane's effect cleanup:wmux/src/renderer/components/Browser/BrowserPane.tsx
Lines 100 to 106 in fae21a5
There is no
render-process-gone,crashed,destroyedordid-fail-loadlistener on the webview, and none insrc/main/. I grepped for all of them. So a guest that dies while the component stays mounted never reachesdetach, and its entry stays intargets.2. Attach only re-runs on
dom-ready.claimCdpis wired todom-ready, and an Electron<webview>whose renderer dies does not reload itself — so no seconddom-ready, no re-attach.3. The stale entry is still a match, so the binding never heals.
wcIdForSurfacescanstargetsbysurfaceIdand has no liveness check:wmux/src/main/cdp-bridge.ts
Lines 184 to 189 in fae21a5
resolveBrowserWcIdis written to self-heal, but its escape hatch iswcId === null:wmux/src/main/v2-browser.ts
Lines 70 to 77 in fae21a5
A dead target still returns a non-null id, so the
callerBrowserSurface/boundBrowserSurfacesentries 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.
getDebuggercorrectly detects the death — and throws without pruning:wmux/src/main/cdp-bridge.ts
Lines 201 to 206 in fae21a5
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.listreads 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.chrome://crash, or killing that specific Electron renderer PID, should both do it.wmux list-surfaces→ the surface is still listed astype: "browser".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:
render-process-gone/destroyedon the guest and callcdpBridge.detach(wcId), so teardown stops depending on React unmounting.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.