From d6ae22c053b4c26465f23ad1c5b08a8c7412dcd9 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 18 Jul 2026 10:33:24 +0000 Subject: [PATCH 1/6] feature: add tracked everything measure --- README.md | 13 + packages/cli/src/parts/ParseArgv/ParseArgv.ts | 2 + packages/cli/test/ParseArgv.test.ts | 8 + .../CreateEverythingWrapperPlugin.ts | 166 +++++++++++++ .../PrepareTrackedVscode.ts | 2 + .../src/parts/Transform/Transform.ts | 117 +++++++++ .../TransformCodeWithEverythingTracking.ts | 29 +++ ...ransformCodeWithEverythingTracking.test.ts | 96 ++++++++ .../launch-worker/src/parts/Launch/Launch.ts | 3 + packages/launch-worker/test/Launch.test.ts | 5 + .../CompareTrackedEverything.ts | 70 ++++++ .../GetTrackedEverything.ts | 67 ++++++ .../src/parts/MeasureId/MeasureId.ts | 1 + .../MeasureTrackedEverything.ts | 87 +++++++ .../src/parts/Measures/Measures.ts | 1 + .../test/GetMeasure.test.ts | 13 + .../test/MeasureTrackedEverything.test.ts | 110 +++++++++ .../src/TrackedEverythingApp.tsx | 224 ++++++++++++++++++ .../src/TrackedEverythingTimeline.tsx | 55 +++++ .../src/trackedEverythingModel.ts | 106 +++++++++ .../src/trackedEverythingTypes.ts | 34 +++ .../src/trackedEverythingWorker.ts | 83 +++++++ 22 files changed, 1292 insertions(+) create mode 100644 packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts create mode 100644 packages/function-tracker/src/parts/TransformCodeWithEverythingTracking/TransformCodeWithEverythingTracking.ts create mode 100644 packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts create mode 100644 packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts create mode 100644 packages/memory-leak-finder/src/parts/GetTrackedEverything/GetTrackedEverything.ts create mode 100644 packages/memory-leak-finder/src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts create mode 100644 packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts create mode 100644 packages/visualizations/src/TrackedEverythingApp.tsx create mode 100644 packages/visualizations/src/TrackedEverythingTimeline.tsx create mode 100644 packages/visualizations/src/trackedEverythingModel.ts create mode 100644 packages/visualizations/src/trackedEverythingTypes.ts create mode 100644 packages/visualizations/src/trackedEverythingWorker.ts diff --git a/README.md b/README.md index df65ded278..5b8305e8d7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,19 @@ npm run e2e ## Measures +### TrackedEverything + +Records every source-observable value creation in the VS Code renderer from the +first instrumented startup script through the end of the measured scenario. +The result includes a lossless allocation-order stream and an interactive +timeline city. This exhaustive instrumentation substantially changes runtime +performance and memory use and does not include hidden V8 or Chromium +allocations. + +```sh +node packages/cli/bin/test.js --cwd packages/e2e --measure tracked-everything --only base +``` + ### MemoryCity Captures allocation-aware renderer and extension-host heap snapshots, computes diff --git a/packages/cli/src/parts/ParseArgv/ParseArgv.ts b/packages/cli/src/parts/ParseArgv/ParseArgv.ts index 8ac0b2717a..3c46ad8c59 100644 --- a/packages/cli/src/parts/ParseArgv/ParseArgv.ts +++ b/packages/cli/src/parts/ParseArgv/ParseArgv.ts @@ -384,6 +384,8 @@ const parseTrackFunctions = (argv: readonly string[]): boolean => { measure === 'trackedAllocationsFromStart' || measure === 'tracked-allocation-timeline' || measure === 'trackedAllocationTimeline' || + measure === 'tracked-everything' || + measure === 'trackedEverything' || measure === 'tracked-timeouts' || measure === 'trackedTimeouts' ) diff --git a/packages/cli/test/ParseArgv.test.ts b/packages/cli/test/ParseArgv.test.ts index 39f47ae9f8..185459a67b 100644 --- a/packages/cli/test/ParseArgv.test.ts +++ b/packages/cli/test/ParseArgv.test.ts @@ -597,6 +597,14 @@ test('parseArgv - tracked allocation timeline enables tracking transform', () => expect(options.trackFunctions).toBe(true) }) +test('parseArgv - tracked everything enables tracking transform', () => { + for (const measure of ['tracked-everything', 'trackedEverything']) { + const options = ParseArgv.parseArgv('linux', 'x64', ['--measure', measure]) + expect(options.measure).toBe(measure) + expect(options.trackFunctions).toBe(true) + } +}) + test('parseArgv - tracked timeouts enables tracking transform', () => { const argv = ['--measure', 'tracked-timeouts'] const options = ParseArgv.parseArgv('linux', 'x64', argv) diff --git a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts new file mode 100644 index 0000000000..e9e4233c63 --- /dev/null +++ b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts @@ -0,0 +1,166 @@ +import type { NodePath, Visitor } from '@babel/core' +import * as t from '@babel/types' + +export interface CreateEverythingWrapperPluginOptions { + readonly scriptId?: number | string +} + +const helperName = '__vscodeMemoryLeakFinderTrackEverything' + +const getLocationNodes = (node: t.Node, scriptIdNode: t.Expression): readonly t.Expression[] => { + return [ + scriptIdNode, + t.numericLiteral(node.loc?.start.line ?? -1), + t.numericLiteral(node.loc?.start.column ?? -1), + ] +} + +const getCalleeName = (node: t.Expression | t.V8IntrinsicIdentifier): string => { + if (t.isIdentifier(node)) { + return node.name + } + if (t.isMemberExpression(node) && t.isIdentifier(node.property)) { + return node.property.name + } + return 'Instance' +} + +const isGeneratedHelperCall = (node: t.CallExpression | t.OptionalCallExpression): boolean => { + return t.isIdentifier(node.callee) && node.callee.name === helperName +} + +const shouldTrackLiteral = (path: NodePath): boolean => { + const parent = path.parentPath + if (!parent) { + return true + } + if (parent.isExpressionStatement() && Boolean((parent.node as t.ExpressionStatement & { directive?: string }).directive)) { + return false + } + if ( + (parent.isObjectProperty() || parent.isObjectMethod() || parent.isClassProperty() || parent.isClassMethod()) && + parent.node.key === path.node && + !parent.node.computed + ) { + return false + } + if (parent.isMemberExpression() && parent.node.property === path.node && !parent.node.computed) { + return false + } + return !( + parent.isImportDeclaration() || + parent.isExportAllDeclaration() || + parent.isExportNamedDeclaration() || + parent.isImportAttribute() + ) +} + +const getMethodLocations = (node: t.ObjectExpression | t.ClassExpression | t.ClassDeclaration): t.ArrayExpression => { + const methods: t.Node[] = t.isObjectExpression(node) + ? node.properties.filter((property) => t.isObjectMethod(property)) + : node.body.body.filter( + (element) => + (t.isClassMethod(element) || t.isClassPrivateMethod(element)) && + !(t.isClassMethod(element) && element.kind === 'constructor'), + ) + return t.arrayExpression( + methods.map((method) => + t.arrayExpression([ + t.numericLiteral(method.loc?.start.line ?? -1), + t.numericLiteral(method.loc?.start.column ?? -1), + ]), + ), + ) +} + +export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPluginOptions): Visitor => { + const { scriptId = 123 } = options + const scriptIdNode = typeof scriptId === 'number' ? t.numericLiteral(scriptId) : t.stringLiteral(scriptId) + + const wrap = ( + path: NodePath, + hint: string, + identityMode: 'created' | 'observed' = 'created', + methodLocations: t.ArrayExpression = t.arrayExpression(), + ): void => { + const node = path.node + const wrapped = t.callExpression(t.identifier(helperName), [ + node, + ...getLocationNodes(node, scriptIdNode), + t.stringLiteral(hint), + t.stringLiteral(identityMode), + methodLocations, + ]) + path.replaceWith(wrapped) + path.skip() + } + + const registerDeclaration = (path: NodePath, hint: string): void => { + const { id } = path.node + const { node } = path + if (!id) { + return + } + const methods = t.isClassDeclaration(node) ? getMethodLocations(node) : t.arrayExpression() + const call = t.expressionStatement( + t.callExpression(t.identifier(helperName), [ + t.identifier(id.name), + ...getLocationNodes(node, scriptIdNode), + t.stringLiteral(hint), + t.stringLiteral('created'), + methods, + ]), + ) + path.insertAfter(call) + } + + const literalExit = (path: NodePath, hint: string): void => { + if (shouldTrackLiteral(path)) { + wrap(path, hint) + } + } + + return { + ArrayExpression: { exit: (path: NodePath) => wrap(path, 'Array') }, + ArrowFunctionExpression: { exit: (path: NodePath) => wrap(path, 'Function') }, + BigIntLiteral: { exit: (path: NodePath) => literalExit(path, 'BigInt') }, + BinaryExpression: { exit: (path: NodePath) => wrap(path, 'Dynamic') }, + BooleanLiteral: { exit: (path: NodePath) => literalExit(path, 'Boolean') }, + CallExpression: { + exit: (path: NodePath) => { + if (!isGeneratedHelperCall(path.node)) { + wrap(path, 'Dynamic', 'observed') + } + }, + }, + ClassDeclaration: { exit: (path: NodePath) => registerDeclaration(path, 'Class') }, + ClassExpression: { + exit: (path: NodePath) => wrap(path, 'Class', 'created', getMethodLocations(path.node)), + }, + FunctionDeclaration: { exit: (path: NodePath) => registerDeclaration(path, 'Function') }, + FunctionExpression: { exit: (path: NodePath) => wrap(path, 'Function') }, + NewExpression: { + exit: (path: NodePath) => wrap(path, getCalleeName(path.node.callee)), + }, + NullLiteral: { exit: (path: NodePath) => literalExit(path, 'Null') }, + NumericLiteral: { exit: (path: NodePath) => literalExit(path, 'Number') }, + ObjectExpression: { + exit: (path: NodePath) => wrap(path, 'Object', 'created', getMethodLocations(path.node)), + }, + OptionalCallExpression: { + exit: (path: NodePath) => { + if (!isGeneratedHelperCall(path.node)) { + wrap(path as NodePath, 'Dynamic', 'observed') + } + }, + }, + RegExpLiteral: { exit: (path: NodePath) => wrap(path, 'RegExp') }, + StringLiteral: { exit: (path: NodePath) => literalExit(path, 'String') }, + TaggedTemplateExpression: { + exit: (path: NodePath) => wrap(path, 'Dynamic', 'observed'), + }, + TemplateLiteral: { exit: (path: NodePath) => wrap(path, 'String') }, + UnaryExpression: { exit: (path: NodePath) => wrap(path, 'Dynamic') }, + UpdateExpression: { exit: (path: NodePath) => wrap(path, 'Number') }, + } +} diff --git a/packages/function-tracker/src/parts/PrepareTrackedVscode/PrepareTrackedVscode.ts b/packages/function-tracker/src/parts/PrepareTrackedVscode/PrepareTrackedVscode.ts index 3515caf086..ef5d3b0b26 100644 --- a/packages/function-tracker/src/parts/PrepareTrackedVscode/PrepareTrackedVscode.ts +++ b/packages/function-tracker/src/parts/PrepareTrackedVscode/PrepareTrackedVscode.ts @@ -32,8 +32,10 @@ const transformerFiles = [ join(import.meta.dirname, '..', 'Transform', 'Transform.ts'), join(import.meta.dirname, '..', 'TransformCodeWithTracking', 'TransformCodeWithTracking.ts'), join(import.meta.dirname, '..', 'TransformCodeWithAllocationTracking', 'TransformCodeWithAllocationTracking.ts'), + join(import.meta.dirname, '..', 'TransformCodeWithEverythingTracking', 'TransformCodeWithEverythingTracking.ts'), join(import.meta.dirname, '..', 'CreateFunctionWrapperPlugin', 'CreateFunctionWrapperPlugin.ts'), join(import.meta.dirname, '..', 'CreateAllocationWrapperPlugin', 'CreateAllocationWrapperPlugin.ts'), + join(import.meta.dirname, '..', 'CreateEverythingWrapperPlugin', 'CreateEverythingWrapperPlugin.ts'), ] const excludedDirectoryNames = new Set(['.build', '.cache', '.git', '.vscode-test']) diff --git a/packages/function-tracker/src/parts/Transform/Transform.ts b/packages/function-tracker/src/parts/Transform/Transform.ts index 8e6a499344..2543336fcf 100644 --- a/packages/function-tracker/src/parts/Transform/Transform.ts +++ b/packages/function-tracker/src/parts/Transform/Transform.ts @@ -1,5 +1,6 @@ import type { TransformOptions } from '../Types/Types.ts' import { transformCodeWithAllocationTracking } from '../TransformCodeWithAllocationTracking/TransformCodeWithAllocationTracking.ts' +import { transformCodeWithEverythingTracking } from '../TransformCodeWithEverythingTracking/TransformCodeWithEverythingTracking.ts' import { transformCodeWithTracking } from '../TransformCodeWithTracking/TransformCodeWithTracking.ts' const PREAMBLE_CODE = `(() => { @@ -112,6 +113,118 @@ const ALLOCATION_PREAMBLE_CODE = `(() => { })(); ` +const EVERYTHING_PREAMBLE_CODE = `(() => { + if(typeof globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingMetadata === 'function'){ + return + } + + const CHUNK_SIZE = 65536 + const TIME_MARK_INTERVAL = 1024 + const chunks = [] + let currentChunk = new Uint32Array(CHUNK_SIZE) + let currentChunkLength = 0 + let eventCount = 0 + const sites = [] + const siteIds = Object.create(null) + const seenIdentities = new WeakSet() + const seenSymbols = new Set() + const timeMarks = [] + const now = () => typeof performance === 'object' && typeof performance.now === 'function' ? performance.now() : Date.now() + const startedAt = now() + + const getType = (value, hint) => { + if(hint !== 'Dynamic'){ + return hint + } + if(value === null){ + return 'Null' + } + const valueType = typeof value + if(valueType === 'object'){ + return Array.isArray(value) ? 'Array' : 'Object' + } + return valueType[0].toUpperCase() + valueType.slice(1) + } + + const markTime = (force = false) => { + if(!force && eventCount !== 0 && eventCount % TIME_MARK_INTERVAL !== 0){ + return + } + const elapsedMs = now() - startedAt + const previous = timeMarks[timeMarks.length - 1] + if(previous && previous.eventIndex === eventCount){ + previous.elapsedMs = elapsedMs + return + } + timeMarks.push({ eventIndex: eventCount, elapsedMs }) + } + + const appendEvent = (siteId) => { + markTime() + if(currentChunkLength === CHUNK_SIZE){ + chunks.push(currentChunk) + currentChunk = new Uint32Array(CHUNK_SIZE) + currentChunkLength = 0 + } + currentChunk[currentChunkLength++] = siteId + eventCount++ + } + + const recordSite = (scriptId, line, column, type) => { + const location = \`\${scriptId}:\${line}:\${column}\` + const key = \`\${location}:\${type}\` + let siteId = siteIds[key] + if(siteId === undefined){ + siteId = sites.length + siteIds[key] = siteId + sites.push({ id: siteId, location, type }) + } + appendEvent(siteId) + } + + const trackEverything = (value, scriptId, line, column, hint, identityMode, methodLocations) => { + const valueType = typeof value + if(value !== null && (valueType === 'object' || valueType === 'function')){ + if(identityMode === 'observed' && seenIdentities.has(value)){ + return value + } + seenIdentities.add(value) + } else if(valueType === 'symbol'){ + if(identityMode === 'observed' && seenSymbols.has(value)){ + return value + } + seenSymbols.add(value) + } + recordSite(scriptId, line, column, getType(value, hint)) + for(const methodLocation of methodLocations){ + recordSite(scriptId, methodLocation[0], methodLocation[1], 'Function') + } + return value + } + + globalThis.__vscodeMemoryLeakFinderTrackEverything = trackEverything + globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingMetadata = () => { + markTime(true) + return { + chunkCount: chunks.length + (currentChunkLength > 0 ? 1 : 0), + durationMs: now() - startedAt, + eventCount, + sites, + timeMarks, + } + } + globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingChunk = (index) => { + if(index < chunks.length){ + return Array.from(chunks[index]) + } + if(index === chunks.length && currentChunkLength > 0){ + return Array.from(currentChunk.subarray(0, currentChunkLength)) + } + return [] + } +})(); +` + const TIMEOUT_PREAMBLE_CODE = `(() => { if(typeof globalThis.getTrackedTimeoutCount === 'function'){ return @@ -153,6 +266,10 @@ const TIMEOUT_PREAMBLE_CODE = `(() => { export const transformCode = async (code: string, options: TransformOptions = {}): Promise => { const { trackingMode = 'functions' } = options + if (trackingMode === 'everything') { + const transformedCode = transformCodeWithEverythingTracking(code, { ...options }) + return EVERYTHING_PREAMBLE_CODE + '\n' + transformedCode + } if (trackingMode === 'allocations') { const transformedCode = transformCodeWithAllocationTracking(code, { ...options }) return ALLOCATION_PREAMBLE_CODE + '\n' + transformedCode diff --git a/packages/function-tracker/src/parts/TransformCodeWithEverythingTracking/TransformCodeWithEverythingTracking.ts b/packages/function-tracker/src/parts/TransformCodeWithEverythingTracking/TransformCodeWithEverythingTracking.ts new file mode 100644 index 0000000000..1e7c998148 --- /dev/null +++ b/packages/function-tracker/src/parts/TransformCodeWithEverythingTracking/TransformCodeWithEverythingTracking.ts @@ -0,0 +1,29 @@ +import { VError } from '@lvce-editor/verror' +import type { TransformOptions } from '../Types/Types.ts' +import { generate2, parser2, traverse2 } from '../BabelHelpers/BabelHelpers.ts' +import { createEverythingWrapperPlugin } from '../CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts' + +export const transformCodeWithEverythingTracking = (code: string, options: TransformOptions = {}): string => { + if (!code) { + return '' + } + const { filename = '', minify = false, scriptId = filename || 123 } = options + try { + const ast = parser2.parse(code, { + plugins: [], + ranges: false, + sourceType: 'module', + tokens: false, + }) + traverse2(ast, createEverythingWrapperPlugin({ scriptId })) + return generate2(ast, { + comments: true, + compact: false, + jsonCompatibleStrings: false, + minified: minify, + retainLines: false, + }).code + } catch (error) { + throw new VError(error, `Error transforming code with everything tracking:`) + } +} diff --git a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts new file mode 100644 index 0000000000..bd389b20c8 --- /dev/null +++ b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@jest/globals' +import { runInNewContext } from 'node:vm' +import { transformCode } from '../src/parts/Transform/Transform.js' +import { transformCodeWithEverythingTracking } from '../src/parts/TransformCodeWithEverythingTracking/TransformCodeWithEverythingTracking.js' + +const run = async (code: string) => { + const transformed = await transformCode(code, { scriptId: 7, trackingMode: 'everything' }) + const context = { Date, Set, Uint32Array, WeakSet } + runInNewContext(transformed, context) + const metadata = (context as any).__vscodeMemoryLeakFinderGetTrackedEverythingMetadata() + const events = Array.from({ length: metadata.chunkCount }, (_, index) => + (context as any).__vscodeMemoryLeakFinderGetTrackedEverythingChunk(index), + ).flat() + return { context: context as any, events, metadata, transformed } +} + +test('tracks primitive evaluations and explicit identities in source order', async () => { + const { events, metadata } = await run(` + const number = 1 + 2 + const string = \`value-\${number}\` + const array = [] + const object = {} + globalThis.result = [number, string, array, object] + `) + const types = events.map((event: number) => metadata.sites[event].type) + expect(types).toEqual([ + 'Number', + 'Number', + 'Number', + 'String', + 'Array', + 'Object', + 'Array', + ]) +}) + +test('counts repeated primitive call results but only first observes an object identity', async () => { + const { events, metadata } = await run(` + const object = {} + const returnObject = () => object + const returnNumber = () => 1 + returnObject() + returnObject() + returnNumber() + returnNumber() + `) + const types = events.map((event: number) => metadata.sites[event].type) + expect(types.filter((type: string) => type === 'Object')).toHaveLength(1) + expect(types.filter((type: string) => type === 'Number')).toHaveLength(4) + expect(events).toHaveLength(metadata.eventCount) + expect(metadata.sites.some((site: any) => site.type === 'Number')).toBe(true) +}) + +test('preserves directives, object keys, direct eval, optional calls, and results', async () => { + const { context, transformed } = await run(` + "use strict" + const value = { answer: 40 } + const fn = (amount) => amount + 2 + globalThis.result = [eval('value.answer'), fn?.(40)] + `) + expect(context.result).toEqual([40, 42]) + expect(transformed).toContain('"use strict"') + expect(transformed).toContain('answer:') +}) + +test('records function and class declarations and method creation', async () => { + const { events, metadata } = await run(` + function fn() {} + class Example { method() {} } + const instance = new Example() + const object = { method() {} } + `) + const types = events.map((event: number) => metadata.sites[event].type) + expect(types.filter((type: string) => type === 'Function')).toHaveLength(3) + expect(types).toContain('Class') + expect(types).toContain('Example') + expect(types).toContain('Object') +}) + +test('uses fixed-size chunks without dropping events', async () => { + const { context } = await run(`globalThis.record = () => 1`) + for (let index = 0; index < 65_540; index++) { + context.record() + } + const metadata = context.__vscodeMemoryLeakFinderGetTrackedEverythingMetadata() + const total = Array.from({ length: metadata.chunkCount }, (_, index) => + context.__vscodeMemoryLeakFinderGetTrackedEverythingChunk(index), + ).reduce((sum: number, chunk: readonly number[]) => sum + chunk.length, 0) + expect(total).toBe(metadata.eventCount) + expect(metadata.chunkCount).toBeGreaterThan(1) + expect(metadata.timeMarks.length).toBeGreaterThan(60) +}) + +test('empty input stays empty', () => { + expect(transformCodeWithEverythingTracking('')).toBe('') +}) diff --git a/packages/launch-worker/src/parts/Launch/Launch.ts b/packages/launch-worker/src/parts/Launch/Launch.ts index 6ab3dcb5d7..b5307eb231 100644 --- a/packages/launch-worker/src/parts/Launch/Launch.ts +++ b/packages/launch-worker/src/parts/Launch/Launch.ts @@ -47,6 +47,9 @@ export interface LaunchOptions { let proxyWorkerRpc: any = null export const getTrackingMode = (measureId: string): string => { + if (measureId === 'tracked-everything' || measureId === 'trackedEverything') { + return 'everything' + } if (measureId === 'tracked-timeouts' || measureId === 'trackedTimeouts') { return 'timeouts' } diff --git a/packages/launch-worker/test/Launch.test.ts b/packages/launch-worker/test/Launch.test.ts index ea1282c669..5def161aad 100644 --- a/packages/launch-worker/test/Launch.test.ts +++ b/packages/launch-worker/test/Launch.test.ts @@ -15,6 +15,11 @@ test('getTrackingMode - tracked timeout measures use timeout tracking', () => { expect(getTrackingMode('trackedTimeouts')).toBe('timeouts') }) +test('getTrackingMode - tracked everything uses everything tracking', () => { + expect(getTrackingMode('tracked-everything')).toBe('everything') + expect(getTrackingMode('trackedEverything')).toBe('everything') +}) + test('getTrackingMode - other measures use function tracking', () => { expect(getTrackingMode('tracked-functions')).toBe('functions') expect(getTrackingMode('event-listener-count')).toBe('functions') diff --git a/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts b/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts new file mode 100644 index 0000000000..3c95681a8e --- /dev/null +++ b/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts @@ -0,0 +1,70 @@ +import { basename, dirname, extname, join } from 'node:path' +import { mkdir, rename } from 'node:fs/promises' +import type { Session } from '../Session/Session.ts' +import type { ScriptMap } from '../ResolveTrackedLocationSourceMaps/ResolveTrackedLocationSourceMaps.ts' +import * as ResolveTrackedLocationSourceMaps from '../ResolveTrackedLocationSourceMaps/ResolveTrackedLocationSourceMaps.ts' +import type { + TrackedEverythingMetadata, + TrackedEverythingSite, +} from '../GetTrackedEverything/GetTrackedEverything.ts' + +export interface TrackedEverythingAfter { + readonly metadata: TrackedEverythingMetadata + readonly scriptMap: ScriptMap + readonly temporaryEventPath: string +} + +export interface TrackedEverythingResultSite extends TrackedEverythingSite { + readonly originalColumn: number | null + readonly originalLine: number | null + readonly originalLocation: string | null + readonly originalSource: string | null +} + +export interface TrackedEverythingResult { + readonly durationMs: number + readonly eventCount: number + readonly eventFile: string + readonly schemaVersion: 1 + readonly sites: readonly TrackedEverythingResultSite[] + readonly timeMarks: TrackedEverythingMetadata['timeMarks'] +} + +const getEventPath = (resultPath: string): string => { + const extension = extname(resultPath) + const stem = extension ? basename(resultPath, extension) : basename(resultPath) + return join(dirname(resultPath), `${stem}.events.bin`) +} + +export const compareTrackedEverything = async ( + _before: unknown, + after: TrackedEverythingAfter, + context: Session & { readonly resultPath?: string }, +): Promise => { + if (!context.resultPath) { + throw new Error(`Tracked everything measure requires resultPath in compare context`) + } + const locations = after.metadata.sites.map((site) => site.location) + const resolved = await ResolveTrackedLocationSourceMaps.resolveTrackedLocationSourceMaps(locations, after.scriptMap) + const sites = after.metadata.sites.map((site) => { + const original = resolved[site.location] + return { + ...site, + originalColumn: original?.originalColumn ?? null, + originalLine: original?.originalLine ?? null, + originalLocation: original?.originalLocation ?? null, + originalSource: original?.originalSource ?? null, + } + }) + const eventPath = getEventPath(context.resultPath) + await mkdir(dirname(eventPath), { recursive: true }) + await rename(after.temporaryEventPath, eventPath) + return { + durationMs: after.metadata.durationMs, + eventCount: after.metadata.eventCount, + eventFile: basename(eventPath), + schemaVersion: 1, + sites, + timeMarks: after.metadata.timeMarks, + } +} diff --git a/packages/memory-leak-finder/src/parts/GetTrackedEverything/GetTrackedEverything.ts b/packages/memory-leak-finder/src/parts/GetTrackedEverything/GetTrackedEverything.ts new file mode 100644 index 0000000000..baac57f90a --- /dev/null +++ b/packages/memory-leak-finder/src/parts/GetTrackedEverything/GetTrackedEverything.ts @@ -0,0 +1,67 @@ +import type { Session } from '../Session/Session.ts' +import { DevtoolsProtocolRuntime } from '../DevtoolsProtocol/DevtoolsProtocol.ts' +import { VError } from '../VError/VError.ts' + +export interface TrackedEverythingSite { + readonly id: number + readonly location: string + readonly type: string +} + +export interface TrackedEverythingTimeMark { + readonly elapsedMs: number + readonly eventIndex: number +} + +export interface TrackedEverythingMetadata { + readonly chunkCount: number + readonly durationMs: number + readonly eventCount: number + readonly sites: readonly TrackedEverythingSite[] + readonly timeMarks: readonly TrackedEverythingTimeMark[] +} + +const emptyMetadata: TrackedEverythingMetadata = { + chunkCount: 0, + durationMs: 0, + eventCount: 0, + sites: [], + timeMarks: [], +} + +export const getTrackedEverythingMetadata = async (session: Session): Promise => { + try { + const result = await DevtoolsProtocolRuntime.evaluate(session, { + expression: `(() => { + if (typeof globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingMetadata === 'function') { + return globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingMetadata() + } + return null + })()`, + returnByValue: true, + }) + if (!result || typeof result !== 'object') { + return emptyMetadata + } + return result as TrackedEverythingMetadata + } catch (error) { + throw new VError(error, `Failed to get tracked-everything metadata`) + } +} + +export const getTrackedEverythingChunk = async (session: Session, index: number): Promise => { + try { + const result = await DevtoolsProtocolRuntime.evaluate(session, { + expression: `(() => { + if (typeof globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingChunk === 'function') { + return globalThis.__vscodeMemoryLeakFinderGetTrackedEverythingChunk(${index}) + } + return [] + })()`, + returnByValue: true, + }) + return Array.isArray(result) ? result : [] + } catch (error) { + throw new VError(error, `Failed to get tracked-everything event chunk ${index}`) + } +} diff --git a/packages/memory-leak-finder/src/parts/MeasureId/MeasureId.ts b/packages/memory-leak-finder/src/parts/MeasureId/MeasureId.ts index 9ae91aad90..ad59c79198 100644 --- a/packages/memory-leak-finder/src/parts/MeasureId/MeasureId.ts +++ b/packages/memory-leak-finder/src/parts/MeasureId/MeasureId.ts @@ -133,6 +133,7 @@ export const TextEncoderCount = 'textEncoderCount' export const TrackedAllocationTimeline = 'trackedAllocationTimeline' export const TrackedAllocations = 'trackedAllocations' export const TrackedAllocationsFromStart = 'trackedAllocationsFromStart' +export const TrackedEverything = 'trackedEverything' export const TrackedFunctions = 'trackedFunctions' export const TrackedTimeouts = 'trackedTimeouts' export const TrustedTypePolicyCount = 'trustedTypePolicyCount' diff --git a/packages/memory-leak-finder/src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts b/packages/memory-leak-finder/src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts new file mode 100644 index 0000000000..ce5656cd98 --- /dev/null +++ b/packages/memory-leak-finder/src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, open, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { IScriptHandler } from '../IScriptHandler/IScriptHandler.ts' +import type { Session } from '../Session/Session.ts' +import * as CompareTrackedEverything from '../CompareTrackedEverything/CompareTrackedEverything.ts' +import * as GetTrackedEverything from '../GetTrackedEverything/GetTrackedEverything.ts' +import * as MeasureId from '../MeasureId/MeasureId.ts' +import * as Root from '../Root/Root.ts' +import * as ScriptHandler from '../ScriptHandler/ScriptHandler.ts' +import * as TargetId from '../TargetId/TargetId.ts' + +interface TrackedEverythingState { + readonly scriptHandler: IScriptHandler + readonly temporaryEventPath: string +} + +export const id = MeasureId.TrackedEverything +export const targets = [TargetId.Browser] + +export const create = (session: Session): readonly [Session, TrackedEverythingState] => { + return [ + session, + { + scriptHandler: ScriptHandler.create(), + temporaryEventPath: join(Root.root, '.vscode-tracked-everything', `${randomUUID()}.events.bin`), + }, + ] +} + +export const start = async (session: Session, state: TrackedEverythingState) => { + await state.scriptHandler.start(session) + return {} +} + +const writeChunk = async (handle: Awaited>, chunk: readonly number[]): Promise => { + const buffer = Buffer.allocUnsafe(chunk.length * 4) + for (let index = 0; index < chunk.length; index++) { + buffer.writeUInt32LE(chunk[index], index * 4) + } + await handle.write(buffer) +} + +export const stop = async (session: Session, state: TrackedEverythingState) => { + const metadata = await GetTrackedEverything.getTrackedEverythingMetadata(session) + if (metadata.eventCount === 0) { + await state.scriptHandler.stop(session) + throw new Error( + 'Tracked everything produced no data. The VS Code workbench was not instrumented, or no instrumented modules were loaded.', + ) + } + await mkdir(dirname(state.temporaryEventPath), { recursive: true }) + const handle = await open(state.temporaryEventPath, 'w') + let writtenEventCount = 0 + try { + for (let index = 0; index < metadata.chunkCount; index++) { + const chunk = await GetTrackedEverything.getTrackedEverythingChunk(session, index) + await writeChunk(handle, chunk) + writtenEventCount += chunk.length + } + } finally { + await handle.close() + await state.scriptHandler.stop(session) + } + if (writtenEventCount !== metadata.eventCount) { + await rm(state.temporaryEventPath, { force: true }) + throw new Error(`Tracked everything event stream is incomplete: expected ${metadata.eventCount}, received ${writtenEventCount}`) + } + return { + metadata, + scriptMap: state.scriptHandler.scriptMap, + temporaryEventPath: state.temporaryEventPath, + } +} + +export const compare = CompareTrackedEverything.compareTrackedEverything + +export const isLeak = () => false + +export const releaseResources = async (session: Session, state: TrackedEverythingState): Promise => { + try { + await state.scriptHandler.stop(session) + } catch { + // Debugger may already be disabled. + } + await rm(state.temporaryEventPath, { force: true }) +} diff --git a/packages/memory-leak-finder/src/parts/Measures/Measures.ts b/packages/memory-leak-finder/src/parts/Measures/Measures.ts index 38066b9865..34df629dca 100644 --- a/packages/memory-leak-finder/src/parts/Measures/Measures.ts +++ b/packages/memory-leak-finder/src/parts/Measures/Measures.ts @@ -129,6 +129,7 @@ export * as MeasureTextEncoderCount from '../MeasureTextEncoderCount/MeasureText export * as MeasureTrackedAllocationTimeline from '../MeasureTrackedAllocationTimeline/MeasureTrackedAllocationTimeline.ts' export * as MeasureTrackedAllocations from '../MeasureTrackedAllocations/MeasureTrackedAllocations.ts' export * as MeasureTrackedAllocationsFromStart from '../MeasureTrackedAllocationsFromStart/MeasureTrackedAllocationsFromStart.ts' +export * as MeasureTrackedEverything from '../MeasureTrackedEverything/MeasureTrackedEverything.ts' export * as MeasureTrackedFunctions from '../MeasureTrackedFunctions/MeasureTrackedFunctions.ts' export * as MeasureTrackedTimeouts from '../MeasureTrackedTimeouts/MeasureTrackedTimeouts.ts' export * as MeasureTrustedTypePolicyCount from '../MeasureTrustedTypePolicyCount/MeasureTrustedTypePolicyCount.ts' diff --git a/packages/memory-leak-finder/test/GetMeasure.test.ts b/packages/memory-leak-finder/test/GetMeasure.test.ts index 96356b2a2d..edf4e45a3e 100644 --- a/packages/memory-leak-finder/test/GetMeasure.test.ts +++ b/packages/memory-leak-finder/test/GetMeasure.test.ts @@ -52,6 +52,19 @@ test('getMeasure resolves tracked allocation timeline public measure id', () => expect(GetMeasure.getMeasure(MemoryLeakFinder, 'tracked-allocation-timeline')).toBe(measure) }) +test('getMeasure resolves tracked everything public measure id', () => { + const measure = { + id: 'trackedEverything', + } + const MemoryLeakFinder = { + Measures: { + MeasureTrackedEverything: measure, + }, + } + expect(GetMeasure.getMeasure(MemoryLeakFinder, 'tracked-everything')).toBe(measure) + expect(GetMeasure.getMeasure(MemoryLeakFinder, 'trackedEverything')).toBe(measure) +}) + test('getMeasure resolves tracked timeouts public measure id', () => { const measure = { id: 'trackedTimeouts', diff --git a/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts b/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts new file mode 100644 index 0000000000..d4f29136a7 --- /dev/null +++ b/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts @@ -0,0 +1,110 @@ +import { expect, jest, test } from '@jest/globals' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const mockGetMetadata = jest.fn<() => Promise>() +const mockGetChunk = jest.fn<(_session: any, index: number) => Promise>() + +jest.unstable_mockModule('../src/parts/GetTrackedEverything/GetTrackedEverything.ts', () => ({ + getTrackedEverythingChunk: mockGetChunk, + getTrackedEverythingMetadata: mockGetMetadata, +})) + +const MeasureTrackedEverything = await import( + '../src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts' +) +const CompareTrackedEverything = await import( + '../src/parts/CompareTrackedEverything/CompareTrackedEverything.ts' +) + +test('tracked everything preserves startup data and writes chunks in order', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tracked-everything-measure-')) + const eventPath = join(directory, 'events.bin') + const start = jest.fn<() => Promise>().mockResolvedValue() + const stop = jest.fn<() => Promise>().mockResolvedValue() + const state = { + scriptHandler: { scriptMap: {}, start, stop }, + temporaryEventPath: eventPath, + } + const session = {} as any + mockGetMetadata.mockResolvedValue({ + chunkCount: 2, + durationMs: 12, + eventCount: 3, + sites: [{ id: 0, location: '7:1:2', type: 'Object' }], + timeMarks: [{ elapsedMs: 0, eventIndex: 0 }], + }) + mockGetChunk.mockImplementation(async (_session, index) => (index === 0 ? [4, 2] : [9])) + + await expect(MeasureTrackedEverything.start(session, state)).resolves.toEqual({}) + expect(mockGetMetadata).not.toHaveBeenCalled() + const result = await MeasureTrackedEverything.stop(session, state) + + expect(start).toHaveBeenCalledWith(session) + expect(stop).toHaveBeenCalledWith(session) + expect(result.metadata.eventCount).toBe(3) + const bytes = await readFile(eventPath) + expect([bytes.readUInt32LE(0), bytes.readUInt32LE(4), bytes.readUInt32LE(8)]).toEqual([4, 2, 9]) + await rm(directory, { force: true, recursive: true }) +}) + +test('tracked everything rejects empty instrumentation output', async () => { + const stop = jest.fn<() => Promise>().mockResolvedValue() + mockGetMetadata.mockResolvedValue({ chunkCount: 0, durationMs: 0, eventCount: 0, sites: [], timeMarks: [] }) + await expect( + MeasureTrackedEverything.stop({} as any, { + scriptHandler: { scriptMap: {}, start: jest.fn(), stop }, + temporaryEventPath: '/unused', + }), + ).rejects.toThrow('Tracked everything produced no data') + expect(stop).toHaveBeenCalled() +}) + +test('tracked everything comparison publishes the binary sidecar and versioned metadata', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tracked-everything-compare-')) + const temporaryEventPath = join(directory, 'temporary.bin') + const resultPath = join(directory, 'scenario.json') + await writeFile(temporaryEventPath, Buffer.from([0, 0, 0, 0])) + const result = await CompareTrackedEverything.compareTrackedEverything( + {}, + { + metadata: { + chunkCount: 1, + durationMs: 42, + eventCount: 1, + sites: [{ id: 0, location: '7:1:2', type: 'Object' }], + timeMarks: [ + { elapsedMs: 0, eventIndex: 0 }, + { elapsedMs: 42, eventIndex: 1 }, + ], + }, + scriptMap: {}, + temporaryEventPath, + }, + { resultPath } as any, + ) + expect(result).toEqual({ + durationMs: 42, + eventCount: 1, + eventFile: 'scenario.events.bin', + schemaVersion: 1, + sites: [ + { + id: 0, + location: '7:1:2', + originalColumn: null, + originalLine: null, + originalLocation: null, + originalSource: null, + type: 'Object', + }, + ], + timeMarks: [ + { elapsedMs: 0, eventIndex: 0 }, + { elapsedMs: 42, eventIndex: 1 }, + ], + }) + expect(await readFile(join(directory, result.eventFile))).toEqual(Buffer.from([0, 0, 0, 0])) + await rm(directory, { force: true, recursive: true }) +}) diff --git a/packages/visualizations/src/TrackedEverythingApp.tsx b/packages/visualizations/src/TrackedEverythingApp.tsx new file mode 100644 index 0000000000..911b3caefa --- /dev/null +++ b/packages/visualizations/src/TrackedEverythingApp.tsx @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronRight, Layers3, Pause, Play, Search } from 'lucide-react' +import { computeCityLayout } from './layout.ts' +import { MemoryCityScene } from './MemoryCityScene.tsx' +import { TrackedEverythingTimeline } from './TrackedEverythingTimeline.tsx' +import { eventIndexToTime, getSitePath, timeToEventIndex } from './trackedEverythingModel.ts' +import type { TrackedEverythingAggregates, TrackedEverythingDataset } from './trackedEverythingTypes.ts' +import type { BuildingLayout, BuildingView } from './types.ts' + +const emptyAggregates: TrackedEverythingAggregates = { + cursor: 0, + fileCounts: {}, + siteCounts: [], + timeline: {}, + typeCounts: {}, + types: [], +} + +const getFinalFileCounts = (events: Uint32Array, dataset: TrackedEverythingDataset): Record => { + const counts: Record = Object.create(null) + for (const siteId of events) { + const path = getSitePath(dataset.sites[siteId]) + counts[path] = (counts[path] || 0) + 1 + } + return counts +} + +const toView = (path: string, count: number): BuildingView => ({ + deltaBytes: count, + growthPercent: count === 0 ? 0 : 100, + growthSlope: count, + kind: 'source', + largestObjectRetainedBytes: count, + objectCount: count, + path, + retainedBytes: count, + shallowBytes: count, +}) + +export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEverythingDataset }) => { + const workerRef = useRef(null) + const [aggregates, setAggregates] = useState(emptyAggregates) + const [axis, setAxis] = useState<'allocation' | 'time'>('time') + const [error, setError] = useState('') + const [finalFileCounts, setFinalFileCounts] = useState>({}) + const [playing, setPlaying] = useState(false) + const [query, setQuery] = useState('') + const [selectedPath, setSelectedPath] = useState('') + const [selectedType, setSelectedType] = useState('') + + useEffect(() => { + const worker = new Worker(new URL('./trackedEverythingWorker.ts', import.meta.url), { type: 'module' }) + workerRef.current = worker + worker.onmessage = (event: MessageEvent) => { + setAggregates(event.data) + } + const load = async (): Promise => { + try { + const response = await fetch(dataset.eventFile) + if (!response.ok) { + throw new Error(`event stream request returned ${response.status}`) + } + const buffer = await response.arrayBuffer() + if (buffer.byteLength !== dataset.eventCount * 4) { + throw new Error(`expected ${dataset.eventCount * 4} event bytes, received ${buffer.byteLength}`) + } + const events = new Uint32Array(buffer) + setFinalFileCounts(getFinalFileCounts(events, dataset)) + worker.postMessage({ buffer, dataset, kind: 'init' }, [buffer]) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)) + } + } + void load() + return () => { + worker.terminate() + workerRef.current = null + } + }, [dataset]) + + const setCursor = useCallback( + (cursor: number, type = selectedType) => { + workerRef.current?.postMessage({ cursor, kind: 'cursor', selectedType: type }) + }, + [selectedType], + ) + + useEffect(() => { + if (!playing) { + return + } + const increment = Math.max(1, Math.ceil(dataset.eventCount / 240)) + const handle = window.setInterval(() => { + const next = Math.min(dataset.eventCount, aggregates.cursor + increment) + setCursor(next) + if (next === dataset.eventCount) { + setPlaying(false) + } + }, 50) + return () => window.clearInterval(handle) + }, [aggregates.cursor, dataset.eventCount, playing, setCursor]) + + const finalViews = useMemo( + () => Object.entries(finalFileCounts).map(([path, count]) => toView(path, count)), + [finalFileCounts], + ) + const finalLayout = useMemo(() => computeCityLayout(finalViews), [finalViews]) + const buildings = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase() + return finalLayout.buildings + .filter((building) => !normalizedQuery || building.path.toLowerCase().includes(normalizedQuery)) + .map((building): BuildingLayout => { + const current = aggregates.fileCounts[building.path] || 0 + const final = finalFileCounts[building.path] || 1 + return { + ...building, + deltaBytes: current, + growthPercent: (current / final) * 100, + growthSlope: current, + height: current === 0 ? 0.001 : 0.35 + (Math.log1p(current) / Math.log1p(final)) * building.height, + objectCount: current, + retainedBytes: current, + shallowBytes: current, + } + }) + }, [aggregates.fileCounts, finalFileCounts, finalLayout.buildings, query]) + const selectedSites = useMemo( + () => + dataset.sites + .filter((site) => getSitePath(site) === selectedPath && (!selectedType || site.type === selectedType)) + .map((site) => ({ ...site, count: aggregates.siteCounts[site.id] || 0 })) + .filter((site) => site.count > 0) + .toSorted((left, right) => right.count - left.count) + .slice(0, 12), + [aggregates.siteCounts, dataset.sites, selectedPath, selectedType], + ) + const elapsedMs = eventIndexToTime(dataset.timeMarks, aggregates.cursor, dataset.durationMs) + const sliderValue = axis === 'time' ? elapsedMs : aggregates.cursor + const sliderMax = axis === 'time' ? dataset.durationMs : dataset.eventCount + + return ( +
+
+
+ +
LOSSLESS CREATION HISTORY

