mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user