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.
This commit is contained in:
Neil 2026-06-01 14:16:18 -07:00 committed by GitHub
parent 9ca6e4c088
commit 9c37bab97a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 438 additions and 45 deletions

View File

@ -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.

View File

@ -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

View File

@ -320,16 +320,28 @@ export function collectSubtree(index: ProcIndex, root: number): number[] {
type AppBucketsRaw = Omit<AppMemory, 'history'>
function bucketElectronMetrics(): AppBucketsRaw {
function electronMetricMemoryBytes(
proc: ReturnType<typeof app.getAppMetrics>[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<MemorySnapshot> {
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

View File

@ -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)

View File

@ -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 {

View File

@ -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<HTMLElement | null>(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 {
})}
</div>
{/* 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. */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${
activeTabType !== 'browser' ? 'hidden' : ''
@ -1798,7 +1794,9 @@ function Terminal(): React.JSX.Element | null {
key={browserTab.id}
className={`absolute inset-0${isBrowserActive ? '' : ' pointer-events-none hidden'}`}
>
<BrowserPane browserTab={browserTab} isActive={isBrowserActive} />
{isBrowserActive ? (
<BrowserPane browserTab={browserTab} isActive={isBrowserActive} />
) : null}
</div>
)
})}

View File

@ -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<string, readonly BrowserTabState[]>
unifiedTabsByWorktree: Record<string, readonly Tab[]>
groupsByWorktree: Record<string, readonly TabGroup[]>
focusGroup: (worktreeId: string, groupId: string) => void
}
const mocks = vi.hoisted(() => ({
state: null as MockAppState | null,
automationVisiblePageIds: new Set<string>(),
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 }) => (
<span
data-browser-pane-id={browserTab.id}
data-browser-pane-active={isActive ? 'true' : 'false'}
/>
)
}))
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(
<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={isWorktreeActive} />
)
}
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
}
}

View File

@ -7,11 +7,11 @@ import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility'
// Why: Electron `<webview>` 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 `<webview>` 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}
>
<BrowserPane browserTab={browserTab} isActive={isActive} />
{/* 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 ? <BrowserPane browserTab={browserTab} isActive={isActive} /> : null}
</div>
)
})
@ -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 `<webview>` 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<string, BrowserOverlayAssignment>()
for (const tab of unifiedTabs) {

View File

@ -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')

View File

@ -11,6 +11,14 @@ export const parkedAtByTabId = new Map<string, number>()
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()) {

View File

@ -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
}
})

View File

@ -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
})
)
}