fix(crash-reporting): re-arm the renderer memory census so it is not 21h stale

A highwater mark fired at most once per renderer lifetime, so the leak census
was emitted early on the way up and never again. Crashes fb476b1c and d4d32680
crossed 600MB, ran 21 more hours, and died at 577MB without re-crossing, so
both reports carried a byte-identical census describing a 21-hour-old workload
(browserWebviews=0, blinkAllocatedMB=215) instead of the crash-time one
(browserWebviews=1, blinkAllocatedMB=21, outsideHeapMB=429).

Track the last emit time per mark instead of a fired/not-fired bit and re-arm
after 15 minutes. Timer-based, not dip-based: the field renderer plateaued and
never dipped, so a dip-armed rearm would have shipped the same stale census.
The first census on the way up is unchanged, and the breadcrumb store already
keys retained highwater crumbs by mark, so a refresh replaces the stale entry
rather than consuming another retained slot.
This commit is contained in:
m4air
2026-09-10 16:47:22 -07:00
parent f2d5711b2d
commit 01ef464d2c
2 changed files with 162 additions and 11 deletions
@@ -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<typeof vi.fn>
let readProcessMemory: ReturnType<typeof vi.fn>
let heapStats: Record<string, number>
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<void> => {
nowMs += SAMPLE_INTERVAL_MS
sampling.recordRendererMemorySample('interval')
await Promise.resolve()
await Promise.resolve()
}
const censuses = (): 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> }).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)
})
})
@@ -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<number>()
const emittedPrivateHighwaterMarks = new Set<number>()
/** Mark -> monotonic time it last emitted a census. */
const emittedHighwaterRatios = new Map<number, number>()
const emittedPrivateHighwaterMarks = new Map<number, number>()
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<number, number>, 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)
}