From 804d4dd11059c447b9d63d210acac1bc63a7d20c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:02:51 -0700 Subject: [PATCH] fix(crash-reporting): record exact V8 heap sizes, not Blink's quantized ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer heap breadcrumbs read `window.performance.memory`, which Blink both bucket-quantizes (~100 log buckets, ratio 1.06175) and caches ~20 minutes as a Spectre mitigation. Measured in a sandboxed Electron 43 renderer: a heap climbing 1MB -> 93MB reported an identical `usedJSHeapSize` on all 7 samples. Heap growth is therefore invisible to it at any sampling rate, which is why three crash investigations could not localize a suspected renderer leak. Prefer `process.getHeapStatistics()` via the preload bridge, falling back to `performance.memory` when unavailable (web surface, older shell). Adds `mallocedMB` and `blinkAllocatedMB`, which the old API never exposed, and tags every sample `heapSource: 'v8' | 'quantized'` so bundles stay disambiguable after the semantics of `usedHeapMB` change. The bridge is unreachable from untrusted content: browser panes and guests run with no preload. Cost is +0.8 µs per call (1.95 vs 1.15 µs) once per 60s. --- src/preload/api-types.ts | 3 + src/preload/index.ts | 22 ++++- .../src/lib/crash-diagnostics.test.ts | 96 +++++++++++++++++++ src/renderer/src/lib/crash-diagnostics.ts | 46 ++++++++- src/renderer/src/web/web-preload-api.ts | 4 +- src/shared/renderer-heap-statistics.ts | 21 ++++ 6 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/shared/renderer-heap-statistics.ts diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b72739e9854..68f3497dba3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -354,6 +354,7 @@ import type { ReactErrorBoundaryReportArgs, ReactErrorBoundaryReportResult } from '../shared/crash-reporting' +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' export type { ShellOpenExternalEditorRequest, @@ -1503,6 +1504,8 @@ export type PreloadApi = { copyLatestDiagnostics: ( args?: CrashReportCopyDiagnosticsArgs ) => Promise<{ ok: true } | { ok: false; error: string }> + /** Exact V8/Blink heap sizes; null when the runtime withholds them. */ + readHeapStatistics: () => RendererHeapStatistics | null } export: ExportApi gh: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7de16fc46f0..82fe01c1ebf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -239,6 +239,7 @@ import type { ReactErrorBoundaryReportArgs, ReactErrorBoundaryReportResult } from '../shared/crash-reporting' +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import type { PreloadApi } from './api-types' import { createUpdaterQuitAbortRelay, @@ -424,6 +425,24 @@ document.addEventListener( const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' +// Why: `performance.memory` is bucket-quantized and cached ~20min by Blink, so it +// cannot observe heap growth. These are exact and available in a sandboxed preload. +function readRendererHeapStatistics(): RendererHeapStatistics | null { + try { + const heap = process.getHeapStatistics() + return { + usedHeapKB: heap.usedHeapSize, + totalHeapKB: heap.totalHeapSize, + heapLimitKB: heap.heapSizeLimit, + mallocedKB: heap.mallocedMemory, + blinkAllocatedKB: process.getBlinkMemoryInfo().allocated + } + } catch { + // Why: diagnostics must never break the renderer if Electron drops an API. + return null + } +} + // Custom APIs for renderer const api = { app: { @@ -1151,7 +1170,8 @@ const api = { submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args) + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics() }, export: { diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 049a9fdf706..524bcbc0e8a 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -81,6 +81,9 @@ describe('renderer crash diagnostics', () => { usedHeapMB: 32, totalHeapMB: 64, heapLimitMB: 512, + // Why: without this tag a reader cannot tell an exact heap number from a + // Blink-quantized one, which is what made earlier bundles unanalyzable. + heapSource: 'quantized', browserWebviews: 4, registeredBrowserGuests: 3 } @@ -242,4 +245,97 @@ describe('renderer crash diagnostics', () => { ) ).toBe(false) }) + + describe('exact V8 heap statistics', () => { + const KB = 1024 + let readHeapStatistics: ReturnType + + const stubHeap = (usedMB: number, limitMB = 512): void => { + readHeapStatistics.mockReturnValue({ + usedHeapKB: usedMB * KB, + totalHeapKB: usedMB * KB * 2, + heapLimitKB: limitMB * KB, + mallocedKB: 3 * KB, + blinkAllocatedKB: 7 * KB + }) + } + + beforeEach(() => { + readHeapStatistics = vi.fn() + ;(window.api.crashReports as unknown as { readHeapStatistics: unknown }).readHeapStatistics = + readHeapStatistics + }) + + it('prefers exact statistics over performance.memory and labels the source', () => { + stubHeap(101) + + diagnostics.installRendererCrashDiagnostics() + + // Why: performance.memory still says 32MB here. Reporting 101 proves the + // exact reading wins rather than merely being recorded alongside. + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory', + data: expect.objectContaining({ + usedHeapMB: 101, + heapLimitMB: 512, + heapSource: 'v8', + mallocedMB: 3, + blinkAllocatedMB: 7 + }) + }) + }) + + it('observes growth that performance.memory quantizes away', () => { + // Why: this is the whole point. Blink pins usedJSHeapSize to a bucket and + // caches it ~20min, so a real climb reports byte-identical values and a + // highwater threshold never fires. Exact stats must still cross it. + const quantized = (window.performance as unknown as { memory: Record }).memory + quantized.usedJSHeapSize = 32 * 1024 * 1024 + stubHeap(100) + vi.stubGlobal('document', { + getElementsByTagName: () => ({ length: 1 }), + querySelectorAll: () => ({ length: 0 }) + }) + + diagnostics.installRendererCrashDiagnostics() + const highwaterCalls = (): unknown[] => + recordBreadcrumbMock.mock.calls.filter( + (call) => (call[0] as { name: string }).name === 'renderer_memory_highwater' + ) + expect(highwaterCalls()).toHaveLength(0) + + stubHeap(400) // 78% of 512 — past the 60% threshold, still below 80%. + const tick = setIntervalMock.mock.calls[0][0] as () => void + tick() + + expect(quantized.usedJSHeapSize).toBe(32 * 1024 * 1024) + expect(highwaterCalls()).toHaveLength(1) + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory_highwater', + data: expect.objectContaining({ thresholdPct: 60, usedHeapMB: 400, heapSource: 'v8' }) + }) + }) + + it('falls back to performance.memory when the bridge returns null', () => { + readHeapStatistics.mockReturnValue(null) + + diagnostics.installRendererCrashDiagnostics() + + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory', + data: expect.objectContaining({ usedHeapMB: 32, heapSource: 'quantized' }) + }) + }) + + it('samples on an older shell whose preload lacks the bridge', () => { + ;(window.api.crashReports as unknown as Record).readHeapStatistics = + undefined + + expect(() => diagnostics.installRendererCrashDiagnostics()).not.toThrow() + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory', + data: expect.objectContaining({ usedHeapMB: 32, heapSource: 'quantized' }) + }) + }) + }) }) diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 3fd2a4794ee..7a9131f5e75 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -11,6 +11,7 @@ import { collectRendererMemoryProfileCounts } from './renderer-memory-profile' const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000 const BYTES_PER_MEGABYTE = 1024 * 1024 +const BYTES_PER_KILOBYTE = 1024 // Why: one detailed breadcrumb per threshold names what grew before an OOM. const RENDERER_MEMORY_HIGHWATER_RATIOS = [0.6, 0.8] as const @@ -22,6 +23,13 @@ type BrowserPerformanceMemory = { jsHeapSizeLimit?: number } +/** Heap sizes in bytes, tagged with whether they are exact or Blink-quantized. */ +type HeapMetrics = BrowserPerformanceMemory & { + mallocedBytes?: number + blinkAllocatedBytes?: number + exact: boolean +} + let rendererCrashDiagnosticsInstalled = false let rendererMemoryInterval: number | null = null let rendererSurface: RendererSurface = 'main' @@ -42,7 +50,7 @@ export function installRendererCrashDiagnostics(surface: RendererSurface = 'main window.addEventListener('error', recordRendererError) window.addEventListener('unhandledrejection', recordRendererUnhandledRejection) - if (getPerformanceMemory()) { + if (readHeapMetrics()) { recordRendererMemory('startup') rendererMemoryInterval = window.setInterval( () => recordRendererMemory('interval'), @@ -108,7 +116,7 @@ function recordRendererUnhandledRejection(event: PromiseRejectionEvent): void { } function recordRendererMemory(reason: string): void { - const memory = getPerformanceMemory() + const memory = readHeapMetrics() if (!memory) { return } @@ -121,6 +129,9 @@ function recordRendererMemory(reason: string): void { usedHeapMB: toMegabytes(memory.usedJSHeapSize), totalHeapMB: toMegabytes(memory.totalJSHeapSize), heapLimitMB: toMegabytes(memory.jsHeapSizeLimit), + heapSource: memory.exact ? 'v8' : 'quantized', + mallocedMB: toMegabytes(memory.mallocedBytes), + blinkAllocatedMB: toMegabytes(memory.blinkAllocatedBytes), browserWebviews: browserWebviews.browserWebviewCount, registeredBrowserGuests: browserWebviews.registeredBrowserGuestCount }) @@ -129,7 +140,7 @@ function recordRendererMemory(reason: string): void { } function recordRendererMemoryHighwater( - memory: BrowserPerformanceMemory, + memory: HeapMetrics, browserWebviews: BrowserWebviewMemoryProfile ): void { const used = memory.usedJSHeapSize @@ -156,6 +167,9 @@ function recordRendererMemoryHighwater( usedHeapMB: toMegabytes(used), totalHeapMB: toMegabytes(memory.totalJSHeapSize), heapLimitMB: toMegabytes(limit), + heapSource: memory.exact ? 'v8' : 'quantized', + mallocedMB: toMegabytes(memory.mallocedBytes), + blinkAllocatedMB: toMegabytes(memory.blinkAllocatedBytes), domNodes: document.getElementsByTagName('*').length, terminalElements: document.querySelectorAll('.xterm').length, browserWebviews: browserWebviews.browserWebviewCount, @@ -185,6 +199,32 @@ function getPerformanceMemory(): BrowserPerformanceMemory | undefined { return (window.performance as Performance & { memory?: BrowserPerformanceMemory }).memory } +/** + * Prefers V8's exact numbers; falls back to `performance.memory` only when the + * preload bridge is unavailable (older shell, or a surface without it). + * + * Both are normalized to bytes so callers and the emitted MB fields stay + * comparable with breadcrumbs recorded before this bridge existed. + */ +function readHeapMetrics(): HeapMetrics | undefined { + if (typeof window === 'undefined') { + return undefined + } + const exact = window.api?.crashReports?.readHeapStatistics?.() + if (exact) { + return { + usedJSHeapSize: exact.usedHeapKB * BYTES_PER_KILOBYTE, + totalJSHeapSize: exact.totalHeapKB * BYTES_PER_KILOBYTE, + jsHeapSizeLimit: exact.heapLimitKB * BYTES_PER_KILOBYTE, + mallocedBytes: exact.mallocedKB * BYTES_PER_KILOBYTE, + blinkAllocatedBytes: exact.blinkAllocatedKB * BYTES_PER_KILOBYTE, + exact: true + } + } + const fallback = getPerformanceMemory() + return fallback ? { ...fallback, exact: false } : undefined +} + function describeUnknownValue( prefix: string, value: unknown diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e36c0249da2..bd454183ecc 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -677,7 +677,9 @@ function createWebPreloadApi(): Partial { Promise.resolve({ ok: false, error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }) + }), + // Why: no Electron process on web; the caller falls back to performance.memory. + readHeapStatistics: () => null }, diagnostics: { getStatus: () => diff --git a/src/shared/renderer-heap-statistics.ts b/src/shared/renderer-heap-statistics.ts new file mode 100644 index 00000000000..5c8f550083e --- /dev/null +++ b/src/shared/renderer-heap-statistics.ts @@ -0,0 +1,21 @@ +/** + * V8 heap statistics read from the renderer's own process. + * + * Why this exists rather than `window.performance.memory`: Blink quantizes that + * API onto ~100 logarithmic buckets and caches each reading for ~20 minutes as a + * Spectre mitigation. Measured here: a renderer climbing 1MB -> 93MB reported an + * identical `usedJSHeapSize` on all 7 samples, so heap growth is invisible to it + * at any sampling rate. `process.getHeapStatistics()` is exact and uncached, and + * works in a sandboxed, context-isolated preload. + * + * Electron reports these in kilobytes; we keep that unit unconverted here. + */ +export type RendererHeapStatistics = { + usedHeapKB: number + totalHeapKB: number + heapLimitKB: number + /** Off-heap V8 allocations, which `usedJSHeapSize` never included. */ + mallocedKB: number + /** Blink's own allocator (DOM, layout), invisible to the V8 heap counters. */ + blinkAllocatedKB: number +}