authorgravatar for sethwebster@gmail.comSeth Webster <sethwebster@gmail.com> 2026-09-03 21:16:57-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 18:16:57-07:00
log6319a9714881a1d25549f797c468fabebae92813
tree599247de8a5d4e300b17818d9c2f8d773206bda2
parent75ab5ab3fb6ad35117da754644c404a31b2fed84
signature Signed by PGP key B5690EEEBB952194

fix(desktop): preview CDP sessions no longer hard-crash the app (#9068)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 81 insertions(+), 12 deletions(-)

apps/desktop/src/preview/Manager.test.ts+61
...@@ -677,6 +677,67 @@ describe("PreviewManager", () => {...@@ -677,6 +677,67 @@ describe("PreviewManager", () => {
677 ),677 ),
678 );678 );
679679
680 effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () =>
681 withManager((manager) =>
682 Effect.gen(function* () {
683 // Real Electron throws on any `wc.debugger` access once the
684 // WebContents is destroyed, so cleanup must go through the debugger
685 // reference captured at attach time (electron/electron#53376).
686 let destroyed = false;
687 let attached = false;
688 const debuggerOff = vi.fn();
689 const debuggerDetach = vi.fn(() => {
690 attached = false;
691 });
692 const wcDebugger = {
693 isAttached: () => attached,
694 attach: vi.fn(() => {
695 attached = true;
696 }),
697 detach: debuggerDetach,
698 sendCommand: vi.fn(async () => undefined),
699 on: vi.fn(),
700 off: debuggerOff,
701 };
702 fromId.mockReturnValue({
703 id: 42,
704 isDestroyed: () => destroyed,
705 getType: () => "webview",
706 getURL: () => "http://localhost:3200/",
707 getTitle: () => "Preview",
708 isLoading: () => false,
709 isDevToolsOpened: () => false,
710 getZoomFactor: () => 1,
711 setZoomFactor: vi.fn(),
712 setAudioMuted: vi.fn(),
713 isCurrentlyAudible: () => false,
714 reload: vi.fn(),
715 loadURL: vi.fn(async () => undefined),
716 on: vi.fn(),
717 off: vi.fn(),
718 ipc: { on: vi.fn(), off: vi.fn() },
719 send: webviewSend,
720 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
721 setWindowOpenHandler: vi.fn(),
722 get debugger() {
723 if (destroyed) throw new Error("Object has been destroyed");
724 return wcDebugger;
725 },
726 } as never);
727 yield* manager.createTab("tab_pinned_debugger");
728 yield* manager.registerWebview("tab_pinned_debugger", 42);
729 yield* manager.setColorScheme("tab_pinned_debugger", "dark");
730 expect(attached).toBe(true);
731 destroyed = true;
732
733 yield* manager.navigate("tab_pinned_debugger", "https://example.com/");
734
735 expect(debuggerOff).toHaveBeenCalledWith("message", expect.any(Function));
736 expect(debuggerDetach).toHaveBeenCalledOnce();
737 }),
738 ),
739 );
740
680 effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>741 effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>
681 withManager((manager) =>742 withManager((manager) =>
682 Effect.gen(function* () {743 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+20-12
...@@ -437,6 +437,12 @@ interface PickSession {...@@ -437,6 +437,12 @@ interface PickSession {
437437
438interface BrowserControlSession {438interface BrowserControlSession {
439 readonly webContentsId: number;439 readonly webContentsId: number;
440 // Pins the WebContents' Debugger wrapper for the session's lifetime.
441 // Electron's Debugger is GC-managed but registered with Chromium as a raw
442 // DevToolsAgentHostClient pointer; collecting it while attached crashes the
443 // browser process (electron/electron#53376). Detach must also go through
444 // this reference: `wc.debugger` throws once the WebContents is destroyed.
445 readonly debugger: Electron.Debugger;
440 readonly semaphore: Semaphore.Semaphore;446 readonly semaphore: Semaphore.Semaphore;
441 readonly scope: Scope.Closeable;447 readonly scope: Scope.Closeable;
442 readonly onMessage: (448 readonly onMessage: (
...@@ -1184,6 +1190,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1184,6 +1190,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1184 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {1190 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
1185 const semaphore = yield* Semaphore.make(1);1191 const semaphore = yield* Semaphore.make(1);
1186 const scope = yield* Scope.fork(parentScope, "sequential");1192 const scope = yield* Scope.fork(parentScope, "sequential");
1193 const wcDebugger = wc.debugger;
1187 const handleDebuggerMessage = Effect.fnUntraced(function* (1194 const handleDebuggerMessage = Effect.fnUntraced(function* (
1188 method: string,1195 method: string,
1189 params: Record<string, unknown>,1196 params: Record<string, unknown>,
...@@ -1196,7 +1203,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1196,7 +1203,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1196 operation: "ackScreencastFrame",1203 operation: "ackScreencastFrame",
1197 webContentsId: wc.id,1204 webContentsId: wc.id,
1198 },1205 },
1199 () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),1206 () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }),
1200 ).pipe(Effect.ignore);1207 ).pipe(Effect.ignore);
1201 }1208 }
1202 const tabId = yield* tabIdForWebContents(wc.id);1209 const tabId = yield* tabIdForWebContents(wc.id);
...@@ -1244,8 +1251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1244,8 +1251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1244 }),1251 }),
1245 ),1252 ),
1246 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {1253 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1247 wc.debugger.off("message", onMessage);1254 wcDebugger.off("message", onMessage);
1248 if (wc.debugger.isAttached()) wc.debugger.detach();1255 if (wcDebugger.isAttached()) wcDebugger.detach();
1249 }).pipe(Effect.ignore),1256 }).pipe(Effect.ignore),
1250 ],1257 ],
1251 { discard: true },1258 { discard: true },
...@@ -1253,6 +1260,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1253,6 +1260,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1253 );1260 );
1254 const control: BrowserControlSession = {1261 const control: BrowserControlSession = {
1255 webContentsId: wc.id,1262 webContentsId: wc.id,
1263 debugger: wcDebugger,
1256 semaphore,1264 semaphore,
1257 scope,1265 scope,
1258 onMessage,1266 onMessage,
...@@ -1268,15 +1276,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1268,15 +1276,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1268 }),1276 }),
1269 );1277 );
1270 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {1278 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
1271 wc.debugger.on("message", onMessage);1279 wcDebugger.on("message", onMessage);
1272 wc.debugger.attach("1.3");1280 wcDebugger.attach("1.3");
1273 });1281 });
1274 yield* Effect.all(1282 yield* Effect.all(
1275 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(1283 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(
1276 (method) =>1284 (method) =>
1277 attemptPromise(1285 attemptPromise(
1278 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },1286 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },
1279 () => wc.debugger.sendCommand(method),1287 () => wcDebugger.sendCommand(method),
1280 ),1288 ),
1281 ),1289 ),
1282 { concurrency: "unbounded", discard: true },1290 { concurrency: "unbounded", discard: true },
...@@ -1365,7 +1373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1365,7 +1373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1365 }1373 }
1366 const result = yield* attemptPromise(1374 const result = yield* attemptPromise(
1367 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },1375 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },
1368 () => wc.debugger.sendCommand(method, commandParams),1376 () => control.debugger.sendCommand(method, commandParams),
1369 );1377 );
1370 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;1378 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
1371 if (after !== epoch) {1379 if (after !== epoch) {
...@@ -1389,7 +1397,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1389,7 +1397,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1389 tabId,1397 tabId,
1390 webContentsId: wc.id,1398 webContentsId: wc.id,
1391 },1399 },
1392 () => wc.debugger.sendCommand(method, commandParams),1400 () => control.debugger.sendCommand(method, commandParams),
1393 );1401 );
1394 },1402 },
1395 );1403 );
...@@ -2578,9 +2586,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2578,9 +2586,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2578 wc: Electron.WebContents,2586 wc: Electron.WebContents,
2579 colorScheme: DesktopPreviewColorScheme,2587 colorScheme: DesktopPreviewColorScheme,
2580 ) {2588 ) {
2581 yield* ensureControlSession(wc);2589 const control = yield* ensureControlSession(wc);
2582 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>2590 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2583 wc.debugger.sendCommand("Emulation.setEmulatedMedia", {2591 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
2584 features: [2592 features: [
2585 {2593 {
2586 name: "prefers-color-scheme",2594 name: "prefers-color-scheme",
...@@ -2600,7 +2608,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2600,7 +2608,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2600 Effect.gen(function* () {2608 Effect.gen(function* () {
2601 const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);2609 const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2602 if (beforeAttach?.webContentsId !== wc.id) return;2610 if (beforeAttach?.webContentsId !== wc.id) return;
2603 yield* ensureControlSession(wc);2611 const control = yield* ensureControlSession(wc);
2604 const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);2612 const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2605 if (afterAttach?.webContentsId !== wc.id) {2613 if (afterAttach?.webContentsId !== wc.id) {
2606 yield* detachControlSession(wc.id);2614 yield* detachControlSession(wc.id);
...@@ -2608,7 +2616,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2608,7 +2616,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2608 }2616 }
2609 if (afterAttach.colorScheme !== "system") {2617 if (afterAttach.colorScheme !== "system") {
2610 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>2618 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2611 wc.debugger.sendCommand("Emulation.setEmulatedMedia", {2619 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
2612 features: [2620 features: [
2613 {2621 {
2614 name: "prefers-color-scheme",2622 name: "prefers-color-scheme",