From cc24f5adaf8b28f5594813ee1fb91caad6725124 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 29 Jul 2026 01:05:33 -0700 Subject: [PATCH] [eric] crash-safety: cap parallel CDP detach at 10 concurrent (unbounded could flood the debugger host at pathological card counts and leave one attached at unmount) --- frontend/src/shared/browserTeardown.ts | 17 +++++++++++++---- frontend/src/shared/dashboardSwitchTeardown.ts | 6 ++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/shared/browserTeardown.ts b/frontend/src/shared/browserTeardown.ts index c225b6df..3b76f489 100644 --- a/frontend/src/shared/browserTeardown.ts +++ b/frontend/src/shared/browserTeardown.ts @@ -39,14 +39,23 @@ export async function removeBrowserCardCleanly( dispatch(removeBrowserCard(browserId)); } -// Batch remove: detach every browser's CDP in PARALLEL (order-independent, the only invariant is -// all debuggers gone before any unmount), THEN remove them. Serial detach cost ~72ms/browser, so a -// 20-card multi-select delete lagged over a second; this keeps the crash-safe ordering but flat. +// Detach many browsers' CDP concurrently but CAPPED (order-independent, the only invariant is all +// debuggers gone before any unmount). Serial cost ~72ms/browser (a 20-card teardown lagged >1s); +// unbounded parallel risks flooding the debugger host at pathological counts and leaving one +// attached at unmount. Batches of DETACH_CONCURRENCY are the middle: flat-ish latency, no flood. +const DETACH_CONCURRENCY = 10; +export async function detachBrowsersCdpBounded(browserIds: string[]): Promise { + for (let i = 0; i < browserIds.length; i += DETACH_CONCURRENCY) { + await Promise.allSettled(browserIds.slice(i, i + DETACH_CONCURRENCY).map((id) => detachBrowserCdp(id))); + } +} + +// Batch remove: bounded-parallel detach, THEN remove. Keeps the crash-safe ordering but flat. export async function removeBrowserCardsCleanly( browserIds: string[], dispatch: Dispatch, ): Promise { - await Promise.allSettled(browserIds.map((id) => detachBrowserCdp(id))); + await detachBrowsersCdpBounded(browserIds); for (const id of browserIds) { forgetBrowser(id); dispatch(removeBrowserCard(id)); diff --git a/frontend/src/shared/dashboardSwitchTeardown.ts b/frontend/src/shared/dashboardSwitchTeardown.ts index 7952b690..22660849 100644 --- a/frontend/src/shared/dashboardSwitchTeardown.ts +++ b/frontend/src/shared/dashboardSwitchTeardown.ts @@ -1,7 +1,7 @@ import { getAllViewOutputIds } from '@/shared/viewWebviewRegistry'; import { getAllBrowserIds } from '@/shared/browserRegistry'; import { quiesceViewWebview } from '@/shared/viewTeardown'; -import { detachBrowserCdp } from '@/shared/browserTeardown'; +import { detachBrowsersCdpBounded } from '@/shared/browserTeardown'; // Switching dashboards clears every card from the store in ONE reducer (resetLayout), so React // unmounts all the outgoing app + browser s in a single frame. Ripping several live GPU @@ -20,7 +20,5 @@ export async function prepareDashboardSwitch(keepBrowserIds: string[]): Promise< for (const outputId of getAllViewOutputIds()) { await quiesceViewWebview(outputId); } - await Promise.allSettled( - getAllBrowserIds().filter((b) => !keep.has(b)).map((b) => detachBrowserCdp(b)), - ); + await detachBrowsersCdpBounded(getAllBrowserIds().filter((b) => !keep.has(b))); }