mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(crash-reporting): record exact V8 heap sizes, not Blink's quantized ones (#10683)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user