fix(crash-reporting): record exact V8 heap sizes, not Blink's quantized ones

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.
This commit is contained in:
Neil
2026-07-25 22:02:51 -07:00
parent c06bf64b48
commit 804d4dd110
6 changed files with 187 additions and 5 deletions
+3
View File
@@ -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: {
+21 -1
View File
@@ -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<CrashReportSubmitResult> =>
ipcRenderer.invoke('crashReports:submit', args),
copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) =>
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args)
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args),
readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics()
},
export: {
@@ -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<typeof vi.fn>
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<string, number> }).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<string, unknown>).readHeapStatistics =
undefined
expect(() => diagnostics.installRendererCrashDiagnostics()).not.toThrow()
expect(recordBreadcrumbMock).toHaveBeenCalledWith({
name: 'renderer_memory',
data: expect.objectContaining({ usedHeapMB: 32, heapSource: 'quantized' })
})
})
})
})
+43 -3
View File
@@ -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
+3 -1
View File
@@ -677,7 +677,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
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: () =>
+21
View File
@@ -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
}