Tracked Everything

+
+
+
Created{aggregates.cursor.toLocaleString()}
+
Sites{dataset.sites.length.toLocaleString()}
+
Elapsed{elapsedMs.toFixed(1)} ms
+
+
+
+
+
+ + +
+ + +
+
{dataset.scenario}{selectedPath && <>{selectedPath}}
+
+
+ setError('WebGL could not initialize')} + onHover={() => {}} + onSelect={(building) => setSelectedPath(building.path)} + resetToken={0} + selectedPath={selectedPath} + /> +
+ + {error &&
{error}
} +
+
+ +
+ + { + setPlaying(false) + const value = Number(event.target.value) + setCursor(axis === 'time' ? timeToEventIndex(dataset.timeMarks, value, dataset.eventCount) : value) + }} + /> +
0{axis === 'time' ? `${dataset.durationMs.toFixed(1)} ms` : dataset.eventCount.toLocaleString()}
+
+
+
+ ) +} diff --git a/packages/visualizations/src/TrackedEverythingTimeline.tsx b/packages/visualizations/src/TrackedEverythingTimeline.tsx new file mode 100644 index 0000000000..a3d33b758a --- /dev/null +++ b/packages/visualizations/src/TrackedEverythingTimeline.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react' + +const colors = ['#ee48b1', '#24d5de', '#8b7cf6', '#f5b84b', '#63d471', '#f4775b', '#5aa8ff', '#c4cfdf'] + +export const TrackedEverythingTimeline = ({ + cursor, + eventCount, + timeline, + types, +}: { + readonly cursor: number + readonly eventCount: number + readonly timeline: Readonly> + readonly types: readonly string[] +}) => { + const ref = useRef(null) + useEffect(() => { + const canvas = ref.current + if (!canvas) { + return + } + const width = canvas.clientWidth + const height = canvas.clientHeight + const scale = window.devicePixelRatio || 1 + canvas.width = Math.round(width * scale) + canvas.height = Math.round(height * scale) + const context = canvas.getContext('2d') + if (!context) { + return + } + context.scale(scale, scale) + context.clearRect(0, 0, width, height) + const binCount = types.length ? timeline[types[0]]?.length || 0 : 0 + const totals = Array.from({ length: binCount }, (_, bin) => + types.reduce((total, type) => total + (timeline[type]?.[bin] || 0), 0), + ) + const maximum = Math.max(1, ...totals) + for (let bin = 0; bin < binCount; bin++) { + const x = (bin / Math.max(1, binCount)) * width + const binWidth = Math.ceil(width / Math.max(1, binCount)) + 1 + let bottom = height + for (let typeIndex = 0; typeIndex < types.length; typeIndex++) { + const value = timeline[types[typeIndex]]?.[bin] || 0 + const segmentHeight = (value / maximum) * height + context.fillStyle = colors[typeIndex % colors.length] + context.fillRect(x, bottom - segmentHeight, binWidth, segmentHeight) + bottom -= segmentHeight + } + } + const cursorX = (cursor / Math.max(1, eventCount)) * width + context.fillStyle = '#ffffff' + context.fillRect(cursorX - 1, 0, 2, height) + }, [cursor, eventCount, timeline, types]) + return +} diff --git a/packages/visualizations/src/trackedEverythingModel.ts b/packages/visualizations/src/trackedEverythingModel.ts new file mode 100644 index 0000000000..b7b74f35de --- /dev/null +++ b/packages/visualizations/src/trackedEverythingModel.ts @@ -0,0 +1,106 @@ +import type { TrackedEverythingDataset, TrackedEverythingTimeMark } from './trackedEverythingTypes.ts' + +export const isTrackedEverythingDataset = (value: unknown): value is TrackedEverythingDataset => { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Partial + return ( + candidate.kind === 'tracked-everything' && + candidate.schemaVersion === 1 && + typeof candidate.scenario === 'string' && + typeof candidate.eventFile === 'string' && + Number.isInteger(candidate.eventCount) && + Number.isFinite(candidate.durationMs) && + Array.isArray(candidate.sites) && + candidate.sites.every( + (site) => + Number.isInteger(site?.id) && + typeof site?.location === 'string' && + typeof site?.type === 'string' && + (site.originalSource === null || typeof site.originalSource === 'string'), + ) && + Array.isArray(candidate.timeMarks) && + candidate.timeMarks.every((mark) => Number.isInteger(mark?.eventIndex) && Number.isFinite(mark?.elapsedMs)) + ) +} + +export const getSitePath = (site: TrackedEverythingDataset['sites'][number]): string => { + if (site.originalSource) { + return site.originalSource.replaceAll('\\', '/') + } + const match = site.location.match(/^(.+):\d+:\d+$/) + return (match?.[1] || 'runtime/unmapped').replaceAll('\\', '/') +} + +export const decodeTrackedEverythingEvents = ( + buffer: ArrayBuffer, + dataset: TrackedEverythingDataset, +): Uint32Array => { + if (buffer.byteLength !== dataset.eventCount * 4) { + throw new Error(`Invalid event stream length: expected ${dataset.eventCount * 4} bytes, received ${buffer.byteLength}`) + } + const events = new Uint32Array(buffer) + for (const siteId of events) { + if (!dataset.sites[siteId] || dataset.sites[siteId].id !== siteId) { + throw new Error(`Invalid tracked-everything site id ${siteId}`) + } + } + return events +} + +export const timeToEventIndex = ( + timeMarks: readonly TrackedEverythingTimeMark[], + elapsedMs: number, + eventCount: number, +): number => { + if (timeMarks.length === 0 || elapsedMs <= timeMarks[0].elapsedMs) { + return 0 + } + const last = timeMarks.at(-1)! + if (elapsedMs >= last.elapsedMs) { + return eventCount + } + let low = 0 + let high = timeMarks.length - 1 + while (low + 1 < high) { + const middle = (low + high) >> 1 + if (timeMarks[middle].elapsedMs <= elapsedMs) { + low = middle + } else { + high = middle + } + } + const left = timeMarks[low] + const right = timeMarks[high] + const progress = right.elapsedMs === left.elapsedMs ? 0 : (elapsedMs - left.elapsedMs) / (right.elapsedMs - left.elapsedMs) + return Math.max(0, Math.min(eventCount, Math.round(left.eventIndex + (right.eventIndex - left.eventIndex) * progress))) +} + +export const eventIndexToTime = ( + timeMarks: readonly TrackedEverythingTimeMark[], + eventIndex: number, + durationMs: number, +): number => { + if (timeMarks.length === 0 || eventIndex <= 0) { + return 0 + } + const last = timeMarks.at(-1)! + if (eventIndex >= last.eventIndex) { + return durationMs + } + let low = 0 + let high = timeMarks.length - 1 + while (low + 1 < high) { + const middle = (low + high) >> 1 + if (timeMarks[middle].eventIndex <= eventIndex) { + low = middle + } else { + high = middle + } + } + const left = timeMarks[low] + const right = timeMarks[high] + const progress = right.eventIndex === left.eventIndex ? 0 : (eventIndex - left.eventIndex) / (right.eventIndex - left.eventIndex) + return left.elapsedMs + (right.elapsedMs - left.elapsedMs) * progress +} diff --git a/packages/visualizations/src/trackedEverythingTypes.ts b/packages/visualizations/src/trackedEverythingTypes.ts new file mode 100644 index 0000000000..ee5f7a1f7f --- /dev/null +++ b/packages/visualizations/src/trackedEverythingTypes.ts @@ -0,0 +1,34 @@ +export interface TrackedEverythingSite { + readonly id: number + readonly location: string + readonly originalColumn: number | null + readonly originalLine: number | null + readonly originalLocation: string | null + readonly originalSource: string | null + readonly type: string +} + +export interface TrackedEverythingTimeMark { + readonly elapsedMs: number + readonly eventIndex: number +} + +export interface TrackedEverythingDataset { + readonly durationMs: number + readonly eventCount: number + readonly eventFile: string + readonly kind: 'tracked-everything' + readonly scenario: string + readonly schemaVersion: 1 + readonly sites: readonly TrackedEverythingSite[] + readonly timeMarks: readonly TrackedEverythingTimeMark[] +} + +export interface TrackedEverythingAggregates { + readonly cursor: number + readonly fileCounts: Readonly> + readonly siteCounts: readonly number[] + readonly timeline: Readonly> + readonly typeCounts: Readonly> + readonly types: readonly string[] +} diff --git a/packages/visualizations/src/trackedEverythingWorker.ts b/packages/visualizations/src/trackedEverythingWorker.ts new file mode 100644 index 0000000000..87c8448cf6 --- /dev/null +++ b/packages/visualizations/src/trackedEverythingWorker.ts @@ -0,0 +1,83 @@ +/// + +import { getSitePath } from './trackedEverythingModel.ts' +import type { TrackedEverythingAggregates, TrackedEverythingDataset } from './trackedEverythingTypes.ts' + +let dataset: TrackedEverythingDataset +let events = new Uint32Array() +let counts = new Uint32Array() +let cursor = 0 +let selectedType = '' +const timelineBinCount = 320 +let timeline: Record = Object.create(null) +let types: string[] = [] + +const buildTimeline = (): void => { + types = [...new Set(dataset.sites.map((site) => site.type))].sort() + timeline = Object.fromEntries(types.map((type) => [type, Array.from({ length: timelineBinCount }, () => 0)])) + for (let index = 0; index < events.length; index++) { + const site = dataset.sites[events[index]] + const bin = Math.min(timelineBinCount - 1, Math.floor((index / Math.max(1, events.length)) * timelineBinCount)) + timeline[site.type][bin]++ + } +} + +const moveCursor = (nextCursor: number): void => { + nextCursor = Math.max(0, Math.min(events.length, Math.round(nextCursor))) + if (nextCursor > cursor) { + for (let index = cursor; index < nextCursor; index++) { + counts[events[index]]++ + } + } else { + for (let index = cursor - 1; index >= nextCursor; index--) { + counts[events[index]]-- + } + } + cursor = nextCursor +} + +const publish = (): void => { + const fileCounts: Record = Object.create(null) + const typeCounts: Record = Object.create(null) + for (let siteId = 0; siteId < counts.length; siteId++) { + const count = counts[siteId] + if (count === 0) { + continue + } + const site = dataset.sites[siteId] + typeCounts[site.type] = (typeCounts[site.type] || 0) + count + if (!selectedType || site.type === selectedType) { + const path = getSitePath(site) + fileCounts[path] = (fileCounts[path] || 0) + count + } + } + const message: TrackedEverythingAggregates & { readonly kind: 'aggregates' } = { + cursor, + fileCounts, + kind: 'aggregates', + siteCounts: Array.from(counts), + timeline, + typeCounts, + types, + } + postMessage(message) +} + +self.onmessage = ( + event: MessageEvent< + | { readonly buffer: ArrayBuffer; readonly dataset: TrackedEverythingDataset; readonly kind: 'init' } + | { readonly cursor: number; readonly kind: 'cursor'; readonly selectedType: string } + >, +): void => { + if (event.data.kind === 'init') { + dataset = event.data.dataset + events = new Uint32Array(event.data.buffer) + counts = new Uint32Array(dataset.sites.length) + buildTimeline() + publish() + return + } + selectedType = event.data.selectedType + moveCursor(event.data.cursor) + publish() +} From 218cad37060de2377519fe213694dfe0703a50bc Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 18 Jul 2026 10:34:23 +0000 Subject: [PATCH 2/6] fix --- .../parts/GenerateCharts/GenerateCharts.ts | 18 +++ .../GenerateIndexHtml/GenerateIndexHtml.ts | 5 + .../src/generateTrackedEverything.ts | 106 ++++++++++++++++++ packages/visualizations/src/main.tsx | 11 +- packages/visualizations/src/styles.css | 78 +++++++++++++ 5 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 packages/visualizations/src/generateTrackedEverything.ts diff --git a/packages/charts/src/parts/GenerateCharts/GenerateCharts.ts b/packages/charts/src/parts/GenerateCharts/GenerateCharts.ts index 2d1374b785..9f5dd1e498 100644 --- a/packages/charts/src/parts/GenerateCharts/GenerateCharts.ts +++ b/packages/charts/src/parts/GenerateCharts/GenerateCharts.ts @@ -160,4 +160,22 @@ export const generateCharts = async () => { throw error } } + + const trackedEverythingResults = join(Root.root, '.vscode-memory-leak-finder-results', 'tracked-everything') + try { + await access(trackedEverythingResults) + await execFileAsync(process.execPath, [ + join(Root.root, 'packages', 'visualizations', 'src', 'generateTrackedEverything.ts'), + '--results', + trackedEverythingResults, + '--assets', + join(Root.root, 'packages', 'visualizations', 'dist'), + '--out', + join(Root.root, '.vscode-charts', 'tracked-everything'), + ]) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } } diff --git a/packages/charts/src/parts/GenerateIndexHtml/GenerateIndexHtml.ts b/packages/charts/src/parts/GenerateIndexHtml/GenerateIndexHtml.ts index 2c0ea1b038..cefb77aacb 100644 --- a/packages/charts/src/parts/GenerateIndexHtml/GenerateIndexHtml.ts +++ b/packages/charts/src/parts/GenerateIndexHtml/GenerateIndexHtml.ts @@ -29,6 +29,11 @@ const baseStructure = ` const getMiddleHtml = (dirents: string[]) => { let html = '
    \n' + for (const directory of ['memory-city', 'tracked-everything']) { + if (dirents.includes(directory)) { + html += `
  • Open ${directory.replaceAll('-', ' ')}
  • \n` + } + } for (const dirent of dirents) { if (!dirent.endsWith('.svg')) { continue diff --git a/packages/visualizations/src/generateTrackedEverything.ts b/packages/visualizations/src/generateTrackedEverything.ts new file mode 100644 index 0000000000..ba76ba6f3a --- /dev/null +++ b/packages/visualizations/src/generateTrackedEverything.ts @@ -0,0 +1,106 @@ +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { basename, dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +interface RawTrackedEverythingResult { + readonly durationMs: number + readonly eventCount: number + readonly eventFile: string + readonly schemaVersion: 1 + readonly sites: readonly unknown[] + readonly timeMarks: readonly unknown[] +} + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const repositoryRoot = resolve(packageRoot, '..', '..') + +const parseArguments = (argv: readonly string[]) => { + let assets = resolve(packageRoot, 'dist') + let out = resolve(repositoryRoot, '.vscode-charts', 'tracked-everything') + let results = resolve(repositoryRoot, '.vscode-memory-leak-finder-results', 'tracked-everything') + for (let index = 0; index < argv.length; index++) { + if (argv[index] === '--assets') { + assets = resolve(argv[++index]) + } else if (argv[index] === '--out') { + out = resolve(argv[++index]) + } else if (argv[index] === '--results') { + results = resolve(argv[++index]) + } + } + return { assets, out, results } +} + +const findResults = async (root: string): Promise => { + const output: string[] = [] + const visit = async (path: string): Promise => { + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) { + await visit(child) + } else if (entry.name.endsWith('.json')) { + const value = JSON.parse(await readFile(child, 'utf8')) + if ( + value?.schemaVersion === 1 && + typeof value?.eventFile === 'string' && + Number.isInteger(value?.eventCount) && + Array.isArray(value?.sites) + ) { + output.push(child) + } + } + } + } + await visit(root) + return output.toSorted() +} + +const writeViewer = async (resultPath: string, resultsRoot: string, assets: string, outputRoot: string): Promise => { + const result = JSON.parse(await readFile(resultPath, 'utf8')) as RawTrackedEverythingResult + const relativeResult = relative(resultsRoot, resultPath) + const outputPath = join(outputRoot, relativeResult.replace(/\.json$/, '')) + const eventSource = join(dirname(resultPath), result.eventFile) + const eventFile = basename(result.eventFile) + const dataset = { + ...result, + eventFile, + kind: 'tracked-everything', + scenario: relativeResult.replace(/\.json$/, '').replaceAll('\\', '/'), + } + await mkdir(outputPath, { recursive: true }) + await cp(assets, outputPath, { force: true, recursive: true }) + await cp(eventSource, join(outputPath, eventFile), { force: true }) + await writeFile(join(outputPath, 'tracked-everything.json'), `${JSON.stringify(dataset, null, 2)}\n`) + await writeFile( + join(outputPath, 'tracked-everything-data.js'), + `globalThis.__TRACKED_EVERYTHING_DATA__ = ${JSON.stringify(dataset).replaceAll('<', '\\u003c')}\n`, + ) + const indexPath = join(outputPath, 'index.html') + const html = (await readFile(indexPath, 'utf8')) + .replace('VS Code Memory City', 'VS Code Tracked Everything') + .replace('./memory-city-data.js', './tracked-everything-data.js') + await writeFile(indexPath, html) + return outputPath +} + +export const generateTrackedEverything = async (results: string, assets: string, out: string): Promise => { + const resultPaths = await findResults(results) + const outputs: string[] = [] + for (const resultPath of resultPaths) { + outputs.push(await writeViewer(resultPath, results, assets, out)) + } + await mkdir(out, { recursive: true }) + const links = outputs + .map((output) => `
  • ${relative(out, output)}
  • `) + .join('\n') + await writeFile( + join(out, 'index.html'), + `Tracked Everything

    Tracked Everything

      ${links}
    `, + ) + return outputs +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const { assets, out, results } = parseArguments(process.argv.slice(2)) + const outputs = await generateTrackedEverything(results, assets, out) + console.log(`Generated ${outputs.length} tracked-everything visualization(s) at ${out}`) +} diff --git a/packages/visualizations/src/main.tsx b/packages/visualizations/src/main.tsx index 1d1deebcb2..de3d8bc048 100644 --- a/packages/visualizations/src/main.tsx +++ b/packages/visualizations/src/main.tsx @@ -1,16 +1,23 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App.tsx' +import { TrackedEverythingApp } from './TrackedEverythingApp.tsx' import { isMemoryCityDataset } from './model.ts' import { sampleData } from './sampleData.ts' +import { isTrackedEverythingDataset } from './trackedEverythingModel.ts' +import type { TrackedEverythingDataset } from './trackedEverythingTypes.ts' import type { MemoryCityDataset } from './types.ts' import './styles.css' declare global { var __MEMORY_CITY_DATA__: unknown + var __TRACKED_EVERYTHING_DATA__: unknown } -const loadDataset = async (): Promise<{ dataset: MemoryCityDataset; loadError?: string }> => { +const loadDataset = async (): Promise<{ dataset: MemoryCityDataset | TrackedEverythingDataset; loadError?: string }> => { + if (isTrackedEverythingDataset(globalThis.__TRACKED_EVERYTHING_DATA__)) { + return { dataset: globalThis.__TRACKED_EVERYTHING_DATA__ } + } if (isMemoryCityDataset(globalThis.__MEMORY_CITY_DATA__)) { return { dataset: globalThis.__MEMORY_CITY_DATA__ } } @@ -42,6 +49,6 @@ if (!root) { } createRoot(root).render( - + {isTrackedEverythingDataset(dataset) ? : } , ) diff --git a/packages/visualizations/src/styles.css b/packages/visualizations/src/styles.css index b4edf854b2..92557d2f31 100644 --- a/packages/visualizations/src/styles.css +++ b/packages/visualizations/src/styles.css @@ -447,6 +447,84 @@ h1 { box-shadow: 0 -14px 50px rgb(0 0 0 / 20%); } +.EverythingApp .Viewport { + min-height: 0; +} + +.TypeSelect { + background: #101b2e; + border: 1px solid #29405f; + border-radius: 7px; + color: #dce9fa; + min-width: 145px; + padding: 8px 10px; +} + +.EverythingPath { + align-items: center; + color: #7891b2; + display: flex; + font-size: 12px; + gap: 5px; + min-height: 18px; +} + +.EverythingInspector { + max-height: calc(100% - 42px); + overflow: auto; + width: min(360px, 34vw); +} + +.EverythingInspector ol { + list-style: none; + margin: 14px 0 0; + padding: 0; +} + +.EverythingInspector li { + border-top: 1px solid #243752; + display: grid; + gap: 2px; + padding: 8px 0; +} + +.EverythingInspector li span { + color: #8ca2c0; + font-size: 11px; + overflow-wrap: anywhere; +} + +.EverythingTimeline { + min-height: 112px; +} + +.EverythingTrack { + display: grid; + flex: 1; + grid-template-rows: 52px 18px 16px; + min-width: 0; +} + +.EverythingDensity { + height: 52px; + opacity: 0.82; + width: 100%; +} + +.EverythingTrack input { + margin: -8px 0 0; + position: relative; + width: 100%; + z-index: 2; +} + +.EverythingScale { + color: #7891b2; + display: flex; + font-size: 10px; + justify-content: space-between; +} + .PlayButton { display: grid; width: 38px; From f5b46a9ffbce0f6b3ba68f07bfcaed98c5df93e0 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 18 Jul 2026 10:38:56 +0000 Subject: [PATCH 3/6] fix --- packages/cli/src/parts/Cli/Cli.ts | 5 ++ .../CreateEverythingWrapperPlugin.ts | 19 ++++- .../src/parts/Transform/Transform.ts | 2 +- ...ransformCodeWithEverythingTracking.test.ts | 11 +++ .../CompareTrackedEverything.ts | 3 +- .../test/MeasureTrackedEverything.test.ts | 8 +- .../src/TrackedEverythingApp.tsx | 4 +- .../test/GenerateTrackedEverything.test.ts | 36 ++++++++ .../test/TrackedEverythingModel.test.ts | 82 +++++++++++++++++++ 9 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 packages/visualizations/test/GenerateTrackedEverything.test.ts create mode 100644 packages/visualizations/test/TrackedEverythingModel.test.ts diff --git a/packages/cli/src/parts/Cli/Cli.ts b/packages/cli/src/parts/Cli/Cli.ts index c6b4df3331..4ef16394c8 100644 --- a/packages/cli/src/parts/Cli/Cli.ts +++ b/packages/cli/src/parts/Cli/Cli.ts @@ -15,6 +15,11 @@ export const run = async (platform: string, arch: string, argv: readonly string[ return } const options = ParseArgv.parseArgv(platform, arch, argv) + if (options.measure === 'tracked-everything' || options.measure === 'trackedEverything') { + console.warn( + 'Warning: tracked-everything exhaustively instruments source-observable creations and substantially changes runtime performance and memory use.', + ) + } // Parse isGithubActions once at startup const isGithubActions = Boolean(env.GITHUB_ACTIONS) diff --git a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts index e9e4233c63..917d416488 100644 --- a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts +++ b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts @@ -6,6 +6,7 @@ export interface CreateEverythingWrapperPluginOptions { } const helperName = '__vscodeMemoryLeakFinderTrackEverything' +const createHelper = (): t.MemberExpression => t.memberExpression(t.identifier('globalThis'), t.identifier(helperName)) const getLocationNodes = (node: t.Node, scriptIdNode: t.Expression): readonly t.Expression[] => { return [ @@ -26,7 +27,11 @@ const getCalleeName = (node: t.Expression | t.V8IntrinsicIdentifier): string => } const isGeneratedHelperCall = (node: t.CallExpression | t.OptionalCallExpression): boolean => { - return t.isIdentifier(node.callee) && node.callee.name === helperName + return ( + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.object, { name: 'globalThis' }) && + t.isIdentifier(node.callee.property, { name: helperName }) + ) } const shouldTrackLiteral = (path: NodePath): boolean => { @@ -84,7 +89,7 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl methodLocations: t.ArrayExpression = t.arrayExpression(), ): void => { const node = path.node - const wrapped = t.callExpression(t.identifier(helperName), [ + const wrapped = t.callExpression(createHelper(), [ node, ...getLocationNodes(node, scriptIdNode), t.stringLiteral(hint), @@ -103,7 +108,7 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl } const methods = t.isClassDeclaration(node) ? getMethodLocations(node) : t.arrayExpression() const call = t.expressionStatement( - t.callExpression(t.identifier(helperName), [ + t.callExpression(createHelper(), [ t.identifier(id.name), ...getLocationNodes(node, scriptIdNode), t.stringLiteral(hint), @@ -159,7 +164,13 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl TaggedTemplateExpression: { exit: (path: NodePath) => wrap(path, 'Dynamic', 'observed'), }, - TemplateLiteral: { exit: (path: NodePath) => wrap(path, 'String') }, + TemplateLiteral: { + exit: (path: NodePath) => { + if (!path.parentPath?.isTaggedTemplateExpression()) { + wrap(path, 'String') + } + }, + }, UnaryExpression: { exit: (path: NodePath) => wrap(path, 'Dynamic') }, UpdateExpression: { exit: (path: NodePath) => wrap(path, 'Number') }, } diff --git a/packages/function-tracker/src/parts/Transform/Transform.ts b/packages/function-tracker/src/parts/Transform/Transform.ts index 2543336fcf..df79f85d21 100644 --- a/packages/function-tracker/src/parts/Transform/Transform.ts +++ b/packages/function-tracker/src/parts/Transform/Transform.ts @@ -195,10 +195,10 @@ const EVERYTHING_PREAMBLE_CODE = `(() => { } seenSymbols.add(value) } - recordSite(scriptId, line, column, getType(value, hint)) for(const methodLocation of methodLocations){ recordSite(scriptId, methodLocation[0], methodLocation[1], 'Function') } + recordSite(scriptId, line, column, getType(value, hint)) return value } diff --git a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts index bd389b20c8..d0e6e43988 100644 --- a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts +++ b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts @@ -77,6 +77,17 @@ test('records function and class declarations and method creation', async () => expect(types).toContain('Object') }) +test('preserves tagged templates, inheritance, and super calls', async () => { + const { context } = await run(` + const tag = (parts, value) => parts[0] + value + class Base { constructor() { this.value = 2 } } + class Child extends Base { constructor() { super() } } + const child = new Child() + globalThis.result = tag\`value-\${child.value}\` + `) + expect(context.result).toBe('value-2') +}) + test('uses fixed-size chunks without dropping events', async () => { const { context } = await run(`globalThis.record = () => 1`) for (let index = 0; index < 65_540; index++) { diff --git a/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts b/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts index 3c95681a8e..b2642fd68e 100644 --- a/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts +++ b/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts @@ -1,5 +1,5 @@ import { basename, dirname, extname, join } from 'node:path' -import { mkdir, rename } from 'node:fs/promises' +import { mkdir, rename, rm } from 'node:fs/promises' import type { Session } from '../Session/Session.ts' import type { ScriptMap } from '../ResolveTrackedLocationSourceMaps/ResolveTrackedLocationSourceMaps.ts' import * as ResolveTrackedLocationSourceMaps from '../ResolveTrackedLocationSourceMaps/ResolveTrackedLocationSourceMaps.ts' @@ -58,6 +58,7 @@ export const compareTrackedEverything = async ( }) const eventPath = getEventPath(context.resultPath) await mkdir(dirname(eventPath), { recursive: true }) + await rm(eventPath, { force: true }) await rename(after.temporaryEventPath, eventPath) return { durationMs: after.metadata.durationMs, diff --git a/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts b/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts index d4f29136a7..0383a7d1d5 100644 --- a/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts +++ b/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts @@ -21,8 +21,8 @@ const CompareTrackedEverything = await import( test('tracked everything preserves startup data and writes chunks in order', async () => { const directory = await mkdtemp(join(tmpdir(), 'tracked-everything-measure-')) const eventPath = join(directory, 'events.bin') - const start = jest.fn<() => Promise>().mockResolvedValue() - const stop = jest.fn<() => Promise>().mockResolvedValue() + const start = jest.fn<(_session: any) => Promise>().mockResolvedValue() + const stop = jest.fn<(_session: any) => Promise>().mockResolvedValue() const state = { scriptHandler: { scriptMap: {}, start, stop }, temporaryEventPath: eventPath, @@ -50,11 +50,11 @@ test('tracked everything preserves startup data and writes chunks in order', asy }) test('tracked everything rejects empty instrumentation output', async () => { - const stop = jest.fn<() => Promise>().mockResolvedValue() + const stop = jest.fn<(_session: any) => Promise>().mockResolvedValue() mockGetMetadata.mockResolvedValue({ chunkCount: 0, durationMs: 0, eventCount: 0, sites: [], timeMarks: [] }) await expect( MeasureTrackedEverything.stop({} as any, { - scriptHandler: { scriptMap: {}, start: jest.fn(), stop }, + scriptHandler: { scriptMap: {}, start: jest.fn<(_session: any) => Promise>().mockResolvedValue(), stop }, temporaryEventPath: '/unused', }), ).rejects.toThrow('Tracked everything produced no data') diff --git a/packages/visualizations/src/TrackedEverythingApp.tsx b/packages/visualizations/src/TrackedEverythingApp.tsx index 911b3caefa..c1de3dbec5 100644 --- a/packages/visualizations/src/TrackedEverythingApp.tsx +++ b/packages/visualizations/src/TrackedEverythingApp.tsx @@ -3,7 +3,7 @@ import { ChevronRight, Layers3, Pause, Play, Search } from 'lucide-react' import { computeCityLayout } from './layout.ts' import { MemoryCityScene } from './MemoryCityScene.tsx' import { TrackedEverythingTimeline } from './TrackedEverythingTimeline.tsx' -import { eventIndexToTime, getSitePath, timeToEventIndex } from './trackedEverythingModel.ts' +import { decodeTrackedEverythingEvents, eventIndexToTime, getSitePath, timeToEventIndex } from './trackedEverythingModel.ts' import type { TrackedEverythingAggregates, TrackedEverythingDataset } from './trackedEverythingTypes.ts' import type { BuildingLayout, BuildingView } from './types.ts' @@ -64,7 +64,7 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve if (buffer.byteLength !== dataset.eventCount * 4) { throw new Error(`expected ${dataset.eventCount * 4} event bytes, received ${buffer.byteLength}`) } - const events = new Uint32Array(buffer) + const events = decodeTrackedEverythingEvents(buffer, dataset) setFinalFileCounts(getFinalFileCounts(events, dataset)) worker.postMessage({ buffer, dataset, kind: 'init' }, [buffer]) } catch (loadError) { diff --git a/packages/visualizations/test/GenerateTrackedEverything.test.ts b/packages/visualizations/test/GenerateTrackedEverything.test.ts new file mode 100644 index 0000000000..03532fd217 --- /dev/null +++ b/packages/visualizations/test/GenerateTrackedEverything.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@jest/globals' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { generateTrackedEverything } from '../src/generateTrackedEverything.ts' + +test('generates a standalone tracked-everything viewer beside its event stream', async () => { + const root = await mkdtemp(join(tmpdir(), 'tracked-everything-viewer-')) + const results = join(root, 'results') + const assets = join(root, 'assets') + const out = join(root, 'out') + await mkdir(join(results, 'nested'), { recursive: true }) + await mkdir(assets, { recursive: true }) + await writeFile(join(assets, 'index.html'), 'VS Code Memory City') + await writeFile(join(results, 'nested', 'scenario.events.bin'), Buffer.alloc(4)) + await writeFile( + join(results, 'nested', 'scenario.json'), + JSON.stringify({ + durationMs: 1, + eventCount: 1, + eventFile: 'scenario.events.bin', + schemaVersion: 1, + sites: [{ id: 0, location: 'x:1:2', type: 'Object' }], + timeMarks: [{ elapsedMs: 0, eventIndex: 0 }], + }), + ) + const outputs = await generateTrackedEverything(results, assets, out) + expect(outputs).toEqual([join(out, 'nested', 'scenario')]) + expect(await readFile(join(outputs[0], 'scenario.events.bin'))).toEqual(Buffer.alloc(4)) + expect(await readFile(join(outputs[0], 'index.html'), 'utf8')).toContain('tracked-everything-data.js') + expect(JSON.parse(await readFile(join(outputs[0], 'tracked-everything.json'), 'utf8'))).toEqual( + expect.objectContaining({ kind: 'tracked-everything', scenario: 'nested/scenario' }), + ) + expect(await readFile(join(out, 'index.html'), 'utf8')).toContain('nested/scenario/index.html') + await rm(root, { force: true, recursive: true }) +}) diff --git a/packages/visualizations/test/TrackedEverythingModel.test.ts b/packages/visualizations/test/TrackedEverythingModel.test.ts new file mode 100644 index 0000000000..b9bd7bef52 --- /dev/null +++ b/packages/visualizations/test/TrackedEverythingModel.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@jest/globals' +import { + decodeTrackedEverythingEvents, + eventIndexToTime, + getSitePath, + isTrackedEverythingDataset, + timeToEventIndex, +} from '../src/trackedEverythingModel.ts' +import type { TrackedEverythingDataset } from '../src/trackedEverythingTypes.ts' + +const dataset: TrackedEverythingDataset = { + durationMs: 100, + eventCount: 4, + eventFile: 'events.bin', + kind: 'tracked-everything', + scenario: 'startup', + schemaVersion: 1, + sites: [ + { + id: 0, + location: '/out/workbench.js:1:2', + originalColumn: 4, + originalLine: 3, + originalLocation: 'src/workbench.ts:3:4', + originalSource: 'src/workbench.ts', + type: 'Object', + }, + { + id: 1, + location: '/out/editor.js:5:6', + originalColumn: null, + originalLine: null, + originalLocation: null, + originalSource: null, + type: 'String', + }, + ], + timeMarks: [ + { elapsedMs: 0, eventIndex: 0 }, + { elapsedMs: 40, eventIndex: 2 }, + { elapsedMs: 100, eventIndex: 4 }, + ], +} + +test('validates the tracked-everything dataset contract', () => { + expect(isTrackedEverythingDataset(dataset)).toBe(true) + expect(isTrackedEverythingDataset({ ...dataset, kind: 'other' })).toBe(false) + expect(isTrackedEverythingDataset({ ...dataset, eventCount: 1.5 })).toBe(false) +}) + +test('validates and decodes the little event stream', () => { + const events = new Uint32Array([0, 1, 1, 0]) + expect([...decodeTrackedEverythingEvents(events.buffer, dataset)]).toEqual([0, 1, 1, 0]) + expect(() => decodeTrackedEverythingEvents(new Uint32Array([0, 2, 1, 0]).buffer, dataset)).toThrow('Invalid tracked-everything site id 2') + expect(() => decodeTrackedEverythingEvents(new Uint32Array([0]).buffer, dataset)).toThrow('Invalid event stream length') +}) + +test('maps between sparse elapsed-time checkpoints and exact allocation indexes', () => { + expect(timeToEventIndex(dataset.timeMarks, 0, 4)).toBe(0) + expect(timeToEventIndex(dataset.timeMarks, 20, 4)).toBe(1) + expect(timeToEventIndex(dataset.timeMarks, 70, 4)).toBe(3) + expect(timeToEventIndex(dataset.timeMarks, 100, 4)).toBe(4) + expect(eventIndexToTime(dataset.timeMarks, 1, 100)).toBe(20) + expect(eventIndexToTime(dataset.timeMarks, 3, 100)).toBe(70) +}) + +test('prefers original source paths and falls back to generated paths', () => { + expect(getSitePath(dataset.sites[0])).toBe('src/workbench.ts') + expect(getSitePath(dataset.sites[1])).toBe('/out/editor.js') +}) + +test('decodes a synthetic 400,000-event capture without losing order', () => { + const largeDataset = { ...dataset, eventCount: 400_000 } + const source = new Uint32Array(largeDataset.eventCount) + for (let index = 0; index < source.length; index++) { + source[index] = index % 2 + } + const decoded = decodeTrackedEverythingEvents(source.buffer, largeDataset) + expect(decoded).toHaveLength(400_000) + expect(decoded[0]).toBe(0) + expect(decoded[399_999]).toBe(1) +}) From a93094c3395850f3cfbb307f37cc43943b6b55e9 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 18 Jul 2026 10:40:13 +0000 Subject: [PATCH 4/6] fix --- .../CreateEverythingWrapperPlugin.ts | 14 +-- ...ransformCodeWithEverythingTracking.test.ts | 10 +- .../CompareTrackedEverything.ts | 5 +- .../test/MeasureTrackedEverything.test.ts | 8 +- .../src/TrackedEverythingApp.tsx | 114 +++++++++++++----- .../src/TrackedEverythingTimeline.tsx | 4 +- .../src/trackedEverythingModel.ts | 17 +-- 7 files changed, 96 insertions(+), 76 deletions(-) diff --git a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts index 917d416488..a2c7e59ae8 100644 --- a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts +++ b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts @@ -9,11 +9,7 @@ const helperName = '__vscodeMemoryLeakFinderTrackEverything' const createHelper = (): t.MemberExpression => t.memberExpression(t.identifier('globalThis'), t.identifier(helperName)) const getLocationNodes = (node: t.Node, scriptIdNode: t.Expression): readonly t.Expression[] => { - return [ - scriptIdNode, - t.numericLiteral(node.loc?.start.line ?? -1), - t.numericLiteral(node.loc?.start.column ?? -1), - ] + return [scriptIdNode, t.numericLiteral(node.loc?.start.line ?? -1), t.numericLiteral(node.loc?.start.column ?? -1)] } const getCalleeName = (node: t.Expression | t.V8IntrinsicIdentifier): string => { @@ -65,15 +61,11 @@ const getMethodLocations = (node: t.ObjectExpression | t.ClassExpression | t.Cla ? node.properties.filter((property) => t.isObjectMethod(property)) : node.body.body.filter( (element) => - (t.isClassMethod(element) || t.isClassPrivateMethod(element)) && - !(t.isClassMethod(element) && element.kind === 'constructor'), + (t.isClassMethod(element) || t.isClassPrivateMethod(element)) && !(t.isClassMethod(element) && element.kind === 'constructor'), ) return t.arrayExpression( methods.map((method) => - t.arrayExpression([ - t.numericLiteral(method.loc?.start.line ?? -1), - t.numericLiteral(method.loc?.start.column ?? -1), - ]), + t.arrayExpression([t.numericLiteral(method.loc?.start.line ?? -1), t.numericLiteral(method.loc?.start.column ?? -1)]), ), ) } diff --git a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts index d0e6e43988..d7749e78f1 100644 --- a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts +++ b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts @@ -23,15 +23,7 @@ test('tracks primitive evaluations and explicit identities in source order', asy globalThis.result = [number, string, array, object] `) const types = events.map((event: number) => metadata.sites[event].type) - expect(types).toEqual([ - 'Number', - 'Number', - 'Number', - 'String', - 'Array', - 'Object', - 'Array', - ]) + expect(types).toEqual(['Number', 'Number', 'Number', 'String', 'Array', 'Object', 'Array']) }) test('counts repeated primitive call results but only first observes an object identity', async () => { diff --git a/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts b/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts index b2642fd68e..722a284f8f 100644 --- a/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts +++ b/packages/memory-leak-finder/src/parts/CompareTrackedEverything/CompareTrackedEverything.ts @@ -3,10 +3,7 @@ import { mkdir, rename, rm } from 'node:fs/promises' import type { Session } from '../Session/Session.ts' import type { ScriptMap } from '../ResolveTrackedLocationSourceMaps/ResolveTrackedLocationSourceMaps.ts' import * as ResolveTrackedLocationSourceMaps from '../ResolveTrackedLocationSourceMaps/ResolveTrackedLocationSourceMaps.ts' -import type { - TrackedEverythingMetadata, - TrackedEverythingSite, -} from '../GetTrackedEverything/GetTrackedEverything.ts' +import type { TrackedEverythingMetadata, TrackedEverythingSite } from '../GetTrackedEverything/GetTrackedEverything.ts' export interface TrackedEverythingAfter { readonly metadata: TrackedEverythingMetadata diff --git a/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts b/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts index 0383a7d1d5..89e1ede823 100644 --- a/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts +++ b/packages/memory-leak-finder/test/MeasureTrackedEverything.test.ts @@ -11,12 +11,8 @@ jest.unstable_mockModule('../src/parts/GetTrackedEverything/GetTrackedEverything getTrackedEverythingMetadata: mockGetMetadata, })) -const MeasureTrackedEverything = await import( - '../src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts' -) -const CompareTrackedEverything = await import( - '../src/parts/CompareTrackedEverything/CompareTrackedEverything.ts' -) +const MeasureTrackedEverything = await import('../src/parts/MeasureTrackedEverything/MeasureTrackedEverything.ts') +const CompareTrackedEverything = await import('../src/parts/CompareTrackedEverything/CompareTrackedEverything.ts') test('tracked everything preserves startup data and writes chunks in order', async () => { const directory = await mkdtemp(join(tmpdir(), 'tracked-everything-measure-')) diff --git a/packages/visualizations/src/TrackedEverythingApp.tsx b/packages/visualizations/src/TrackedEverythingApp.tsx index c1de3dbec5..8d633279a3 100644 --- a/packages/visualizations/src/TrackedEverythingApp.tsx +++ b/packages/visualizations/src/TrackedEverythingApp.tsx @@ -100,10 +100,7 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve return () => window.clearInterval(handle) }, [aggregates.cursor, dataset.eventCount, playing, setCursor]) - const finalViews = useMemo( - () => Object.entries(finalFileCounts).map(([path, count]) => toView(path, count)), - [finalFileCounts], - ) + const finalViews = useMemo(() => Object.entries(finalFileCounts).map(([path, count]) => toView(path, count)), [finalFileCounts]) const finalLayout = useMemo(() => computeCityLayout(finalViews), [finalViews]) const buildings = useMemo(() => { const normalizedQuery = query.trim().toLowerCase() @@ -142,32 +139,69 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve
    - -
    LOSSLESS CREATION HISTORY

    Tracked Everything

    + + + +
    +
    LOSSLESS CREATION HISTORY
    +

    Tracked Everything

    +
    -
    Created{aggregates.cursor.toLocaleString()}
    -
    Sites{dataset.sites.length.toLocaleString()}
    -
    Elapsed{elapsedMs.toFixed(1)} ms
    +
    + Created + {aggregates.cursor.toLocaleString()} +
    +
    + Sites + {dataset.sites.length.toLocaleString()} +
    +
    + Elapsed + {elapsedMs.toFixed(1)} ms +
    - - + +
    - - setQuery(event.target.value)} placeholder="Find a source file…" /> + +
    -
    {dataset.scenario}{selectedPath && <>{selectedPath}}
    +
    + {dataset.scenario} + {selectedPath && ( + <> + + {selectedPath} + + )} +
    {selectedSites.map((site) => ( -
  • {site.count.toLocaleString()}{site.type} · line {site.originalLine ?? site.location}
  • +
  • + {site.count.toLocaleString()} + + {site.type} · line {site.originalLine ?? site.location} + +
  • ))} ) : ( -

    Select a building to inspect its hottest creation sites. Building footprints use final totals; heights show progress at the cursor.

    +

    + Select a building to inspect its hottest creation sites. Building footprints use final totals; heights show progress at the + cursor. +

    )} {error &&
    {error}
    }
    - +
    - + -
    0{axis === 'time' ? `${dataset.durationMs.toFixed(1)} ms` : dataset.eventCount.toLocaleString()}
    +
    + 0 + {axis === 'time' ? `${dataset.durationMs.toFixed(1)} ms` : dataset.eventCount.toLocaleString()} +
    diff --git a/packages/visualizations/src/TrackedEverythingTimeline.tsx b/packages/visualizations/src/TrackedEverythingTimeline.tsx index a3d33b758a..6f6909f3ca 100644 --- a/packages/visualizations/src/TrackedEverythingTimeline.tsx +++ b/packages/visualizations/src/TrackedEverythingTimeline.tsx @@ -31,9 +31,7 @@ export const TrackedEverythingTimeline = ({ context.scale(scale, scale) context.clearRect(0, 0, width, height) const binCount = types.length ? timeline[types[0]]?.length || 0 : 0 - const totals = Array.from({ length: binCount }, (_, bin) => - types.reduce((total, type) => total + (timeline[type]?.[bin] || 0), 0), - ) + const totals = Array.from({ length: binCount }, (_, bin) => types.reduce((total, type) => total + (timeline[type]?.[bin] || 0), 0)) const maximum = Math.max(1, ...totals) for (let bin = 0; bin < binCount; bin++) { const x = (bin / Math.max(1, binCount)) * width diff --git a/packages/visualizations/src/trackedEverythingModel.ts b/packages/visualizations/src/trackedEverythingModel.ts index b7b74f35de..ecd830aa7b 100644 --- a/packages/visualizations/src/trackedEverythingModel.ts +++ b/packages/visualizations/src/trackedEverythingModel.ts @@ -33,10 +33,7 @@ export const getSitePath = (site: TrackedEverythingDataset['sites'][number]): st return (match?.[1] || 'runtime/unmapped').replaceAll('\\', '/') } -export const decodeTrackedEverythingEvents = ( - buffer: ArrayBuffer, - dataset: TrackedEverythingDataset, -): Uint32Array => { +export const decodeTrackedEverythingEvents = (buffer: ArrayBuffer, dataset: TrackedEverythingDataset): Uint32Array => { if (buffer.byteLength !== dataset.eventCount * 4) { throw new Error(`Invalid event stream length: expected ${dataset.eventCount * 4} bytes, received ${buffer.byteLength}`) } @@ -49,11 +46,7 @@ export const decodeTrackedEverythingEvents = ( return events } -export const timeToEventIndex = ( - timeMarks: readonly TrackedEverythingTimeMark[], - elapsedMs: number, - eventCount: number, -): number => { +export const timeToEventIndex = (timeMarks: readonly TrackedEverythingTimeMark[], elapsedMs: number, eventCount: number): number => { if (timeMarks.length === 0 || elapsedMs <= timeMarks[0].elapsedMs) { return 0 } @@ -77,11 +70,7 @@ export const timeToEventIndex = ( return Math.max(0, Math.min(eventCount, Math.round(left.eventIndex + (right.eventIndex - left.eventIndex) * progress))) } -export const eventIndexToTime = ( - timeMarks: readonly TrackedEverythingTimeMark[], - eventIndex: number, - durationMs: number, -): number => { +export const eventIndexToTime = (timeMarks: readonly TrackedEverythingTimeMark[], eventIndex: number, durationMs: number): number => { if (timeMarks.length === 0 || eventIndex <= 0) { return 0 } From fc96be8165dac0021bff6127e2d9f4a503e6e8a9 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 18 Jul 2026 11:05:17 +0000 Subject: [PATCH 5/6] fix --- .../CreateEverythingWrapperPlugin.ts | 153 +++++++++++++++-- .../src/parts/Transform/Transform.ts | 19 +++ ...ransformCodeWithEverythingTracking.test.ts | 47 +++++- .../src/TrackedEverythingApp.tsx | 155 ++++++++++++++---- packages/visualizations/src/styles.css | 21 +++ .../src/trackedEverythingModel.ts | 52 +++++- .../src/trackedEverythingTypes.ts | 2 + .../src/trackedEverythingWorker.ts | 22 ++- .../test/TrackedEverythingModel.test.ts | 13 ++ 9 files changed, 418 insertions(+), 66 deletions(-) diff --git a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts index a2c7e59ae8..0a42f072a7 100644 --- a/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts +++ b/packages/function-tracker/src/parts/CreateEverythingWrapperPlugin/CreateEverythingWrapperPlugin.ts @@ -5,6 +5,12 @@ export interface CreateEverythingWrapperPluginOptions { readonly scriptId?: number | string } +interface RestBinding { + readonly hint: 'Array' | 'Object' + readonly identifier: t.Identifier + readonly node: t.RestElement +} + const helperName = '__vscodeMemoryLeakFinderTrackEverything' const createHelper = (): t.MemberExpression => t.memberExpression(t.identifier('globalThis'), t.identifier(helperName)) @@ -59,10 +65,7 @@ const shouldTrackLiteral = (path: NodePath): boolean => { const getMethodLocations = (node: t.ObjectExpression | t.ClassExpression | t.ClassDeclaration): t.ArrayExpression => { const methods: t.Node[] = t.isObjectExpression(node) ? node.properties.filter((property) => t.isObjectMethod(property)) - : node.body.body.filter( - (element) => - (t.isClassMethod(element) || t.isClassPrivateMethod(element)) && !(t.isClassMethod(element) && element.kind === 'constructor'), - ) + : node.body.body.filter((element) => t.isClassMethod(element) || t.isClassPrivateMethod(element)) return t.arrayExpression( methods.map((method) => t.arrayExpression([t.numericLiteral(method.loc?.start.line ?? -1), t.numericLiteral(method.loc?.start.column ?? -1)]), @@ -70,6 +73,39 @@ const getMethodLocations = (node: t.ObjectExpression | t.ClassExpression | t.Cla ) } +const collectRestBindings = (node: t.Node | null | undefined, hint: 'Array' | 'Object', output: RestBinding[]): void => { + if (!node) { + return + } + if (t.isRestElement(node)) { + if (t.isIdentifier(node.argument)) { + output.push({ hint, identifier: node.argument, node }) + } else { + collectRestBindings(node.argument, hint, output) + } + return + } + if (t.isObjectPattern(node)) { + for (const property of node.properties) { + if (t.isRestElement(property)) { + collectRestBindings(property, 'Object', output) + } else { + collectRestBindings(property.value, 'Object', output) + } + } + return + } + if (t.isArrayPattern(node)) { + for (const element of node.elements) { + collectRestBindings(element, 'Array', output) + } + return + } + if (t.isAssignmentPattern(node)) { + collectRestBindings(node.left, hint, output) + } +} + export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPluginOptions): Visitor => { const { scriptId = 123 } = options const scriptIdNode = typeof scriptId === 'number' ? t.numericLiteral(scriptId) : t.stringLiteral(scriptId) @@ -80,6 +116,15 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl identityMode: 'created' | 'observed' = 'created', methodLocations: t.ArrayExpression = t.arrayExpression(), ): void => { + if ( + path.findParent( + (parent) => + (parent.isCallExpression() || parent.isOptionalCallExpression()) && + isGeneratedHelperCall(parent.node as t.CallExpression | t.OptionalCallExpression), + ) + ) { + return + } const node = path.node const wrapped = t.callExpression(createHelper(), [ node, @@ -92,22 +137,73 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl path.skip() } - const registerDeclaration = (path: NodePath, hint: string): void => { - const { id } = path.node - const { node } = path - if (!id) { - return - } - const methods = t.isClassDeclaration(node) ? getMethodLocations(node) : t.arrayExpression() - const call = t.expressionStatement( + const createRegistrationCall = ( + value: t.Expression, + node: t.Node, + hint: string, + methods: t.ArrayExpression = t.arrayExpression(), + ): t.ExpressionStatement => { + return t.expressionStatement( t.callExpression(createHelper(), [ - t.identifier(id.name), + value, ...getLocationNodes(node, scriptIdNode), t.stringLiteral(hint), t.stringLiteral('created'), methods, ]), ) + } + + const getRestRegistrations = (nodes: readonly (t.Node | null | undefined)[], defaultHint: 'Array' | 'Object') => { + const bindings: RestBinding[] = [] + for (const node of nodes) { + collectRestBindings(node, defaultHint, bindings) + } + return bindings.map((binding) => createRegistrationCall(t.identifier(binding.identifier.name), binding.node, binding.hint)) + } + + const prependRegistrations = (body: t.BlockStatement, registrations: readonly t.ExpressionStatement[]): void => { + body.body.unshift(...registrations) + } + + const registerRestParameter = (path: NodePath): void => { + const registrations = getRestRegistrations(path.node.params || [], 'Array') + if (registrations.length === 0) { + return + } + if (t.isBlockStatement(path.node.body)) { + prependRegistrations(path.node.body, registrations) + return + } + path.node.body = t.blockStatement([...registrations, t.returnStatement(path.node.body as t.Expression)]) + } + + const registerVariableRestBindings = (path: NodePath): void => { + const registrations = getRestRegistrations( + path.node.declarations.map((declaration) => declaration.id), + 'Array', + ) + if (registrations.length === 0) { + return + } + if (path.parentPath?.isForInStatement() || path.parentPath?.isForOfStatement()) { + const loop = path.parentPath.node + if (!t.isBlockStatement(loop.body)) { + loop.body = t.blockStatement([loop.body]) + } + prependRegistrations(loop.body, registrations) + return + } + if (path.parentPath?.isProgram() || path.parentPath?.isBlockStatement()) { + path.insertAfter(registrations) + } + } + + const registerDeclaration = (path: NodePath, hint: string): void => { + const { id } = path.node + const { node } = path + const methods = t.isClassDeclaration(node) ? getMethodLocations(node) : t.arrayExpression() + const call = createRegistrationCall(id ? t.identifier(id.name) : t.unaryExpression('void', t.numericLiteral(0)), node, hint, methods) path.insertAfter(call) } @@ -119,7 +215,12 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl return { ArrayExpression: { exit: (path: NodePath) => wrap(path, 'Array') }, - ArrowFunctionExpression: { exit: (path: NodePath) => wrap(path, 'Function') }, + ArrowFunctionExpression: { + exit: (path: NodePath) => { + registerRestParameter(path) + wrap(path, 'Function') + }, + }, BigIntLiteral: { exit: (path: NodePath) => literalExit(path, 'BigInt') }, BinaryExpression: { exit: (path: NodePath) => wrap(path, 'Dynamic') }, BooleanLiteral: { exit: (path: NodePath) => literalExit(path, 'Boolean') }, @@ -130,12 +231,30 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl } }, }, + CatchClause: { + exit: (path: NodePath) => { + const registrations = getRestRegistrations([path.node.param], 'Object') + prependRegistrations(path.node.body, registrations) + }, + }, ClassDeclaration: { exit: (path: NodePath) => registerDeclaration(path, 'Class') }, ClassExpression: { exit: (path: NodePath) => wrap(path, 'Class', 'created', getMethodLocations(path.node)), }, - FunctionDeclaration: { exit: (path: NodePath) => registerDeclaration(path, 'Function') }, - FunctionExpression: { exit: (path: NodePath) => wrap(path, 'Function') }, + ClassMethod: { exit: (path: NodePath) => registerRestParameter(path) }, + ClassPrivateMethod: { exit: (path: NodePath) => registerRestParameter(path) }, + FunctionDeclaration: { + exit: (path: NodePath) => { + registerRestParameter(path) + registerDeclaration(path, 'Function') + }, + }, + FunctionExpression: { + exit: (path: NodePath) => { + registerRestParameter(path) + wrap(path, 'Function') + }, + }, NewExpression: { exit: (path: NodePath) => wrap(path, getCalleeName(path.node.callee)), }, @@ -144,6 +263,7 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl ObjectExpression: { exit: (path: NodePath) => wrap(path, 'Object', 'created', getMethodLocations(path.node)), }, + ObjectMethod: { exit: (path: NodePath) => registerRestParameter(path) }, OptionalCallExpression: { exit: (path: NodePath) => { if (!isGeneratedHelperCall(path.node)) { @@ -165,5 +285,6 @@ export const createEverythingWrapperPlugin = (options: CreateEverythingWrapperPl }, UnaryExpression: { exit: (path: NodePath) => wrap(path, 'Dynamic') }, UpdateExpression: { exit: (path: NodePath) => wrap(path, 'Number') }, + VariableDeclaration: { exit: registerVariableRestBindings }, } } diff --git a/packages/function-tracker/src/parts/Transform/Transform.ts b/packages/function-tracker/src/parts/Transform/Transform.ts index df79f85d21..4c148182bb 100644 --- a/packages/function-tracker/src/parts/Transform/Transform.ts +++ b/packages/function-tracker/src/parts/Transform/Transform.ts @@ -195,6 +195,25 @@ const EVERYTHING_PREAMBLE_CODE = `(() => { } seenSymbols.add(value) } + if(methodLocations.length > 0 && value !== null && (valueType === 'object' || valueType === 'function')){ + const containers = hint === 'Class' ? [value, value.prototype] : [value] + for(const container of containers){ + if(!container){ + continue + } + for(const descriptor of Object.values(Object.getOwnPropertyDescriptors(container))){ + if(typeof descriptor.value === 'function'){ + seenIdentities.add(descriptor.value) + } + if(typeof descriptor.get === 'function'){ + seenIdentities.add(descriptor.get) + } + if(typeof descriptor.set === 'function'){ + seenIdentities.add(descriptor.set) + } + } + } + } for(const methodLocation of methodLocations){ recordSite(scriptId, methodLocation[0], methodLocation[1], 'Function') } diff --git a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts index d7749e78f1..d6ee985082 100644 --- a/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts +++ b/packages/function-tracker/test/TransformCodeWithEverythingTracking.test.ts @@ -58,15 +58,31 @@ test('preserves directives, object keys, direct eval, optional calls, and result test('records function and class declarations and method creation', async () => { const { events, metadata } = await run(` function fn() {} - class Example { method() {} } + class Example { constructor() {} method() {} } const instance = new Example() const object = { method() {} } `) const types = events.map((event: number) => metadata.sites[event].type) - expect(types.filter((type: string) => type === 'Function')).toHaveLength(3) + expect(types.filter((type: string) => type === 'Function')).toHaveLength(4) expect(types).toContain('Class') expect(types).toContain('Example') expect(types).toContain('Object') + expect(metadata.sites.every((site: any) => !site.location.includes(':-1:-1'))).toBe(true) +}) + +test('does not instrument generated declaration metadata', () => { + const transformed = transformCodeWithEverythingTracking(`export function fn() {}\nexport class Example { method() {} }`, { scriptId: 7 }) + expect(transformed).not.toContain('__vscodeMemoryLeakFinderTrackEverything(7, 7') + expect(transformed.match(/__vscodeMemoryLeakFinderTrackEverything/g)).toHaveLength(2) +}) + +test('counts anonymous default function and class declarations without renaming them', () => { + const functionCode = transformCodeWithEverythingTracking(`export default function () {}`, { scriptId: 7 }) + const classCode = transformCodeWithEverythingTracking(`export default class { method() {} }`, { scriptId: 7 }) + expect(functionCode).toContain('export default function () {}') + expect(functionCode).toContain('__vscodeMemoryLeakFinderTrackEverything(void 0, 7') + expect(classCode).toContain('export default class {') + expect(classCode).toContain('__vscodeMemoryLeakFinderTrackEverything(void 0, 7') }) test('preserves tagged templates, inheritance, and super calls', async () => { @@ -80,6 +96,33 @@ test('preserves tagged templates, inheritance, and super calls', async () => { expect(context.result).toBe('value-2') }) +test('tracks rest parameter arrays for functions, arrows, and methods', async () => { + const { context, events, metadata } = await run(` + function declared(...values) { return values.length } + const arrow = (...values) => values.length + const object = { method(...values) { return values.length } } + globalThis.result = [declared(1), arrow(1, 2), object.method(1, 2, 3)] + `) + expect(context.result).toEqual([1, 2, 3]) + const types = events.map((event: number) => metadata.sites[event].type) + expect(types.filter((type: string) => type === 'Array').length).toBeGreaterThanOrEqual(4) +}) + +test('tracks array and object values created by destructuring rest', async () => { + const { context, events, metadata } = await run(` + const sourceObject = { keep: 1, value: 2 } + const sourceArray = [1, 2, 3] + const { keep, ...objectRest } = sourceObject + const [first, ...arrayRest] = sourceArray + const read = ({ keep, ...parameterRest }) => parameterRest.value + globalThis.result = [objectRest.value, arrayRest.length, read(sourceObject)] + `) + expect(context.result).toEqual([2, 2, 2]) + const types = events.map((event: number) => metadata.sites[event].type) + expect(types.filter((type: string) => type === 'Object').length).toBeGreaterThanOrEqual(3) + expect(types.filter((type: string) => type === 'Array').length).toBeGreaterThanOrEqual(3) +}) + test('uses fixed-size chunks without dropping events', async () => { const { context } = await run(`globalThis.record = () => 1`) for (let index = 0; index < 65_540; index++) { diff --git a/packages/visualizations/src/TrackedEverythingApp.tsx b/packages/visualizations/src/TrackedEverythingApp.tsx index 8d633279a3..3a31a625ca 100644 --- a/packages/visualizations/src/TrackedEverythingApp.tsx +++ b/packages/visualizations/src/TrackedEverythingApp.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, Layers3, Pause, Play, Search } from 'lucide-react' -import { computeCityLayout } from './layout.ts' +import { computeCityLayout, getGrowthColor } from './layout.ts' import { MemoryCityScene } from './MemoryCityScene.tsx' import { TrackedEverythingTimeline } from './TrackedEverythingTimeline.tsx' import { decodeTrackedEverythingEvents, eventIndexToTime, getSitePath, timeToEventIndex } from './trackedEverythingModel.ts' @@ -10,19 +10,26 @@ import type { BuildingLayout, BuildingView } from './types.ts' const emptyAggregates: TrackedEverythingAggregates = { cursor: 0, fileCounts: {}, + recentFileCounts: {}, + recentSiteCounts: [], siteCounts: [], timeline: {}, typeCounts: {}, types: [], } -const getFinalFileCounts = (events: Uint32Array, dataset: TrackedEverythingDataset): Record => { - const counts: Record = Object.create(null) +const getFinalCounts = ( + events: Uint32Array, + dataset: TrackedEverythingDataset, +): { readonly fileCounts: Record; readonly siteCounts: number[] } => { + const fileCounts: Record = Object.create(null) + const siteCounts = Array.from({ length: dataset.sites.length }, () => 0) for (const siteId of events) { const path = getSitePath(dataset.sites[siteId]) - counts[path] = (counts[path] || 0) + 1 + fileCounts[path] = (fileCounts[path] || 0) + 1 + siteCounts[siteId]++ } - return counts + return { fileCounts, siteCounts } } const toView = (path: string, count: number): BuildingView => ({ @@ -37,15 +44,19 @@ const toView = (path: string, count: number): BuildingView => ({ shallowBytes: count, }) +const getSiteBuildingPath = (siteId: number): string => `site/${siteId}` + export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEverythingDataset }) => { const workerRef = useRef(null) const [aggregates, setAggregates] = useState(emptyAggregates) const [axis, setAxis] = useState<'allocation' | 'time'>('time') const [error, setError] = useState('') const [finalFileCounts, setFinalFileCounts] = useState>({}) + const [finalSiteCounts, setFinalSiteCounts] = useState([]) const [playing, setPlaying] = useState(false) const [query, setQuery] = useState('') const [selectedPath, setSelectedPath] = useState('') + const [selectedSiteId, setSelectedSiteId] = useState(null) const [selectedType, setSelectedType] = useState('') useEffect(() => { @@ -65,8 +76,11 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve throw new Error(`expected ${dataset.eventCount * 4} event bytes, received ${buffer.byteLength}`) } const events = decodeTrackedEverythingEvents(buffer, dataset) - setFinalFileCounts(getFinalFileCounts(events, dataset)) - worker.postMessage({ buffer, dataset, kind: 'init' }, [buffer]) + const finalCounts = getFinalCounts(events, dataset) + setFinalFileCounts(finalCounts.fileCounts) + setFinalSiteCounts(finalCounts.siteCounts) + const normalizedBuffer = events.buffer as ArrayBuffer + worker.postMessage({ buffer: normalizedBuffer, dataset, kind: 'init' }, [normalizedBuffer]) } catch (loadError) { setError(loadError instanceof Error ? loadError.message : String(loadError)) } @@ -86,7 +100,7 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve ) useEffect(() => { - if (!playing) { + if (!playing || aggregates.cursor >= dataset.eventCount) { return } const increment = Math.max(1, Math.ceil(dataset.eventCount / 240)) @@ -100,37 +114,86 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve return () => window.clearInterval(handle) }, [aggregates.cursor, dataset.eventCount, playing, setCursor]) - const finalViews = useMemo(() => Object.entries(finalFileCounts).map(([path, count]) => toView(path, count)), [finalFileCounts]) + const visibleSites = useMemo( + () => + selectedPath + ? dataset.sites.filter((site) => getSitePath(site) === selectedPath && (!selectedType || site.type === selectedType)) + : [], + [dataset.sites, selectedPath, selectedType], + ) + const finalViews = useMemo( + () => + selectedPath + ? visibleSites.map((site) => toView(getSiteBuildingPath(site.id), finalSiteCounts[site.id] || 0)) + : Object.entries(finalFileCounts).map(([path, count]) => toView(path, count)), + [finalFileCounts, finalSiteCounts, selectedPath, visibleSites], + ) const finalLayout = useMemo(() => computeCityLayout(finalViews), [finalViews]) const buildings = useMemo(() => { const normalizedQuery = query.trim().toLowerCase() + const recentLimit = Math.max( + 1, + ...(selectedPath + ? visibleSites.map((site) => aggregates.recentSiteCounts[site.id] || 0) + : Object.values(aggregates.recentFileCounts)), + ) return finalLayout.buildings - .filter((building) => !normalizedQuery || building.path.toLowerCase().includes(normalizedQuery)) + .filter((building) => { + if (!normalizedQuery) { + return true + } + if (!selectedPath) { + return building.path.toLowerCase().includes(normalizedQuery) + } + const siteId = Number(building.path.split('/').at(-1)) + const site = dataset.sites[siteId] + return `${site.type} ${site.originalSource || site.location} ${site.originalLine || ''}`.toLowerCase().includes(normalizedQuery) + }) .map((building): BuildingLayout => { - const current = aggregates.fileCounts[building.path] || 0 - const final = finalFileCounts[building.path] || 1 + const siteId = selectedPath ? Number(building.path.split('/').at(-1)) : -1 + const current = selectedPath ? aggregates.siteCounts[siteId] || 0 : aggregates.fileCounts[building.path] || 0 + const final = selectedPath ? finalSiteCounts[siteId] || 1 : finalFileCounts[building.path] || 1 + const recent = selectedPath ? aggregates.recentSiteCounts[siteId] || 0 : aggregates.recentFileCounts[building.path] || 0 + const progress = Math.log1p(current) / Math.log1p(final) return { ...building, + color: getGrowthColor(recent, recentLimit), deltaBytes: current, growthPercent: (current / final) * 100, - growthSlope: current, - height: current === 0 ? 0.001 : 0.35 + (Math.log1p(current) / Math.log1p(final)) * building.height, + growthSlope: recent, + height: current === 0 ? 0.001 : 0.001 + (building.height - 0.001) * progress, objectCount: current, retainedBytes: current, shallowBytes: current, } }) - }, [aggregates.fileCounts, finalFileCounts, finalLayout.buildings, query]) + }, [ + aggregates.fileCounts, + aggregates.recentFileCounts, + aggregates.recentSiteCounts, + aggregates.siteCounts, + dataset.sites, + finalFileCounts, + finalLayout.buildings, + finalSiteCounts, + query, + selectedPath, + visibleSites, + ]) const selectedSites = useMemo( () => - dataset.sites - .filter((site) => getSitePath(site) === selectedPath && (!selectedType || site.type === selectedType)) - .map((site) => ({ ...site, count: aggregates.siteCounts[site.id] || 0 })) + visibleSites + .map((site) => ({ + ...site, + count: aggregates.siteCounts[site.id] || 0, + recentCount: aggregates.recentSiteCounts[site.id] || 0, + })) .filter((site) => site.count > 0) .toSorted((left, right) => right.count - left.count) .slice(0, 12), - [aggregates.siteCounts, dataset.sites, selectedPath, selectedType], + [aggregates.recentSiteCounts, aggregates.siteCounts, visibleSites], ) + const selectedSite = selectedSiteId === null ? undefined : dataset.sites[selectedSiteId] const elapsedMs = eventIndexToTime(dataset.timeMarks, aggregates.cursor, dataset.durationMs) const sliderValue = axis === 'time' ? elapsedMs : aggregates.cursor const sliderMax = axis === 'time' ? dataset.durationMs : dataset.eventCount @@ -182,6 +245,7 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve onChange={(event) => { const value = event.target.value setSelectedType(value) + setSelectedSiteId(null) setCursor(aggregates.cursor, value) }} > @@ -194,7 +258,15 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve
    - {dataset.scenario} + {selectedPath && ( <> @@ -209,24 +281,43 @@ export const TrackedEverythingApp = ({ dataset }: { readonly dataset: TrackedEve districts={finalLayout.districts} onFailure={() => setError('WebGL could not initialize')} onHover={() => {}} - onSelect={(building) => setSelectedPath(building.path)} + onSelect={(building) => { + if (selectedPath) { + setSelectedSiteId(Number(building.path.split('/').at(-1))) + } else { + setSelectedPath(building.path) + setSelectedSiteId(null) + } + }} resetToken={0} - selectedPath={selectedPath} + selectedPath={selectedSiteId === null ? '' : getSiteBuildingPath(selectedSiteId)} />