diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index dc3cbba64f9..0b72440f58f 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, @@ -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: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8aa289018f7..dee8542eebd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => 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/preload/renderer-heap-statistics-reader.test.ts b/src/preload/renderer-heap-statistics-reader.test.ts new file mode 100644 index 00000000000..892a9e865b7 --- /dev/null +++ b/src/preload/renderer-heap-statistics-reader.test.ts @@ -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[0] => + ({ + getHeapStatistics: overrides.heap ?? ((): Electron.HeapStatistics => heapStatistics), + getBlinkMemoryInfo: + overrides.blink ?? ((): Electron.BlinkMemoryInfo => ({ allocated: 1227, total: 1280 })) + }) as Parameters[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() + }) +}) diff --git a/src/preload/renderer-heap-statistics-reader.ts b/src/preload/renderer-heap-statistics-reader.ts new file mode 100644 index 00000000000..4d1abecd848 --- /dev/null +++ b/src/preload/renderer-heap-statistics-reader.ts @@ -0,0 +1,37 @@ +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' + +type HeapStatisticsSource = Pick + +/** + * 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 + } +} diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 049a9fdf706..0b93aa15511 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,117 @@ 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('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 } + 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).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..90fa45ebba0 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,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 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..4dfc5fbcd07 --- /dev/null +++ b/src/shared/renderer-heap-statistics.ts @@ -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 +}