diff --git a/src/preload/renderer-heap-statistics-reader.test.ts b/src/preload/renderer-heap-statistics-reader.test.ts index 892a9e865b7..c4640b77934 100644 --- a/src/preload/renderer-heap-statistics-reader.test.ts +++ b/src/preload/renderer-heap-statistics-reader.test.ts @@ -29,7 +29,11 @@ describe('readRendererHeapStatistics', () => { usedHeapKB: 1536, totalHeapKB: 2048, heapLimitKB: 4_292_608, + physicalKB: 2048, + availableKB: 4_000_000, + executableKB: 0, mallocedKB: 64, + peakMallocedKB: 96, blinkAllocatedKB: 1227 }) }) @@ -50,7 +54,11 @@ describe('readRendererHeapStatistics', () => { usedHeapKB: 1536, totalHeapKB: 2048, heapLimitKB: 4_292_608, + physicalKB: 2048, + availableKB: 4_000_000, + executableKB: 0, mallocedKB: 64, + peakMallocedKB: 96, blinkAllocatedKB: undefined }) }) diff --git a/src/preload/renderer-heap-statistics-reader.ts b/src/preload/renderer-heap-statistics-reader.ts index 4d1abecd848..21dd608e1ea 100644 --- a/src/preload/renderer-heap-statistics-reader.ts +++ b/src/preload/renderer-heap-statistics-reader.ts @@ -31,7 +31,11 @@ export function readRendererHeapStatistics( usedHeapKB: heap.usedHeapSize, totalHeapKB: heap.totalHeapSize, heapLimitKB: heap.heapSizeLimit, + physicalKB: heap.totalPhysicalSize, + availableKB: heap.totalAvailableSize, + executableKB: heap.totalHeapSizeExecutable, mallocedKB: heap.mallocedMemory, + peakMallocedKB: heap.peakMallocedMemory, blinkAllocatedKB } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eca968e1b05..9c7361fe7fa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -137,6 +137,7 @@ import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession } from './lib/workspace-session-hydration-keys' +import { capRestoredOpenFilesByWorktree } from './lib/workspace-session-open-file-cap' import { getStartupErrorFallbackUI, hydratePersistedUIAfterStartupRead @@ -963,17 +964,20 @@ function App(): React.JSX.Element { actions.awaitLocalRepoCatalogSettlement() ) if (!cancelled) { + // Why: cap before any slice reads it — editor, tab and tab-group hydration + // all derive from openFilesByWorktree and must agree on the same list. + const restoredSession = capRestoredOpenFilesByWorktree(sessionRead.session) const sessionHydrationOptions = { - additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) + additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(restoredSession) } timeRendererStartupSyncStep('hydrate-session-stores', () => { - actions.hydrateWorkspaceSession(sessionRead.session, { + actions.hydrateWorkspaceSession(restoredSession, { ...sessionHydrationOptions, runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey }) - actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) - actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) - actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateTabsSession(restoredSession, sessionHydrationOptions) + actions.hydrateEditorSession(restoredSession, sessionHydrationOptions) + actions.hydrateBrowserSession(restoredSession, sessionHydrationOptions) }) // Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing. // See docs/cmd-j-empty-query-ordering.md. diff --git a/src/renderer/src/components/editor/editor-content-memory-census.test.ts b/src/renderer/src/components/editor/editor-content-memory-census.test.ts new file mode 100644 index 00000000000..7cc45849d78 --- /dev/null +++ b/src/renderer/src/components/editor/editor-content-memory-census.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { collectRendererMemoryProfileCounts } from '../../lib/renderer-memory-profile' +import { + measureEditorFileContents, + registerEditorContentCensusReader +} from './editor-content-memory-census' + +describe('measureEditorFileContents', () => { + it('counts entries and total characters of the loaded file bodies', () => { + expect( + measureEditorFileContents({ + a: { content: 'abc', isBinary: false }, + b: { content: 'de', isBinary: false } + }) + ).toEqual({ files: 2, chars: 5 }) + }) + + it('tolerates an entry with no content string', () => { + expect( + measureEditorFileContents({ + a: { content: undefined as unknown as string, isBinary: true } + }) + ).toEqual({ files: 1, chars: 0 }) + }) +}) + +describe('editor content memory profile contributor', () => { + it('reports React-held file bodies, which the store-only profile cannot see', () => { + // Why: crash bundles show gigabytes retained with every store collection + // count unchanged. File contents live in useState, outside that profile. + const release = registerEditorContentCensusReader(() => ({ files: 3, chars: 4_000_000 })) + + expect(collectRendererMemoryProfileCounts()).toMatchObject({ + 'editorContent.panels': 1, + 'editorContent.files': 3, + 'editorContent.chars': 4_000_000 + }) + + release() + expect(collectRendererMemoryProfileCounts()).toMatchObject({ + 'editorContent.panels': 0, + 'editorContent.files': 0, + 'editorContent.chars': 0 + }) + }) + + it('sums every mounted panel so a split editor is not undercounted', () => { + const releaseA = registerEditorContentCensusReader(() => ({ files: 2, chars: 100 })) + const releaseB = registerEditorContentCensusReader(() => ({ files: 5, chars: 900 })) + + expect(collectRendererMemoryProfileCounts()).toMatchObject({ + 'editorContent.panels': 2, + 'editorContent.files': 7, + 'editorContent.chars': 1000 + }) + + releaseA() + releaseB() + }) +}) diff --git a/src/renderer/src/components/editor/editor-content-memory-census.ts b/src/renderer/src/components/editor/editor-content-memory-census.ts new file mode 100644 index 00000000000..1181115031f --- /dev/null +++ b/src/renderer/src/components/editor/editor-content-memory-census.ts @@ -0,0 +1,49 @@ +/** + * Sizes of the file bodies each mounted editor panel holds in React state. + * + * Why this is separate from the store profile: renderer-memory-profile only walks + * zustand collections, so the largest strings in the renderer — loaded file + * contents — are structurally invisible when an OOM highwater breadcrumb fires. + */ +import { registerRendererMemoryProfileContributor } from '../../lib/renderer-memory-profile' +import type { FileContent } from './editor-panel-content-types' + +export type EditorContentCensus = { files: number; chars: number } + +const readers = new Set<() => EditorContentCensus>() + +/** Registers one live panel's contents; call the returned function on unmount. */ +export function registerEditorContentCensusReader(read: () => EditorContentCensus): () => void { + readers.add(read) + return () => { + readers.delete(read) + } +} + +export function measureEditorFileContents( + fileContents: Record +): EditorContentCensus { + let files = 0 + let chars = 0 + for (const key in fileContents) { + if (!Object.hasOwn(fileContents, key)) { + continue + } + files += 1 + chars += fileContents[key]?.content?.length ?? 0 + } + return { files, chars } +} + +registerRendererMemoryProfileContributor('editorContent', () => { + let panels = 0 + let files = 0 + let chars = 0 + for (const read of readers) { + const census = read() + panels += 1 + files += census.files + chars += census.chars + } + return { panels, files, chars } +}) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index d7c62b6e6ed..b7368df05b6 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -20,6 +20,10 @@ import { type DiffContent, type FileContent } from './editor-panel-content-types' +import { + measureEditorFileContents, + registerEditorContentCensusReader +} from './editor-content-memory-census' import { canUseChangesModeForFile } from './editor-panel-file-mode' import { isReloadableSingleFileDiffTab, @@ -102,6 +106,15 @@ export function useEditorPanelContentState({ const [diffContents, setDiffContents] = useState>({}) const diffContentsRef = useRef(diffContents) diffContentsRef.current = diffContents + const fileContentsRef = useRef(fileContents) + fileContentsRef.current = fileContents + // Why: file bodies live in React state, which the store-only memory profile + // cannot walk — without this an OOM report never names them. + useEffect( + () => + registerEditorContentCensusReader(() => measureEditorFileContents(fileContentsRef.current)), + [] + ) const fileLoadRetryAttemptsRef = useRef>({}) // Why: per-tab read generations let a forced/external reload supersede an // older in-flight read so a slower stale promise cannot overwrite fresh state. diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 0b93aa15511..84153fd1c8b 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -316,6 +316,43 @@ describe('renderer crash diagnostics', () => { }) }) + it('profiles the V8 space breakdown on highwater but keeps the interval sample lean', () => { + // Why: crash bundles pinned 883MB of growth with every recorded counter + // byte-identical. These four are the only extra shapes Electron exposes. + readHeapStatistics.mockReturnValue({ + usedHeapKB: 400 * KB, + totalHeapKB: 800 * KB, + heapLimitKB: 512 * KB, + physicalKB: 780 * KB, + availableKB: 112 * KB, + executableKB: 9 * KB, + mallocedKB: 3 * KB, + peakMallocedKB: 64 * KB, + blinkAllocatedKB: 7 * KB + }) + vi.stubGlobal('document', { + getElementsByTagName: () => ({ length: 1 }), + querySelectorAll: () => ({ length: 0 }) + }) + + diagnostics.installRendererCrashDiagnostics() + + const named = (name: string): { data: Record } => + recordBreadcrumbMock.mock.calls.find( + ([entry]) => (entry as { name: string }).name === name + )?.[0] as { data: Record } + expect(named('renderer_memory_highwater').data).toMatchObject({ + physicalMB: 780, + availableMB: 112, + executableMB: 9, + peakMallocedMB: 64 + }) + // Why: this one fires every 60s for the whole session; four more numbers + // per sample buys nothing the one-shot profile does not already answer. + expect(named('renderer_memory').data).not.toHaveProperty('physicalMB') + expect(named('renderer_memory').data).not.toHaveProperty('executableMB') + }) + it('omits the Blink field instead of emitting a junk value for it', () => { // Why: `undefined * 1024` is NaN. The breadcrumb must carry no // blinkAllocatedMB at all rather than a meaningless number. diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 90fa45ebba0..ae6e155afee 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -27,6 +27,11 @@ type BrowserPerformanceMemory = { type HeapMetrics = BrowserPerformanceMemory & { mallocedBytes?: number blinkAllocatedBytes?: number + /** Highwater-only shapes; absent on the quantized `performance.memory` path. */ + physicalBytes?: number + availableBytes?: number + executableBytes?: number + peakMallocedBytes?: number exact: boolean } @@ -170,6 +175,12 @@ function recordRendererMemoryHighwater( heapSource: memory.exact ? 'v8' : 'quantized', mallocedMB: toMegabytes(memory.mallocedBytes), blinkAllocatedMB: toMegabytes(memory.blinkAllocatedBytes), + // Why highwater-only: these four separate fragmentation, headroom and compiled + // code from real object retention, and cost nothing at 60s sampling rates. + physicalMB: toMegabytes(memory.physicalBytes), + availableMB: toMegabytes(memory.availableBytes), + executableMB: toMegabytes(memory.executableBytes), + peakMallocedMB: toMegabytes(memory.peakMallocedBytes), domNodes: document.getElementsByTagName('*').length, terminalElements: document.querySelectorAll('.xterm').length, browserWebviews: browserWebviews.browserWebviewCount, @@ -217,6 +228,10 @@ function readHeapMetrics(): HeapMetrics | undefined { totalJSHeapSize: exact.totalHeapKB * BYTES_PER_KILOBYTE, jsHeapSizeLimit: exact.heapLimitKB * BYTES_PER_KILOBYTE, mallocedBytes: exact.mallocedKB * BYTES_PER_KILOBYTE, + physicalBytes: exact.physicalKB * BYTES_PER_KILOBYTE, + availableBytes: exact.availableKB * BYTES_PER_KILOBYTE, + executableBytes: exact.executableKB * BYTES_PER_KILOBYTE, + peakMallocedBytes: exact.peakMallocedKB * BYTES_PER_KILOBYTE, // Why guarded: undefined * 1024 is NaN, which would emit a junk field. blinkAllocatedBytes: exact.blinkAllocatedKB === undefined diff --git a/src/renderer/src/lib/workspace-session-open-file-cap.test.ts b/src/renderer/src/lib/workspace-session-open-file-cap.test.ts new file mode 100644 index 00000000000..f880c43e74f --- /dev/null +++ b/src/renderer/src/lib/workspace-session-open-file-cap.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import type { PersistedOpenFile, WorkspaceSessionState } from '../../../shared/types' +import { + capRestoredOpenFilesByWorktree, + MAX_RESTORED_OPEN_FILES_PER_WORKSPACE +} from './workspace-session-open-file-cap' + +function persistedFile(index: number, overrides: Partial = {}) { + return { + filePath: `/repo/src/file-${index}.ts`, + relativePath: `src/file-${index}.ts`, + worktreeId: 'wt-1', + language: 'typescript', + ...overrides + } satisfies PersistedOpenFile +} + +function sessionWith( + openFilesByWorktree: Record, + activeFileIdByWorktree?: Record +): WorkspaceSessionState { + return { openFilesByWorktree, activeFileIdByWorktree } as unknown as WorkspaceSessionState +} + +describe('capRestoredOpenFilesByWorktree', () => { + it('returns the same session object when every workspace is under the cap', () => { + const session = sessionWith({ + 'wt-1': Array.from({ length: MAX_RESTORED_OPEN_FILES_PER_WORKSPACE }, (_, i) => + persistedFile(i) + ) + }) + + expect(capRestoredOpenFilesByWorktree(session)).toBe(session) + }) + + it('keeps the newest entries when a workspace exceeds the cap', () => { + const total = MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + 50 + const session = sessionWith({ + 'wt-1': Array.from({ length: total }, (_, i) => persistedFile(i)) + }) + + const kept = capRestoredOpenFilesByWorktree(session).openFilesByWorktree?.['wt-1'] ?? [] + + expect(kept).toHaveLength(MAX_RESTORED_OPEN_FILES_PER_WORKSPACE) + expect(kept[0]?.filePath).toBe(persistedFile(50).filePath) + expect(kept.at(-1)?.filePath).toBe(persistedFile(total - 1).filePath) + }) + + it('never drops a file with an unsaved draft, even from the oldest entries', () => { + const total = MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + 50 + const files = Array.from({ length: total }, (_, i) => + persistedFile(i, i === 0 ? { dirtyDraftContent: 'unsaved work' } : {}) + ) + + const kept = capRestoredOpenFilesByWorktree(sessionWith({ 'wt-1': files })) + .openFilesByWorktree?.['wt-1'] + + expect(kept?.[0]?.dirtyDraftContent).toBe('unsaved work') + expect(kept).toHaveLength(MAX_RESTORED_OPEN_FILES_PER_WORKSPACE) + }) + + it('keeps every dirty file even when they alone exceed the cap', () => { + const total = MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + 20 + const files = Array.from({ length: total }, (_, i) => + persistedFile(i, { dirtyDraftContent: `draft ${i}` }) + ) + + const kept = capRestoredOpenFilesByWorktree(sessionWith({ 'wt-1': files })) + .openFilesByWorktree?.['wt-1'] + + expect(kept).toHaveLength(total) + }) + + it('keeps the focused tab addressed by its raw path id', () => { + const total = MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + 50 + const files = Array.from({ length: total }, (_, i) => persistedFile(i)) + const session = sessionWith({ 'wt-1': files }, { 'wt-1': files[0].filePath }) + + const kept = capRestoredOpenFilesByWorktree(session).openFilesByWorktree?.['wt-1'] ?? [] + + expect(kept[0]?.filePath).toBe(files[0].filePath) + }) + + it('keeps the focused tab addressed by its owner-qualified id', () => { + const total = MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + 50 + const files = Array.from({ length: total }, (_, i) => persistedFile(i)) + const ownedId = `editor:wt-1:local:${encodeURIComponent(files[0].filePath)}` + const session = sessionWith({ 'wt-1': files }, { 'wt-1': ownedId }) + + const kept = capRestoredOpenFilesByWorktree(session).openFilesByWorktree?.['wt-1'] ?? [] + + expect(kept[0]?.filePath).toBe(files[0].filePath) + }) + + it('caps each workspace independently and leaves untouched ones referentially stable', () => { + const small = [persistedFile(1), persistedFile(2)] + const large = Array.from({ length: MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + 5 }, (_, i) => + persistedFile(i) + ) + const session = sessionWith({ 'wt-small': small, 'wt-large': large }) + + const capped = capRestoredOpenFilesByWorktree(session) + + expect(capped.openFilesByWorktree?.['wt-small']).toBe(small) + expect(capped.openFilesByWorktree?.['wt-large']).toHaveLength( + MAX_RESTORED_OPEN_FILES_PER_WORKSPACE + ) + }) + + it('tolerates a session with no persisted editor files', () => { + const session = { activeFileIdByWorktree: {} } as unknown as WorkspaceSessionState + + expect(capRestoredOpenFilesByWorktree(session)).toBe(session) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-open-file-cap.ts b/src/renderer/src/lib/workspace-session-open-file-cap.ts new file mode 100644 index 00000000000..26721829046 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-open-file-cap.ts @@ -0,0 +1,70 @@ +import type { PersistedOpenFile, WorkspaceSessionState } from '../../../shared/types' + +/** + * Restore-time bound on persisted editor tabs, per workspace. + * + * Why: `openFilesByWorktree` is append-only and never pruned, so it accumulates + * across every restart. Crash bundles show sessions restoring 3,711 editor tabs + * into one renderer. 100 is far above any real working set. + */ +export const MAX_RESTORED_OPEN_FILES_PER_WORKSPACE = 100 + +/** + * Applied to the session once, before any slice hydrates it: the editor, tab and + * tab-group slices all derive their state from `openFilesByWorktree`, so capping + * it here keeps them consistent instead of leaving orphaned tabs behind. + */ +export function capRestoredOpenFilesByWorktree( + session: WorkspaceSessionState +): WorkspaceSessionState { + const openFilesByWorktree = session.openFilesByWorktree + if (!openFilesByWorktree) { + return session + } + + let capped: Record | null = null + for (const [workspaceKey, files] of Object.entries(openFilesByWorktree)) { + if (files.length <= MAX_RESTORED_OPEN_FILES_PER_WORKSPACE) { + continue + } + capped ??= { ...openFilesByWorktree } + capped[workspaceKey] = selectRestorableOpenFiles( + files, + session.activeFileIdByWorktree?.[workspaceKey] + ) + } + + return capped ? { ...session, openFilesByWorktree: capped } : session +} + +function selectRestorableOpenFiles( + files: readonly PersistedOpenFile[], + activeFileId: string | null | undefined +): PersistedOpenFile[] { + const keep = new Set() + files.forEach((file, index) => { + // Why: an unsaved draft is unrecoverable once dropped, and losing the tab the + // user left focused is the one omission they would notice immediately. + if (file.dirtyDraftContent !== undefined || addressesFile(activeFileId, file.filePath)) { + keep.add(index) + } + }) + // Why: newly opened files are appended, so the tail is the most recent working set. + for ( + let index = files.length - 1; + index >= 0 && keep.size < MAX_RESTORED_OPEN_FILES_PER_WORKSPACE; + index -= 1 + ) { + keep.add(index) + } + return files.filter((_, index) => keep.has(index)) +} + +// Why: hydration keys a file either by its raw path or by an owner-qualified id +// whose final segment is the encoded path; both forms reach activeFileIdByWorktree. +function addressesFile(activeFileId: string | null | undefined, filePath: string): boolean { + if (!activeFileId) { + return false + } + return activeFileId === filePath || activeFileId.endsWith(`:${encodeURIComponent(filePath)}`) +} diff --git a/src/renderer/src/store/store-listener-census.test.ts b/src/renderer/src/store/store-listener-census.test.ts index bfbe67c1a13..3f4ae3a384c 100644 --- a/src/renderer/src/store/store-listener-census.test.ts +++ b/src/renderer/src/store/store-listener-census.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import { createStore } from 'zustand/vanilla' import { useStore } from 'zustand' +import { collectRendererMemoryProfileCounts } from '../lib/renderer-memory-profile' import { installStoreListenerCensus, readStoreListenerCount } from './store-listener-census' type CensusState = { n: number } @@ -76,6 +77,18 @@ describe('store listener census', () => { expect(readStoreListenerCount()).toBe(0) }) + it('reports the live count into the renderer memory profile', () => { + // Why: the count was already computed and then discarded. OOM crash reports + // need it to tell a subscriber leak apart from a data leak. + const { api } = createCensusStore() + const unsubscribe = api.subscribe(() => undefined) + + expect(collectRendererMemoryProfileCounts()).toMatchObject({ 'storeSubscribers.live': 1 }) + + unsubscribe() + expect(collectRendererMemoryProfileCounts()).toMatchObject({ 'storeSubscribers.live': 0 }) + }) + it('still delivers state updates to a counted listener', () => { const { api } = createCensusStore() let seen = 0 diff --git a/src/renderer/src/store/store-listener-census.ts b/src/renderer/src/store/store-listener-census.ts index c8a19ad88ee..95840453832 100644 --- a/src/renderer/src/store/store-listener-census.ts +++ b/src/renderer/src/store/store-listener-census.ts @@ -1,3 +1,8 @@ +import { + registerRendererMemoryProfileContributor, + type RendererMemoryProfileCounts +} from '../lib/renderer-memory-profile' + /** * Live count of zustand store subscribers, for the typing-latency census. * @@ -18,6 +23,13 @@ type StoreListenerCensusApi = { let liveListenerCount: number | null = null +// Why: an OOM report cannot otherwise separate a subscriber leak (components that +// never unmount) from a data leak, and the count is already being computed. +registerRendererMemoryProfileContributor('storeSubscribers', (): RendererMemoryProfileCounts => { + const live = readStoreListenerCount() + return live === null ? {} : { live } +}) + export function readStoreListenerCount(): number | null { return liveListenerCount } diff --git a/src/shared/renderer-heap-statistics.ts b/src/shared/renderer-heap-statistics.ts index 4dfc5fbcd07..3bb7eb895a9 100644 --- a/src/shared/renderer-heap-statistics.ts +++ b/src/shared/renderer-heap-statistics.ts @@ -9,13 +9,25 @@ * works in a sandboxed, context-isolated preload. * * Electron reports these in kilobytes; we keep that unit unconverted here. + * + * No per-space breakdown: `v8.getHeapSpaceStatistics()` needs the `v8` module, + * which a sandboxed preload cannot require. These are every field Electron's own + * `process.getHeapStatistics()` exposes. */ export type RendererHeapStatistics = { usedHeapKB: number totalHeapKB: number heapLimitKB: number + /** Committed vs reserved: separates fragmentation from real retention. */ + physicalKB: number + /** Headroom left before the OOM abort, which `heapLimitKB` alone cannot track. */ + availableKB: number + /** V8 code space — the one growth shape (compiled code) no JS counter reveals. */ + executableKB: number /** Off-heap V8 allocations, which `usedJSHeapSize` never included. */ mallocedKB: number + /** High-water mark for `mallocedKB`, which only ever reports the instant value. */ + peakMallocedKB: number /** * Blink's own allocator (DOM, layout), invisible to the V8 heap counters. * Optional: supplementary, so its absence must never discard the V8 numbers.