From 8b8ac95b39e27a2d3a48985f4249a905c52ef368 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 10 Sep 2026 13:51:36 -0400 Subject: [PATCH] fix(desktop): pin preview CDP debugger across webview teardown Keep the Electron Debugger wrapper alive for each preview control session and detach through that reference. Re-reading wc.debugger after destruction throws and can crash the browser process. Adapted from pingdotgg/t3code#9068. --- apps/desktop/src/preview/Manager.test.ts | 61 ++++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 32 ++++++++----- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 4bffefed7a51..e377d43e44e8 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -580,6 +580,67 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => + withManager((manager) => + Effect.gen(function* () { + // Real Electron throws on any `wc.debugger` access once the + // WebContents is destroyed, so cleanup must go through the debugger + // reference captured at attach time (electron/electron#53376). + let destroyed = false; + let attached = false; + const debuggerOff = vi.fn(); + const debuggerDetach = vi.fn(() => { + attached = false; + }); + const wcDebugger = { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: debuggerDetach, + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => "http://localhost:3200/", + getTitle: () => "Preview", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + reload: vi.fn(), + loadURL: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + get debugger() { + if (destroyed) throw new Error("Object has been destroyed"); + return wcDebugger; + }, + } as never); + yield* manager.createTab("tab_pinned_debugger"); + yield* manager.registerWebview("tab_pinned_debugger", 42); + yield* manager.setColorScheme("tab_pinned_debugger", "dark"); + expect(attached).toBe(true); + destroyed = true; + + yield* manager.navigate("tab_pinned_debugger", "https://example.com/"); + + expect(debuggerOff).toHaveBeenCalledWith("message", expect.any(Function)); + expect(debuggerDetach).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 3d08b8bfb6bb..f08400cbc6d1 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -442,6 +442,12 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; + // Pins the WebContents' Debugger wrapper for the session's lifetime. + // Electron's Debugger is GC-managed but registered with Chromium as a raw + // DevToolsAgentHostClient pointer; collecting it while attached crashes the + // browser process (electron/electron#53376). Detach must also go through + // this reference: `wc.debugger` throws once the WebContents is destroyed. + readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( @@ -1065,6 +1071,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); + const wcDebugger = wc.debugger; const handleDebuggerMessage = Effect.fnUntraced(function* ( method: string, params: Record, @@ -1077,7 +1084,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function operation: "ackScreencastFrame", webContentsId: wc.id, }, - () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } const tabId = yield* tabIdForWebContents(wc.id); @@ -1125,8 +1132,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ), attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); + wcDebugger.off("message", onMessage); + if (wcDebugger.isAttached()) wcDebugger.detach(); }).pipe(Effect.ignore), ], { discard: true }, @@ -1134,6 +1141,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const control: BrowserControlSession = { webContentsId: wc.id, + debugger: wcDebugger, semaphore, scope, onMessage, @@ -1149,15 +1157,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); + wcDebugger.on("message", onMessage); + wcDebugger.attach("1.3"); }); yield* Effect.all( ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( (method) => attemptPromise( { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method), + () => wcDebugger.sendCommand(method), ), ), { concurrency: "unbounded", discard: true }, @@ -1246,7 +1254,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const result = yield* attemptPromise( { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { @@ -1270,7 +1278,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, webContentsId: wc.id, }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); }, ); @@ -2399,9 +2407,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, colorScheme: DesktopPreviewColorScheme, ) { - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", @@ -2421,7 +2429,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (afterAttach?.webContentsId !== wc.id) { yield* detachControlSession(wc.id); @@ -2429,7 +2437,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme",