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", () => {
677677 ),
678678 );
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
680741 effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>
681742 withManager((manager) =>
682743 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+20-12
......@@ -437,6 +437,12 @@ interface PickSession {
437437
438438interface BrowserControlSession {
439439 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;
440446 readonly semaphore: Semaphore.Semaphore;
441447 readonly scope: Scope.Closeable;
442448 readonly onMessage: (
......@@ -1184,6 +1190,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11841190 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
11851191 const semaphore = yield* Semaphore.make(1);
11861192 const scope = yield* Scope.fork(parentScope, "sequential");
1193 const wcDebugger = wc.debugger;
11871194 const handleDebuggerMessage = Effect.fnUntraced(function* (
11881195 method: string,
11891196 params: Record<string, unknown>,
......@@ -1196,7 +1203,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11961203 operation: "ackScreencastFrame",
11971204 webContentsId: wc.id,
11981205 },
1199 () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
1206 () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }),
12001207 ).pipe(Effect.ignore);
12011208 }
12021209 const tabId = yield* tabIdForWebContents(wc.id);
......@@ -1244,8 +1251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12441251 }),
12451252 ),
12461253 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1247 wc.debugger.off("message", onMessage);
1248 if (wc.debugger.isAttached()) wc.debugger.detach();
1254 wcDebugger.off("message", onMessage);
1255 if (wcDebugger.isAttached()) wcDebugger.detach();
12491256 }).pipe(Effect.ignore),
12501257 ],
12511258 { discard: true },
......@@ -1253,6 +1260,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12531260 );
12541261 const control: BrowserControlSession = {
12551262 webContentsId: wc.id,
1263 debugger: wcDebugger,
12561264 semaphore,
12571265 scope,
12581266 onMessage,
......@@ -1268,15 +1276,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12681276 }),
12691277 );
12701278 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
1271 wc.debugger.on("message", onMessage);
1272 wc.debugger.attach("1.3");
1279 wcDebugger.on("message", onMessage);
1280 wcDebugger.attach("1.3");
12731281 });
12741282 yield* Effect.all(
12751283 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(
12761284 (method) =>
12771285 attemptPromise(
12781286 { operation: `initializeDebugger.${method}`, webContentsId: wc.id },
1279 () => wc.debugger.sendCommand(method),
1287 () => wcDebugger.sendCommand(method),
12801288 ),
12811289 ),
12821290 { concurrency: "unbounded", discard: true },
......@@ -1365,7 +1373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13651373 }
13661374 const result = yield* attemptPromise(
13671375 { operation: `${action}.${method}`, tabId, webContentsId: wc.id },
1368 () => wc.debugger.sendCommand(method, commandParams),
1376 () => control.debugger.sendCommand(method, commandParams),
13691377 );
13701378 const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0;
13711379 if (after !== epoch) {
......@@ -1389,7 +1397,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13891397 tabId,
13901398 webContentsId: wc.id,
13911399 },
1392 () => wc.debugger.sendCommand(method, commandParams),
1400 () => control.debugger.sendCommand(method, commandParams),
13931401 );
13941402 },
13951403 );
......@@ -2578,9 +2586,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25782586 wc: Electron.WebContents,
25792587 colorScheme: DesktopPreviewColorScheme,
25802588 ) {
2581 yield* ensureControlSession(wc);
2589 const control = yield* ensureControlSession(wc);
25822590 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2583 wc.debugger.sendCommand("Emulation.setEmulatedMedia", {
2591 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
25842592 features: [
25852593 {
25862594 name: "prefers-color-scheme",
......@@ -2600,7 +2608,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26002608 Effect.gen(function* () {
26012609 const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
26022610 if (beforeAttach?.webContentsId !== wc.id) return;
2603 yield* ensureControlSession(wc);
2611 const control = yield* ensureControlSession(wc);
26042612 const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
26052613 if (afterAttach?.webContentsId !== wc.id) {
26062614 yield* detachControlSession(wc.id);
......@@ -2608,7 +2616,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26082616 }
26092617 if (afterAttach.colorScheme !== "system") {
26102618 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2611 wc.debugger.sendCommand("Emulation.setEmulatedMedia", {
2619 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
26122620 features: [
26132621 {
26142622 name: "prefers-color-scheme",