mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(crash-reporting): see the renderer memory the heap counters never report (#16449)
* fix(crash-reporting): see the renderer memory the heap counters never report Windows renderer crash 36048e26 arrived with 618MB of private renderer memory and a `renderer_memory` breadcrumb reporting a 150MB V8 heap. Both numbers were right: xterm scrollback lives in `Uint32Array` backing stores and glyph atlases live in GPU transfer buffers, and neither is counted by `usedHeapSize`, `mallocedMemory`, or Blink's allocator. That made the report unanalyzable. `renderer_memory_highwater` is the crumb carrying the subsystem census that names what grew, and it is armed on `usedHeapSize / heapSizeLimit`. At 150MB of a 4192MB limit that ratio is 3.6% — nowhere near the 60% mark — so the census never reached a single one of these reports. Measured on Windows (6 worktrees x 4 terminal tabs, 8000 lines each, this app at 4218d505): filling 24 mounted panes moved the renderer working set from 210MB to 656MB while `usedJSHeapSize` stayed at 43MB for the whole run. Sample the renderer's own OS footprint through `process.getProcessMemoryInfo()` (available in the sandboxed preload) and: - report `privateMB`, `residentMB`, and `outsideHeapMB` — the footprint minus everything V8 and Blink admit to holding — on every `renderer_memory` crumb; - arm the highwater census on private-footprint marks (600MB / 1000MB) as well as the heap ratio, so growth outside the JS heap now carries the pane and store census that names it. The footprint read is async, so a sample annotates with the previous read and refreshes in the background: one interval of staleness is irrelevant to a footprint trend, and awaiting it would make every sample reentrant. A shell without the bridge, or a runtime that withholds the read, keeps sampling exactly as before. Retained-breadcrumb keys now distinguish the two threshold ladders; keying only on `thresholdPct` collapsed every footprint crumb onto one slot. crash-diagnostics.ts split at the max-lines budget: memory sampling moves to renderer-memory-sampling.ts and the shared payload shaping to crash-breadcrumb-data.ts. * fix(crash-reporting): retain all renderer memory marks
This commit is contained in:
@@ -50,7 +50,7 @@ describe('crash breadcrumb store', () => {
|
||||
})
|
||||
|
||||
it('caps retained high-water profiles', () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
recordCrashBreadcrumb('renderer_memory_highwater', {
|
||||
rendererSurface: `surface-${index}`,
|
||||
thresholdPct: 80
|
||||
@@ -59,7 +59,46 @@ describe('crash breadcrumb store', () => {
|
||||
|
||||
expect(
|
||||
getCrashBreadcrumbSnapshot().map((breadcrumb) => breadcrumb.data?.rendererSurface)
|
||||
).toEqual(['surface-1', 'surface-2', 'surface-3', 'surface-4'])
|
||||
).toEqual([
|
||||
'surface-1',
|
||||
'surface-2',
|
||||
'surface-3',
|
||||
'surface-4',
|
||||
'surface-5',
|
||||
'surface-6',
|
||||
'surface-7',
|
||||
'surface-8'
|
||||
])
|
||||
})
|
||||
|
||||
it('retains both threshold ladders for both renderer surfaces', () => {
|
||||
for (const rendererSurface of ['main', 'dashboard-popout']) {
|
||||
for (const thresholdPct of [60, 80]) {
|
||||
recordCrashBreadcrumb('renderer_memory_highwater', { rendererSurface, thresholdPct })
|
||||
}
|
||||
for (const thresholdPrivateMB of [600, 1000]) {
|
||||
recordCrashBreadcrumb('renderer_memory_highwater', {
|
||||
rendererSurface,
|
||||
thresholdPrivateMB
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
getCrashBreadcrumbSnapshot().map((breadcrumb) => [
|
||||
breadcrumb.data?.rendererSurface,
|
||||
breadcrumb.data?.thresholdPct ?? breadcrumb.data?.thresholdPrivateMB
|
||||
])
|
||||
).toEqual([
|
||||
['main', 60],
|
||||
['main', 80],
|
||||
['main', 600],
|
||||
['main', 1000],
|
||||
['dashboard-popout', 60],
|
||||
['dashboard-popout', 80],
|
||||
['dashboard-popout', 600],
|
||||
['dashboard-popout', 1000]
|
||||
])
|
||||
})
|
||||
|
||||
it('redacts sensitive breadcrumb fields before they can be snapshotted', () => {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from '../../shared/crash-reporting'
|
||||
|
||||
const MAX_BREADCRUMBS = 30
|
||||
// Why: retain two thresholds for each renderer surface without growing the ring.
|
||||
const MAX_RETAINED_BREADCRUMBS = 4
|
||||
// Two threshold ladders, two marks each, across both renderer surfaces.
|
||||
const MAX_RETAINED_BREADCRUMBS = 8
|
||||
// Why: coalesceKey embeds an open-string agentType (length-trimmed only, never
|
||||
// enum-checked), so the key space is unbounded over a long multi-agent/SSH session.
|
||||
// Bound the coalesce map the same way ProcessGoneDedupe bounds its key map.
|
||||
@@ -42,8 +42,14 @@ function retainedBreadcrumbKey(breadcrumb: CrashReportBreadcrumb): string | null
|
||||
return null
|
||||
}
|
||||
const surface = breadcrumb.data?.rendererSurface
|
||||
const threshold = breadcrumb.data?.thresholdPct
|
||||
return `${breadcrumb.name}:${String(surface)}:${String(threshold)}:${breadcrumb.origin ?? 'global'}`
|
||||
// Why both: the heap-ratio marks and the private-footprint marks are separate
|
||||
// one-shot ladders. Keying only on `thresholdPct` collapses every footprint
|
||||
// crumb onto one `undefined` slot, so the second mark evicts the first.
|
||||
const threshold =
|
||||
breadcrumb.data?.thresholdPct !== undefined
|
||||
? `pct${String(breadcrumb.data.thresholdPct)}`
|
||||
: `privMB${String(breadcrumb.data?.thresholdPrivateMB)}`
|
||||
return `${breadcrumb.name}:${String(surface)}:${threshold}:${breadcrumb.origin ?? 'global'}`
|
||||
}
|
||||
|
||||
/** Returns the stored breadcrumb so coalescing can refresh the entry it owns. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ReactErrorBoundaryReportResult
|
||||
} from '../../shared/crash-reporting'
|
||||
import type { RendererHeapStatistics } from '../../shared/renderer-heap-statistics'
|
||||
import type { RendererProcessMemory } from '../../shared/renderer-process-memory'
|
||||
|
||||
export type CrashReportsApi = {
|
||||
getLatestPending: () => Promise<CrashReportRecord | null>
|
||||
@@ -23,6 +24,8 @@ export type CrashReportsApi = {
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
/** Exact V8/Blink heap sizes; null when the runtime withholds them. */
|
||||
readHeapStatistics: () => RendererHeapStatistics | null
|
||||
/** This renderer's OS-level footprint, which the heap counters never include. */
|
||||
readProcessMemory?: () => Promise<RendererProcessMemory | null>
|
||||
}
|
||||
|
||||
export type FeedbackApi = {
|
||||
|
||||
@@ -339,7 +339,9 @@ import type {
|
||||
ReactErrorBoundaryReportResult
|
||||
} from '../shared/crash-reporting'
|
||||
import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics'
|
||||
import type { RendererProcessMemory } from '../shared/renderer-process-memory'
|
||||
import { readRendererHeapStatistics } from './renderer-heap-statistics-reader'
|
||||
import { readRendererProcessMemory } from './renderer-process-memory-reader'
|
||||
import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation'
|
||||
import {
|
||||
prepareAndInvokeAppRestart,
|
||||
@@ -1389,7 +1391,8 @@ const api = {
|
||||
ipcRenderer.invoke('crashReports:submit', args),
|
||||
copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) =>
|
||||
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args),
|
||||
readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics()
|
||||
readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(),
|
||||
readProcessMemory: (): Promise<RendererProcessMemory | null> => readRendererProcessMemory()
|
||||
},
|
||||
|
||||
export: {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readRendererProcessMemory } from './renderer-process-memory-reader'
|
||||
|
||||
// Why the partial type: Electron types `residentSet` as required, but Chromium
|
||||
// omits it on macOS — the reader's optional handling exists for exactly that.
|
||||
const source = (
|
||||
getProcessMemoryInfo: () => Promise<Partial<Electron.ProcessMemoryInfo>>
|
||||
): Parameters<typeof readRendererProcessMemory>[0] =>
|
||||
({ getProcessMemoryInfo }) as unknown as Parameters<typeof readRendererProcessMemory>[0]
|
||||
|
||||
describe('readRendererProcessMemory', () => {
|
||||
it('reports the private footprint in the kilobytes Electron returns', async () => {
|
||||
await expect(
|
||||
readRendererProcessMemory(
|
||||
source(async () => ({ private: 632_832, residentSet: 1_143_808, shared: 0 }))
|
||||
)
|
||||
).resolves.toEqual({ privateKB: 632_832, residentKB: 1_143_808 })
|
||||
})
|
||||
|
||||
it('omits the resident set where Chromium does not report one', async () => {
|
||||
await expect(
|
||||
readRendererProcessMemory(source(async () => ({ private: 1024, shared: 0 })))
|
||||
).resolves.toEqual({ privateKB: 1024 })
|
||||
})
|
||||
|
||||
it('returns null rather than throwing when the runtime withholds the read', async () => {
|
||||
await expect(
|
||||
readRendererProcessMemory(
|
||||
source(() => Promise.reject(new Error('getProcessMemoryInfo unavailable')))
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-finite private size', async () => {
|
||||
// Why: a NaN would propagate into breadcrumb megabytes and read as a real
|
||||
// footprint of zero, which is worse than reporting nothing.
|
||||
await expect(
|
||||
readRendererProcessMemory(source(async () => ({ private: Number.NaN, shared: 0 })))
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not call the API more than once per read', async () => {
|
||||
const getProcessMemoryInfo = vi.fn(async () => ({ private: 2048, shared: 0 }))
|
||||
await readRendererProcessMemory(source(getProcessMemoryInfo))
|
||||
expect(getProcessMemoryInfo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RendererProcessMemory } from '../shared/renderer-process-memory'
|
||||
|
||||
type ProcessMemorySource = Pick<NodeJS.Process, 'getProcessMemoryInfo'>
|
||||
|
||||
/**
|
||||
* Reads this renderer's OS-level footprint. Available in a sandboxed,
|
||||
* context-isolated preload; resolves null when the runtime withholds it so a
|
||||
* dropped Electron API can never break renderer diagnostics.
|
||||
*/
|
||||
export async function readRendererProcessMemory(
|
||||
source: ProcessMemorySource = process
|
||||
): Promise<RendererProcessMemory | null> {
|
||||
try {
|
||||
const info = await source.getProcessMemoryInfo()
|
||||
if (!isFiniteKilobytes(info?.private)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
privateKB: info.private,
|
||||
// Why optional: Chromium reports no resident set on macOS.
|
||||
...(isFiniteKilobytes(info.residentSet) ? { residentKB: info.residentSet } : {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteKilobytes(value: number | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Breadcrumb payload shaping shared by the renderer's crash and memory
|
||||
* samplers. Kept as a leaf so neither importer pulls the other's dependencies.
|
||||
*/
|
||||
import type {
|
||||
CrashReportBreadcrumbData,
|
||||
CrashReportDetailValue
|
||||
} from '../../../shared/crash-reporting'
|
||||
|
||||
const BYTES_PER_MEGABYTE = 1024 * 1024
|
||||
|
||||
export function describeUnknownValue(
|
||||
prefix: string,
|
||||
value: unknown
|
||||
): Record<string, CrashReportDetailValue | undefined> {
|
||||
if (value === null) {
|
||||
return { [`${prefix}Type`]: 'null' }
|
||||
}
|
||||
if (value === undefined) {
|
||||
return { [`${prefix}Type`]: 'undefined' }
|
||||
}
|
||||
if (typeof value === 'object' || typeof value === 'function') {
|
||||
const candidate = value as {
|
||||
name?: unknown
|
||||
message?: unknown
|
||||
stack?: unknown
|
||||
constructor?: { name?: string }
|
||||
}
|
||||
return {
|
||||
[`${prefix}Type`]: typeof value === 'function' ? 'function' : candidate.constructor?.name,
|
||||
[`${prefix}Name`]: typeof candidate.name === 'string' ? candidate.name : undefined,
|
||||
[`${prefix}Message`]: typeof candidate.message === 'string' ? candidate.message : undefined,
|
||||
[`${prefix}Stack`]: typeof candidate.stack === 'string' ? candidate.stack : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[`${prefix}Type`]: typeof value,
|
||||
[`${prefix}Message`]: stringifyUnknown(value)
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '[unstringifiable]'
|
||||
}
|
||||
}
|
||||
|
||||
export function compactBreadcrumbData(
|
||||
data: Record<string, CrashReportDetailValue | undefined>
|
||||
): CrashReportBreadcrumbData {
|
||||
const compacted: CrashReportBreadcrumbData = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' || typeof value === 'boolean' || value === null) {
|
||||
compacted[key] = value
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
compacted[key] = value
|
||||
}
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
export function toMegabytes(value: number | undefined): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.round(value / BYTES_PER_MEGABYTE)
|
||||
: undefined
|
||||
}
|
||||
@@ -347,4 +347,149 @@ describe('renderer crash diagnostics', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('process footprint outside the heap counters', () => {
|
||||
const KB = 1024
|
||||
let readHeapStatistics: ReturnType<typeof vi.fn>
|
||||
let readProcessMemory: ReturnType<typeof vi.fn>
|
||||
|
||||
const stubFootprint = (privateMB: number): void => {
|
||||
readProcessMemory.mockResolvedValue({ privateKB: privateMB * KB })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
readHeapStatistics = vi.fn().mockReturnValue({
|
||||
usedHeapKB: 150 * KB,
|
||||
totalHeapKB: 305 * KB,
|
||||
heapLimitKB: 4192 * KB,
|
||||
mallocedKB: 1 * KB,
|
||||
blinkAllocatedKB: 29 * KB
|
||||
})
|
||||
readProcessMemory = vi.fn().mockResolvedValue(null)
|
||||
Object.assign(window.api.crashReports as unknown as Record<string, unknown>, {
|
||||
readHeapStatistics,
|
||||
readProcessMemory
|
||||
})
|
||||
vi.stubGlobal('document', {
|
||||
getElementsByTagName: () => ({ length: 3064 }),
|
||||
querySelectorAll: () => ({ length: 24 })
|
||||
})
|
||||
})
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const memoryCalls = (): { data: Record<string, unknown> }[] =>
|
||||
recordBreadcrumbMock.mock.calls
|
||||
.filter((call) => (call[0] as { name: string }).name === 'renderer_memory')
|
||||
.map((call) => call[0] as { data: Record<string, unknown> })
|
||||
|
||||
const highwaterCalls = (): { data: Record<string, unknown> }[] =>
|
||||
recordBreadcrumbMock.mock.calls
|
||||
.filter((call) => (call[0] as { name: string }).name === 'renderer_memory_highwater')
|
||||
.map((call) => call[0] as { data: Record<string, unknown> })
|
||||
|
||||
it('reports the private footprint and what it holds outside the heap counters', async () => {
|
||||
// Why these numbers: Windows crash 36048e26 — a 618MB private renderer
|
||||
// whose V8 heap was 150MB. 438MB of it is xterm scrollback and glyph
|
||||
// atlases, which no V8 or Blink counter reports.
|
||||
stubFootprint(618)
|
||||
diagnostics.installRendererCrashDiagnostics()
|
||||
await flush()
|
||||
const tick = setIntervalMock.mock.calls[0][0] as () => void
|
||||
tick()
|
||||
|
||||
const latest = memoryCalls().at(-1)!
|
||||
expect(latest.data).toMatchObject({
|
||||
usedHeapMB: 150,
|
||||
privateMB: 618,
|
||||
outsideHeapMB: 438
|
||||
})
|
||||
})
|
||||
|
||||
it('arms the leak census on footprint even when the heap ratio never trips', async () => {
|
||||
// Why: 150MB of a 4192MB limit is 3.6% — far below the 60% ratio mark, so
|
||||
// before this the census that names the leak never reached a report.
|
||||
stubFootprint(618)
|
||||
diagnostics.installRendererCrashDiagnostics()
|
||||
expect(highwaterCalls()).toHaveLength(0)
|
||||
await flush()
|
||||
const tick = setIntervalMock.mock.calls[0][0] as () => void
|
||||
tick()
|
||||
|
||||
expect(highwaterCalls()).toHaveLength(1)
|
||||
expect(highwaterCalls()[0].data).toMatchObject({
|
||||
thresholdPrivateMB: 600,
|
||||
privateMB: 618,
|
||||
terminalElements: 24,
|
||||
domNodes: 3064
|
||||
})
|
||||
expect(highwaterCalls()[0].data).not.toHaveProperty('thresholdPct')
|
||||
})
|
||||
|
||||
it('emits each footprint mark once and both when one sample clears them', async () => {
|
||||
stubFootprint(618)
|
||||
diagnostics.installRendererCrashDiagnostics()
|
||||
await flush()
|
||||
const tick = setIntervalMock.mock.calls[0][0] as () => void
|
||||
tick()
|
||||
expect(highwaterCalls()).toHaveLength(1)
|
||||
|
||||
await flush()
|
||||
tick()
|
||||
expect(highwaterCalls()).toHaveLength(1)
|
||||
|
||||
stubFootprint(1200)
|
||||
await flush()
|
||||
tick()
|
||||
await flush()
|
||||
tick()
|
||||
expect(highwaterCalls()).toHaveLength(2)
|
||||
expect(highwaterCalls().at(-1)!.data).toMatchObject({ thresholdPrivateMB: 1000 })
|
||||
})
|
||||
|
||||
it('keeps sampling when the shell has no footprint bridge at all', async () => {
|
||||
;(window.api.crashReports as unknown as Record<string, unknown>).readProcessMemory = undefined
|
||||
|
||||
expect(() => diagnostics.installRendererCrashDiagnostics()).not.toThrow()
|
||||
await flush()
|
||||
const latest = memoryCalls().at(-1)!
|
||||
expect(latest.data).toMatchObject({ usedHeapMB: 150 })
|
||||
expect(latest.data).not.toHaveProperty('privateMB')
|
||||
expect(highwaterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not let a rejected footprint read break the heap sample', async () => {
|
||||
readProcessMemory.mockRejectedValue(new Error('bridge gone'))
|
||||
|
||||
diagnostics.installRendererCrashDiagnostics()
|
||||
await flush()
|
||||
const tick = setIntervalMock.mock.calls[0][0] as () => void
|
||||
expect(() => tick()).not.toThrow()
|
||||
expect(memoryCalls().at(-1)!.data).toMatchObject({ usedHeapMB: 150 })
|
||||
})
|
||||
|
||||
it('does not overlap footprint reads while the previous read is pending', async () => {
|
||||
let resolveRead: (value: { privateKB: number } | null) => void = () => undefined
|
||||
readProcessMemory.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ privateKB: number } | null>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
|
||||
diagnostics.installRendererCrashDiagnostics()
|
||||
const tick = setIntervalMock.mock.calls[0][0] as () => void
|
||||
tick()
|
||||
tick()
|
||||
expect(readProcessMemory).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRead({ privateKB: 618 * KB })
|
||||
await flush()
|
||||
tick()
|
||||
expect(readProcessMemory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
import type {
|
||||
CrashReportBreadcrumbData,
|
||||
CrashReportDetailValue
|
||||
} from '../../../shared/crash-reporting'
|
||||
import {
|
||||
getBrowserWebviewMemoryProfile,
|
||||
type BrowserWebviewMemoryProfile
|
||||
} from '../components/browser-pane/host-guest/webview-registry'
|
||||
import { compactBreadcrumbData, describeUnknownValue } from './crash-breadcrumb-data'
|
||||
import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder'
|
||||
import { collectRendererMemoryProfileCounts } from './renderer-memory-profile'
|
||||
import {
|
||||
readHeapMetrics,
|
||||
recordRendererMemorySample,
|
||||
resetRendererMemorySampling,
|
||||
setRendererMemorySamplingSurface,
|
||||
type RendererSurface
|
||||
} from './renderer-memory-sampling'
|
||||
|
||||
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
|
||||
|
||||
type RendererSurface = 'main' | 'dashboard-popout'
|
||||
|
||||
type BrowserPerformanceMemory = {
|
||||
usedJSHeapSize?: number
|
||||
totalJSHeapSize?: number
|
||||
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'
|
||||
const emittedHighwaterRatios = new Set<number>()
|
||||
|
||||
// Why re-exported from a leaf module: terminal modules and their e2e-visible
|
||||
// import chains need breadcrumb recording without this file's import.meta /
|
||||
@@ -46,14 +24,14 @@ export function installRendererCrashDiagnostics(surface: RendererSurface = 'main
|
||||
}
|
||||
|
||||
rendererCrashDiagnosticsInstalled = true
|
||||
rendererSurface = surface
|
||||
setRendererMemorySamplingSurface(surface)
|
||||
window.addEventListener('error', recordRendererError)
|
||||
window.addEventListener('unhandledrejection', recordRendererUnhandledRejection)
|
||||
|
||||
if (readHeapMetrics()) {
|
||||
recordRendererMemory('startup')
|
||||
recordRendererMemorySample('startup')
|
||||
rendererMemoryInterval = window.setInterval(
|
||||
() => recordRendererMemory('interval'),
|
||||
() => recordRendererMemorySample('interval'),
|
||||
RENDERER_MEMORY_SAMPLE_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
@@ -70,8 +48,7 @@ function disposeRendererCrashDiagnostics(): void {
|
||||
window.clearInterval(rendererMemoryInterval)
|
||||
rendererMemoryInterval = null
|
||||
}
|
||||
emittedHighwaterRatios.clear()
|
||||
rendererSurface = 'main'
|
||||
resetRendererMemorySampling()
|
||||
}
|
||||
|
||||
if (import.meta !== undefined && import.meta.hot) {
|
||||
@@ -110,176 +87,3 @@ function recordRendererUnhandledRejection(event: PromiseRejectionEvent): void {
|
||||
compactBreadcrumbData(describeUnknownValue('reason', event.reason))
|
||||
)
|
||||
}
|
||||
|
||||
function recordRendererMemory(reason: string): void {
|
||||
const memory = readHeapMetrics()
|
||||
if (!memory) {
|
||||
return
|
||||
}
|
||||
const browserWebviews = getBrowserWebviewMemoryProfile()
|
||||
|
||||
recordRendererCrashBreadcrumb(
|
||||
'renderer_memory',
|
||||
compactBreadcrumbData({
|
||||
reason,
|
||||
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
|
||||
})
|
||||
)
|
||||
recordRendererMemoryHighwater(memory, browserWebviews)
|
||||
}
|
||||
|
||||
function recordRendererMemoryHighwater(
|
||||
memory: HeapMetrics,
|
||||
browserWebviews: BrowserWebviewMemoryProfile
|
||||
): void {
|
||||
const used = memory.usedJSHeapSize
|
||||
const limit = memory.jsHeapSizeLimit
|
||||
// Why: NaN would satisfy `ratio < threshold` for nothing, emitting both
|
||||
// levels spuriously and disarming the one-shot for the session.
|
||||
if (!isFiniteHeapBytes(used) || !isFiniteHeapBytes(limit) || limit <= 0) {
|
||||
return
|
||||
}
|
||||
const ratio = used / limit
|
||||
let crossedThreshold = false
|
||||
for (const threshold of RENDERER_MEMORY_HIGHWATER_RATIOS) {
|
||||
if (ratio >= threshold && !emittedHighwaterRatios.has(threshold)) {
|
||||
crossedThreshold = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!crossedThreshold) {
|
||||
return
|
||||
}
|
||||
// Why: a single sample can cross both thresholds; profile the large heap once.
|
||||
const profile = compactBreadcrumbData({
|
||||
rendererSurface,
|
||||
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,
|
||||
registeredBrowserGuests: browserWebviews.registeredBrowserGuestCount,
|
||||
...collectRendererMemoryProfileCounts()
|
||||
})
|
||||
for (const threshold of RENDERER_MEMORY_HIGHWATER_RATIOS) {
|
||||
if (ratio < threshold || emittedHighwaterRatios.has(threshold)) {
|
||||
continue
|
||||
}
|
||||
emittedHighwaterRatios.add(threshold)
|
||||
recordRendererCrashBreadcrumb('renderer_memory_highwater', {
|
||||
...profile,
|
||||
thresholdPct: Math.round(threshold * 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteHeapBytes(value: number | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function getPerformanceMemory(): BrowserPerformanceMemory | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return 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
|
||||
): Record<string, CrashReportDetailValue | undefined> {
|
||||
if (value === null) {
|
||||
return { [`${prefix}Type`]: 'null' }
|
||||
}
|
||||
if (value === undefined) {
|
||||
return { [`${prefix}Type`]: 'undefined' }
|
||||
}
|
||||
if (typeof value === 'object' || typeof value === 'function') {
|
||||
const candidate = value as {
|
||||
name?: unknown
|
||||
message?: unknown
|
||||
stack?: unknown
|
||||
constructor?: { name?: string }
|
||||
}
|
||||
return {
|
||||
[`${prefix}Type`]: typeof value === 'function' ? 'function' : candidate.constructor?.name,
|
||||
[`${prefix}Name`]: typeof candidate.name === 'string' ? candidate.name : undefined,
|
||||
[`${prefix}Message`]: typeof candidate.message === 'string' ? candidate.message : undefined,
|
||||
[`${prefix}Stack`]: typeof candidate.stack === 'string' ? candidate.stack : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[`${prefix}Type`]: typeof value,
|
||||
[`${prefix}Message`]: stringifyUnknown(value)
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '[unstringifiable]'
|
||||
}
|
||||
}
|
||||
|
||||
function compactBreadcrumbData(
|
||||
data: Record<string, CrashReportDetailValue | undefined>
|
||||
): CrashReportBreadcrumbData {
|
||||
const compacted: CrashReportBreadcrumbData = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' || typeof value === 'boolean' || value === null) {
|
||||
compacted[key] = value
|
||||
} else if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
compacted[key] = value
|
||||
}
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
function toMegabytes(value: number | undefined): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.round(value / BYTES_PER_MEGABYTE)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Renderer memory sampling for crash reports: the periodic `renderer_memory`
|
||||
* crumb, and the one-shot `renderer_memory_highwater` crumbs that carry the
|
||||
* subsystem census naming whatever grew.
|
||||
*/
|
||||
import type { CrashReportDetailValue } from '../../../shared/crash-reporting'
|
||||
import type { RendererProcessMemory } from '../../../shared/renderer-process-memory'
|
||||
import {
|
||||
getBrowserWebviewMemoryProfile,
|
||||
type BrowserWebviewMemoryProfile
|
||||
} from '../components/browser-pane/host-guest/webview-registry'
|
||||
import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder'
|
||||
import { compactBreadcrumbData, toMegabytes } from './crash-breadcrumb-data'
|
||||
import { collectRendererMemoryProfileCounts } from './renderer-memory-profile'
|
||||
|
||||
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
|
||||
/**
|
||||
* Private-footprint marks that arm the same profile when the growth is NOT in
|
||||
* the JS heap. Windows crash 36048e26 reported a 618MB private renderer whose
|
||||
* V8 heap sat at 150MB of a 4192MB limit — 3.6% of the ratio the marks above
|
||||
* need, so the census that would have named the leak never fired. xterm
|
||||
* scrollback (`Uint32Array` backing stores) and WebGL glyph atlases both live
|
||||
* outside every heap counter, so footprint is the only mark that sees them.
|
||||
*/
|
||||
const RENDERER_PRIVATE_HIGHWATER_MB = [600, 1000] as const
|
||||
|
||||
export type RendererSurface = 'main' | 'dashboard-popout'
|
||||
|
||||
type BrowserPerformanceMemory = {
|
||||
usedJSHeapSize?: number
|
||||
totalJSHeapSize?: number
|
||||
jsHeapSizeLimit?: number
|
||||
}
|
||||
|
||||
/** Heap sizes in bytes, tagged with whether they are exact or Blink-quantized. */
|
||||
type HeapMetrics = BrowserPerformanceMemory & {
|
||||
mallocedBytes?: number
|
||||
blinkAllocatedBytes?: number
|
||||
exact: boolean
|
||||
}
|
||||
|
||||
const emittedHighwaterRatios = new Set<number>()
|
||||
const emittedPrivateHighwaterMarks = new Set<number>()
|
||||
let lastProcessFootprint: RendererProcessMemory | null = null
|
||||
let processFootprintReadGeneration = 0
|
||||
let processFootprintReadInFlight = false
|
||||
let rendererSurface: RendererSurface = 'main'
|
||||
|
||||
export function setRendererMemorySamplingSurface(surface: RendererSurface): void {
|
||||
rendererSurface = surface
|
||||
}
|
||||
|
||||
export function resetRendererMemorySampling(): void {
|
||||
emittedHighwaterRatios.clear()
|
||||
emittedPrivateHighwaterMarks.clear()
|
||||
lastProcessFootprint = null
|
||||
processFootprintReadGeneration += 1
|
||||
processFootprintReadInFlight = false
|
||||
rendererSurface = 'main'
|
||||
}
|
||||
|
||||
export function recordRendererMemorySample(reason: string): void {
|
||||
const memory = readHeapMetrics()
|
||||
if (!memory) {
|
||||
return
|
||||
}
|
||||
const browserWebviews = getBrowserWebviewMemoryProfile()
|
||||
// Why the previous read: the footprint bridge is async, and awaiting it here
|
||||
// would make every sample (and its highwater arming) reentrant. Refresh in the
|
||||
// background and annotate with the last answer instead — one sample interval
|
||||
// of staleness is irrelevant to a footprint trend, and the first sample of a
|
||||
// session simply carries no footprint.
|
||||
const footprint = lastProcessFootprint
|
||||
refreshProcessFootprint()
|
||||
|
||||
recordRendererCrashBreadcrumb(
|
||||
'renderer_memory',
|
||||
compactBreadcrumbData({
|
||||
reason,
|
||||
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),
|
||||
...describeProcessFootprint(memory, footprint),
|
||||
browserWebviews: browserWebviews.browserWebviewCount,
|
||||
registeredBrowserGuests: browserWebviews.registeredBrowserGuestCount
|
||||
})
|
||||
)
|
||||
recordRendererMemoryHighwater(memory, browserWebviews, footprint)
|
||||
}
|
||||
|
||||
/** Stays null on shells without the bridge, or when the runtime withholds it. */
|
||||
function refreshProcessFootprint(): void {
|
||||
const read = window.api?.crashReports?.readProcessMemory
|
||||
if (!read || processFootprintReadInFlight) {
|
||||
return
|
||||
}
|
||||
const generation = processFootprintReadGeneration
|
||||
processFootprintReadInFlight = true
|
||||
const settle = (footprint: RendererProcessMemory | null): void => {
|
||||
if (generation !== processFootprintReadGeneration) {
|
||||
return
|
||||
}
|
||||
lastProcessFootprint = footprint
|
||||
processFootprintReadInFlight = false
|
||||
}
|
||||
try {
|
||||
void read().then(
|
||||
(footprint) => settle(footprint ?? null),
|
||||
() => settle(null)
|
||||
)
|
||||
} catch {
|
||||
settle(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the memory the heap counters cannot see. `outsideHeapMB` is the field
|
||||
* that distinguishes a JS leak from scrollback/atlas growth: it is what the OS
|
||||
* charges this renderer minus everything V8 and Blink admit to holding.
|
||||
*/
|
||||
function describeProcessFootprint(
|
||||
memory: HeapMetrics,
|
||||
footprint: RendererProcessMemory | null
|
||||
): Record<string, CrashReportDetailValue | undefined> {
|
||||
if (!footprint) {
|
||||
return {}
|
||||
}
|
||||
const privateMB = toMegabytes(footprint.privateKB * BYTES_PER_KILOBYTE)
|
||||
const accountedBytes =
|
||||
(memory.usedJSHeapSize ?? 0) + (memory.mallocedBytes ?? 0) + (memory.blinkAllocatedBytes ?? 0)
|
||||
return {
|
||||
privateMB,
|
||||
residentMB:
|
||||
footprint.residentKB === undefined
|
||||
? undefined
|
||||
: toMegabytes(footprint.residentKB * BYTES_PER_KILOBYTE),
|
||||
outsideHeapMB:
|
||||
privateMB === undefined
|
||||
? undefined
|
||||
: Math.max(0, privateMB - (toMegabytes(accountedBytes) ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
function recordRendererMemoryHighwater(
|
||||
memory: HeapMetrics,
|
||||
browserWebviews: BrowserWebviewMemoryProfile,
|
||||
footprint: RendererProcessMemory | null = null
|
||||
): void {
|
||||
const used = memory.usedJSHeapSize
|
||||
const limit = memory.jsHeapSizeLimit
|
||||
// Why: NaN would satisfy `ratio < threshold` for nothing, emitting both
|
||||
// levels spuriously and disarming the one-shot for the session.
|
||||
const ratio =
|
||||
isFiniteHeapBytes(used) && isFiniteHeapBytes(limit) && limit > 0 ? used / limit : null
|
||||
const privateMB =
|
||||
footprint === null ? null : (toMegabytes(footprint.privateKB * BYTES_PER_KILOBYTE) ?? null)
|
||||
let crossedThreshold = false
|
||||
if (ratio !== null) {
|
||||
for (const threshold of RENDERER_MEMORY_HIGHWATER_RATIOS) {
|
||||
if (ratio >= threshold && !emittedHighwaterRatios.has(threshold)) {
|
||||
crossedThreshold = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (privateMB !== null) {
|
||||
for (const mark of RENDERER_PRIVATE_HIGHWATER_MB) {
|
||||
if (privateMB >= mark && !emittedPrivateHighwaterMarks.has(mark)) {
|
||||
crossedThreshold = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!crossedThreshold) {
|
||||
return
|
||||
}
|
||||
// Why: a single sample can cross both thresholds; profile the large heap once.
|
||||
const profile = compactBreadcrumbData({
|
||||
rendererSurface,
|
||||
usedHeapMB: toMegabytes(used),
|
||||
totalHeapMB: toMegabytes(memory.totalJSHeapSize),
|
||||
heapLimitMB: toMegabytes(limit),
|
||||
heapSource: memory.exact ? 'v8' : 'quantized',
|
||||
mallocedMB: toMegabytes(memory.mallocedBytes),
|
||||
blinkAllocatedMB: toMegabytes(memory.blinkAllocatedBytes),
|
||||
...describeProcessFootprint(memory, footprint),
|
||||
domNodes: document.getElementsByTagName('*').length,
|
||||
terminalElements: document.querySelectorAll('.xterm').length,
|
||||
browserWebviews: browserWebviews.browserWebviewCount,
|
||||
registeredBrowserGuests: browserWebviews.registeredBrowserGuestCount,
|
||||
...collectRendererMemoryProfileCounts()
|
||||
})
|
||||
if (ratio !== null) {
|
||||
for (const threshold of RENDERER_MEMORY_HIGHWATER_RATIOS) {
|
||||
if (ratio < threshold || emittedHighwaterRatios.has(threshold)) {
|
||||
continue
|
||||
}
|
||||
emittedHighwaterRatios.add(threshold)
|
||||
recordRendererCrashBreadcrumb('renderer_memory_highwater', {
|
||||
...profile,
|
||||
thresholdPct: Math.round(threshold * 100)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (privateMB !== null) {
|
||||
for (const mark of RENDERER_PRIVATE_HIGHWATER_MB) {
|
||||
if (privateMB < mark || emittedPrivateHighwaterMarks.has(mark)) {
|
||||
continue
|
||||
}
|
||||
emittedPrivateHighwaterMarks.add(mark)
|
||||
recordRendererCrashBreadcrumb('renderer_memory_highwater', {
|
||||
...profile,
|
||||
thresholdPrivateMB: mark
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteHeapBytes(value: number | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function getPerformanceMemory(): BrowserPerformanceMemory | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return 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.
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** OS-level memory for the current renderer, in Electron-reported kilobytes. */
|
||||
export type RendererProcessMemory = {
|
||||
/** Not shared with any other process — the number Windows Task Manager shows. */
|
||||
privateKB: number
|
||||
/** Absent on platforms where Chromium does not report a resident set. */
|
||||
residentKB?: number
|
||||
}
|
||||
Reference in New Issue
Block a user