diff --git a/src/main/crash-reporting/crash-breadcrumb-store.test.ts b/src/main/crash-reporting/crash-breadcrumb-store.test.ts index 7690a01ff27..9c5a9dcce4d 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.test.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.test.ts @@ -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', () => { diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index 0a9a71f452e..2c0b68b043e 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -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. */ diff --git a/src/preload/api/crash-report-api.ts b/src/preload/api/crash-report-api.ts index c99e1ccf767..10107336615 100644 --- a/src/preload/api/crash-report-api.ts +++ b/src/preload/api/crash-report-api.ts @@ -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 @@ -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 } export type FeedbackApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index da96fddf1c8..980e99efcbf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => readRendererProcessMemory() }, export: { diff --git a/src/preload/renderer-process-memory-reader.test.ts b/src/preload/renderer-process-memory-reader.test.ts new file mode 100644 index 00000000000..7e40574c984 --- /dev/null +++ b/src/preload/renderer-process-memory-reader.test.ts @@ -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> +): Parameters[0] => + ({ getProcessMemoryInfo }) as unknown as Parameters[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) + }) +}) diff --git a/src/preload/renderer-process-memory-reader.ts b/src/preload/renderer-process-memory-reader.ts new file mode 100644 index 00000000000..fd46b97248e --- /dev/null +++ b/src/preload/renderer-process-memory-reader.ts @@ -0,0 +1,30 @@ +import type { RendererProcessMemory } from '../shared/renderer-process-memory' + +type ProcessMemorySource = Pick + +/** + * 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 { + 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 +} diff --git a/src/renderer/src/lib/crash-breadcrumb-data.ts b/src/renderer/src/lib/crash-breadcrumb-data.ts new file mode 100644 index 00000000000..f422ed9ef01 --- /dev/null +++ b/src/renderer/src/lib/crash-breadcrumb-data.ts @@ -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 { + 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 +): 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 +} diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 47ee857eb1b..da9fd63fda3 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -347,4 +347,149 @@ describe('renderer crash diagnostics', () => { }) }) }) + + describe('process footprint outside the heap counters', () => { + const KB = 1024 + let readHeapStatistics: ReturnType + let readProcessMemory: ReturnType + + 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, { + readHeapStatistics, + readProcessMemory + }) + vi.stubGlobal('document', { + getElementsByTagName: () => ({ length: 3064 }), + querySelectorAll: () => ({ length: 24 }) + }) + }) + + const flush = async (): Promise => { + await Promise.resolve() + await Promise.resolve() + } + + const memoryCalls = (): { data: Record }[] => + recordBreadcrumbMock.mock.calls + .filter((call) => (call[0] as { name: string }).name === 'renderer_memory') + .map((call) => call[0] as { data: Record }) + + const highwaterCalls = (): { data: Record }[] => + recordBreadcrumbMock.mock.calls + .filter((call) => (call[0] as { name: string }).name === 'renderer_memory_highwater') + .map((call) => call[0] as { data: Record }) + + 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).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) + }) + }) }) diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 25a6b75caae..e024b4a90ca 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -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() // 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 { - 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 -): 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 -} diff --git a/src/renderer/src/lib/renderer-memory-sampling.ts b/src/renderer/src/lib/renderer-memory-sampling.ts new file mode 100644 index 00000000000..605adc6d5fa --- /dev/null +++ b/src/renderer/src/lib/renderer-memory-sampling.ts @@ -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() +const emittedPrivateHighwaterMarks = new Set() +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 { + 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 +} diff --git a/src/shared/renderer-process-memory.ts b/src/shared/renderer-process-memory.ts new file mode 100644 index 00000000000..d817ab4837b --- /dev/null +++ b/src/shared/renderer-process-memory.ts @@ -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 +}