From 9c37bab97a197d70b3fd7cf654d32ce90d2b9b8e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:16:18 -0700 Subject: [PATCH] Reduce hidden browser and terminal memory churn Reduce hidden browser webview retention, correct Resource Usage app memory attribution, and normalize terminal TUI redraw previews. Includes docs/renderer-memory-profile-2026-06-01.md with the profiling evidence. --- docs/renderer-memory-profile-2026-06-01.md | 78 ++++++++++ src/main/memory/collector.test.ts | 74 +++++++++- src/main/memory/collector.ts | 22 ++- src/main/runtime/orca-runtime.test.ts | 22 +++ src/main/runtime/orca-runtime.ts | 30 +++- src/renderer/src/components/Terminal.tsx | 16 +- .../BrowserPaneOverlayLayer.test.tsx | 137 ++++++++++++++++++ .../browser-pane/BrowserPaneOverlayLayer.tsx | 37 +++-- .../browser-pane/webview-registry.test.ts | 17 +++ .../browser-pane/webview-registry.ts | 25 ++++ .../src/lib/crash-diagnostics.test.ts | 16 +- src/renderer/src/lib/crash-diagnostics.ts | 9 +- 12 files changed, 438 insertions(+), 45 deletions(-) create mode 100644 docs/renderer-memory-profile-2026-06-01.md create mode 100644 src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx diff --git a/docs/renderer-memory-profile-2026-06-01.md b/docs/renderer-memory-profile-2026-06-01.md new file mode 100644 index 000000000..61d8d1333 --- /dev/null +++ b/docs/renderer-memory-profile-2026-06-01.md @@ -0,0 +1,78 @@ +# Renderer Memory Profile, 2026-06-01 + +## Scope + +This profile investigated high Orca renderer memory while working in +`/Users/nwparker/orca/workspaces/orca/goal`. The user suspected the browser +might not actually have an open tab, so the investigation checked browser, +renderer, terminal, and Resource Usage attribution paths separately. + +## Live Evidence + +- Orca runtime was reachable through the packaged CLI fallback. The public + `/usr/local/bin/orca` shim pointed at a removed development app path, so it + failed before contacting the runtime. +- `orca tab list --worktree all --json` returned `tabs: []`. There were no live + Orca browser tabs in the measured session. +- The live packaged app had one renderer process and no separate browser guest + renderer process. A later `ps` sample showed: + - main process: 390 MB RSS + - renderer process: 460 MB RSS, about 40 percent CPU + - GPU process: 145 MB RSS + - network service: 59 MB RSS + - audio service: 48 MB RSS +- `sample` on the renderer showed V8, IPC, and deserialization stacks while the + renderer was busy. It did not show browser guest activity. +- `vmmap -summary` on the renderer showed about 218 MB physical footprint and a + 373 MB peak, while total resident accounting was about 1.7 GB. Most of that + larger number was shared Electron/Chromium mappings, especially read-only + library mappings. +- `orca terminal list --worktree active --json` showed the active Codex terminal + preview retaining repeated status redraw fragments such as repeated + `Working` text. The retained terminal tail buffers were bounded, but the + text normalization path was treating redraw controls as append-only text. + +## Findings + +1. Browser tabs were not the live-session memory source. The session had no + browser tabs and no browser guest renderer process. +2. The browser-pane retention fix is still useful: inactive worktree browser + webviews are now unmounted so Chromium can release guest renderers. Browser + state remains in Orca, and automation-visible webviews stay mounted so + agent-browser can keep driving them. +3. Resource Usage was using `app.getAppMetrics().memory.workingSetSize` for + Orca app buckets. On macOS this can count large shared Electron/Chromium + mappings and make the renderer look much larger than its private footprint. +4. The active terminal path was producing noisy previews from TUI redraws. This + explains the high active renderer churn observed during the profile, even + though the terminal memory buffers were already capped. + +## Changes Made + +- Browser panes now mount their backing webview only when the pane is active or + automation-visible. This sleeps inactive worktree browser guest renderers + without sleeping the main Orca renderer. +- Browser crash breadcrumbs now include webview counts, parked webview counts, + hidden webviews, and registered browser guest counts. +- The memory collector now prefers the existing host process RSS sweep for + Electron app bucket memory, falling back to Electron working-set data only + when a host row is missing. +- Terminal preview retention now applies carriage-return and backspace redraw + controls before appending text to the retained preview tail. + +## Validation + +- Browser overlay, webview registry, and crash diagnostics tests passed. +- Browser tab e2e tests passed. +- Memory collector tests passed, including host RSS preference and fallback + coverage. +- Runtime terminal tests passed for carriage-return and backspace redraw + normalization, plus the existing bounded partial-tail coverage. + +## Remaining Risk + +The current packaged Orca app was not running this worktree's patched code +during the live profile. The fixes are covered by unit and e2e tests, but the +next packaged build should be re-profiled under the same active Codex TUI load +to confirm the Resource Usage display and terminal previews match the expected +lower-churn behavior. diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 829cf61ba..d94607967 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -1,17 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -vi.mock('electron', () => ({ - app: { - getAppMetrics: () => [] - } -})) +type AppMetricFixture = { + pid: number + type: string + cpu: { percentCPUUsage: number } + memory: { workingSetSize: number } +} -const { execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ +const { appMetricsMock, execMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + appMetricsMock: vi.fn<() => AppMetricFixture[]>(() => []), execMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) +vi.mock('electron', () => ({ + app: { + getAppMetrics: appMetricsMock + } +})) + vi.mock('child_process', () => ({ exec: (cmd: string, opts: unknown, cb: (err: Error | null, out: { stdout: string }) => void) => execMock(cmd, opts, cb) @@ -182,6 +190,8 @@ describe('collectSubtree', () => { describe('collectMemorySnapshot', () => { beforeEach(() => { + appMetricsMock.mockReset() + appMetricsMock.mockReturnValue([]) execMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) @@ -221,6 +231,58 @@ describe('collectMemorySnapshot', () => { expect(execMock).toHaveBeenCalledTimes(2) }) + it('uses host process RSS for Electron app metrics when available', async () => { + mockPsResponse(['10 1 1.5 111', '20 10 2.5 222', '30 10 3.5 333'].join('\n')) + appMetricsMock.mockReturnValue([ + { + pid: 10, + type: 'Browser', + cpu: { percentCPUUsage: 1.5 }, + memory: { workingSetSize: 9999 } + }, + { + pid: 20, + type: 'Renderer', + cpu: { percentCPUUsage: 2.5 }, + memory: { workingSetSize: 9999 } + }, + { + pid: 30, + type: 'Utility', + cpu: { percentCPUUsage: 3.5 }, + memory: { workingSetSize: 9999 } + } + ]) + + const { collectMemorySnapshot } = await loadCollector() + const snap = await collectMemorySnapshot(emptyStore) + + expect(snap.app.main.memory).toBe(111 * 1024) + expect(snap.app.renderer.memory).toBe(222 * 1024) + expect(snap.app.other.memory).toBe(333 * 1024) + expect(snap.app.memory).toBe((111 + 222 + 333) * 1024) + expect(snap.totalMemory).toBe((111 + 222 + 333) * 1024) + }) + + it('falls back to Electron working set when a host process row is missing', async () => { + mockPsResponse('10 1 1.5 111') + appMetricsMock.mockReturnValue([ + { + pid: 999, + type: 'Renderer', + cpu: { percentCPUUsage: 2 }, + memory: { workingSetSize: 4096 } + } + ]) + + const { collectMemorySnapshot } = await loadCollector() + const snap = await collectMemorySnapshot(emptyStore) + + expect(snap.app.renderer.memory).toBe(4096 * 1024) + expect(snap.app.memory).toBe(4096 * 1024) + expect(snap.totalMemory).toBe(4096 * 1024) + }) + it('attributes a process shared by two PTYs to the first registrant only', async () => { // Why: when two PTYs share an ancestor (e.g. a supervisor or a shell // that re-execed), a naive per-PTY subtree walk would double-count diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 045c49de2..03bae642e 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -320,16 +320,28 @@ export function collectSubtree(index: ProcIndex, root: number): number[] { type AppBucketsRaw = Omit -function bucketElectronMetrics(): AppBucketsRaw { +function electronMetricMemoryBytes( + proc: ReturnType[number], + processIndex: ProcIndex +): number { + const hostMemory = processIndex.byPid.get(proc.pid)?.memory + if (typeof hostMemory === 'number' && Number.isFinite(hostMemory) && hostMemory > 0) { + return hostMemory + } + // Why: on macOS, app.getAppMetrics().workingSetSize can include large shared + // Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but + // keep workingSetSize as a fallback when the process disappears mid-snapshot. + return clampNumber(proc.memory?.workingSetSize) * 1024 +} + +function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const main = { cpu: 0, memory: 0 } const renderer = { cpu: 0, memory: 0 } const other = { cpu: 0, memory: 0 } for (const proc of app.getAppMetrics()) { const cpu = clampNumber(proc.cpu?.percentCPUUsage) - // Electron reports workingSetSize in KB. Convert up front so every - // memory value in the snapshot is in bytes. - const memoryBytes = clampNumber(proc.memory?.workingSetSize) * 1024 + const memoryBytes = electronMetricMemoryBytes(proc, processIndex) // Why: lowercase once so future Electron versions emitting different // casing ('browser' vs 'Browser') still bucket correctly. @@ -403,7 +415,7 @@ function makeEmptyBucket( async function runSnapshot(store: Store): Promise { const processIndex = await enumerateProcesses() - const appBuckets = bucketElectronMetrics() + const appBuckets = bucketElectronMetrics(processIndex) const ptys = listRegisteredPtys() // Why: when two PTYs share an ancestor in the process tree (e.g. a diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index a166328c6..b942ca2e4 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -4438,6 +4438,28 @@ describe('OrcaRuntimeService', () => { expect(appendRecentPtyOutput(undefined, data)).toBe(data.slice(-4096)) }) + it('applies terminal redraw controls before retaining previews', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', 'Working\rWorking 1s\rWorking 2s', 100) + + const carriageRead = await runtime.readTerminal(terminal.handle) + expect(carriageRead.tail).toEqual(['Working 2s']) + expect(carriageRead.latestCursor).toBe('0') + + runtime.onPtyData('pty-1', '\b\b3s', 101) + const backspaceRead = await runtime.readTerminal(terminal.handle) + expect(backspaceRead.tail).toEqual(['Working 3s']) + expect(backspaceRead.latestCursor).toBe('0') + + runtime.onPtyData('pty-1', '\rDone\n', 102) + const completedRead = await runtime.readTerminal(terminal.handle) + expect(completedRead.tail).toEqual(['Done']) + expect(completedRead.latestCursor).toBe('1') + }) + it('bounds retained partial terminal output before preview reads', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 30e9cfa99..a712664c1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -13106,7 +13106,12 @@ export function appendNormalizedToTailBuffer( // larger line transcript for pagination, but keep partial-line work bounded. const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) - const pieces = `${boundedPreviousPartialLine}${normalizedChunk}`.split('\n') + // Why: status UIs redraw a single line with CR/backspace controls. Terminal + // previews are text, not a full screen model, so retain the latest visible + // redraw segment instead of appending every spinner frame. + const pieces = `${boundedPreviousPartialLine}${normalizedChunk}` + .split('\n') + .map(applyTerminalLineControls) const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '') const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) const newCompleteLines = pieces.length @@ -13146,6 +13151,25 @@ export function appendNormalizedToTailBuffer( } } +function applyTerminalLineControls(line: string): string { + const carriageIndex = line.lastIndexOf('\r') + const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line + if (!latestRedraw.includes('\u0008')) { + return latestRedraw + } + + const chars: string[] = [] + for (let index = 0; index < latestRedraw.length; index += 1) { + const char = latestRedraw[index] + if (char === '\u0008') { + chars.pop() + } else { + chars.push(char) + } + } + return chars.join('') +} + function tailStateMatches( lines: string[], partialLine: string, @@ -13658,12 +13682,10 @@ function normalizeTerminalChunk(chunk: string): string { } return chunk .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') .replace(/\x1b[@-_]/g, '') - .replace(/\u0008/g, '') - .replace(/[^\x09\x0a\x20-\x7e]/g, '') + .replace(/[^\x08\x09\x0a\x0d\x20-\x7e]/g, '') } function terminalChunkNeedsNormalization(chunk: string): boolean { diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 4ed8f3e23..d9f7b1466 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -250,11 +250,7 @@ function Terminal(): React.JSX.Element | null { // Why: the TabBar is rendered into the titlebar via a portal so tabs share // the same row as the "Orca" title. The target element is created by App.tsx. - // Uses useEffect because the DOM element doesn't exist during the render phase. - const [titlebarTabsTarget, setTitlebarTabsTarget] = useState(null) - useEffect(() => { - setTitlebarTabsTarget(document.getElementById('titlebar-tabs')) - }, []) + const titlebarTabsTarget = document.getElementById('titlebar-tabs') useEffect(() => { if (!activeWorktreeId) { @@ -1765,9 +1761,9 @@ function Terminal(): React.JSX.Element | null { })} - {/* Browser panes container — all browser panes for the active worktree - stay mounted so webview DOM state (scroll position, form inputs, etc.) - survives tab switches. BrowserPagePane uses isActive + CSS to show/hide. */} + {/* Browser panes container — only the active pane mounts so inactive + webviews park into the bounded registry instead of keeping hidden + Electron guest renderers alive indefinitely. */}
- + {isBrowserActive ? ( + + ) : null}
) })} diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx new file mode 100644 index 000000000..2611dc10f --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx @@ -0,0 +1,137 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' + +type MockAppState = { + browserTabsByWorktree: Record + unifiedTabsByWorktree: Record + groupsByWorktree: Record + focusGroup: (worktreeId: string, groupId: string) => void +} + +const mocks = vi.hoisted(() => ({ + state: null as MockAppState | null, + automationVisiblePageIds: new Set(), + focusGroup: vi.fn() +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: MockAppState) => unknown) => { + if (!mocks.state) { + throw new Error('mock app state not initialized') + } + return selector(mocks.state) + } +})) + +vi.mock('./browser-automation-visibility', () => ({ + useBrowserAutomationVisibilityForAny: (pageIds: readonly string[]) => + pageIds.some((pageId) => mocks.automationVisiblePageIds.has(pageId)) +})) + +vi.mock('./BrowserPane', () => ({ + default: ({ browserTab, isActive }: { browserTab: BrowserTabState; isActive: boolean }) => ( + + ) +})) + +import BrowserPaneOverlayLayer from './BrowserPaneOverlayLayer' + +describe('BrowserPaneOverlayLayer', () => { + beforeEach(() => { + mocks.automationVisiblePageIds.clear() + mocks.focusGroup.mockClear() + mocks.state = createState() + }) + + it('mounts only the active browser pane for a visible worktree', () => { + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain('data-browser-pane-id="browser-a"') + expect(markup).toContain('data-browser-pane-active="true"') + expect(markup).not.toContain('data-browser-pane-id="browser-b"') + }) + + it('keeps automation-visible inactive browser panes mounted and paintable', () => { + mocks.automationVisiblePageIds.add('page-b') + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain('data-browser-pane-id="browser-a"') + expect(markup).toContain('data-browser-pane-id="browser-b"') + expect(markup).toContain('data-browser-pane-active="false"') + }) + + it('unmounts browser panes when their worktree is not visible', () => { + const markup = renderOverlay({ isWorktreeActive: false }) + + expect(markup).not.toContain('data-browser-pane-id="browser-a"') + expect(markup).not.toContain('data-browser-pane-id="browser-b"') + }) +}) + +function renderOverlay({ isWorktreeActive }: { isWorktreeActive: boolean }): string { + return renderToStaticMarkup( + + ) +} + +function createState(): MockAppState { + const browserA = createBrowserTab('browser-a', ['page-a']) + const browserB = createBrowserTab('browser-b', ['page-b']) + const tabA = createUnifiedBrowserTab('tab-a', browserA.id, 0) + const tabB = createUnifiedBrowserTab('tab-b', browserB.id, 1) + + return { + browserTabsByWorktree: { 'wt-1': [browserA, browserB] }, + unifiedTabsByWorktree: { 'wt-1': [tabA, tabB] }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: tabA.id, + tabOrder: [tabA.id, tabB.id] + } + ] + }, + focusGroup: mocks.focusGroup + } +} + +function createUnifiedBrowserTab(id: string, browserTabId: string, sortOrder: number): Tab { + return { + id, + entityId: browserTabId, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +function createBrowserTab(id: string, pageIds: string[]): BrowserTabState { + return { + id, + worktreeId: 'wt-1', + label: id, + sessionProfileId: null, + activePageId: pageIds[0] ?? null, + pageIds, + url: 'about:blank', + title: id, + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } +} diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index 60d93ae5e..8d582fce6 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -7,11 +7,11 @@ import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility' // Why: Electron `` destroys its guest contents whenever its DOM -// parent changes. Rendering one BrowserPane per tab at the worktree level -// (keyed only by browserTab.id) means moving a tab between groups never -// remounts the pane and never reparents the webview — it only updates the -// overlay's CSS `position-anchor` so the pane tracks the new owning group's -// body via native CSS anchor positioning. +// parent changes. Rendering paintable BrowserPanes at the worktree level +// (keyed only by browserTab.id) means moving an active tab between groups +// never reparents the webview — it only updates the overlay's CSS +// `position-anchor` so the pane tracks the new owning group's body via +// native CSS anchor positioning. type BrowserOverlayAssignment = { groupId: string @@ -26,7 +26,7 @@ type BrowserOverlaySlotProps = { browserTab: BrowserTabState // Why: `undefined` means this browser tab has no owning group (an "orphan" — // present in `browserTabs` but not referenced by any group's unified-tab - // list). See the fallback branch below for why we keep such tabs mounted. + // list). See the fallback branch below for why these slots remain hidden. groupId: string | undefined isActive: boolean // Why: the legacy architecture rendered BrowserPane inside TabGroupPanel, so @@ -67,10 +67,8 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ // groups, only `positionAnchor` changes and the browser relayouts on its // own — no measurement or state updates. // - // The orphan branch (no anchorName) keeps the pane mounted at 0×0 - // display:none so the DOM parent stays stable and the `` guest - // survives until the tab is reassigned (e.g. mid-move) or explicitly - // destroyed via `closeBrowserTab`. + // The orphan branch (no anchorName) stays display:none until the tab is + // reassigned (e.g. mid-move) or explicitly destroyed via `closeBrowserTab`. const style: React.CSSProperties = useMemo( () => anchorName @@ -109,7 +107,10 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ onPointerDown={handleFocus} onFocusCapture={handleFocus} > - + {/* Why: inactive BrowserPane subtrees keep Electron guest renderers alive. + Unmounting parks the webview in the registry, where eviction enforces + the small recent-tab cap; automation-visible panes stay paintable. */} + {isPaintable ? : null} ) }) @@ -159,14 +160,12 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ // Map each browser tab to the group that owns it (if any) and whether it's // the currently active tab in that group. Tabs that exist in `browserTabs` - // but are not referenced by any group's unified-tab list are "orphans": we - // still render the pane (at 0×0 display:none — see fallback branch below) - // so the `` survives until the tab is either reassigned or - // explicitly destroyed. In normal flows this is a transient mid-move - // state, not a steady state: closing a tab calls `closeBrowserTab` which - // removes it from `browserTabs` (and `destroyPersistentWebview` tears - // down the guest), and "Close Group" closes each browser tab before - // collapsing the group shell — no follow-to-sibling migration happens. + // but are not referenced by any group's unified-tab list are "orphans". In + // normal flows this is a transient mid-move state, not a steady state: + // closing a tab calls `closeBrowserTab` which removes it from `browserTabs` + // (and `destroyPersistentWebview` tears down the guest), and "Close Group" + // closes each browser tab before collapsing the group shell — no + // follow-to-sibling migration happens. const assignments = useMemo(() => { const entries = new Map() for (const tab of unifiedTabs) { diff --git a/src/renderer/src/components/browser-pane/webview-registry.test.ts b/src/renderer/src/components/browser-pane/webview-registry.test.ts index 4ae6260b3..f2a858790 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.test.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.test.ts @@ -124,6 +124,23 @@ describe('webview registry drag listeners', () => { expect(addedListeners).toHaveLength(3) }) + it('profiles live webviews and registered browser guests for memory breadcrumbs', async () => { + const { getBrowserWebviewMemoryProfile, registeredWebContentsIds, registerPersistentWebview } = + await import('./webview-registry') + + registerPersistentWebview('page-1', createWebview()) + registerPersistentWebview('page-2', createWebview()) + registeredWebContentsIds.set('page-1', 101) + + expect(getBrowserWebviewMemoryProfile()).toEqual({ + browserWebviewCount: 2, + parkedBrowserWebviewCount: 0, + registeredBrowserGuestCount: 1, + hiddenBrowserWebviewCount: 0, + maxParkedBrowserWebviews: 6 + }) + }) + it('keeps webviews in passthrough until every renderer drag releases', async () => { const { acquireWebviewsDragPassthrough, registerPersistentWebview } = await import('./webview-registry') diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index d57f71b37..7f3a8bda2 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -11,6 +11,14 @@ export const parkedAtByTabId = new Map() export const MAX_PARKED_WEBVIEWS = 6 +export type BrowserWebviewMemoryProfile = { + browserWebviewCount: number + parkedBrowserWebviewCount: number + registeredBrowserGuestCount: number + hiddenBrowserWebviewCount: number + maxParkedBrowserWebviews: number +} + let hiddenContainer: HTMLDivElement | null = null const DRAG_LISTENER_KEY = '__orcaBrowserPaneDragListeners' let dragListenersAttached = false @@ -84,6 +92,23 @@ export function getHiddenContainer(): HTMLDivElement { return hiddenContainer } +export function getBrowserWebviewMemoryProfile(): BrowserWebviewMemoryProfile { + let parkedBrowserWebviewCount = 0 + for (const webview of webviewRegistry.values()) { + if (hiddenContainer && webview.parentElement === hiddenContainer) { + parkedBrowserWebviewCount += 1 + } + } + + return { + browserWebviewCount: webviewRegistry.size, + parkedBrowserWebviewCount, + registeredBrowserGuestCount: registeredWebContentsIds.size, + hiddenBrowserWebviewCount: hiddenContainer?.children.length ?? 0, + maxParkedBrowserWebviews: MAX_PARKED_WEBVIEWS + } +} + function applyWebviewsDragPassthrough(): void { const passthrough = dragPassthroughTokens.size > 0 for (const webview of webviewRegistry.values()) { diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 24c4ded56..d47e1bcc1 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -46,6 +46,15 @@ describe('renderer crash diagnostics', () => { } } }) + vi.doMock('../components/browser-pane/webview-registry', () => ({ + getBrowserWebviewMemoryProfile: () => ({ + browserWebviewCount: 4, + parkedBrowserWebviewCount: 2, + registeredBrowserGuestCount: 3, + hiddenBrowserWebviewCount: 2, + maxParkedBrowserWebviews: 6 + }) + })) diagnostics = (await import('./crash-diagnostics')) as DiagnosticsModule }) @@ -74,7 +83,12 @@ describe('renderer crash diagnostics', () => { reason: 'startup', usedHeapMB: 32, totalHeapMB: 64, - heapLimitMB: 512 + heapLimitMB: 512, + browserWebviews: 4, + parkedBrowserWebviews: 2, + registeredBrowserGuests: 3, + hiddenBrowserWebviews: 2, + maxParkedBrowserWebviews: 6 } }) diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 9068c49b4..39234b1f1 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -2,6 +2,7 @@ import type { CrashReportBreadcrumbData, CrashReportDetailValue } from '../../../shared/crash-reporting' +import { getBrowserWebviewMemoryProfile } from '../components/browser-pane/webview-registry' const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000 const BYTES_PER_MEGABYTE = 1024 * 1024 @@ -98,6 +99,7 @@ function recordRendererMemory(reason: string): void { if (!memory) { return } + const browserWebviews = getBrowserWebviewMemoryProfile() recordRendererCrashBreadcrumb( 'renderer_memory', @@ -105,7 +107,12 @@ function recordRendererMemory(reason: string): void { reason, usedHeapMB: toMegabytes(memory.usedJSHeapSize), totalHeapMB: toMegabytes(memory.totalJSHeapSize), - heapLimitMB: toMegabytes(memory.jsHeapSizeLimit) + heapLimitMB: toMegabytes(memory.jsHeapSizeLimit), + browserWebviews: browserWebviews.browserWebviewCount, + parkedBrowserWebviews: browserWebviews.parkedBrowserWebviewCount, + registeredBrowserGuests: browserWebviews.registeredBrowserGuestCount, + hiddenBrowserWebviews: browserWebviews.hiddenBrowserWebviewCount, + maxParkedBrowserWebviews: browserWebviews.maxParkedBrowserWebviews }) ) }