mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
feat(diagnostics): name the nested container that grows in memory breadcrumbs
53 renderer heap-exhaustion crash reports could not localize the leak they prove exists, because summarizeStateCollectionSizes walks only top-level state keys. Anything growing INSIDE a slice value, or outside the store entirely, is invisible — diffComments has no top-level key at all. Adds a bounded recursive walker registered as a contributor on the EXISTING memory-profile path (no new timer; runs at most twice per session, at the 60% and 80% one-shot highwater thresholds), plus a registry so long-lived module-level containers self-report their size. Diagnostics only. This stops no crash. Bounded four ways, because it runs on the main thread of a renderer already at 95% of its heap: entry-visit and key-scan budgets, depth 3, and a wall-clock deadline — for-in materializes V8's whole enumeration cache before yielding key one, so node budgets alone bounded count but not time (3353ms -> 112ms on a store of 300k-key objects). Costs 0.93ms and 17.6 KiB on a realistic store. Emits shapes, sizes and path labels only, never user values: dictionary keys collapse to `[]` so repo names, branch names and paths cannot reach a breadcrumb that gets uploaded with crash reports.
This commit is contained in:
@@ -226,6 +226,7 @@ import {
|
||||
type TaskSourceContext
|
||||
} from '../../../shared/task-source-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { registerModuleSingletonSize } from '@/lib/module-singleton-size-registry'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
|
||||
// Why: the item URL is the only host-aware repository identity present on every work item across IPC.
|
||||
@@ -1469,6 +1470,10 @@ const workItemDetailsCache = new Map<string, WorkItemDetailsCacheEntry>()
|
||||
|
||||
// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set).
|
||||
const workItemDetailsCacheListeners = new Set<() => void>()
|
||||
registerModuleSingletonSize('prPage.workItemDetailsCache', workItemDetailsCache)
|
||||
// Why registered: a listener Set that only grows is a mounted-component leak,
|
||||
// and it retains every closure's captured props.
|
||||
registerModuleSingletonSize('prPage.workItemDetailsListeners', workItemDetailsCacheListeners)
|
||||
function subscribeWorkItemDetailsCache(listener: () => void): () => void {
|
||||
workItemDetailsCacheListeners.add(listener)
|
||||
return () => {
|
||||
@@ -1653,6 +1658,10 @@ type PRFileContentCacheEntry = {
|
||||
}
|
||||
const prFileContentCache = new Map<string, PRFileContentCacheEntry>()
|
||||
let prFileContentCacheBytes = 0
|
||||
// Why registered: byte-capped, but the cap only holds if eviction actually runs;
|
||||
// reporting both entries and bytes proves it in a crash report rather than assuming.
|
||||
registerModuleSingletonSize('prPage.fileContentCache', prFileContentCache)
|
||||
registerModuleSingletonSize('prPage.fileContentCacheKB', () => prFileContentCacheBytes / 1024)
|
||||
|
||||
function getUtf8ByteCount(value: string): number {
|
||||
let byteCount = 0
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* output flows or while no PTY delivery is expected).
|
||||
*/
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import { registerModuleSingletonSize } from '@/lib/module-singleton-size-registry'
|
||||
import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health'
|
||||
import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics'
|
||||
import { deliverPulledPtyModelRestoreMarkers } from './pty-model-restore-channel'
|
||||
@@ -44,6 +45,8 @@ type TerminalDeliveryWatchdogDeps = {
|
||||
}
|
||||
|
||||
const receivedPtyCharTotals = new Map<string, number>()
|
||||
// Why registered: one entry per PTY ever seen, deleted only on explicit detach.
|
||||
registerModuleSingletonSize('watchdog.receivedPtyCharTotals', receivedPtyCharTotals)
|
||||
let receivedPtyDataEventCount = 0
|
||||
let blackholePtyPushDelivery = false
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetModuleSingletonSizesForTests,
|
||||
collectModuleSingletonSizes,
|
||||
registerModuleSingletonSize
|
||||
} from './module-singleton-size-registry'
|
||||
|
||||
afterEach(() => {
|
||||
_resetModuleSingletonSizesForTests()
|
||||
})
|
||||
|
||||
describe('collectModuleSingletonSizes', () => {
|
||||
it('reports live container sizes, not the size at registration time', () => {
|
||||
const cache = new Map<string, number>()
|
||||
registerModuleSingletonSize('runtime.cache', cache)
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({})
|
||||
|
||||
cache.set('a', 1)
|
||||
cache.set('b', 2)
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({ 'runtime.cache': 2 })
|
||||
})
|
||||
|
||||
it('reads Maps and Sets alike, plus derived numeric sources', () => {
|
||||
registerModuleSingletonSize('m', new Map([['a', 1]]))
|
||||
registerModuleSingletonSize('s', new Set([1, 2, 3]))
|
||||
registerModuleSingletonSize('derived', () => 42.6)
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({ m: 1, s: 3, derived: 43 })
|
||||
})
|
||||
|
||||
it('contains a throwing source instead of losing the other counts', () => {
|
||||
registerModuleSingletonSize('broken', () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
registerModuleSingletonSize('healthy', new Set([1, 2]))
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({ 'broken.error': 1, healthy: 2 })
|
||||
})
|
||||
|
||||
it('skips non-finite sizes rather than emitting junk fields', () => {
|
||||
registerModuleSingletonSize('nan', () => Number.NaN)
|
||||
registerModuleSingletonSize('infinite', () => Number.POSITIVE_INFINITY)
|
||||
registerModuleSingletonSize('real', () => 5)
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({ real: 5 })
|
||||
})
|
||||
|
||||
it('unregisters cleanly and does not clobber a re-registered name', () => {
|
||||
const dispose = registerModuleSingletonSize('a', new Set([1]))
|
||||
registerModuleSingletonSize('a', new Set([1, 2]))
|
||||
|
||||
// The stale disposer must not remove the newer registration.
|
||||
dispose()
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({ a: 2 })
|
||||
})
|
||||
|
||||
it('refuses registrations past the cap so one module cannot crowd out the rest', () => {
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
registerModuleSingletonSize(`s${index}`, new Set([1]))
|
||||
}
|
||||
registerModuleSingletonSize('overflow', new Set([1, 2, 3]))
|
||||
|
||||
const counts = collectModuleSingletonSizes()
|
||||
|
||||
expect(Object.keys(counts)).toHaveLength(32)
|
||||
expect(counts.overflow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an oversized name that would blow the breadcrumb key budget', () => {
|
||||
registerModuleSingletonSize('x'.repeat(49), new Set([1]))
|
||||
|
||||
expect(collectModuleSingletonSizes()).toEqual({})
|
||||
})
|
||||
|
||||
it('returns a no-op disposer for a rejected registration', () => {
|
||||
registerModuleSingletonSize('kept', new Set([1]))
|
||||
const rejected = registerModuleSingletonSize('', new Set([1]))
|
||||
|
||||
expect(() => rejected()).not.toThrow()
|
||||
expect(collectModuleSingletonSizes()).toEqual({ kept: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('production wiring', () => {
|
||||
// No reset here: the assertion depends on the module's own top-level
|
||||
// registration, which is the thing under test.
|
||||
it('registers the watchdog PTY table, and its count tracks real activity', async () => {
|
||||
const watchdog = await import('@/components/terminal-pane/terminal-delivery-watchdog')
|
||||
const read = (): number | undefined =>
|
||||
collectModuleSingletonSizes()['watchdog.receivedPtyCharTotals']
|
||||
|
||||
// Empty registrations are skipped, so a real count is what proves the wiring.
|
||||
expect(read()).toBeUndefined()
|
||||
try {
|
||||
watchdog.recordPtyDataReceived('pty-a', 12)
|
||||
watchdog.recordPtyDataReceived('pty-b', 34)
|
||||
|
||||
expect(read()).toBe(2)
|
||||
} finally {
|
||||
watchdog.clearReceivedPtyCharTotal('pty-a')
|
||||
watchdog.clearReceivedPtyCharTotal('pty-b')
|
||||
}
|
||||
|
||||
expect(read()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Self-reported sizes for long-lived module-level containers.
|
||||
*
|
||||
* Why: the Zustand store is only part of the renderer's retained graph. Module
|
||||
* scope holds Maps/Sets that live for the whole session and are invisible to any
|
||||
* store walk, so a leak there names nothing in a crash report. Registering one
|
||||
* is a single call next to the declaration and costs a `.size` read per memory
|
||||
* breadcrumb — no timer, no subscription, no retention of the values.
|
||||
*
|
||||
* Opt-in on purpose: only containers whose growth would be a real leak signal
|
||||
* belong here. Diagnostics-only; nothing reads these counts to make decisions.
|
||||
*/
|
||||
|
||||
type SizedContainer = { readonly size: number }
|
||||
|
||||
type SingletonSizeSource = SizedContainer | (() => number)
|
||||
|
||||
const sources = new Map<string, SingletonSizeSource>()
|
||||
|
||||
// Why: these bound the breadcrumb, not the renderer — one misfiled registration
|
||||
// must not crowd out the store counts that share the profile budget.
|
||||
const MAX_SINGLETONS = 32
|
||||
const MAX_NAME_LENGTH = 48
|
||||
|
||||
/**
|
||||
* Registers a module-level container to report its entry count in memory
|
||||
* breadcrumbs. Call once at module scope; `name` becomes the breadcrumb key.
|
||||
* The returned disposer exists for tests and HMR.
|
||||
*/
|
||||
export function registerModuleSingletonSize(
|
||||
name: string,
|
||||
source: SingletonSizeSource
|
||||
): () => void {
|
||||
if (name.length === 0 || name.length > MAX_NAME_LENGTH) {
|
||||
return () => undefined
|
||||
}
|
||||
if (!sources.has(name) && sources.size >= MAX_SINGLETONS) {
|
||||
return () => undefined
|
||||
}
|
||||
sources.set(name, source)
|
||||
return () => {
|
||||
if (sources.get(name) === source) {
|
||||
sources.delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Current entry count of every registered singleton; skips empty and broken ones. */
|
||||
export function collectModuleSingletonSizes(): Record<string, number> {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const [name, source] of sources) {
|
||||
// Why: a container with a throwing accessor must not lose the other counts.
|
||||
try {
|
||||
const size = typeof source === 'function' ? source() : source.size
|
||||
// Why skip zero: an idle registration should not spend a breadcrumb slot.
|
||||
if (typeof size === 'number' && Number.isFinite(size) && size > 0) {
|
||||
counts[name] = Math.round(size)
|
||||
}
|
||||
} catch {
|
||||
counts[`${name}.error`] = 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function _resetModuleSingletonSizesForTests(): void {
|
||||
sources.clear()
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { walkNestedCollectionSizes } from './nested-collection-size-walker'
|
||||
import { summarizeStateCollectionSizes } from './renderer-memory-profile'
|
||||
|
||||
type PaneEntry = { paneKey: string; state: string; stateHistory: { state: string; at: number }[] }
|
||||
|
||||
// MAX_ENTRY_VISITS + MAX_KEYS_SCANNED from the walker; nodesVisited sums both.
|
||||
const NODES_CEILING = 34_000
|
||||
|
||||
/** Store shaped like the real one: pane dictionary whose entries hold histories. */
|
||||
function buildStoreState(options: {
|
||||
panes: number
|
||||
historyPerPane: number
|
||||
worktreesPerRepo?: number
|
||||
commentsPerWorktree?: number
|
||||
}): Record<string, unknown> {
|
||||
const agentStatusByPaneKey: Record<string, PaneEntry> = {}
|
||||
for (let pane = 0; pane < options.panes; pane += 1) {
|
||||
agentStatusByPaneKey[`tab-${pane}:leaf-${pane}`] = {
|
||||
paneKey: `tab-${pane}:leaf-${pane}`,
|
||||
state: 'working',
|
||||
stateHistory: Array.from({ length: options.historyPerPane }, (_, index) => ({
|
||||
state: 'idle',
|
||||
at: index
|
||||
}))
|
||||
}
|
||||
}
|
||||
const worktreesByRepo: Record<string, { id: string; diffComments: { id: string }[] }[]> = {}
|
||||
// Real repo ids are randomUUID(), which is what makes the label collapse to `[]`.
|
||||
worktreesByRepo['3f2a1c9e-8b74-4d21-9f0a-6c5e2b7d1a83'] = Array.from(
|
||||
{ length: options.worktreesPerRepo ?? 0 },
|
||||
(_, index) => ({
|
||||
id: `wt-${index}`,
|
||||
diffComments: Array.from({ length: options.commentsPerWorktree ?? 0 }, (_, c) => ({
|
||||
id: `c-${c}`
|
||||
}))
|
||||
})
|
||||
)
|
||||
return { agentStatusByPaneKey, worktreesByRepo, activeWorktreeId: 'wt-0' }
|
||||
}
|
||||
|
||||
describe('the gap the walker closes', () => {
|
||||
it('top-level summary reports an unchanged number while nested entries grow 40x', () => {
|
||||
const before = buildStoreState({ panes: 20, historyPerPane: 1 })
|
||||
const after = buildStoreState({ panes: 20, historyPerPane: 40 })
|
||||
|
||||
const summaryBefore = summarizeStateCollectionSizes(before, 20)
|
||||
const summaryAfter = summarizeStateCollectionSizes(after, 20)
|
||||
|
||||
// The existing breadcrumb: identical before and after 780 new retained objects.
|
||||
expect(summaryBefore).toEqual(summaryAfter)
|
||||
expect(summaryAfter.agentStatusByPaneKey).toBe(20)
|
||||
|
||||
const walkBefore = walkNestedCollectionSizes(before, 12)
|
||||
const walkAfter = walkNestedCollectionSizes(after, 12)
|
||||
|
||||
expect(walkBefore.counts['agentStatusByPaneKey[].stateHistory']).toBe(20)
|
||||
expect(walkAfter.counts['agentStatusByPaneKey[].stateHistory']).toBe(800)
|
||||
})
|
||||
|
||||
it('names a three-level-deep container the top-level summary cannot reach', () => {
|
||||
const state = buildStoreState({
|
||||
panes: 1,
|
||||
historyPerPane: 0,
|
||||
worktreesPerRepo: 4,
|
||||
commentsPerWorktree: 250
|
||||
})
|
||||
|
||||
// diffComments has no top-level state key at all, so it is unreported today.
|
||||
expect(summarizeStateCollectionSizes(state, 20)).toEqual({
|
||||
agentStatusByPaneKey: 1,
|
||||
worktreesByRepo: 1
|
||||
})
|
||||
|
||||
expect(walkNestedCollectionSizes(state, 12).counts['worktreesByRepo[][].diffComments']).toBe(
|
||||
1000
|
||||
)
|
||||
})
|
||||
|
||||
it('ranks the largest nested container first', () => {
|
||||
const state = {
|
||||
small: { a: { items: [1, 2, 3] } },
|
||||
big: { a: { items: Array.from({ length: 500 }, (_, index) => index) } }
|
||||
}
|
||||
|
||||
expect(Object.keys(walkNestedCollectionSizes(state, 1).counts)).toEqual(['big.a.items'])
|
||||
})
|
||||
|
||||
it('does not spend breadcrumb slots re-reporting top-level keys', () => {
|
||||
const walk = walkNestedCollectionSizes({ worktrees: [1, 2, 3] }, 12)
|
||||
|
||||
expect(walk.counts).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('path labels', () => {
|
||||
it('collapses id-like dictionary keys so one path names the whole family', () => {
|
||||
const state = {
|
||||
byId: Object.fromEntries(
|
||||
Array.from({ length: 30 }, (_, index) => [
|
||||
`2f9c-${index}-uuid-like`,
|
||||
{ history: [1, 2, 3, 4] }
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
// `byId` itself is a top-level key the existing summary already reports.
|
||||
expect(walkNestedCollectionSizes(state, 12).counts).toEqual({ 'byId[].history': 120 })
|
||||
})
|
||||
|
||||
it('keeps named properties distinct from dictionary entries', () => {
|
||||
const state = { slice: { settings: { tabs: [1, 2], panes: [1, 2, 3] } } }
|
||||
|
||||
// `slice.settings` is a struct, so its field count is not a collection size.
|
||||
expect(walkNestedCollectionSizes(state, 12).counts).toEqual({
|
||||
'slice.settings.tabs': 2,
|
||||
'slice.settings.panes': 3
|
||||
})
|
||||
})
|
||||
|
||||
it('never leaks dictionary keys into the label, which crash reporting does not sanitize', () => {
|
||||
const secretish = '/Users/someone/work/acme-secret-repo'
|
||||
const state = {
|
||||
byPath: {
|
||||
[secretish]: { comments: [1, 2, 3] },
|
||||
'C:\\Users\\someone\\proj': { comments: [1, 2] }
|
||||
}
|
||||
}
|
||||
|
||||
const paths = Object.keys(walkNestedCollectionSizes(state, 12).counts)
|
||||
|
||||
expect(paths).toEqual(['byPath[].comments'])
|
||||
expect(paths.join('|')).not.toContain('someone')
|
||||
})
|
||||
|
||||
it('collapses a SMALL user-keyed dictionary, whose keys are repo and branch names', () => {
|
||||
// The dangerous case: key-count alone reads 3 keys as a struct and emits
|
||||
// them verbatim into a breadcrumb that gets uploaded to Slack. Real users
|
||||
// have a handful of repos, so this shape is the common one, not the exotic one.
|
||||
const state = {
|
||||
settingsByRepo: {
|
||||
acme_billing_secret: { branches: [1, 2, 3] },
|
||||
project_darkstar: { branches: [1, 2] },
|
||||
clientwork: { branches: [1] }
|
||||
}
|
||||
}
|
||||
|
||||
const paths = Object.keys(walkNestedCollectionSizes(state, 12).counts)
|
||||
|
||||
expect(paths).toEqual(['settingsByRepo[].branches'])
|
||||
expect(paths.join('|')).not.toMatch(/acme|darkstar|clientwork/)
|
||||
})
|
||||
|
||||
it('collapses a ONE-entry dictionary, where there is no sibling to compare shape against', () => {
|
||||
// Repeated-shape detection cannot fire on a single entry, so the key syntax
|
||||
// rule is the only thing left; a branch name must still not reach the label.
|
||||
const state = { draftsByBranch: { feature_ceo_comp_model: { comments: [1, 2, 3] } } }
|
||||
|
||||
const paths = Object.keys(walkNestedCollectionSizes(state, 12).counts)
|
||||
|
||||
expect(paths).toEqual(['draftsByBranch[].comments'])
|
||||
expect(paths.join('|')).not.toContain('ceo_comp')
|
||||
})
|
||||
|
||||
it('collapses an accidentally-camelCase branch name when a sibling gives the set away', () => {
|
||||
// The residual risk in key-syntax filtering: `myFeature` is a legal branch
|
||||
// name AND a legal field name. Its siblings are what disambiguate.
|
||||
// Entry shapes DIFFER, so repeated-shape detection cannot fire; the
|
||||
// camelCase key would pass on its own. Only the user-shaped sibling saves it.
|
||||
const state = {
|
||||
draftsByBranch: {
|
||||
myFeature: { comments: [1, 2, 3] },
|
||||
'fix/login-crash': { comments: [1], resolved: true }
|
||||
}
|
||||
}
|
||||
|
||||
const paths = Object.keys(walkNestedCollectionSizes(state, 12).counts)
|
||||
|
||||
expect(paths).toEqual(['draftsByBranch[].comments'])
|
||||
expect(paths.join('|')).not.toContain('myFeature')
|
||||
})
|
||||
|
||||
it('rejects non-camelCase key shapes that user data takes', () => {
|
||||
const state = {
|
||||
slice: {
|
||||
'kebab-branch-name': { items: [1] },
|
||||
snake_case_repo: { items: [1, 2] },
|
||||
'9f2a-4c81-uuid': { items: [1, 2, 3] },
|
||||
'C:\\Users\\someone': { items: [1, 2, 3, 4] }
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of Object.keys(walkNestedCollectionSizes(state, 12).counts)) {
|
||||
expect(path).not.toMatch(/kebab|snake|uuid|someone/)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a path label that would exceed the breadcrumb key budget', () => {
|
||||
const longKey = 'k'.repeat(90)
|
||||
const state = { slice: { [longKey]: { items: [1, 2, 3] } } }
|
||||
|
||||
for (const path of Object.keys(walkNestedCollectionSizes(state, 12).counts)) {
|
||||
expect(path.length).toBeLessThanOrEqual(64)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a dictionary field but not a struct field, at the same depth', () => {
|
||||
const state = {
|
||||
slice: {
|
||||
struct: Object.fromEntries(Array.from({ length: 8 }, (_, i) => [`field${i}`, i])),
|
||||
dictionary: Object.fromEntries(Array.from({ length: 400 }, (_, i) => [`id-${i}`, i]))
|
||||
}
|
||||
}
|
||||
|
||||
expect(walkNestedCollectionSizes(state, 12).counts).toEqual({ 'slice.dictionary': 400 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('safety rails', () => {
|
||||
it('survives a cycle instead of hanging', () => {
|
||||
const node: Record<string, unknown> = { items: [1, 2, 3] }
|
||||
node.self = node
|
||||
const nested: Record<string, unknown> = { child: node }
|
||||
node.parent = nested
|
||||
|
||||
const walk = walkNestedCollectionSizes({ root: nested }, 12)
|
||||
|
||||
expect(walk.counts['root.child.items']).toBe(3)
|
||||
})
|
||||
|
||||
it('counts a shared reference once rather than once per referrer', () => {
|
||||
const shared = { entries: [1, 2, 3, 4, 5] }
|
||||
const walk = walkNestedCollectionSizes({ a: { s: shared }, b: { s: shared } }, 12)
|
||||
|
||||
expect(walk.counts['a.s.entries']).toBe(5)
|
||||
expect(walk.counts['b.s.entries']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never enters class instances, so xterm/fiber-shaped fan-out is unreachable', () => {
|
||||
// Real fibers/xterm hold their fan-out in OWN instance fields, which for-in
|
||||
// enumerates — a prototype getter would not exercise the guard at all.
|
||||
class FiberLike {
|
||||
readonly child = { grandchild: Array.from({ length: 500 }, () => 0) }
|
||||
readonly stateNode = { buffers: Array.from({ length: 500 }, () => 0) }
|
||||
readonly memoizedState = { queue: Array.from({ length: 500 }, () => 0) }
|
||||
}
|
||||
const walk = walkNestedCollectionSizes({ terminals: { pane: new FiberLike() } }, 12)
|
||||
|
||||
// Nothing under the instance may be reported, at any depth.
|
||||
expect(walk.counts).toEqual({})
|
||||
})
|
||||
|
||||
it('does not descend through a Map VALUE that is a class instance', () => {
|
||||
class TerminalLike {
|
||||
readonly buffer = { lines: Array.from({ length: 900 }, () => 0) }
|
||||
}
|
||||
const byPane = new Map([['pane-1', new TerminalLike()]])
|
||||
|
||||
const walk = walkNestedCollectionSizes({ slice: { byPane } }, 12)
|
||||
|
||||
expect(walk.counts).toEqual({ 'slice.byPane': 1 })
|
||||
})
|
||||
|
||||
it('never enters DOM-shaped or React-element-shaped objects', () => {
|
||||
const domLike = { nodeType: 1, childNodes: Array.from({ length: 500 }, () => ({})) }
|
||||
const reactLike = { $$typeof: Symbol.for('react.element'), props: { children: [1, 2, 3] } }
|
||||
|
||||
const walk = walkNestedCollectionSizes({ a: { dom: domLike }, b: { el: reactLike } }, 12)
|
||||
|
||||
expect(walk.counts['a.dom.childNodes']).toBeUndefined()
|
||||
expect(walk.counts['b.el.props']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports partial results with truncated set instead of throwing on a hostile getter', () => {
|
||||
const hostile = {
|
||||
list: [1, 2, 3],
|
||||
get boom(): never {
|
||||
throw new Error('nope')
|
||||
}
|
||||
}
|
||||
const walk = walkNestedCollectionSizes({ slice: hostile }, 12)
|
||||
|
||||
// Degrades to partial results: what it read before the throw, plus the flag.
|
||||
expect(walk.truncated).toBe(true)
|
||||
expect(walk.counts).toEqual({ 'slice.list': 3 })
|
||||
})
|
||||
|
||||
it('isolates a throwing slice so its SIBLING slices are still reported', () => {
|
||||
const state = {
|
||||
hostile: {
|
||||
get boom(): never {
|
||||
throw new Error('nope')
|
||||
}
|
||||
},
|
||||
healthy: { comments: Array.from({ length: 40 }, () => 0) },
|
||||
alsoHealthy: { history: Array.from({ length: 25 }, () => 0) }
|
||||
}
|
||||
|
||||
const walk = walkNestedCollectionSizes(state, 12)
|
||||
|
||||
// Without per-frame isolation the throw would abort the whole walk and
|
||||
// these siblings would vanish from the breadcrumb.
|
||||
expect(walk.counts['healthy.comments']).toBe(40)
|
||||
expect(walk.counts['alsoHealthy.history']).toBe(25)
|
||||
expect(walk.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('survives every exotic value a real store could hold', () => {
|
||||
const nullPrototype = Object.create(null) as Record<string, unknown>
|
||||
nullPrototype.items = [1, 2, 3]
|
||||
const selfReferential: unknown[] = [1]
|
||||
selfReferential.push(selfReferential)
|
||||
|
||||
const exotic: unknown[] = [
|
||||
new Proxy({}, { ownKeys: () => { throw new Error('nope') } }),
|
||||
new Proxy({ a: {} }, { get: () => { throw new Error('nope') } }),
|
||||
{ get boom(): never { throw new Error('nope') } },
|
||||
{ get nodeType(): never { throw new Error('nope') } },
|
||||
{ [Symbol.toPrimitive]: () => { throw new Error('nope') }, items: [1, 2] },
|
||||
nullPrototype,
|
||||
new Uint8Array(64),
|
||||
new WeakMap(),
|
||||
Promise.resolve(1),
|
||||
Object.freeze({ a: Object.freeze({ items: [1, 2, 3] }) }),
|
||||
selfReferential,
|
||||
{ d: new Date(), r: /x/g, items: [1] },
|
||||
Array.from({ length: 16 })
|
||||
]
|
||||
|
||||
for (const value of exotic) {
|
||||
expect(() => walkNestedCollectionSizes({ root: value }, 12)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not throw on non-object roots', () => {
|
||||
for (const root of [null, undefined, 7, 'x', Symbol('s')]) {
|
||||
expect(walkNestedCollectionSizes(root, 12).counts).toEqual({})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('node budget', () => {
|
||||
it('stays under the budget on a pathologically wide store and flags truncation', () => {
|
||||
const state = {
|
||||
wide: Object.fromEntries(
|
||||
Array.from({ length: 50_000 }, (_, index) => [`k${index}`, { items: [1, 2, 3] }])
|
||||
)
|
||||
}
|
||||
|
||||
const walk = walkNestedCollectionSizes(state, 12)
|
||||
const reported = walk.counts['wide[].items'] ?? 0
|
||||
|
||||
expect(walk.nodesVisited).toBeLessThanOrEqual(NODES_CEILING)
|
||||
expect(walk.estimated).toBe(true)
|
||||
// Past the key cap the contract is a flagged LOWER BOUND, not an estimate:
|
||||
// truncated says "at least this much", which still names the container and
|
||||
// still moves when it grows. Reporting the sample size would not.
|
||||
expect(walk.truncated).toBe(true)
|
||||
expect(reported).toBeGreaterThan(50_000)
|
||||
expect(reported).toBeLessThanOrEqual(150_000)
|
||||
})
|
||||
|
||||
it('keeps rising as a huge dictionary grows, instead of saturating', () => {
|
||||
const build = (panes: number): Record<string, unknown> => ({
|
||||
panes: Object.fromEntries(
|
||||
Array.from({ length: panes }, (_, index) => [`p${index}`, { history: [1, 2, 3, 4, 5] }])
|
||||
)
|
||||
})
|
||||
|
||||
const at3k = walkNestedCollectionSizes(build(3000), 12).counts['panes[].history'] ?? 0
|
||||
const at9k = walkNestedCollectionSizes(build(9000), 12).counts['panes[].history'] ?? 0
|
||||
|
||||
// A saturating count would report the same number twice and hide the leak.
|
||||
expect(at9k).toBeGreaterThan(at3k * 2)
|
||||
})
|
||||
|
||||
it('bounds ENTRY visits on a wide-and-deep store whose keys are never scanned', () => {
|
||||
// Why arrays specifically: the key-scan budget does not apply to them, so
|
||||
// this is the only shape where MAX_ENTRY_VISITS is the sole thing standing
|
||||
// between the walker and a half-million entry reads. Mutating that constant
|
||||
// away leaves every other test in this file passing.
|
||||
const state: Record<string, unknown> = {}
|
||||
for (let slice = 0; slice < 256; slice += 1) {
|
||||
state[`slice${slice}`] = Array.from({ length: 2000 }, () => ({ items: [1, 2, 3] }))
|
||||
}
|
||||
|
||||
const walk = walkNestedCollectionSizes(state, 12)
|
||||
|
||||
// 512_000 entries exist; an unbudgeted walk reads all of them.
|
||||
expect(walk.nodesVisited).toBeLessThanOrEqual(10_000)
|
||||
expect(walk.estimated).toBe(true)
|
||||
// Bounded, but still names the container rather than going silent.
|
||||
expect(Object.keys(walk.counts).some((path) => path.endsWith('[].items'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stays under the budget on a pathologically deep store', () => {
|
||||
let deep: Record<string, unknown> = { items: [1, 2, 3] }
|
||||
for (let level = 0; level < 5000; level += 1) {
|
||||
deep = { child: deep }
|
||||
}
|
||||
|
||||
const walk = walkNestedCollectionSizes({ root: deep }, 12)
|
||||
|
||||
expect(walk.nodesVisited).toBeLessThanOrEqual(NODES_CEILING)
|
||||
expect(Object.keys(walk.counts).every((path) => path.split('.').length <= 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds the reported path count regardless of store fan-out', () => {
|
||||
const state = Object.fromEntries(
|
||||
Array.from({ length: 200 }, (_, index) => [
|
||||
`slice${index}`,
|
||||
{ [`child${index}`]: { items: [1, 2] } }
|
||||
])
|
||||
)
|
||||
|
||||
expect(Object.keys(walkNestedCollectionSizes(state, 12).counts)).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('scales a sampled count toward the true population', () => {
|
||||
const state = {
|
||||
panes: Object.fromEntries(
|
||||
Array.from({ length: 2000 }, (_, index) => [`p${index}`, { history: [1, 2, 3, 4, 5] }])
|
||||
)
|
||||
}
|
||||
const walk = walkNestedCollectionSizes(state, 12)
|
||||
const reported = walk.counts['panes[].history'] ?? 0
|
||||
|
||||
expect(walk.estimated).toBe(true)
|
||||
// True total is 10_000; sampling must land within 2x, not report the sample.
|
||||
expect(reported).toBeGreaterThan(5000)
|
||||
expect(reported).toBeLessThan(20_000)
|
||||
})
|
||||
|
||||
it('bounds WALL TIME when a store holds objects too big for a node budget to help', () => {
|
||||
// Why node budgets are not enough: `for...in` materializes V8's enumeration
|
||||
// cache for the whole object before yielding key one, so an 8-key sample of
|
||||
// a 300k-key value costs O(300k). This shape measured 3.3s unbounded.
|
||||
const slice: Record<string, unknown> = {}
|
||||
for (let entry = 0; entry < 10; entry += 1) {
|
||||
const huge: Record<string, unknown> = {}
|
||||
for (let field = 0; field < 300_000; field += 1) {
|
||||
huge[`f${field}`] = field
|
||||
}
|
||||
slice[`entry${entry}`] = huge
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const walk = walkNestedCollectionSizes({ slice }, 12)
|
||||
const elapsed = performance.now() - startedAt
|
||||
|
||||
// Generous vs. the ~75ms measured: one for-in is uninterruptible, so the
|
||||
// deadline bounds how many are STARTED, not how long one runs.
|
||||
expect(elapsed).toBeLessThan(600)
|
||||
expect(walk.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('is exact and unflagged when the store fits inside the budget', () => {
|
||||
const state = buildStoreState({ panes: 30, historyPerPane: 20 })
|
||||
const walk = walkNestedCollectionSizes(state, 12)
|
||||
|
||||
expect(walk.estimated).toBe(false)
|
||||
expect(walk.truncated).toBe(false)
|
||||
expect(walk.counts['agentStatusByPaneKey[].stateHistory']).toBe(600)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost on a realistically-sized store', () => {
|
||||
it('completes a realistic store well inside a frame budget', () => {
|
||||
const state = buildStoreState({
|
||||
panes: 300,
|
||||
historyPerPane: 20,
|
||||
worktreesPerRepo: 120,
|
||||
commentsPerWorktree: 30
|
||||
})
|
||||
|
||||
// Warm up so the measurement is steady-state, not first-call JIT.
|
||||
for (let run = 0; run < 20; run += 1) {
|
||||
walkNestedCollectionSizes(state, 12)
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
const runs = 200
|
||||
for (let run = 0; run < runs; run += 1) {
|
||||
walkNestedCollectionSizes(state, 12)
|
||||
}
|
||||
const msPerRun = (performance.now() - startedAt) / runs
|
||||
const walk = walkNestedCollectionSizes(state, 12)
|
||||
|
||||
// Generous vs. the measured cost so slow CI machines don't flake; the real
|
||||
// guarantee is the node budget asserted above, which is deterministic.
|
||||
expect(msPerRun).toBeLessThan(15)
|
||||
expect(walk.nodesVisited).toBeLessThanOrEqual(NODES_CEILING)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Bounded recursive size walker for renderer_memory_highwater breadcrumbs.
|
||||
*
|
||||
* Why: summarizeStateCollectionSizes counts only top-level state keys, so a
|
||||
* collection growing INSIDE a slice value — agentStatusByPaneKey[].stateHistory,
|
||||
* worktreesByRepo[][].diffComments — is invisible in every crash report we have.
|
||||
* This names the nested container instead. Pure diagnostics: it reads state,
|
||||
* changes nothing, and runs only while a memory breadcrumb is already being built
|
||||
* (at most twice per session, since each highwater threshold fires once).
|
||||
*
|
||||
* Counts can be MAGNITUDES rather than exact totals: past the caps the walker
|
||||
* samples sibling entries and scales, setting `estimated`. A run that hits a
|
||||
* budget reports what it has and sets `truncated` rather than throwing or hanging.
|
||||
*/
|
||||
|
||||
import {
|
||||
hasOnlyFieldNameShapedKeys,
|
||||
hasRepeatedEntryShape,
|
||||
isCountableContainer,
|
||||
isFieldNameShaped,
|
||||
isWalkableContainer
|
||||
} from './nested-container-shape'
|
||||
|
||||
export type NestedCollectionSizeWalk = {
|
||||
/** Largest nested containers by entry count, keyed by path label. */
|
||||
counts: Record<string, number>
|
||||
/** Entry reads plus key reads; the walker's entire cost is proportional to this. */
|
||||
nodesVisited: number
|
||||
/** A budget cut coverage short, so counts are lower bounds. */
|
||||
truncated: boolean
|
||||
/** At least one count was scaled up from a sample of sibling entries. */
|
||||
estimated: boolean
|
||||
}
|
||||
|
||||
// Two budgets because the two kinds of work cost differently: descending into an
|
||||
// entry is real recursion, while counting a dictionary's keys is a flat ~0.12us
|
||||
// per key. Both are measured in nested-collection-size-walker.test.ts.
|
||||
const MAX_ENTRY_VISITS = 4000
|
||||
const MAX_KEYS_SCANNED = 30_000
|
||||
const MAX_KEYS_PER_CONTAINER = 20_000
|
||||
const MAX_FRAME_DEPTH = 3
|
||||
const MAX_FRAMES_PER_LEVEL = 128
|
||||
const MAX_ENTRIES_PER_FRAME = 256
|
||||
const MIN_ENTRIES_PER_FRAME = 8
|
||||
const MAX_TRACKED_PATHS = 96
|
||||
const MAX_PATH_LENGTH = 64
|
||||
/**
|
||||
* Above this many keys a plain object is a dictionary (sample it, scale it, and
|
||||
* report its size); at or below it the object is a struct, so every field is
|
||||
* visited unsampled — otherwise a heterogeneous field like `stateHistory` could
|
||||
* be missed entirely by sampling, and its field count is noise, not a leak.
|
||||
*/
|
||||
const MAX_STRUCT_KEYS = 32
|
||||
/**
|
||||
* Wall-clock backstop, because the node budgets cannot bound time on their own:
|
||||
* `for...in` materializes V8's enumeration cache for the WHOLE object before
|
||||
* yielding the first key, so breaking after 8 keys still costs O(object size).
|
||||
* A store holding 300k-key objects measured 3.3s without this. Blocking the
|
||||
* main thread of an already-dying renderer is worse than a partial breadcrumb,
|
||||
* so the walk reports what it has and sets `truncated`.
|
||||
*/
|
||||
const MAX_WALK_MS = 25
|
||||
|
||||
type Frame = {
|
||||
container: object
|
||||
path: string
|
||||
depth: number
|
||||
/** Entry count of `container`, measured when it was queued. */
|
||||
size: number
|
||||
/** True when `container` is homogeneous, so sampling its entries is valid. */
|
||||
samplable: boolean
|
||||
/** True when every sampled value is a container, i.e. keys are data, not code. */
|
||||
keysAreData: boolean
|
||||
/** Population this frame stands in for, from sampled ancestors. */
|
||||
scale: number
|
||||
}
|
||||
|
||||
type WalkState = {
|
||||
totals: Map<string, number>
|
||||
/** performance.now() past which the walk stops, whatever budget remains. */
|
||||
deadline: number
|
||||
seen: WeakSet<object>
|
||||
entryVisits: number
|
||||
keysScanned: number
|
||||
/** Frames discarded at the current level's width cap; reset per level. */
|
||||
droppedFrames: number
|
||||
truncated: boolean
|
||||
estimated: boolean
|
||||
}
|
||||
|
||||
/** Walks `root` a few levels deep and reports its biggest nested collections. */
|
||||
export function walkNestedCollectionSizes(root: unknown, limit: number): NestedCollectionSizeWalk {
|
||||
const state: WalkState = {
|
||||
totals: new Map(),
|
||||
deadline: now() + MAX_WALK_MS,
|
||||
seen: new WeakSet(),
|
||||
entryVisits: 0,
|
||||
keysScanned: 0,
|
||||
droppedFrames: 0,
|
||||
truncated: false,
|
||||
estimated: false
|
||||
}
|
||||
// Why: a diagnostic must never be the thing that breaks crash reporting.
|
||||
try {
|
||||
if (isWalkableContainer(root)) {
|
||||
state.seen.add(root)
|
||||
walkLevels(describeFrame(root, '', 0, 1, state), state)
|
||||
}
|
||||
} catch {
|
||||
state.truncated = true
|
||||
}
|
||||
return {
|
||||
counts: largestPaths(state.totals, limit),
|
||||
nodesVisited: state.entryVisits + state.keysScanned,
|
||||
truncated: state.truncated,
|
||||
estimated: state.estimated
|
||||
}
|
||||
}
|
||||
|
||||
function walkLevels(rootFrame: Frame, state: WalkState): void {
|
||||
let level: Frame[] = [rootFrame]
|
||||
while (level.length > 0) {
|
||||
if (state.entryVisits >= MAX_ENTRY_VISITS || isOutOfTime(state)) {
|
||||
state.truncated = true
|
||||
return
|
||||
}
|
||||
// Why half: a fat top level must not consume the whole budget before
|
||||
// reaching the depth where the leak actually hides. The last level has no
|
||||
// deeper level to save for, so it may use everything that is left.
|
||||
const remaining = MAX_ENTRY_VISITS - state.entryVisits
|
||||
const levelAllowance =
|
||||
level[0].depth >= MAX_FRAME_DEPTH ? remaining : Math.ceil(remaining / 2)
|
||||
const perFrame = clamp(
|
||||
Math.floor(levelAllowance / level.length),
|
||||
MIN_ENTRIES_PER_FRAME,
|
||||
MAX_ENTRIES_PER_FRAME
|
||||
)
|
||||
const next: Frame[] = []
|
||||
state.droppedFrames = 0
|
||||
for (const frame of level) {
|
||||
if (state.entryVisits >= MAX_ENTRY_VISITS || isOutOfTime(state)) {
|
||||
state.truncated = true
|
||||
break
|
||||
}
|
||||
scanFrame(frame, perFrame, next, state)
|
||||
}
|
||||
applyDroppedFrameScale(next, state.droppedFrames)
|
||||
level = next
|
||||
}
|
||||
}
|
||||
|
||||
/** Visits a sample of one container's entries and queues their containers. */
|
||||
function scanFrame(frame: Frame, perFrame: number, next: Frame[], state: WalkState): void {
|
||||
// Why structs ignore perFrame: their fields are heterogeneous, so a partial
|
||||
// scan could silently omit the one field that is leaking.
|
||||
const target = frame.samplable ? Math.min(frame.size, perFrame) : frame.size
|
||||
if (target <= 0) {
|
||||
return
|
||||
}
|
||||
if (target < frame.size) {
|
||||
state.estimated = true
|
||||
}
|
||||
const scale = (frame.scale * frame.size) / target
|
||||
try {
|
||||
scanFrameEntries(frame, target, scale, next, state)
|
||||
} catch {
|
||||
// Why: one hostile getter or exotic proxy must not lose the whole walk.
|
||||
state.truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
function scanFrameEntries(
|
||||
frame: Frame,
|
||||
target: number,
|
||||
scale: number,
|
||||
next: Frame[],
|
||||
state: WalkState
|
||||
): void {
|
||||
const container = frame.container
|
||||
if (Array.isArray(container)) {
|
||||
for (let index = 0; index < target; index += 1) {
|
||||
state.entryVisits += 1
|
||||
visitChild(container[index], null, frame, scale, next, state)
|
||||
}
|
||||
return
|
||||
}
|
||||
let seen = 0
|
||||
if (container instanceof Map) {
|
||||
for (const value of container.values()) {
|
||||
if (seen >= target) {
|
||||
return
|
||||
}
|
||||
seen += 1
|
||||
state.entryVisits += 1
|
||||
visitChild(value, null, frame, scale, next, state)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why no hasOwn: isWalkableContainer already proved the prototype is
|
||||
// Object.prototype or null, neither of which has enumerable properties.
|
||||
for (const key in container) {
|
||||
if (seen >= target) {
|
||||
return
|
||||
}
|
||||
seen += 1
|
||||
state.entryVisits += 1
|
||||
visitChild((container as Record<string, unknown>)[key], key, frame, scale, next, state)
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a container child's scaled size and queues it for the next level. */
|
||||
function visitChild(
|
||||
value: unknown,
|
||||
key: string | null,
|
||||
frame: Frame,
|
||||
scale: number,
|
||||
next: Frame[],
|
||||
state: WalkState
|
||||
): void {
|
||||
if (!isCountableContainer(value)) {
|
||||
return
|
||||
}
|
||||
const child = value as object
|
||||
// Why: cycle safety, and shared references (EMPTY_* sentinels) counted once.
|
||||
if (state.seen.has(child)) {
|
||||
return
|
||||
}
|
||||
state.seen.add(child)
|
||||
const path = childPath(frame, key)
|
||||
if (path === null) {
|
||||
return
|
||||
}
|
||||
const child_ = describeFrame(child, path, frame.depth + 1, scale, state)
|
||||
if (child_.size <= 0) {
|
||||
return
|
||||
}
|
||||
// Why depth >= 1 only: depth-0 children are the top-level keys the `store`
|
||||
// contributor already reports exactly, so recording them here adds nothing.
|
||||
// Why reportable: a struct's field count is not a collection size.
|
||||
if (frame.depth >= 1 && isReportableContainer(child, child_.size)) {
|
||||
addPathTotal(state, path, child_.size * scale)
|
||||
}
|
||||
if (frame.depth < MAX_FRAME_DEPTH && isWalkableContainer(child)) {
|
||||
queueFrame(next, child_, state)
|
||||
}
|
||||
}
|
||||
|
||||
/** Measures a container's size and classifies it as samplable or struct-shaped. */
|
||||
function describeFrame(
|
||||
container: object,
|
||||
path: string,
|
||||
depth: number,
|
||||
scale: number,
|
||||
state: WalkState
|
||||
): Frame {
|
||||
if (Array.isArray(container)) {
|
||||
// Array/Map indices are positional, never user strings, so `[]` always applies.
|
||||
return { container, path, depth, size: container.length, samplable: true, keysAreData: true, scale }
|
||||
}
|
||||
if (container instanceof Map || container instanceof Set) {
|
||||
return { container, path, depth, size: container.size, samplable: true, keysAreData: true, scale }
|
||||
}
|
||||
const size = countKeys(container, state)
|
||||
const samplable = size > MAX_STRUCT_KEYS
|
||||
// Why the deadline short-circuits to `true`: classification costs a for-in per
|
||||
// sampled entry, and `true` is the privacy-safe answer — keys get collapsed to
|
||||
// `[]` rather than printed. Running out of time must never start printing keys.
|
||||
// Why both rules: repeated entry shapes catch a dictionary of records, and the
|
||||
// key-syntax scan catches the rest (a dictionary whose entries differ, or has
|
||||
// only one). Either one alone leaves a hole a real branch name fits through.
|
||||
const keysAreData =
|
||||
samplable ||
|
||||
isOutOfTime(state) ||
|
||||
hasRepeatedEntryShape(container, () => isOutOfTime(state)) ||
|
||||
!hasOnlyFieldNameShapedKeys(container)
|
||||
return { container, path, depth, size, samplable, keysAreData, scale }
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact key count when the budget allows; otherwise a lower bound with
|
||||
* `truncated` set, because a silently saturating count would hide the growth
|
||||
* that is the entire signal.
|
||||
*
|
||||
* Why a per-container cap on top of the global one: without it the first huge
|
||||
* dictionary eats the whole budget and every later container measures 0, which
|
||||
* drops them from the report entirely — the same blindness this walker exists
|
||||
* to fix. Capping per container keeps every path visible.
|
||||
*/
|
||||
function countKeys(container: object, state: WalkState): number {
|
||||
let size = 0
|
||||
for (const _key in container) {
|
||||
size += 1
|
||||
state.keysScanned += 1
|
||||
if (size >= MAX_KEYS_PER_CONTAINER || state.keysScanned >= MAX_KEYS_SCANNED) {
|
||||
state.truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
function childPath(frame: Frame, key: string | null): string | null {
|
||||
if (frame.depth === 0) {
|
||||
return key !== null && key.length <= MAX_PATH_LENGTH ? key : null
|
||||
}
|
||||
// Why the parent's shape decides: inside a dictionary every key is user data
|
||||
// (repo name, branch, path), so collapsing to `[]` both yields one path for
|
||||
// the family and keeps user values out of a breadcrumb we upload to Slack.
|
||||
const named = !frame.keysAreData && key !== null && isFieldNameShaped(key)
|
||||
const path = named ? `${frame.path}.${key}` : `${frame.path}[]`
|
||||
return path.length <= MAX_PATH_LENGTH ? path : null
|
||||
}
|
||||
|
||||
function addPathTotal(state: WalkState, path: string, value: number): void {
|
||||
const existing = state.totals.get(path)
|
||||
if (existing !== undefined) {
|
||||
state.totals.set(path, existing + value)
|
||||
return
|
||||
}
|
||||
if (state.totals.size >= MAX_TRACKED_PATHS) {
|
||||
state.truncated = true
|
||||
return
|
||||
}
|
||||
state.totals.set(path, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a frame, reservoir-sampling once the level is full so that dropping
|
||||
* frames scales the survivors instead of silently under-reporting the level.
|
||||
*/
|
||||
function queueFrame(next: Frame[], frame: Frame, state: WalkState): void {
|
||||
if (next.length < MAX_FRAMES_PER_LEVEL) {
|
||||
next.push(frame)
|
||||
return
|
||||
}
|
||||
state.estimated = true
|
||||
state.droppedFrames += 1
|
||||
// Deterministic stride keeps the sample spread across the level without an RNG.
|
||||
const replaced = state.droppedFrames % MAX_FRAMES_PER_LEVEL
|
||||
next[replaced] = frame
|
||||
}
|
||||
|
||||
/** Scales a level's surviving frames to stand in for the ones that were dropped. */
|
||||
function applyDroppedFrameScale(level: Frame[], dropped: number): void {
|
||||
if (dropped <= 0 || level.length === 0) {
|
||||
return
|
||||
}
|
||||
const factor = (level.length + dropped) / level.length
|
||||
for (const frame of level) {
|
||||
frame.scale *= factor
|
||||
}
|
||||
}
|
||||
|
||||
/** Real collections only; a struct's field count is noise in a leak breadcrumb. */
|
||||
function isReportableContainer(value: object, size: number): boolean {
|
||||
return (
|
||||
Array.isArray(value) || value instanceof Map || value instanceof Set || size > MAX_STRUCT_KEYS
|
||||
)
|
||||
}
|
||||
|
||||
function largestPaths(totals: Map<string, number>, limit: number): Record<string, number> {
|
||||
if (limit <= 0 || totals.size === 0) {
|
||||
return {}
|
||||
}
|
||||
const counts: Record<string, number> = {}
|
||||
for (const [path, value] of [...totals].sort((a, b) => b[1] - a[1]).slice(0, limit)) {
|
||||
counts[path] = Math.round(value)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
function isOutOfTime(state: WalkState): boolean {
|
||||
return now() >= state.deadline
|
||||
}
|
||||
|
||||
// Why not performance.now() directly: it is absent in some worker/test realms,
|
||||
// and a diagnostic must degrade to "no deadline" rather than throw.
|
||||
function now(): number {
|
||||
return typeof performance === 'object' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: 0
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasOnlyFieldNameShapedKeys,
|
||||
hasRepeatedEntryShape,
|
||||
isFieldNameShaped,
|
||||
isPlainObjectShape,
|
||||
isWalkableContainer
|
||||
} from './nested-container-shape'
|
||||
|
||||
describe('isFieldNameShaped', () => {
|
||||
it('accepts the lowerCamelCase field names this store actually uses', () => {
|
||||
for (const key of ['stateHistory', 'diffComments', 'tabs', 'browserUrlHistory']) {
|
||||
expect(isFieldNameShaped(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects every shape user data takes, because labels ship to Slack', () => {
|
||||
for (const key of [
|
||||
'my-feature-branch',
|
||||
'acme_billing_secret',
|
||||
'/Users/someone/repo',
|
||||
'C:\\Users\\someone',
|
||||
'9f2a1c9e-8b74-4d21-9f0a-6c5e2b7d1a83',
|
||||
'API_TOKEN',
|
||||
'Repo Name With Spaces',
|
||||
'café',
|
||||
''
|
||||
]) {
|
||||
expect(isFieldNameShaped(key)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('caps key length at 32 so one label cannot crowd the breadcrumb budget', () => {
|
||||
expect(isFieldNameShaped(`a${'b'.repeat(31)}`)).toBe(true)
|
||||
expect(isFieldNameShaped(`a${'b'.repeat(32)}`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasRepeatedEntryShape', () => {
|
||||
it('calls repeated records a dictionary, so its user-derived keys collapse', () => {
|
||||
expect(
|
||||
hasRepeatedEntryShape({
|
||||
acme_secret: { branches: [1], head: 'x' },
|
||||
other_repo: { branches: [2], head: 'y' }
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('calls a mixed-field record a struct, so its field names survive', () => {
|
||||
expect(hasRepeatedEntryShape({ paneKey: 'p', state: 'idle', stateHistory: [1, 2] })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats differing field sets as a struct rather than a dictionary', () => {
|
||||
expect(hasRepeatedEntryShape({ a: { x: 1 }, b: { y: 2 } })).toBe(false)
|
||||
})
|
||||
|
||||
it('cannot infer repetition from a single entry', () => {
|
||||
expect(hasRepeatedEntryShape({ onlyOne: { branches: [1] } })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a struct whose values are arrays as a struct', () => {
|
||||
// Arrays carry no field names, so nothing proves the keys are interchangeable.
|
||||
expect(hasRepeatedEntryShape({ tabs: [1, 2], panes: [3] })).toBe(false)
|
||||
})
|
||||
|
||||
it('assumes keys are data when an entry cannot be read', () => {
|
||||
const hostile = {
|
||||
get boom(): never {
|
||||
throw new Error('nope')
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasRepeatedEntryShape(hostile)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores field order when matching entry shapes', () => {
|
||||
expect(hasRepeatedEntryShape({ a: { x: 1, y: 2 }, b: { y: 3, x: 4 } })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPlainObjectShape', () => {
|
||||
it('rejects class instances, which is what keeps xterm and fibers unreachable', () => {
|
||||
class Terminal {
|
||||
readonly buffer = {}
|
||||
}
|
||||
|
||||
expect(isPlainObjectShape(new Terminal())).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects DOM-shaped and React-element-shaped objects that are prototype-plain', () => {
|
||||
expect(isPlainObjectShape({ nodeType: 1 })).toBe(false)
|
||||
expect(isPlainObjectShape({ $$typeof: Symbol.for('react.element') })).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts plain and null-prototype data objects', () => {
|
||||
expect(isPlainObjectShape({ a: 1 })).toBe(true)
|
||||
expect(isPlainObjectShape(Object.create(null))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects scalars, null, and exotic built-ins', () => {
|
||||
for (const value of [null, undefined, 7, 'x', new Promise(() => {}), new Uint8Array(2)]) {
|
||||
expect(isPlainObjectShape(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWalkableContainer', () => {
|
||||
it('admits arrays, Maps, and plain objects only', () => {
|
||||
expect(isWalkableContainer([1])).toBe(true)
|
||||
expect(isWalkableContainer(new Map())).toBe(true)
|
||||
expect(isWalkableContainer({})).toBe(true)
|
||||
// A Set is countable but not walkable: its entries are values, not a shape.
|
||||
expect(isWalkableContainer(new Set([1]))).toBe(false)
|
||||
expect(isWalkableContainer(new WeakMap())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasOnlyFieldNameShapedKeys', () => {
|
||||
it('condemns the whole container when one sibling key is user-shaped', () => {
|
||||
expect(hasOnlyFieldNameShapedKeys({ myFeature: 1, 'fix/login': 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a container whose keys are all field names', () => {
|
||||
expect(hasOnlyFieldNameShapedKeys({ stateHistory: 1, diffComments: 2 })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Shape classification for the nested collection size walker.
|
||||
*
|
||||
* Two questions, both answered without reading a single user VALUE: is this
|
||||
* object safe to walk at all, and are its keys field names (safe to print) or
|
||||
* user data (must be collapsed to `[]`)? The second question is a privacy
|
||||
* boundary — walker output is uploaded with crash reports — so it lives here
|
||||
* rather than inline, where it is easy to test in isolation.
|
||||
*/
|
||||
|
||||
/** Entries sampled to decide dictionary-vs-struct; shape is uniform, so few suffice. */
|
||||
export const HOMOGENEITY_SAMPLE = 8
|
||||
|
||||
/**
|
||||
* Strict lowerCamelCase, the convention every field name in this store follows.
|
||||
* Deliberately narrower than "valid identifier": it rejects the shapes user data
|
||||
* actually takes — snake_case and kebab branch names, paths, UUIDs, SCREAMING
|
||||
* keys — so a one-entry dictionary cannot smuggle a key into a Slack-bound
|
||||
* breadcrumb on the one path where repeated-shape detection has nothing to
|
||||
* compare against. A rejected key costs a label; an accepted one costs a leak.
|
||||
*/
|
||||
const NAMED_PROPERTY_KEY = /^[a-z][A-Za-z0-9]{0,31}$/
|
||||
|
||||
export function isFieldNameShaped(key: string): boolean {
|
||||
return NAMED_PROPERTY_KEY.test(key)
|
||||
}
|
||||
|
||||
/** Anything whose entries we can count: containers, plus Sets (counted, not entered). */
|
||||
export function isCountableContainer(value: unknown): boolean {
|
||||
return value instanceof Set || isWalkableContainer(value)
|
||||
}
|
||||
|
||||
/** True for containers safe to iterate: arrays, Maps, and plain objects. */
|
||||
export function isWalkableContainer(value: unknown): value is object {
|
||||
return Array.isArray(value) || value instanceof Map || isPlainObjectShape(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain data objects only. Class instances (xterm Terminals, React fibers, DOM
|
||||
* nodes, Promises, typed arrays, Zustand stores) fail the prototype test and are
|
||||
* never entered, which is what keeps huge fan-out and getter side effects out.
|
||||
*/
|
||||
export function isPlainObjectShape(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
return false
|
||||
}
|
||||
const shape = value as { $$typeof?: unknown; nodeType?: unknown }
|
||||
// Why: React elements and cross-realm DOM wrappers can be prototype-plain yet
|
||||
// reach the entire tree through _owner / parentNode.
|
||||
return shape.$$typeof === undefined && typeof shape.nodeType !== 'number'
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes a dictionary from a struct by whether its ENTRIES REPEAT A
|
||||
* SHAPE, because key count alone cannot: a user with three repos has a
|
||||
* three-key dictionary whose keys are repo names, and those keys would
|
||||
* otherwise be uploaded to Slack verbatim.
|
||||
*
|
||||
* A dictionary's values are interchangeable records — two or more plain objects
|
||||
* with the same field names (`{[repo]: {branches}}`). A struct's fields are not
|
||||
* (`{paneKey, state, stateHistory}` mixes scalars with an array; `{tabs, panes}`
|
||||
* holds arrays, which carry no field names to match). Erring toward "dictionary"
|
||||
* costs a field name in the label; erring the other way leaks user data.
|
||||
*/
|
||||
export function hasRepeatedEntryShape(container: object, isExpired?: () => boolean): boolean {
|
||||
let signature: string | null = null
|
||||
let sampled = 0
|
||||
try {
|
||||
for (const key in container) {
|
||||
// Why checked per entry: each one costs a for-in over a value that may be
|
||||
// huge, so the cap alone bounds the entry COUNT but not the time. Bailing
|
||||
// returns `true`, which collapses keys — the privacy-safe direction.
|
||||
if (sampled >= HOMOGENEITY_SAMPLE || isExpired?.() === true) {
|
||||
break
|
||||
}
|
||||
const value = (container as Record<string, unknown>)[key]
|
||||
// Why plain objects only: arrays and Maps expose no field names, so a
|
||||
// struct of arrays is indistinguishable from a dictionary of arrays.
|
||||
if (!isPlainObjectShape(value)) {
|
||||
return false
|
||||
}
|
||||
const entrySignature = fieldSignature(value as object)
|
||||
if (signature === null) {
|
||||
signature = entrySignature
|
||||
} else if (signature !== entrySignature) {
|
||||
return false
|
||||
}
|
||||
sampled += 1
|
||||
}
|
||||
} catch {
|
||||
// Why: an unreadable entry proves nothing about shape; treat keys as data.
|
||||
return true
|
||||
}
|
||||
// Why two: a single entry cannot demonstrate repetition, and a one-entry
|
||||
// container is never the leak this walker is looking for. A run cut short by
|
||||
// the deadline lands here with sampled < 2 only if it never got going, in
|
||||
// which case the strict key-syntax rule is what keeps user data out.
|
||||
return sampled >= 2
|
||||
}
|
||||
|
||||
/**
|
||||
* True when EVERY sampled key looks like a field name, not just the one being
|
||||
* labelled.
|
||||
*
|
||||
* Why sibling-wide: a lone key is weak evidence, because user data can be
|
||||
* accidentally camelCase — a branch named `myFeature`, a repo named `orcaWeb`.
|
||||
* Siblings are what give it away: real branch and repo sets almost always
|
||||
* contain at least one `feature/x`, `fix-y`, or `snake_name`, and one such
|
||||
* sibling now condemns the whole container's keys instead of only itself.
|
||||
*/
|
||||
export function hasOnlyFieldNameShapedKeys(container: object): boolean {
|
||||
let sampled = 0
|
||||
try {
|
||||
for (const key in container) {
|
||||
if (sampled >= HOMOGENEITY_SAMPLE) {
|
||||
break
|
||||
}
|
||||
if (!isFieldNameShaped(key)) {
|
||||
return false
|
||||
}
|
||||
sampled += 1
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Field names of one entry, order-independent.
|
||||
*
|
||||
* Why for-in rather than Object.keys: both are O(entry size), but for-in reuses
|
||||
* V8's enumeration cache — which the pre-existing top-level summary has already
|
||||
* paid for on these same objects — instead of allocating a fresh key array in a
|
||||
* renderer that is at 95% of its heap. The per-entry cost is bounded by the
|
||||
* caller's deadline, not by this cap.
|
||||
*/
|
||||
function fieldSignature(entry: object): string {
|
||||
const fields: string[] = []
|
||||
for (const key in entry) {
|
||||
if (fields.length >= HOMOGENEITY_SAMPLE) {
|
||||
break
|
||||
}
|
||||
fields.push(key)
|
||||
}
|
||||
return fields.sort().join(',')
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics'
|
||||
import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { registerModuleSingletonSize } from '@/lib/module-singleton-size-registry'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution'
|
||||
import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
|
||||
@@ -81,6 +82,8 @@ type AgentStatusProjectionCache = {
|
||||
const registeredTabs = new Map<string, RegisteredTerminalTab>()
|
||||
// Why: registration time suppresses the "no live transport" warning during the async PTY-connect window; after the grace period it's a real stuck state.
|
||||
const tabRegisteredAt = new Map<string, number>()
|
||||
registerModuleSingletonSize('syncGraph.registeredTabs', registeredTabs)
|
||||
registerModuleSingletonSize('syncGraph.tabRegisteredAt', tabRegisteredAt)
|
||||
const NO_TRANSPORT_GRACE_MS = 10_000
|
||||
const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {}
|
||||
const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {}
|
||||
@@ -104,6 +107,12 @@ const mobileSessionSnapshotCacheByWorktree = new Map<
|
||||
string,
|
||||
{ content: unknown; snapshot: RuntimeMobileSessionTabsSnapshot }
|
||||
>()
|
||||
// Why registered: retains a full session snapshot per worktree, so unbounded
|
||||
// growth here is heap, not just bookkeeping.
|
||||
registerModuleSingletonSize(
|
||||
'syncGraph.mobileSnapshotCache',
|
||||
mobileSessionSnapshotCacheByWorktree
|
||||
)
|
||||
|
||||
// Structural equality under JSON-serialization semantics (undefined-valued
|
||||
// keys are absent), so version reuse matches a JSON fingerprint exactly
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { OpenFile } from '../store/slices/editor'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { registerModuleSingletonSize } from '@/lib/module-singleton-size-registry'
|
||||
import {
|
||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
||||
getRuntimeSessionMirrorEnvironmentIds
|
||||
@@ -104,6 +105,14 @@ const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
const replayableSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
const lastHostTerminalTabCountByWorktree = new Map<string, number>()
|
||||
const hostSessionTabIdByLocalKey = new Map<string, string>()
|
||||
// Why registered: keyed by worktree/tab with no eviction pass, so SSH sessions
|
||||
// that churn worktrees would grow these silently.
|
||||
registerModuleSingletonSize('webTabsSync.latestSnapshots', latestSessionTabsSnapshotByWorktree)
|
||||
registerModuleSingletonSize(
|
||||
'webTabsSync.replayableSnapshots',
|
||||
replayableSessionTabsSnapshotByWorktree
|
||||
)
|
||||
registerModuleSingletonSize('webTabsSync.hostTabIdByLocalKey', hostSessionTabIdByLocalKey)
|
||||
|
||||
type TerminalSurface = RuntimeMobileSessionTerminalClientTab
|
||||
type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' }
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
registerRendererMemoryProfileContributor,
|
||||
summarizeStateCollectionSizes
|
||||
} from '@/lib/renderer-memory-profile'
|
||||
import { walkNestedCollectionSizes } from '@/lib/nested-collection-size-walker'
|
||||
import { collectModuleSingletonSizes } from '@/lib/module-singleton-size-registry'
|
||||
|
||||
export const useAppStore = create<AppState>()((...a) => {
|
||||
// Why: the inner api is only reachable here, before create() copies subscribe onto the hook.
|
||||
@@ -104,6 +106,21 @@ registerRendererMemoryProfileContributor('store', () =>
|
||||
summarizeStateCollectionSizes(useAppStore.getState(), 20)
|
||||
)
|
||||
|
||||
// Why: the top-level summary above cannot see growth INSIDE a slice value, which
|
||||
// is why 53 heap-exhaustion reports named nothing. Diagnostics only — bounded
|
||||
// walk, no fix. See nested-collection-size-walker.ts.
|
||||
registerRendererMemoryProfileContributor('nested', () => {
|
||||
const walk = walkNestedCollectionSizes(useAppStore.getState(), 12)
|
||||
return {
|
||||
...walk.counts,
|
||||
nodesVisited: walk.nodesVisited,
|
||||
truncated: walk.truncated ? 1 : 0,
|
||||
estimated: walk.estimated ? 1 : 0
|
||||
}
|
||||
})
|
||||
|
||||
registerRendererMemoryProfileContributor('singleton', collectModuleSingletonSizes)
|
||||
|
||||
export type { AppState } from './types'
|
||||
|
||||
// Why: exposes the Zustand store on window for console debugging (dev) and
|
||||
|
||||
Reference in New Issue
Block a user