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

This commit is contained in:
Neil
2026-07-25 22:54:20 -07:00
committed by GitHub
parent 33bd676644
commit c67aadbc18
8 changed files with 308 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,
@@ -1505,6 +1506,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: {
+4 -1
View File
@@ -239,6 +239,8 @@ import type {
ReactErrorBoundaryReportArgs,
ReactErrorBoundaryReportResult
} from '../shared/crash-reporting'
import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics'
import { readRendererHeapStatistics } from './renderer-heap-statistics-reader'
import type { PreloadApi } from './api-types'
import {
createUpdaterQuitAbortRelay,
@@ -1154,7 +1156,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: {
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import { readRendererHeapStatistics } from './renderer-heap-statistics-reader'
const heapStatistics = {
totalHeapSize: 2048,
totalHeapSizeExecutable: 0,
totalPhysicalSize: 2048,
totalAvailableSize: 4_000_000,
usedHeapSize: 1536,
heapSizeLimit: 4_292_608,
mallocedMemory: 64,
peakMallocedMemory: 96,
doesZapGarbage: false
}
const source = (overrides: {
heap?: () => Electron.HeapStatistics
blink?: () => Electron.BlinkMemoryInfo
}): Parameters<typeof readRendererHeapStatistics>[0] =>
({
getHeapStatistics: overrides.heap ?? ((): Electron.HeapStatistics => heapStatistics),
getBlinkMemoryInfo:
overrides.blink ?? ((): Electron.BlinkMemoryInfo => ({ allocated: 1227, total: 1280 }))
}) as Parameters<typeof readRendererHeapStatistics>[0]
describe('readRendererHeapStatistics', () => {
it('reports V8 sizes in the kilobytes Electron returns', () => {
expect(readRendererHeapStatistics(source({}))).toEqual({
usedHeapKB: 1536,
totalHeapKB: 2048,
heapLimitKB: 4_292_608,
mallocedKB: 64,
blinkAllocatedKB: 1227
})
})
it('keeps the exact V8 read when only the Blink metric throws', () => {
// Why: Blink's number is supplementary. Discarding the V8 read over its
// absence would send callers back to the quantized `performance.memory`
// this reader exists to replace — silently defeating the whole feature.
const result = readRendererHeapStatistics(
source({
blink: () => {
throw new Error('getBlinkMemoryInfo unavailable')
}
})
)
expect(result).toEqual({
usedHeapKB: 1536,
totalHeapKB: 2048,
heapLimitKB: 4_292_608,
mallocedKB: 64,
blinkAllocatedKB: undefined
})
})
it('returns null only when the primary V8 read fails', () => {
const blink = vi.fn()
expect(
readRendererHeapStatistics(
source({
heap: () => {
throw new Error('getHeapStatistics unavailable')
},
blink: blink as never
})
)
).toBeNull()
// Why: no reason to pay for the auxiliary call once the result is unusable.
expect(blink).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,37 @@
import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics'
type HeapStatisticsSource = Pick<NodeJS.Process, 'getHeapStatistics' | 'getBlinkMemoryInfo'>
/**
* Reads exact V8 heap sizes, which `performance.memory` cannot express: Blink
* quantizes that API and caches it ~20 minutes, so heap growth is invisible to
* it. Available in a sandboxed, context-isolated preload.
*/
export function readRendererHeapStatistics(
source: HeapStatisticsSource = process
): RendererHeapStatistics | null {
let heap: Electron.HeapStatistics
try {
heap = source.getHeapStatistics()
} catch {
// Why: diagnostics must never break the renderer if Electron drops an API.
return null
}
let blinkAllocatedKB: number | undefined
try {
// Why a separate try: Blink's number is supplementary. Losing it must not
// discard the exact V8 read and send callers back to the quantized metric.
blinkAllocatedKB = source.getBlinkMemoryInfo().allocated
} catch {
blinkAllocatedKB = undefined
}
return {
usedHeapKB: heap.usedHeapSize,
totalHeapKB: heap.totalHeapSize,
heapLimitKB: heap.heapSizeLimit,
mallocedKB: heap.mallocedMemory,
blinkAllocatedKB
}
}
@@ -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,117 @@ 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('omits the Blink field instead of emitting a junk value for it', () => {
// Why: `undefined * 1024` is NaN. The breadcrumb must carry no
// blinkAllocatedMB at all rather than a meaningless number.
readHeapStatistics.mockReturnValue({
usedHeapKB: 77 * KB,
totalHeapKB: 154 * KB,
heapLimitKB: 512 * KB,
mallocedKB: 3 * KB,
blinkAllocatedKB: undefined
})
diagnostics.installRendererCrashDiagnostics()
const call = recordBreadcrumbMock.mock.calls.find(
([entry]) => (entry as { name: string }).name === 'renderer_memory'
)?.[0] as { data: Record<string, unknown> }
expect(call.data).toMatchObject({ usedHeapMB: 77, heapSource: 'v8', mallocedMB: 3 })
expect(call.data).not.toHaveProperty('blinkAllocatedMB')
})
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' })
})
})
})
})
+47 -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,36 @@ 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,
// Why guarded: undefined * 1024 is NaN, which would emit a junk field.
blinkAllocatedBytes:
exact.blinkAllocatedKB === undefined
? undefined
: 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: () =>
+24
View File
@@ -0,0 +1,24 @@
/**
* 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.
* Optional: supplementary, so its absence must never discard the V8 numbers.
*/
blinkAllocatedKB?: number
}