diff --git a/src/renderer/src/lib/renderer-memory-sampling.test.ts b/src/renderer/src/lib/renderer-memory-sampling.test.ts new file mode 100644 index 00000000000..f5bfd03e191 --- /dev/null +++ b/src/renderer/src/lib/renderer-memory-sampling.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RendererMemorySampling from './renderer-memory-sampling' + +type SamplingModule = typeof RendererMemorySampling + +const KB = 1024 +const SAMPLE_INTERVAL_MS = 60_000 +const WEBVIEW_REGISTRY = '../components/browser-pane/host-guest/webview-registry' + +describe('renderer memory highwater census re-arming', () => { + let sampling: SamplingModule + let recordBreadcrumbMock: ReturnType + let readProcessMemory: ReturnType + let heapStats: Record + let webviewProfile: { browserWebviewCount: number; registeredBrowserGuestCount: number } + let nowMs: number + + const stubFootprint = (privateMB: number): void => { + readProcessMemory.mockResolvedValue({ privateKB: privateMB * KB }) + } + + /** One 60s sampling tick; the async footprint read settles before the next. */ + const tick = async (): Promise => { + nowMs += SAMPLE_INTERVAL_MS + sampling.recordRendererMemorySample('interval') + await Promise.resolve() + await Promise.resolve() + } + + const censuses = (): Record[] => + recordBreadcrumbMock.mock.calls + .filter((call) => (call[0] as { name: string }).name === 'renderer_memory_highwater') + .map((call) => (call[0] as { data: Record }).data) + + beforeEach(async () => { + vi.resetModules() + nowMs = 0 + recordBreadcrumbMock = vi.fn() + readProcessMemory = vi.fn().mockResolvedValue(null) + // Why 150MB of a 4192MB limit: 3.6% of the ratio ladder, so only the + // private-footprint marks can arm and the ratio marks stay out of the way. + heapStats = { + usedHeapKB: 150 * KB, + totalHeapKB: 305 * KB, + heapLimitKB: 4192 * KB, + mallocedKB: 1 * KB, + blinkAllocatedKB: 215 * KB + } + webviewProfile = { browserWebviewCount: 0, registeredBrowserGuestCount: 0 } + vi.stubGlobal('performance', { now: () => nowMs }) + vi.stubGlobal('window', { + performance: {}, + api: { + crashReports: { + recordBreadcrumb: recordBreadcrumbMock, + readProcessMemory, + readHeapStatistics: () => heapStats + } + } + }) + vi.stubGlobal('document', { + getElementsByTagName: () => ({ length: 2376 }), + querySelectorAll: () => ({ length: 12 }) + }) + vi.doMock(WEBVIEW_REGISTRY, () => ({ + getBrowserWebviewMemoryProfile: () => webviewProfile + })) + sampling = (await import('./renderer-memory-sampling')) as SamplingModule + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock(WEBVIEW_REGISTRY) + }) + + it('emits nothing until a mark is crossed, then exactly one census', async () => { + stubFootprint(420) + await tick() + await tick() + expect(censuses()).toHaveLength(0) + + stubFootprint(658) + await tick() + await tick() + expect(censuses()).toHaveLength(1) + expect(censuses()[0]).toMatchObject({ thresholdPrivateMB: 600, privateMB: 658 }) + }) + + it('re-emits the census while a renderer plateaus above a mark for 21 hours', async () => { + // Field: crashes fb476b1c / d4d32680 crossed 600MB, emitted one census, + // then ran 21 more hours and died at 577MB without re-crossing — so both + // reports carried a byte-identical census describing a 21h-old workload. + stubFootprint(658) + await tick() + await tick() + expect(censuses()).toHaveLength(1) + expect(censuses()[0]).toMatchObject({ + thresholdPrivateMB: 600, + privateMB: 658, + blinkAllocatedMB: 215, + browserWebviews: 0 + }) + + // The workload the stale census could not describe: a browser guest opened, + // Blink's own allocation collapsed, and the growth moved outside the heap. + webviewProfile.browserWebviewCount = 1 + heapStats.blinkAllocatedKB = 21 * KB + stubFootprint(640) + for (let minute = 0; minute < 21 * 60; minute += 1) { + await tick() + } + + expect(censuses().length).toBeGreaterThan(1) + expect(censuses().at(-1)).toMatchObject({ + thresholdPrivateMB: 600, + privateMB: 640, + blinkAllocatedMB: 21, + browserWebviews: 1 + }) + // Anti-spam: 1260 samples must not become 1260 censuses. + expect(censuses().length).toBeLessThanOrEqual(90) + }) + + it('emits at most one census per mark while a renderer oscillates around it', async () => { + stubFootprint(601) + await tick() + await tick() + expect(censuses()).toHaveLength(1) + + for (let minute = 0; minute < 14; minute += 1) { + stubFootprint(minute % 2 === 0 ? 599 : 601) + await tick() + } + + expect(censuses()).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/lib/renderer-memory-sampling.ts b/src/renderer/src/lib/renderer-memory-sampling.ts index 605adc6d5fa..8e246943a85 100644 --- a/src/renderer/src/lib/renderer-memory-sampling.ts +++ b/src/renderer/src/lib/renderer-memory-sampling.ts @@ -1,7 +1,8 @@ /** * 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. + * crumb, and the periodically re-armed + * `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' @@ -25,6 +26,11 @@ const RENDERER_MEMORY_HIGHWATER_RATIOS = [0.6, 0.8] as const * outside every heap counter, so footprint is the only mark that sees them. */ const RENDERER_PRIVATE_HIGHWATER_MB = [600, 1000] as const +// Why re-arm on a timer, not on a dip below the mark: fb476b1c crossed 600MB +// once, plateaued 21h and died at 577MB, so a dip-armed rearm ships that same +// stale census. 15min = 1 census per 15 samples, and the store keys retained +// highwater crumbs by mark, so a refresh replaces the stale one. +const RENDERER_HIGHWATER_RECENSUS_MS = 15 * 60_000 export type RendererSurface = 'main' | 'dashboard-popout' @@ -41,8 +47,9 @@ type HeapMetrics = BrowserPerformanceMemory & { exact: boolean } -const emittedHighwaterRatios = new Set() -const emittedPrivateHighwaterMarks = new Set() +/** Mark -> monotonic time it last emitted a census. */ +const emittedHighwaterRatios = new Map() +const emittedPrivateHighwaterMarks = new Map() let lastProcessFootprint: RendererProcessMemory | null = null let processFootprintReadGeneration = 0 let processFootprintReadInFlight = false @@ -154,15 +161,16 @@ function recordRendererMemoryHighwater( 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. + // levels spuriously and disarming both marks. const ratio = isFiniteHeapBytes(used) && isFiniteHeapBytes(limit) && limit > 0 ? used / limit : null const privateMB = footprint === null ? null : (toMegabytes(footprint.privateKB * BYTES_PER_KILOBYTE) ?? null) + const nowMs = performance.now() let crossedThreshold = false if (ratio !== null) { for (const threshold of RENDERER_MEMORY_HIGHWATER_RATIOS) { - if (ratio >= threshold && !emittedHighwaterRatios.has(threshold)) { + if (ratio >= threshold && isHighwaterMarkArmed(emittedHighwaterRatios, threshold, nowMs)) { crossedThreshold = true break } @@ -170,7 +178,7 @@ function recordRendererMemoryHighwater( } if (privateMB !== null) { for (const mark of RENDERER_PRIVATE_HIGHWATER_MB) { - if (privateMB >= mark && !emittedPrivateHighwaterMarks.has(mark)) { + if (privateMB >= mark && isHighwaterMarkArmed(emittedPrivateHighwaterMarks, mark, nowMs)) { crossedThreshold = true break } @@ -197,10 +205,10 @@ function recordRendererMemoryHighwater( }) if (ratio !== null) { for (const threshold of RENDERER_MEMORY_HIGHWATER_RATIOS) { - if (ratio < threshold || emittedHighwaterRatios.has(threshold)) { + if (ratio < threshold || !isHighwaterMarkArmed(emittedHighwaterRatios, threshold, nowMs)) { continue } - emittedHighwaterRatios.add(threshold) + emittedHighwaterRatios.set(threshold, nowMs) recordRendererCrashBreadcrumb('renderer_memory_highwater', { ...profile, thresholdPct: Math.round(threshold * 100) @@ -209,10 +217,10 @@ function recordRendererMemoryHighwater( } if (privateMB !== null) { for (const mark of RENDERER_PRIVATE_HIGHWATER_MB) { - if (privateMB < mark || emittedPrivateHighwaterMarks.has(mark)) { + if (privateMB < mark || !isHighwaterMarkArmed(emittedPrivateHighwaterMarks, mark, nowMs)) { continue } - emittedPrivateHighwaterMarks.add(mark) + emittedPrivateHighwaterMarks.set(mark, nowMs) recordRendererCrashBreadcrumb('renderer_memory_highwater', { ...profile, thresholdPrivateMB: mark @@ -221,6 +229,12 @@ function recordRendererMemoryHighwater( } } +/** Why monotonic: a wall-clock correction must not stretch or collapse the window. */ +function isHighwaterMarkArmed(emitted: Map, mark: number, nowMs: number): boolean { + const lastEmittedAtMs = emitted.get(mark) + return lastEmittedAtMs === undefined || nowMs - lastEmittedAtMs >= RENDERER_HIGHWATER_RECENSUS_MS +} + function isFiniteHeapBytes(value: number | undefined): value is number { return typeof value === 'number' && Number.isFinite(value) }