mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
Reference in New Issue
Block a user