diff --git a/.github/scripts/backend-memory-report.mts b/.github/scripts/backend-memory-report.mts index 77ea7883e6..9aae10d887 100644 --- a/.github/scripts/backend-memory-report.mts +++ b/.github/scripts/backend-memory-report.mts @@ -54,7 +54,7 @@ const metrics = [ function formatMemoryMb(valueKiB: number | null | undefined) { if (valueKiB == null) return '-'; - return `${util.formatNumber(valueKiB / 1024)} MB`; + return `${util.formatNumber(valueKiB / 1000)} MB`; } function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) { @@ -79,8 +79,8 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', ]; - function formatDeltaMemory(diffKiB: number) { - return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB); + function formatDeltaMemory(deltaKiB: number) { + return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold } for (const metric of metrics) { @@ -91,7 +91,7 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: const headSpread = getSampleSpread(head, phase, metric); const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); const percent = summary.median * 100 / baseValue; - const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`); } @@ -183,7 +183,7 @@ function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head const headValue = getJsFootprintValue(head, 'afterRequest', key); if (baseValue == null || headValue == null) continue; - lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(formatter(headValue - baseValue), headValue - baseValue)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`); + lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`); } return lines.join('\n'); @@ -278,7 +278,7 @@ function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, hea ]; for (const packageSummary of increases) { - lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(util.formatBytes(packageSummary.sourceBytes - packageSummary.baseSourceBytes), packageSummary.sourceBytes - packageSummary.baseSourceBytes)} | ${util.formatColoredDelta(util.formatNumber(packageSummary.modules - packageSummary.baseModules), packageSummary.modules - packageSummary.baseModules)} |`); + lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`); } return lines.join('\n'); @@ -406,15 +406,15 @@ function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: type const headValue = getMemoryValue(head, phase, metric); if (baseValue == null || headValue == null) return false; - const diff = headValue - baseValue; - if (diff <= 0) return false; + const delta = headValue - baseValue; + if (delta <= 0) return false; const baseSpread = getSampleSpread(base, phase, metric); const headSpread = getSampleSpread(head, phase, metric); if (baseSpread == null || headSpread == null) return true; const combinedSpread = Math.hypot(baseSpread, headSpread); - return diff > combinedSpread * 3; + return delta > combinedSpread * 3; } const warningMetric = getWarningMetric(base, head); diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 7bbc19ce0b..4d6dd0a0bc 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -299,13 +299,13 @@ function renderVisualizerSummaryTable(before: ReturnType`, ``, `Δ`, - ...summary.map((key) => `${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}`), - ...metrics.map((key) => `${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}`), + ...summary.map((key) => `${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}`), + ...metrics.map((key) => `${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}`), ``, ``, `Δ (%)`, - ...summary.map((key) => `${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}`), - ...metrics.map((key) => `${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}`), + ...summary.map((key) => `${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}`), + ...metrics.map((key) => `${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}`), ``, ``, ``, @@ -357,16 +357,16 @@ function chunkMarkdownTable(rows: ReturnType, tot '| --- | ---: | ---: | ---: | ---: |', ]; if (total != null) { - lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`); + lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); lines.push('| | | | | |'); } for (const row of rows) { if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } return lines.join('\n'); diff --git a/.github/scripts/heap-snapshot-util.mts b/.github/scripts/heap-snapshot-util.mts index d6bb6ee3c7..be105de45d 100644 --- a/.github/scripts/heap-snapshot-util.mts +++ b/.github/scripts/heap-snapshot-util.mts @@ -63,20 +63,20 @@ export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnap const percent = summary.median * 100 / baseValue; if (category === 'total') { - const deltaMedian = `${util.formatDeltaBytes(summary.median)}
${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = `${util.formatDeltaBytes(summary.median, 1000)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; const baseText = `${util.formatBytes(baseValue)}
± ${util.formatBytes(baseSpread)}`; const headText = `${util.formatBytes(headValue)}
± ${util.formatBytes(headSpread)}`; const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; - lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`); + lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 1000)} | ${util.formatDeltaBytes(summary.max, 1000)} |`); lines.push('| | | | | | | |'); } else { - const deltaMedian = util.formatDeltaBytes(summary.median); + const deltaMedian = util.formatDeltaBytes(summary.median, 1000); const baseText = util.formatBytes(baseValue); const headText = util.formatBytes(headValue); const basePercent = util.formatPercent((baseValue * 100) / baseTotal); const headPercent = util.formatPercent((headValue * 100) / headTotal); const metricText = `
$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**${basePercent} → ${headPercent}
`; - lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`); + lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 1000)} | ${util.formatDeltaBytes(summary.max, 1000)} |`); } } diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts index bb3b1d9401..fb65d9191d 100644 --- a/.github/scripts/utility.mts +++ b/.github/scripts/utility.mts @@ -94,11 +94,12 @@ export function escapeLatex(text: string) { .replaceAll('%', '\\%'); } -export function formatColoredDelta(text: string, delta: number) { - if (delta === 0) return text; - const color = delta > 0 ? 'orange' : 'green'; +export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return text(0); const sign = delta > 0 ? '+' : '-'; - return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`; + if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`; + const color = delta > 0 ? 'orange' : 'green'; + return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`; } const numberFormatter = new Intl.NumberFormat('en-US', { @@ -114,8 +115,8 @@ export function formatBytes(value: number) { const units = ['B', 'KB', 'MB', 'GB']; let unitIndex = 0; let size = value; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000; unitIndex += 1; } @@ -123,35 +124,34 @@ export function formatBytes(value: number) { return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; } -export function calcAndFormatDeltaNumber(before: number, after: number) { +export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) { if (before == null || after == null) return '-'; const delta = after - before; - return formatColoredDelta(formatNumber(Math.abs(delta)), delta); + return formatColoredDelta(delta, v => formatNumber(v), colorThreshold); } -export function formatDeltaBytes(deltaBytes: number) { - return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes); +export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { + return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold); } -export function calcAndFormatDeltaBytes(before: number, after: number) { +export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) { if (before == null || after == null) return '-'; const delta = after - before; - return formatDeltaBytes(delta); + return formatDeltaBytes(delta, colorThreshold); } export function formatPercent(value: number) { return `${formatNumber(value)}%`; } -export function formatDeltaPercent(deltaPercent: number) { - if (deltaPercent === 0) return '0%'; - return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent); +export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { + return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold); } -export function calcAndFormatDeltaPercent(before: number, after: number) { +export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) { if (before == null || before === 0 || after == null || after === 0) return '-'; const delta = after - before; - return formatDeltaPercent(delta / before * 100); + return formatDeltaPercent(delta / before * 100, colorThreshold); } export function commandName(command: string) { diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts index 3032f1ae15..74b84d998f 100644 --- a/packages/backend/scripts/measure-memory.mts +++ b/packages/backend/scripts/measure-memory.mts @@ -46,33 +46,20 @@ const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; -const typedArrayNames = new Set([ - 'ArrayBuffer', - 'SharedArrayBuffer', - 'DataView', - 'Int8Array', - 'Uint8Array', - 'Uint8ClampedArray', - 'Int16Array', - 'Uint16Array', - 'Int32Array', - 'Uint32Array', - 'Float16Array', - 'Float32Array', - 'Float64Array', - 'BigInt64Array', - 'BigUint64Array', - 'system / JSArrayBufferData', -]); - -const otherJsNodeTypes = new Set([ - 'object', - 'closure', - 'regexp', - 'number', - 'symbol', - 'bigint', -]); +type GcMessage = 'gc ok' | 'gc unavailable'; +type RuntimeMemoryUsageMessage = { + type: 'memory usage'; + value: NodeJS.MemoryUsage; +}; +type HeapSnapshotMessage = { + type: 'heap snapshot'; + path?: string; +}; +type HeapSnapshotErrorMessage = { + type: 'heap snapshot error'; + message: string; +}; +type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; function parseMemoryFile(content: string, keys: KS, path: string, required: boolean): Record { const result = {} as Record; @@ -92,29 +79,6 @@ function bytesToKiB(value: number) { return Math.round(value / 1024); } -function isTypedArrayNode(type, name) { - return typedArrayNames.has(name) || - (type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray'))); -} - -function isSystemNode(type, name) { - return type === 'hidden' || - type === 'synthetic' || - type === 'object shape' || - name.startsWith('system /') || - name.startsWith('(system '); -} - -function classifyHeapSnapshotNode(type, name): keyof typeof heapSnapshotCategory { - if (type === 'code') return 'code'; - if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'strings'; - if (isTypedArrayNode(type, name)) return 'typedArrays'; - if (type === 'array' || (type === 'object' && name === 'Array')) return 'jsArrays'; - if (isSystemNode(type, name)) return 'systemObjects'; - if (otherJsNodeTypes.has(type)) return 'otherJsObjects'; - return 'otherNonJsObjects'; -} - function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') { const label = String(value ?? '').replace(/\s+/g, ' ').trim(); if (label === '') return fallback; @@ -126,17 +90,13 @@ function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCatego if (category === 'strings') return type; if (category === 'jsArrays') { - if (type === 'array') return 'array nodes'; + if (type === 'array elements') return 'Array elements'; if (type === 'object' && name === 'Array') return 'Array objects'; return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); } if (category === 'typedArrays') { if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; - if (name === 'Uint8Array') return 'Uint8Array / Buffer'; - if (typedArrayNames.has(name)) return name; - if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer'; - if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray'; return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); } @@ -152,6 +112,7 @@ function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCatego } if (category === 'otherNonJsObjects') { + if (type === 'extra native bytes') return 'Extra native bytes'; if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); } @@ -189,32 +150,56 @@ function collapseHeapSnapshotBreakdown(breakdowns: Record [category, 0])) as Record; } - const fieldCount = nodeFields.length; + const nodeFieldCount = nodeFields.length; + const edgeFieldCount = edgeFields.length; + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; const categories = createEmptyHeapSnapshotCategoryMap(); const nodeCounts = createEmptyHeapSnapshotCategoryMap(); const breakdowns = Object.fromEntries( @@ -227,17 +212,104 @@ function analyzeHeapSnapshot(snapshot) { map[key] = (map[key] ?? 0) + value; } - for (let offset = 0; offset < nodes.length; offset += fieldCount) { - const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown'; - const name = strings[nodes[offset + nameOffset]] ?? ''; - const selfSize = nodes[offset + selfSizeOffset] ?? 0; - const category = classifyHeapSnapshotNode(type, name); + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { + const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } - categories[category] += selfSize; + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { + if (value <= 0) return; + categories[category] += value; + addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); + if (nodeIndex != null) nodeCounts[category]++; + } + + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; + if (edgeType !== internalEdgeType) continue; + + const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; + if (edgeName !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; categories.total += selfSize; - nodeCounts[category]++; nodeCounts.total++; - addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize); + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); + continue; + } + + if (typeId === nativeType) { + if (name === 'system / JSArrayBufferData') { + addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); + } else { + addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); + } + continue; + } + + if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name, nodeIndex); + continue; + } + + if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name, nodeIndex); + continue; + } + + if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); + addJsArrayElementSize(nodeIndex); + continue; + } + } + + categories.total += extraNativeBytes; + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + if (name === 'Array') continue; + + const type = nodeTypeNames[typeId] ?? 'unknown'; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); } return { @@ -259,14 +331,32 @@ async function getSmapsRollupMemoryUsage(pid: number) { return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false); } -function waitForMessage(serverProcess: ChildProcess, predicate: (message: any) => boolean, description: string, timeout = IPC_TIMEOUT) { - return new Promise((resolve, reject) => { +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object'; +} + +function isGcMessage(message: unknown): message is GcMessage { + return message === 'gc ok' || message === 'gc unavailable'; +} + +function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { + return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); +} + +function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { + if (!isRecord(message)) return false; + if (message.type === 'heap snapshot') return true; + return message.type === 'heap snapshot error' && typeof message.message === 'string'; +} + +function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) { + return new Promise((resolve, reject) => { const timer = globalThis.setTimeout(() => { serverProcess.off('message', onMessage); reject(new Error(`Timed out waiting for ${description}`)); }, timeout); - const onMessage = (message: any) => { + const onMessage = (message: unknown) => { if (!predicate(message)) return; globalThis.clearTimeout(timer); serverProcess.off('message', onMessage); @@ -280,7 +370,7 @@ function waitForMessage(serverProcess: ChildProcess, predicate: (message: any) = async function getRuntimeMemoryUsage(serverProcess: ChildProcess) { const response = waitForMessage( serverProcess, - message => message != null && typeof message === 'object' && message.type === 'memory usage', + isRuntimeMemoryUsageMessage, 'memory usage', ); @@ -303,7 +393,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'), + isHeapSnapshotResponseMessage, 'heap snapshot', HEAP_SNAPSHOT_TIMEOUT, ); @@ -385,7 +475,7 @@ async function measureMemory() { async function triggerGc() { const ok = waitForMessage( serverProcess, - message => message === 'gc ok' || message === 'gc unavailable', + isGcMessage, 'GC completion', );