
Auto Perf Optimize
- 100 installs
- 188k repo stars
- Updated July 28, 2026
- microsoft/vscode
Run agent-driven VS Code performance or memory investigations.
About
Run agent-driven VS Code performance or memory investigations. Use when asked to launch Code OSS, automate a VS Code scenario, run the Chat memory smoke runner, capture renderer heap snapshots, take workflow screenshots, compare run summaries, or drive a repeatable scenario before heap-snapshot analysis. Drive a repeatable VS Code scenario, collect memory/performance artifacts, verify that the scenario actually happened, then hand the resulting heap snapshots to the generic heap-snapshot-analysis skill when object-level investigation is needed.
- # VS Code Performance Workflow
- User describes a VS Code workflow and asks whether it leaks or grows memory
- User asks the agent to launch VS Code, drive a scenario, and capture heap snapshots
- User asks to run the Chat memory smoke runner bundled with this skill
- User wants screenshots, `summary.json`, renderer heap samples, and targeted `.heapsnapshot` files for one scenario
Auto Perf Optimize by the numbers
- 100 all-time installs (skills.sh)
- Ranked #1,003 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
auto-perf-optimize capabilities & compatibility
- Capabilities
- # vs code performance workflow · user describes a vs code workflow and asks wheth · user asks the agent to launch vs code, drive a s · user asks to run the chat memory smoke runner bu
- Use cases
- documentation
What auto-perf-optimize says it does
Run agent-driven VS Code performance or memory investigations. Use when asked to launch Code OSS, automate a VS Code scenario, run the Chat memory smoke runner, capture renderer heap snapshots, take w
npx skills add https://github.com/microsoft/vscode --skill auto-perf-optimizeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 100 |
|---|---|
| repo stars | ★ 188k |
| Last updated | July 28, 2026 |
| Repository | microsoft/vscode ↗ |
How do I apply auto-perf-optimize using the workflow in its SKILL.md?
Run agent-driven VS Code performance or memory investigations. Use when asked to launch Code OSS, automate a VS Code scenario, run the Chat memory smoke runner, capture renderer heap snap...
Who is it for?
Developers following the auto-perf-optimize skill for the tasks it documents.
Skip if: Tasks outside the auto-perf-optimize scope described in SKILL.md.
When should I use this skill?
User mentions auto-perf-optimize or related triggers from the skill description.
What you get
Working auto-perf-optimize setup aligned with the documented patterns and constraints.
Files
VS Code Performance Workflow
Drive a repeatable VS Code scenario, collect memory/performance artifacts, verify that the scenario actually happened, then hand the resulting heap snapshots to the generic heap-snapshot-analysis skill when object-level investigation is needed.
When to Use
- User describes a VS Code workflow and asks whether it leaks or grows memory
- User asks the agent to launch VS Code, drive a scenario, and capture heap snapshots
- User asks to run the Chat memory smoke runner bundled with this skill
- User wants screenshots,
summary.json, renderer heap samples, and targeted.heapsnapshotfiles for one scenario - User wants a new automation runner for a non-Chat VS Code scenario
Do not use this skill when snapshots already exist and the user only wants heap object/retainer analysis. Use heap-snapshot-analysis directly.
The Story
1. Define the scenario. Write down one warmup action, one repeatable iteration, and one quiescent point where it is fair to force GC and sample memory. 2. Develop the automation. Start with a tiny no-snapshot run. If it fails or the UI state is uncertain, keep the Code window open, connect @playwright/cli to the same CDP port, take workspace-local screenshots, inspect snapshots, and update the runner's selectors/waits. 3. Run a fast smoke. Disable heap snapshots first. Prove the scenario completes and the artifact summary says what you think it says. 4. Capture targeted snapshots. Snapshot a warmed-up baseline and a later iteration. Do not snapshot every sample unless necessary; snapshots are huge and slow. 5. Verify the run. Inspect summary.json and screenshots. Do not analyze a failed login, trust prompt, stuck progress row, or wrong UI state. 6. Analyze snapshots. Switch to heap-snapshot-analysis for compare scripts, object grouping, and retainer paths. 7. Fix and verify. After identifying leaks, make product-code fixes. Then rerun the same scenario with the same snapshot labels and compare like-for-like. Do not stop at analysis — the goal is to ship a fix, not just a report. 8. Document. Save a summary of findings, fixes, and before/after measurements to session memory so the work is preserved.
Checked-in Runners
The scripts/ folder contains stable, generic runners. Use them directly or as templates for scratchpad scripts:
- [chat-memory-smoke.mts](./scripts/chat-memory-smoke.mts) — Multi-turn chat smoke runner. Sends prompts, waits for responses, samples heap, takes optional snapshots. The most versatile runner.
- [chat-session-switch-smoke.mts](./scripts/chat-session-switch-smoke.mts) — Creates multiple chat sessions with different content types, then repeatedly switches between them via the sessions sidebar.
- [userDataProfile.mts](./scripts/userDataProfile.mts) — Utility for managing user-data profiles in smoke test runs.
Chat Workflow: Chat Memory Smoke Runner
Use the bundled Chat memory smoke runner when the scenario is Chat-specific or can be expressed as repeated Chat prompts. It launches Code OSS, opens Chat, sends prompts, waits for responses, writes screenshots and summary.json, samples renderer heap, and can take selected heap snapshots.
Fast health check:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 3 --no-heap-snapshotsTargeted post-warmup snapshots:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 8 --heap-snapshot-label 03-iteration-01 --heap-snapshot-label 03-iteration-08User-described Chat scenario:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 8 --message 'For memory investigation iteration {iteration}, summarize the active workspace in one paragraph.' --heap-snapshot-label 03-iteration-01 --heap-snapshot-label 03-iteration-08Important runner behavior:
- The default profile is persistent at
.build/auto-perf-optimize/user-dataso auth can be reused by all runners in this skill. - Pass
--temporary-user-dataonly if a clean profile is part of the scenario. - Pass
--seed-user-data-dir <path>to copy a logged-in profile into a fresh target profile before launch. The target profile may contain auth secrets; keep it inside ignored local.build/...folders and never attach it to issues or PRs.
Safety: chat runs execute on the real machine. The Code OSS instance launched by these runners is a full VS Code with Copilot auth on the user's actual computer — not a sandbox. Chat prompts you craft will be sent to a real LLM, and any tool calls the agent makes (terminal commands, file edits, etc.) will execute for real. Be responsible:
- Use a throwaway workspace, not the real repo. Pass
--workspace <scratch-folder>pointing to a temporary or gitignored directory (e.g., the runner's scratchpad subfolder, or a folder under.build/). The default workspace in checked-in runners is the repo root for convenience, but scratchpad runners for Chat scenarios should always override it to avoid accidental file modifications in the source tree. - Use safe, read-only commands for prompts that trigger terminal tools (e.g.,
touch /tmp/foo,git log --oneline,ls). Never instruct the agent to delete files, run destructive commands, or modify the user's workspace. - If you need tool calls for testing, use harmless operations and clean up any temp files afterward.
- Don't be afraid to run terminal commands — just be thoughtful about what you ask.
- Pass
--keep-openwhen the user needs to log in or watch the window, then close the window before the next automated run unless intentionally reusing it. - Pass
--reuseonly when attaching to a Code window that was launched with--enable-smoke-test-driverand the chosen remote-debugging port.
Profiles and Auth
Prefer the shared persistent performance profile for routine runs:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --keep-open --iterations 1 --no-heap-snapshotsIf Chat asks for auth, let the user sign in once, close the Code window, then rerun the fast smoke without --keep-open. The same profile is reused by the bundled Chat runner and by other runners that follow this skill's profile convention.
To bootstrap the shared performance profile from an older logged-in automation profile, copy it once into the default target:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --seed-user-data-dir .build/chat-memory-smoke/user-data --keep-open --iterations 1 --no-heap-snapshotsTo run a fresh disposable copy of a logged-in seed:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --temporary-user-data --seed-user-data-dir .build/auto-perf-optimize/user-data --iterations 3 --no-heap-snapshotsSeed-copy rules:
- The seed Code window must be closed. Never copy a profile while a Code process is using it.
- The target user-data-dir must be absent or empty. If the script refuses to copy, pick a fresh
--user-data-dir, use--temporary-user-data, or delete the local target deliberately. - The copy skips root-level caches, logs, crash dumps, singleton lock/socket files, and session storage. It intentionally keeps user/global storage that may contain auth or extension state.
- Use explicit
--user-data-dir <fresh-path> --seed-user-data-dir <seed-path>when you want to keep the copied profile after the run. User-provided--user-data-diris never deleted by the runner.
Develop and Watch a Runner
The first version of an automation runner is rarely correct. Treat the runner as a test you are developing: run a cheap scenario, observe the live workbench, adjust one selector or wait condition, and repeat. Do not collect heap snapshots until the runner is boringly reliable.
New runners go in the [scratchpad](./scratchpad/) folder (gitignored). Checked-in scripts in scripts/ are stable, generic runners — don't modify them for a one-off investigation. Instead, copy patterns from them into a scratchpad script.
Organize scratchpad work into dated subfolders named YYYY-MM-DD-short-description/ (e.g., 2026-04-09-chat-scroll-leak/). Each subfolder should contain:
- The investigation scripts (
.mts,.mjs, etc.) - A `findings.md` file documenting the full investigation: all ideas considered, which ones led to changes and which were rejected (and why), before/after measurements, and a summary of the outcome. This lets the user review the agent's reasoning, decide which changes to keep, and follow up on deferred ideas.
Start fresh. Ignore any existing scratchpad subfolders from previous investigations. They belong to earlier sessions and their context, scripts, and findings are not relevant to your current task. Always create a new dated subfolder for your investigation.
Import path depth: Scripts in dated subfolders are 6 levels below the repo root (.github/skills/auto-perf-optimize/scratchpad/YYYY-MM-DD-name/script.mts), not 4 like the checked-in scripts/*.mts runners. Adjust relative imports accordingly — use 5 .. segments to reach the repo root from a dated subfolder (e.g., '../../../../../src/vs/base/common/stopwatch.ts'), and '../../scripts/userDataProfile.mts' to reach sibling checked-in scripts.
Suggested watch loop for the bundled Chat runner:
node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --keep-open --iterations 1 --no-heap-snapshots --port 9224 --output .build/chat-memory-smoke/watch-chatWhile that Code window is open, inspect it with @playwright/cli from the repo root:
npx @playwright/cli attach --cdp=http://127.0.0.1:9224
npx @playwright/cli tab-list
npx @playwright/cli snapshot
npx @playwright/cli screenshot --filename=.build/chat-memory-smoke/watch-chat/observation.png@playwright/cli checkpoints:
- Run
tab-listfirst. If the selected target isabout:blankor a webview instead of the workbench, switch targets before trusting snapshots. - Use
snapshotto rediscover buttons, textboxes, list rows, webviews, and current accessible names. Prefer discovered state over stale selectors. - Save screenshots inside the runner output folder or another workspace-local
.build/...folder. Do not use/tmpfor screenshots you expect the user to review. The output directory must already exist —screenshot --filenamewill fail withENOENTif it does not. Create it withmkdir -p <dir>first. - If the script is stuck, capture a screenshot and read the incremental
summary.jsonbefore killing the window. The last submitted turn and last screenshot usually identify the missing wait condition. - If auth is required, use
--keep-open, let the user sign in once in the persistent default profile, close the window, then rerun the fast smoke.
When editing a scenario runner:
- Keep a stable output contract:
summary.json, checkpoint screenshots, heap samples, optionalheap/*.heapsnapshotfiles, and anerrorfield on failure. - Write summary/screenshot artifacts before long waits so failed runs are diagnosable.
- Wait for user-visible scenario completion, not arbitrary time. Prefer an observed response, progress disappearance, row-count change, editor content change, or command result.
- Validate with
--no-heap-snapshotsfirst. A broken runner plus a 2GB heap snapshot wastes time and hides the real failure. - Close owned Code windows between runs unless the command intentionally uses
--keep-openor--reuse.
Verify Before Analyzing
Read the run's summary.json before opening heap snapshots. Check:
erroris absentchatTurnshas the expected count- each turn has a response-start reason and final response text, unless the run intentionally used
--skip-send analysis.postFirstTurnUsedBytesandanalysis.postFirstTurnUsedBytesPerTurnare present for multi-turn memory probes- requested snapshot labels exist under
heap/ - screenshots show the requested workflow and settled UI
Prefer a warmed-up baseline such as 03-iteration-01.heapsnapshot over startup snapshots. Startup, Chat opening, login, extension activation, and first-use model loads are expected allocations.
Compare a Chat Runner Result
After capture, use heap-snapshot-analysis. A minimal scratchpad comparison script looks like this:
import path from 'node:path';
import { compareSnapshots, printComparison } from '../helpers/compareSnapshots.ts';
const runDir = process.env.RUN;
if (!runDir) {
throw new Error('Set RUN to a chat-memory-smoke output directory');
}
const before = path.join(runDir, 'heap', '03-iteration-01.heapsnapshot');
const after = path.join(runDir, 'heap', '03-iteration-08.heapsnapshot');
printComparison(compareSnapshots(before, after));Run it from the heap-snapshot-analysis skill folder:
cd .github/skills/heap-snapshot-analysis
RUN=../../../.build/chat-memory-smoke/<run-folder> node --max-old-space-size=16384 scratchpad/compare-chat-run.mjsNon-Chat VS Code Scenarios
When the user describes a non-Chat scenario, ask only for the missing essentials: what action starts the scenario, what counts as one repeatable iteration, what indicates the UI is settled, and whether the profile should be persistent or temporary.
Write new scenario runners in the [scratchpad](./scratchpad/) folder. This folder is gitignored — use it freely for one-off investigation scripts. If a runner proves generally useful, promote it to scripts/ with documentation and validation.
Put each investigation in a dated subfolder (see "Develop and Watch a Runner" for the naming convention).
Example scratchpad workflow:
# Create a dated investigation folder
mkdir -p .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak
# Write a runner inside it
cat > .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak/scenario.mts << 'EOF'
// ... your scenario using patterns from the checked-in scripts
EOF
# Validate without snapshots first
node .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak/scenario.mts \
--iterations 3 --no-heap-snapshots --skip-prelaunch \
--user-data-dir .build/chat-memory-smoke/user-data
# Then capture targeted snapshots
node .github/skills/auto-perf-optimize/scratchpad/2026-04-09-editor-tab-leak/scenario.mts \
--iterations 10 --heap-snapshot-label baseline --heap-snapshot-label final \
--skip-prelaunch --user-data-dir .build/chat-memory-smoke/user-data
# Write findings.md when the investigation concludesReuse these patterns from the checked-in scripts (chat-memory-smoke.mts, chat-session-switch-smoke.mts):
- launch
scripts/code.shorscripts/code.bat - pass
--enable-smoke-test-driver,--disable-workspace-trust, a known--remote-debugging-port, explicit--user-data-dir, explicit--extensions-dir,--skip-welcome, and--skip-release-notes - use a throwaway workspace (
--workspace <scratch-folder>) instead of the repo root to prevent Chat tool calls from modifying real source files - connect Playwright with
chromium.connectOverCDP - wait for
globalThis.driver?.whenWorkbenchRestored?.() - enable CDP
PerformanceandHeapProfiler - collect garbage before memory samples
- write screenshots at important checkpoints
- write a machine-readable
summary.jsonincrementally, especially before long waits - support
--no-heap-snapshotsand targeted snapshot labels so validation stays fast - make cleanup explicit: close the CDP browser, terminate owned Code processes, and preserve user-provided profiles
Keep scenario-specific UI selectors and wait logic in the scenario runner. Avoid making the Chat runner a generic abstraction unless multiple proven scenarios share the exact same lifecycle.
Handoff to Heap Snapshot Analysis
Use heap-snapshot-analysis when you need to:
- compare two
.heapsnapshotfiles by constructor/object group - find direct retainers or paths to GC roots
- inspect why a particular class, model, widget, editor input, or DOM tree survived
- write investigation-specific scratchpad analysis against parsed snapshots
The output of this workflow is evidence: run summaries, screenshots, heap samples, targeted snapshots, comparison output, and retainer paths. Use that evidence to form a concrete leak hypothesis, then fix the product code and verify the fix with another run.
Root-Cause, Don't Treat Symptoms
A surface-level observation ("this Map is growing") is not a diagnosis. Before writing a fix, understand why the code is structured the way it is:
1. Use `git blame` and `git log` on the leaking code. Read the commit message, the PR description, and any linked issues. A guard like if (this._isDisposed) return may exist because removing it once caused crashes — understand the original intent before changing it. 2. Trace the full lifecycle, not just the leak site. If disposeContext() is silently dropped, ask: why is the parent disposed before the child? Is the disposal order wrong, or is the guard wrong? The answer determines whether you fix the guard, fix the disposal order, or add a different cleanup path. 3. Distinguish caches from leaks. A Map that grows but has a trim/eviction mechanism (like UriIdentityService._canonicalUris with its 2^16 limit) is a cache, not a leak. Don't "fix" caches unless they lack any eviction policy. 4. Look for the design-level problem. If transient objects register in a global singleton and the singleton never unregisters them, the fix isn't just adding a delete call — ask whether the registration should happen at all for transient objects, or whether an intermediate scoped registry should exist. 5. Check for prior art. Search the codebase for similar patterns that handle the same lifecycle correctly. If a sibling pool/service already disposes in-use items on clear, follow that pattern. If nothing else does, understand why before introducing a new contract.
The goal is to fix the cause, not paper over the effect. A fix that adds cleanup code without understanding why cleanup was missing will often introduce new bugs or re-break a previous fix.
Fix, Don't Just Report
The goal of this workflow is to ship fixes, not produce reports. After identifying leaks:
1. Make product-code changes that address the root cause. Common patterns:
- Pools that
clear()idle items but leave_inUseorphaned — also dispose_inUseon clear - Global service maps (
ContextKeyService._contexts,HoverService._managedHovers,UriIdentityService._canonicalUris) that grow because transient objects register but never unregister - Disposable chains where
_register(service.createScoped(...))is correct but the parentdispose()is never called - Observable subscriptions (
autorunIterableDeltalastValues) that retain stale model references
2. Verify the fix by rerunning the same scenario with the same snapshot labels. Compare the postFirst*UsedBytes trend and the snapshot diff. A successful fix should show flat or decreasing memory in the iteration phase.
3. Run all tests. Before finishing, run all unit tests and integration tests for any files you changed. Unit tests in this repo are expected to be stable — any unit test failure is very likely caused by your changes and must be fixed. Integration tests are slightly more prone to flakiness, but failures should still be investigated.
4. Document results in the scratchpad findings.md and session memory before declaring done: what leaked, what was fixed, before/after measurements.
Do not stop at analysis. If you have evidence of a leak, attempt a fix. If the fix is unclear or risky, explain why and propose alternatives.
*
!README.md
!.gitignore
Scratchpad — one-off scenario runners
This folder is gitignored. Write investigation-specific runners here freely.
Organization
Put each investigation in a dated subfolder named YYYY-MM-DD-short-description/:
scratchpad/
2026-04-09-chat-scroll-leak/
scenario.mts
findings.md
2026-04-12-editor-tab-switching/
scenario.mts
findings.mdEach subfolder should contain:
- Scripts — scenario runners, analysis scripts, etc.
- `findings.md` — a summary of the investigation: all ideas considered, whether each led to a change or was rejected (and why), and before/after measurements so the user can review decisions and follow up.
Scenario runners you create here can import utilities from the checked-in scripts or copy patterns from them. When a runner proves generally useful, promote it to the parent scripts/ folder.
Quick start
# Write a runner
cat > scratchpad/my-scenario.mts << 'EOF'
import { chromium } from 'playwright-core';
// ... your scenario
EOF
# Run it
node .github/skills/auto-perf-optimize/scratchpad/my-scenario.mtsChecked-in scripts (in scripts/)
These are reusable, generic runners. Use them directly or as templates:
- `chat-memory-smoke.mts` — Multi-turn chat smoke runner. Sends prompts,
waits for responses, samples heap, takes optional snapshots. Supports --message, --iterations, --skip-send, --keep-open, --reuse, etc.
- `chat-session-switch-smoke.mts` — Creates multiple chat sessions with
different content, then repeatedly switches between them via the sessions sidebar. Measures per-switch memory growth.
- `userDataProfile.mts` — Utility for managing user-data profiles in
smoke test runs.
Tips
- Always use
--user-data-dir .build/auto-perf-optimize/user-data(the
persistent profile with Copilot auth). Never create a fresh user-data-dir.
- Use
--skip-prelaunchto avoid re-downloading Electron on every run. - If you need to clean up an orphaned test instance, stop only the specific
Code - OSS process (e.g. by killing the PID that was logged at launch, or lsof -ti :<port> | xargs kill). Avoid pkill -f 'Electron' — it can kill unrelated Electron apps.
- For heap snapshot analysis, use the
heap-snapshot-analysisskill's
scratchpad and helpers.
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Chat memory smoke runner.
*
* Intended workflow:
* - Run `node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 3 --no-heap-snapshots` for a fast health check.
* - Run `node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 8 --heap-snapshot-label 03-iteration-01 --heap-snapshot-label 03-iteration-08` when comparing post-warmup heap snapshots.
* - Inspect the output folder's summary.json, screenshots, and optional heap/*.heapsnapshot files.
*
* The default profile is persistent at .build/auto-perf-optimize/user-data so auth can be reused across performance runners.
* Pass --temporary-user-data when a clean, disposable profile is required; combine it with --seed-user-data-dir to start from a logged-in seed.
*/
import { chromium, type Browser, type CDPSession, type Locator, type Page } from 'playwright-core';
import { spawn, type ChildProcess } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { setTimeout as timeout } from 'node:timers/promises';
import { StopWatch } from '../../../../src/vs/base/common/stopwatch.ts';
import { prepareUserDataProfile } from './userDataProfile.mts';
const root = path.resolve(import.meta.dirname, '..', '..', '..', '..');
const codeScript = process.platform === 'win32' ? path.join(root, 'scripts', 'code.bat') : path.join(root, 'scripts', 'code.sh');
const chatViewSelector = 'div[id="workbench.panel.chat"]';
const chatInputElementSelector = '.native-edit-context, textarea';
const chatInputEditorSelector = `${chatViewSelector} .interactive-input-part .monaco-editor[role="code"]`;
const chatResponseSelector = `${chatViewSelector} .interactive-item-container.interactive-response`;
const chatResponseLoadingSelector = `${chatResponseSelector}.chat-response-loading`;
const chatResponseCompleteSelector = `${chatResponseSelector}:not(.chat-response-loading)`;
const activeChatResponseMarker = 'data-chat-memory-smoke-response-started';
const activeChatInputMarker = 'data-chat-memory-smoke-input';
const activeChatInputSelector = `[${activeChatInputMarker}="true"]`;
const activeChatInputEditorMarker = 'data-chat-memory-smoke-input-editor';
const activeChatInputEditorSelector = `[${activeChatInputEditorMarker}="true"]`;
interface SmokeTestDriver {
whenWorkbenchRestored(): Promise<void>;
typeInEditor(selector: string, text: string): Promise<void>;
}
declare global {
var driver: SmokeTestDriver | undefined;
}
interface HeapSample {
label: string;
jsHeapUsedSize: number | undefined;
jsHeapTotalSize: number | undefined;
runtimeUsedSize: number | undefined;
runtimeTotalSize: number | undefined;
snapshot: string | undefined;
snapshotBytes: number | undefined;
}
interface ChatTurn {
iteration: number;
prompt: string;
skippedSend: boolean;
responseCountBefore: number;
responseCountAfterSend: number;
responseCountAfterSettled: number | undefined;
responseStartReason: 'response-count' | 'loading' | 'text-change' | undefined;
latestResponseTextBefore: string | undefined;
latestResponseText: string | undefined;
screenshot: string | undefined;
}
interface Options {
help: boolean;
verbose: boolean;
reuse: boolean;
skipSend: boolean;
skipPrelaunch: boolean;
heapSnapshots: boolean;
heapSnapshotLabels: Set<string> | undefined;
keepUserData: boolean;
keepOpen: boolean;
temporaryUserData: boolean;
port: number;
iterations: number;
responseTimeout: number;
firstResponseTimeout: number;
settleMs: number;
workspace: string;
outputDir: string;
userDataDir: string | undefined;
seedUserDataDir: string | undefined;
extensionDir: string | undefined;
message: string;
draftMessage: string;
runtimeArgs: string[];
}
interface HeapUsage {
usedSize: number;
totalSize: number;
}
interface MemoryTrend {
firstToLastUsedBytes: number | undefined;
postFirstTurnUsedBytes: number | undefined;
postFirstTurnUsedBytesPerTurn: number | undefined;
}
interface LaunchedCode {
child: ChildProcess;
failedBeforeConnect: Promise<Error>;
markConnected(): void;
terminate(signal: NodeJS.Signals): boolean;
}
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
await main();
async function main(): Promise<void> {
const outputDir = path.resolve(options.outputDir);
const extensionDir = path.resolve(options.extensionDir ?? path.join(outputDir, 'extensions'));
const ownsCode = !options.reuse;
const shouldCloseCode = ownsCode && !options.keepOpen;
await mkdir(outputDir, { recursive: true });
await mkdir(path.join(outputDir, 'heap'), { recursive: true });
const { userDataDir, ownsUserDataDir } = await prepareUserDataProfile({
outputDir,
persistentUserDataDir: path.join(root, '.build', 'auto-perf-optimize', 'user-data'),
temporaryUserData: options.temporaryUserData,
keepOpen: options.keepOpen,
keepUserData: options.keepUserData,
reuse: options.reuse,
userDataDir: options.userDataDir,
seedUserDataDir: options.seedUserDataDir,
});
if (!options.temporaryUserData && options.userDataDir === undefined) {
console.log(`[code] using persistent user-data-dir: ${userDataDir}`);
}
let launchedCode: LaunchedCode | undefined;
let browser: Browser | undefined;
let session: CDPSession | undefined;
const samples: HeapSample[] = [];
const chatTurns: ChatTurn[] = [];
try {
if (!options.reuse && await isCDPAvailable(options.port)) {
throw new Error(`Port ${options.port} already has a CDP endpoint. Stop that process, pass --port <free port>, or pass --reuse.`);
}
launchedCode = ownsCode ? launchCode({ userDataDir, extensionDir }) : undefined;
browser = await connectToCode(options.port, launchedCode?.failedBeforeConnect);
launchedCode?.markConnected();
const page = await findWorkbenchPage(browser);
session = await page.context().newCDPSession(page);
await session.send('Performance.enable');
await session.send('HeapProfiler.enable');
await page.evaluate(() => globalThis.driver?.whenWorkbenchRestored?.());
await page.screenshot({ path: path.join(outputDir, '01-workbench.png') });
samples.push(await sampleHeap(session, outputDir, '01-restored'));
await openChat(page);
await page.screenshot({ path: path.join(outputDir, '02-chat-open.png') });
await typeInChat(page, options.draftMessage);
await clearChatInput(page);
samples.push(await sampleHeap(session, outputDir, '02-chat-opened-and-drafted'));
for (let i = 1; i <= options.iterations; i++) {
const label = String(i).padStart(2, '0');
const message = expandMessage(options.message, i);
const chatTurn = createChatTurn(i, message);
chatTurns.push(chatTurn);
await sendChatMessage(page, chatTurn, options.skipSend, async () => {
chatTurn.screenshot = path.join(outputDir, `03-iteration-${label}-submitted.png`);
await page.screenshot({ path: chatTurn.screenshot });
chatTurn.latestResponseText = await getLatestChatResponseText(page);
await writeSummary(outputDir, samples, chatTurns);
});
chatTurn.latestResponseText = await getLatestChatResponseText(page);
const screenshot = path.join(outputDir, `03-iteration-${label}-after-send.png`);
await page.screenshot({ path: screenshot });
chatTurn.screenshot = screenshot;
await waitForChatToSettle(page, chatTurn, options.responseTimeout, options.settleMs);
chatTurn.screenshot = path.join(outputDir, `03-iteration-${label}-settled.png`);
await page.screenshot({ path: chatTurn.screenshot });
chatTurn.responseCountAfterSettled = await page.locator(chatResponseSelector).count();
chatTurn.latestResponseText = await getLatestChatResponseText(page);
samples.push(await sampleHeap(session, outputDir, `03-iteration-${label}`));
}
await writeSummary(outputDir, samples, chatTurns);
printSummary(samples, outputDir);
} catch (error) {
await writeSummary(outputDir, samples, chatTurns, error);
throw error;
} finally {
await session?.detach().catch(() => undefined);
if (shouldCloseCode) {
await browser?.newBrowserCDPSession().then(browserSession => browserSession.send('Browser.close')).catch(() => undefined);
}
await browser?.close().catch(() => undefined);
if (launchedCode && shouldCloseCode) {
if (!await waitForChildExit(launchedCode.child, 10000)) {
launchedCode.terminate('SIGTERM');
await waitForChildExit(launchedCode.child, 5000);
}
}
if (ownsUserDataDir) {
await rm(userDataDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
}
}
}
function launchCode(dirs: { userDataDir: string; extensionDir: string }): LaunchedCode {
const args = [
'--enable-smoke-test-driver',
'--disable-workspace-trust',
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${dirs.userDataDir}`,
`--extensions-dir=${dirs.extensionDir}`,
'--skip-welcome',
'--skip-release-notes',
...options.runtimeArgs,
options.workspace,
];
let failBeforeConnect: (error: Error) => void = () => undefined;
let connected = false;
let terminating = false;
const failedBeforeConnect = new Promise<Error>(resolve => failBeforeConnect = resolve);
const child = spawn(codeScript, args, {
cwd: root,
env: options.skipPrelaunch ? { ...process.env, VSCODE_SKIP_PRELAUNCH: '1' } : process.env,
detached: options.keepOpen,
shell: process.platform === 'win32',
stdio: options.keepOpen ? 'ignore' : options.verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
if (options.keepOpen) {
child.unref();
}
if (!options.verbose && !options.keepOpen) {
child.stdout?.on('data', data => process.stdout.write(`[code] ${data}`));
child.stderr?.on('data', data => process.stderr.write(`[code] ${data}`));
}
child.once('error', error => {
failBeforeConnect(new Error(`Failed to launch Code from ${codeScript}: ${error.message}`));
});
child.once('exit', (code, signal) => {
if (!connected && !terminating) {
failBeforeConnect(new Error(`Code exited before the script connected to CDP. code=${code} signal=${signal}`));
}
if (!options.reuse && code !== 0 && signal !== 'SIGTERM') {
console.error(`[code] exited with code ${code} signal ${signal}`);
}
});
return {
child,
failedBeforeConnect,
markConnected: () => connected = true,
terminate: signal => {
terminating = true;
return child.kill(signal);
},
};
}
async function connectToCode(port: number, launchFailure?: Promise<Error>): Promise<Browser> {
const endpoint = `http://127.0.0.1:${port}`;
for (let i = 0; i < 120; i++) {
try {
await raceLaunchFailure(waitForCDPEndpoint(port), launchFailure);
return await chromium.connectOverCDP(endpoint);
} catch {
await throwIfLaunchFailed(launchFailure);
await timeout(500);
}
}
throw new Error(`Timed out waiting for Code to expose CDP on ${endpoint}`);
}
async function raceLaunchFailure<T>(promise: Promise<T>, launchFailure: Promise<Error> | undefined): Promise<T> {
if (!launchFailure) {
return promise;
}
return Promise.race([
promise,
launchFailure.then(error => Promise.reject(error)),
]);
}
async function throwIfLaunchFailed(launchFailure: Promise<Error> | undefined): Promise<void> {
const launchError = await Promise.race([
launchFailure,
new Promise<undefined>(resolve => queueMicrotask(() => resolve(undefined))),
]);
if (launchError) {
throw launchError;
}
}
function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve(true);
}
return new Promise(resolve => {
const timer = setTimeout(() => resolve(false), timeoutMs);
timer.unref();
child.once('exit', () => {
clearTimeout(timer);
resolve(true);
});
});
}
async function isCDPAvailable(port: number): Promise<boolean> {
return waitForCDPEndpoint(port).then(() => true, () => false);
}
async function waitForCDPEndpoint(port: number): Promise<void> {
await getJson(`http://127.0.0.1:${port}/json/version`);
}
async function findWorkbenchPage(browser: Browser): Promise<Page> {
for (let i = 0; i < 120; i++) {
const pages = browser.contexts().flatMap(context => context.pages());
for (const page of pages) {
const hasDriver = await page.evaluate(() => !!globalThis.driver?.whenWorkbenchRestored).catch(() => false);
if (hasDriver) {
return page;
}
}
await timeout(500);
}
throw new Error('Timed out waiting for the workbench page and smoke-test driver');
}
async function openChat(page: Page): Promise<string> {
if (!await hasVisibleChatInputEditor(page)) {
const shortcut = process.platform === 'darwin' ? 'Control+Meta+I' : 'Control+Alt+I';
await page.keyboard.press(shortcut);
await waitForVisibleChatInputEditor(page);
}
let inputEditor = await markChatInputEditor(page);
await inputEditor.click({ force: true });
inputEditor = await markChatInputEditor(page);
await inputEditor.locator(chatInputElementSelector).first().waitFor({ state: 'attached', timeout: 30000 });
await waitForInputEditorFocus(inputEditor);
await markActiveChatInput(inputEditor);
return activeChatInputSelector;
}
async function hasVisibleChatInputEditor(page: Page): Promise<boolean> {
return page.evaluate(selector => Array.from(document.querySelectorAll(selector)).some(element => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}), chatInputEditorSelector);
}
async function waitForVisibleChatInputEditor(page: Page): Promise<void> {
await page.waitForFunction(selector => Array.from(document.querySelectorAll(selector)).some(element => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}), chatInputEditorSelector, { timeout: 30000 });
}
async function markChatInputEditor(page: Page): Promise<Locator> {
await page.evaluate(({ editorSelector, marker }) => {
document.querySelectorAll(`[${marker}]`).forEach(element => element.removeAttribute(marker));
const editors = Array.from(document.querySelectorAll(editorSelector)).filter((element): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const activeEditor = editors.find(editor => editor.classList.contains('focused') || editor.contains(document.activeElement));
const inputEditor = activeEditor ?? editors.toSorted((a, b) => a.getBoundingClientRect().bottom - b.getBoundingClientRect().bottom).at(-1);
if (!inputEditor) {
throw new Error('Visible Chat input editor not found');
}
inputEditor.setAttribute(marker, 'true');
}, { editorSelector: chatInputEditorSelector, marker: activeChatInputEditorMarker });
return page.locator(activeChatInputEditorSelector);
}
async function typeInChat(page: Page, message: string): Promise<void> {
const inputSelector = await openChat(page);
await page.evaluate(({ selector, text }) => {
if (!globalThis.driver) {
throw new Error('Smoke-test driver not found');
}
return globalThis.driver.typeInEditor(selector, text);
}, {
selector: inputSelector,
text: message.replace(/\r?\n/g, ' '),
});
}
async function waitForInputEditorFocus(inputEditor: Locator): Promise<void> {
const stopWatch = StopWatch.create();
while (stopWatch.elapsed() < 5000) {
if (await inputEditor.evaluate(editor => editor.classList.contains('focused') || !!editor.querySelector(':focus'))) {
return;
}
await timeout(50);
}
throw new Error('Timed out waiting for Chat input editor focus');
}
async function markActiveChatInput(inputEditor: Locator): Promise<void> {
await inputEditor.evaluate((editor, { inputSelector, marker }) => {
document.querySelectorAll(`[${marker}]`).forEach(element => element.removeAttribute(marker));
const input = editor.querySelector(inputSelector);
if (!input) {
throw new Error('Chat input not found');
}
input.setAttribute(marker, 'true');
}, { inputSelector: chatInputElementSelector, marker: activeChatInputMarker });
}
async function clearChatInput(page: Page): Promise<void> {
await openChat(page);
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.press('Backspace');
}
function createChatTurn(iteration: number, prompt: string): ChatTurn {
return {
iteration,
prompt,
skippedSend: false,
responseCountBefore: 0,
responseCountAfterSend: 0,
responseCountAfterSettled: undefined,
responseStartReason: undefined,
latestResponseText: undefined,
latestResponseTextBefore: undefined,
screenshot: undefined,
};
}
async function sendChatMessage(page: Page, chatTurn: ChatTurn, skipSend: boolean, onSubmitted: () => Promise<void>): Promise<void> {
const responseCount = await page.locator(chatResponseSelector).count();
chatTurn.responseCountBefore = responseCount;
chatTurn.responseCountAfterSend = responseCount;
chatTurn.latestResponseTextBefore = await getLatestChatResponseText(page);
await clearChatInput(page);
await typeInChat(page, chatTurn.prompt);
if (skipSend) {
chatTurn.skippedSend = true;
await onSubmitted();
return;
}
await page.keyboard.press('Enter');
chatTurn.responseCountAfterSend = await page.locator(chatResponseSelector).count();
await onSubmitted();
const responseStartReason = await page.waitForFunction(
({ responseSelector, loadingSelector, marker, count, textBefore }) => {
const getResponseText = (element: Element | undefined) => {
const contentElement = element?.querySelector(':scope > .value') ?? element;
return contentElement instanceof HTMLElement ? contentElement.innerText.trim() : contentElement?.textContent?.trim();
};
const markResponse = (element: Element | undefined) => {
document.querySelectorAll(`[${marker}]`).forEach(element => element.removeAttribute(marker));
element?.setAttribute(marker, 'true');
};
const responses = Array.from(document.querySelectorAll(responseSelector));
const latestText = getResponseText(responses.at(-1));
if (responses.length > count) {
markResponse(responses.at(-1));
return 'response-count';
}
const loadingResponse = document.querySelector(loadingSelector);
if (loadingResponse) {
markResponse(loadingResponse);
return 'loading';
}
if (!!latestText && latestText !== textBefore?.trim()) {
markResponse(responses.at(-1));
return 'text-change';
}
return false;
},
{ responseSelector: chatResponseSelector, loadingSelector: chatResponseLoadingSelector, marker: activeChatResponseMarker, count: responseCount, textBefore: chatTurn.latestResponseTextBefore },
{ timeout: options.firstResponseTimeout }
).then(result => result.jsonValue());
chatTurn.responseStartReason = validateResponseStartReason(responseStartReason);
chatTurn.responseCountAfterSend = await page.locator(chatResponseSelector).count();
}
async function waitForChatToSettle(page: Page, chatTurn: ChatTurn, responseTimeout: number, settleMs: number): Promise<void> {
if (chatTurn.skippedSend) {
return;
}
await page.waitForFunction(
({ completeSelector, loadingSelector, countBefore, textBefore }) => {
const getResponseText = (element: Element | undefined) => {
const contentElement = element?.querySelector(':scope > .value') ?? element;
return contentElement instanceof HTMLElement ? contentElement.innerText.trim() : contentElement?.textContent?.trim();
};
const completedResponses = Array.from(document.querySelectorAll(completeSelector));
const loadingResponses = Array.from(document.querySelectorAll(loadingSelector));
const latestText = getResponseText(completedResponses.at(-1));
return !!latestText && latestText !== 'Working' && loadingResponses.length === 0 && (completedResponses.length > countBefore || latestText !== textBefore?.trim());
},
{ completeSelector: chatResponseCompleteSelector, loadingSelector: chatResponseLoadingSelector, countBefore: chatTurn.responseCountBefore, textBefore: chatTurn.latestResponseTextBefore },
{ timeout: responseTimeout }
);
await timeout(settleMs);
}
async function getLatestChatResponseText(page: Page): Promise<string | undefined> {
return page.evaluate(responseSelector => {
const response = Array.from(document.querySelectorAll(responseSelector)).at(-1);
const contentElement = response?.querySelector(':scope > .value') ?? response;
return contentElement instanceof HTMLElement ? contentElement.innerText : contentElement?.textContent ?? undefined;
}, chatResponseSelector);
}
function validateResponseStartReason(value: unknown): ChatTurn['responseStartReason'] {
if (value === 'response-count' || value === 'loading' || value === 'text-change') {
return value;
}
throw new Error(`Unexpected response start reason: ${String(value)}`);
}
async function sampleHeap(session: CDPSession, outputDir: string, label: string): Promise<HeapSample> {
await forceGarbageCollection(session);
const performanceMetrics = await getPerformanceMetrics(session);
const runtimeHeapUsage = await getRuntimeHeapUsage(session);
const snapshot = shouldTakeHeapSnapshot(label) ? path.join(outputDir, 'heap', `${label}.heapsnapshot`) : undefined;
const snapshotBytes = snapshot ? await takeHeapSnapshot(session, snapshot) : undefined;
const sample = {
label,
jsHeapUsedSize: performanceMetrics.get('JSHeapUsedSize'),
jsHeapTotalSize: performanceMetrics.get('JSHeapTotalSize'),
runtimeUsedSize: runtimeHeapUsage?.usedSize,
runtimeTotalSize: runtimeHeapUsage?.totalSize,
snapshot,
snapshotBytes,
};
console.log(`[heap] ${label}: ${formatBytes(sample.runtimeUsedSize ?? sample.jsHeapUsedSize)} used, ${snapshotBytes === undefined ? 'no snapshot' : `${formatBytes(snapshotBytes)} snapshot`}`);
return sample;
}
async function forceGarbageCollection(session: CDPSession): Promise<void> {
await session.send('HeapProfiler.collectGarbage');
await session.send('HeapProfiler.collectGarbage');
}
async function getPerformanceMetrics(session: CDPSession): Promise<Map<string, number>> {
const response = await session.send('Performance.getMetrics');
return new Map(response.metrics.map(metric => [metric.name, metric.value]));
}
async function getRuntimeHeapUsage(session: CDPSession): Promise<HeapUsage | undefined> {
return session.send('Runtime.getHeapUsage').catch(() => undefined);
}
async function takeHeapSnapshot(session: CDPSession, file: string): Promise<number> {
await mkdir(path.dirname(file), { recursive: true });
const stream = createWriteStream(file);
let snapshotBytes = 0;
let pendingWrite = Promise.resolve();
const onChunk = (event: { chunk: string }) => {
snapshotBytes += Buffer.byteLength(event.chunk);
pendingWrite = pendingWrite.then(() => new Promise((resolve, reject) => {
stream.write(event.chunk, error => error ? reject(error) : resolve());
}));
};
session.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
try {
await session.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
await pendingWrite;
} finally {
session.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
await new Promise<void>((resolve, reject) => {
stream.once('error', reject);
stream.end(resolve);
});
}
return snapshotBytes;
}
function shouldTakeHeapSnapshot(label: string): boolean {
return options.heapSnapshots && (!options.heapSnapshotLabels || options.heapSnapshotLabels.has(label));
}
function printSummary(samples: HeapSample[], outputDir: string): void {
console.log(`\nSummary written to ${path.join(outputDir, 'summary.json')}`);
for (const sample of samples) {
const heapSize = sample.runtimeUsedSize ?? sample.jsHeapUsedSize;
console.log(`${sample.label.padEnd(32)} ${formatBytes(heapSize).padStart(10)} ${sample.snapshot ? path.relative(root, sample.snapshot) : ''}`);
}
const trend = analyzeMemoryTrend(samples);
if (trend.postFirstTurnUsedBytes !== undefined) {
console.log(`Post-first-turn growth: ${formatSignedBytes(trend.postFirstTurnUsedBytes)} (${formatSignedBytes(trend.postFirstTurnUsedBytesPerTurn)}/turn)`);
}
}
async function writeSummary(outputDir: string, samples: HeapSample[], chatTurns: ChatTurn[], error?: unknown): Promise<void> {
await writeFile(path.join(outputDir, 'summary.json'), JSON.stringify({
createdAt: new Date().toISOString(),
workspace: options.workspace,
iterations: options.iterations,
port: options.port,
chatTurns,
error: error === undefined ? undefined : String(error instanceof Error && error.stack ? error.stack : error),
analysis: analyzeMemoryTrend(samples),
samples,
}, undefined, '\t'));
}
function analyzeMemoryTrend(samples: HeapSample[]): MemoryTrend {
const sizedSamples = samples.map(sample => sample.runtimeUsedSize ?? sample.jsHeapUsedSize);
const firstSize = sizedSamples.at(0);
const lastSize = sizedSamples.at(-1);
const turnSamples = samples.filter(sample => sample.label.startsWith('03-iteration-'));
const firstTurnSize = turnSamples.at(0)?.runtimeUsedSize ?? turnSamples.at(0)?.jsHeapUsedSize;
const lastTurnSize = turnSamples.at(-1)?.runtimeUsedSize ?? turnSamples.at(-1)?.jsHeapUsedSize;
const postFirstTurnUsedBytes = firstTurnSize !== undefined && lastTurnSize !== undefined && turnSamples.length > 1 ? lastTurnSize - firstTurnSize : undefined;
return {
firstToLastUsedBytes: firstSize !== undefined && lastSize !== undefined && samples.length > 1 ? lastSize - firstSize : undefined,
postFirstTurnUsedBytes,
postFirstTurnUsedBytesPerTurn: postFirstTurnUsedBytes !== undefined ? postFirstTurnUsedBytes / (turnSamples.length - 1) : undefined,
};
}
function expandMessage(template: string, iteration: number): string {
return template.replace(/\{iteration\}/g, String(iteration));
}
function formatBytes(bytes: number | undefined): string {
if (typeof bytes !== 'number') {
return 'unknown';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatSignedBytes(bytes: number | undefined): string {
if (bytes === undefined) {
return 'unknown';
}
return `${bytes >= 0 ? '+' : '-'}${formatBytes(Math.abs(bytes))}`;
}
function getJson(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const request = http.get(url, response => {
response.resume();
response.once('end', () => response.statusCode === 200 ? resolve(undefined) : reject(new Error(`HTTP ${response.statusCode}`)));
});
request.setTimeout(1000, () => {
request.destroy(new Error('Request timed out'));
});
request.once('error', reject);
});
}
function parseArgs(args: string[]): Options {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const parsed: Options = {
help: false,
verbose: false,
reuse: false,
skipSend: false,
skipPrelaunch: false,
heapSnapshots: true,
heapSnapshotLabels: undefined,
keepUserData: false,
keepOpen: false,
temporaryUserData: false,
port: 9224,
iterations: 3,
responseTimeout: 120000,
firstResponseTimeout: 30000,
settleMs: 3000,
workspace: root,
outputDir: path.join(root, '.build', 'chat-memory-smoke', timestamp),
userDataDir: undefined,
seedUserDataDir: undefined,
extensionDir: undefined,
message: 'For memory-smoke iteration {iteration}, reply with exactly one short sentence.',
draftMessage: 'draft message used by chat memory smoke',
runtimeArgs: [],
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg === '--verbose') {
parsed.verbose = true;
} else if (arg === '--reuse') {
parsed.reuse = true;
} else if (arg === '--skip-send') {
parsed.skipSend = true;
} else if (arg === '--skip-prelaunch') {
parsed.skipPrelaunch = true;
} else if (arg === '--no-heap-snapshots') {
parsed.heapSnapshots = false;
} else if (arg.startsWith('--heap-snapshot-label=')) {
addHeapSnapshotLabels(parsed, arg.slice('--heap-snapshot-label='.length));
} else if (arg === '--heap-snapshot-label') {
addHeapSnapshotLabels(parsed, readArgValue(args, ++i, arg));
} else if (arg === '--keep-user-data') {
parsed.keepUserData = true;
} else if (arg === '--keep-open') {
parsed.keepOpen = true;
} else if (arg === '--temporary-user-data') {
parsed.temporaryUserData = true;
} else if (arg.startsWith('--runtime-arg=')) {
parsed.runtimeArgs.push(arg.slice('--runtime-arg='.length));
} else if (arg === '--runtime-arg') {
parsed.runtimeArgs.push(readArgValue(args, ++i, arg));
} else if (arg.startsWith('--')) {
const [key, inlineValue] = splitArg(arg);
const value = inlineValue ?? readArgValue(args, ++i, key);
switch (key) {
case '--port': parsed.port = parseIntegerArg(key, value, 1); break;
case '--iterations': parsed.iterations = parseIntegerArg(key, value, 0); break;
case '--response-timeout': parsed.responseTimeout = parseIntegerArg(key, value, 1); break;
case '--first-response-timeout': parsed.firstResponseTimeout = parseIntegerArg(key, value, 1); break;
case '--settle-ms': parsed.settleMs = parseIntegerArg(key, value, 0); break;
case '--workspace': parsed.workspace = path.resolve(value); break;
case '--output': parsed.outputDir = value; break;
case '--user-data-dir': parsed.userDataDir = value; break;
case '--seed-user-data-dir': parsed.seedUserDataDir = value; break;
case '--extensions-dir': parsed.extensionDir = value; break;
case '--message': parsed.message = value; break;
case '--draft-message': parsed.draftMessage = value; break;
default: throw new Error(`Unknown argument: ${key}`);
}
} else {
throw new Error(`Unexpected positional argument: ${arg}`);
}
}
return parsed;
}
function addHeapSnapshotLabels(options: Options, value: string): void {
options.heapSnapshotLabels ??= new Set();
for (const label of value.split(',')) {
const trimmedLabel = label.trim();
if (trimmedLabel) {
options.heapSnapshotLabels.add(trimmedLabel);
}
}
}
function splitArg(arg: string): [string, string | undefined] {
const index = arg.indexOf('=');
return index === -1 ? [arg, undefined] : [arg.slice(0, index), arg.slice(index + 1)];
}
function readArgValue(args: string[], index: number, flag: string): string {
const value = args[index];
if (typeof value !== 'string') {
throw new Error(`Missing value for ${flag}`);
}
return value;
}
function parseIntegerArg(flag: string, value: string, min: number): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min) {
throw new Error(`${flag} must be an integer greater than or equal to ${min}. Got: ${value}`);
}
return parsed;
}
function printHelp(): void {
const tmp = path.join(os.tmpdir(), 'vscode-chat-memory-smoke');
console.log([
'Usage: node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts [options]',
'',
'Launches Code - OSS, opens Chat with Playwright, runs a small chat scenario, and writes renderer heap snapshots plus summary.json.',
'',
'Options:',
' --workspace <path> Workspace to open. Default: repo root',
' --output <path> Output folder. Default: .build/chat-memory-smoke/<timestamp>',
' --port <number> Remote debugging port. Default: 9224',
' --iterations <number> Chat send iterations. Default: 3',
' --message <text> Prompt template. Use {iteration} for the loop number',
' --skip-send Type prompts but do not press Enter',
' --no-heap-snapshots Only record small heap-size metrics',
' --heap-snapshot-label <label> Only snapshot matching sample labels. Repeatable or comma-separated',
' --reuse Attach to an already-running --enable-smoke-test-driver window',
' --skip-prelaunch Set VSCODE_SKIP_PRELAUNCH=1 for the launched Code process',
' --temporary-user-data Use an output-local clean user-data-dir and delete it when the run completes',
' --seed-user-data-dir <path> Copy a logged-in profile into a fresh target user-data-dir before launch',
' --keep-user-data Preserve the generated temporary user-data-dir',
' --keep-open Leave launched Code open',
' --runtime-arg <arg> Forward one extra argument to scripts/code.sh. Repeatable',
'',
'Example:',
` node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --iterations 5 --output ${tmp}`,
'',
'Watch/login/retry example:',
' node .github/skills/auto-perf-optimize/scripts/chat-memory-smoke.mts --keep-open --iterations 1 --no-heap-snapshots',
].join('\n'));
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Chat session-switching memory smoke runner.
*
* Creates several chat sessions with different content types (codeblocks,
* markdown, terminal commands), then repeatedly switches between them by
* clicking in the sessions list. Verifies that switching does not leak.
*
* Intended workflow:
* - Fast health check:
* node .github/skills/auto-perf-optimize/scripts/chat-session-switch-smoke.mts --switch-iterations 3 --no-heap-snapshots
* - Targeted snapshots:
* node .github/skills/auto-perf-optimize/scripts/chat-session-switch-smoke.mts --switch-iterations 8 --heap-snapshot-label 04-switch-01 --heap-snapshot-label 04-switch-08
*/
import { chromium, type Browser, type CDPSession, type Page } from 'playwright-core';
import { spawn, type ChildProcess } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { StopWatch } from '../../../../src/vs/base/common/stopwatch.ts';
import { prepareUserDataProfile } from './userDataProfile.mts';
const root = path.resolve(import.meta.dirname, '..', '..', '..', '..');
const codeScript = process.platform === 'win32' ? path.join(root, 'scripts', 'code.bat') : path.join(root, 'scripts', 'code.sh');
// Chat selectors
const chatViewSelector = 'div[id="workbench.panel.chat"]';
const chatInputElementSelector = '.native-edit-context, textarea';
const chatInputEditorSelector = `${chatViewSelector} .interactive-input-part .monaco-editor[role="code"]`;
const chatResponseSelector = `${chatViewSelector} .interactive-item-container.interactive-response`;
const chatResponseLoadingSelector = `${chatResponseSelector}.chat-response-loading`;
const chatResponseCompleteSelector = `${chatResponseSelector}:not(.chat-response-loading)`;
// Session list selectors
const sessionItemSelector = '.agent-session-item';
// Markers
const activeChatInputMarker = 'data-session-switch-smoke-input';
const activeChatInputSelector = `[${activeChatInputMarker}="true"]`;
const activeChatInputEditorMarker = 'data-session-switch-smoke-input-editor';
const activeChatInputEditorSelector = `[${activeChatInputEditorMarker}="true"]`;
interface SmokeTestDriver {
whenWorkbenchRestored(): Promise<void>;
typeInEditor(selector: string, text: string): Promise<void>;
}
declare global {
var driver: SmokeTestDriver | undefined;
}
interface HeapSample {
label: string;
jsHeapUsedSize: number | undefined;
jsHeapTotalSize: number | undefined;
runtimeUsedSize: number | undefined;
runtimeTotalSize: number | undefined;
snapshot: string | undefined;
snapshotBytes: number | undefined;
}
interface SessionInfo {
index: number;
prompt: string;
contentType: string;
responseText: string | undefined;
}
interface Options {
help: boolean;
verbose: boolean;
reuse: boolean;
skipPrelaunch: boolean;
heapSnapshots: boolean;
heapSnapshotLabels: Set<string> | undefined;
keepOpen: boolean;
temporaryUserData: boolean;
port: number;
switchIterations: number;
responseTimeout: number;
firstResponseTimeout: number;
settleMs: number;
workspace: string;
outputDir: string;
userDataDir: string | undefined;
seedUserDataDir: string | undefined;
extensionDir: string | undefined;
}
interface HeapUsage {
usedSize: number;
totalSize: number;
}
interface MemoryTrend {
firstToLastUsedBytes: number | undefined;
postFirstSwitchUsedBytes: number | undefined;
postFirstSwitchUsedBytesPerIteration: number | undefined;
}
interface LaunchedCode {
child: ChildProcess;
failedBeforeConnect: Promise<Error>;
markConnected(): void;
terminate(signal: NodeJS.Signals): boolean;
}
// Prompts designed to generate different content types
const SESSION_PROMPTS = [
{
contentType: 'codeblocks',
prompt: 'Write a short TypeScript function that reverses a linked list. Include the type definition. Reply ONLY with the code in a fenced code block, no explanation.',
},
{
contentType: 'markdown',
prompt: 'List 5 best practices for writing maintainable CSS, using markdown headers, bullet points, and bold text. Keep it under 200 words.',
},
{
contentType: 'terminal',
prompt: 'Show me 3 useful git commands for inspecting history. Format each as a fenced shell code block with a one-line comment above it.',
},
];
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
await main();
async function main(): Promise<void> {
const outputDir = path.resolve(options.outputDir);
const extensionDir = path.resolve(options.extensionDir ?? path.join(outputDir, 'extensions'));
const ownsCode = !options.reuse;
const shouldCloseCode = ownsCode && !options.keepOpen;
await mkdir(outputDir, { recursive: true });
await mkdir(path.join(outputDir, 'heap'), { recursive: true });
const { userDataDir, ownsUserDataDir } = await prepareUserDataProfile({
outputDir,
persistentUserDataDir: path.join(root, '.build', 'auto-perf-optimize', 'user-data'),
temporaryUserData: options.temporaryUserData,
keepOpen: options.keepOpen,
keepUserData: false,
reuse: options.reuse,
userDataDir: options.userDataDir,
seedUserDataDir: options.seedUserDataDir,
});
if (!options.temporaryUserData && options.userDataDir === undefined) {
console.log(`[code] using persistent user-data-dir: ${userDataDir}`);
}
let launchedCode: LaunchedCode | undefined;
let browser: Browser | undefined;
let session: CDPSession | undefined;
const samples: HeapSample[] = [];
const sessions: SessionInfo[] = [];
try {
if (!options.reuse && await isCDPAvailable(options.port)) {
throw new Error(`Port ${options.port} already has a CDP endpoint. Stop that process, pass --port <free port>, or pass --reuse.`);
}
launchedCode = ownsCode ? launchCode({ userDataDir, extensionDir }) : undefined;
browser = await connectToCode(options.port, launchedCode?.failedBeforeConnect);
launchedCode?.markConnected();
const page = await findWorkbenchPage(browser);
session = await page.context().newCDPSession(page);
await session.send('Performance.enable');
await session.send('HeapProfiler.enable');
await page.evaluate(() => globalThis.driver?.whenWorkbenchRestored?.());
await page.screenshot({ path: path.join(outputDir, '01-workbench.png') });
samples.push(await sampleHeap(session, outputDir, '01-restored'));
// --- Phase 1: Create sessions with different content types ---
console.log('\n=== Phase 1: Creating sessions with different content types ===');
for (let i = 0; i < SESSION_PROMPTS.length; i++) {
const { contentType, prompt } = SESSION_PROMPTS[i];
console.log(`\n--- Creating session ${i + 1} (${contentType}) ---`);
if (i === 0) {
// First session: just open chat
await openChat(page);
} else {
// Subsequent sessions: create new chat
await createNewChatSession(page);
}
await page.screenshot({ path: path.join(outputDir, `02-session-${i + 1}-before-send.png`) });
const responseText = await sendAndWaitForResponse(page, prompt);
sessions.push({ index: i, prompt, contentType, responseText });
await page.screenshot({ path: path.join(outputDir, `02-session-${i + 1}-response.png`) });
console.log(`Session ${i + 1} (${contentType}): response received (${responseText?.length ?? 0} chars)`);
}
await writeSummary(outputDir, samples, sessions, options.switchIterations);
samples.push(await sampleHeap(session, outputDir, '02-sessions-created'));
// --- Phase 2: Show sessions list ---
console.log('\n=== Phase 2: Showing sessions sidebar ===');
await showSessionsSidebar(page);
await delay(1000);
await page.screenshot({ path: path.join(outputDir, '03-sessions-sidebar.png') });
samples.push(await sampleHeap(session, outputDir, '03-sidebar-shown'));
// --- Phase 3: Switch between sessions ---
console.log('\n=== Phase 3: Switching between sessions ===');
// Warmup: do 3 switch cycles before the first measured snapshot
console.log('\n--- Warmup: 3 switch cycles ---');
for (let warmup = 0; warmup < 3; warmup++) {
for (let s = 0; s < SESSION_PROMPTS.length; s++) {
await clickSessionInList(page, s);
await waitForSessionContentToLoad(page);
await delay(300);
}
}
console.log('Warmup complete');
for (let iteration = 1; iteration <= options.switchIterations; iteration++) {
const label = String(iteration).padStart(2, '0');
console.log(`\n--- Switch iteration ${iteration} ---`);
// Click through each session in the list
for (let s = 0; s < SESSION_PROMPTS.length; s++) {
await clickSessionInList(page, s);
await waitForSessionContentToLoad(page);
await delay(300);
}
await page.screenshot({ path: path.join(outputDir, `04-switch-${label}.png`) });
samples.push(await sampleHeap(session, outputDir, `04-switch-${label}`));
await writeSummary(outputDir, samples, sessions, options.switchIterations);
}
await writeSummary(outputDir, samples, sessions, options.switchIterations);
printSummary(samples, outputDir);
} catch (error) {
await writeSummary(outputDir, samples, sessions, options.switchIterations, error);
throw error;
} finally {
await session?.detach().catch(() => undefined);
if (shouldCloseCode) {
await browser?.newBrowserCDPSession().then(bs => bs.send('Browser.close')).catch(() => undefined);
}
await browser?.close().catch(() => undefined);
if (launchedCode && shouldCloseCode) {
if (!await waitForChildExit(launchedCode.child, 10000)) {
launchedCode.terminate('SIGTERM');
await waitForChildExit(launchedCode.child, 5000);
}
}
if (ownsUserDataDir) {
await rm(userDataDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
}
}
}
// ---- Code Launch & Connect ----
function launchCode(dirs: { userDataDir: string; extensionDir: string }): LaunchedCode {
const args = [
'--enable-smoke-test-driver',
'--disable-workspace-trust',
`--remote-debugging-port=${options.port}`,
`--user-data-dir=${dirs.userDataDir}`,
`--extensions-dir=${dirs.extensionDir}`,
'--skip-welcome',
'--skip-release-notes',
options.workspace,
];
let failBeforeConnect: (error: Error) => void = () => undefined;
let connected = false;
let terminating = false;
const failedBeforeConnect = new Promise<Error>(resolve => failBeforeConnect = resolve);
const child = spawn(codeScript, args, {
cwd: root,
env: options.skipPrelaunch ? { ...process.env, VSCODE_SKIP_PRELAUNCH: '1' } : process.env,
detached: options.keepOpen,
shell: process.platform === 'win32',
stdio: options.keepOpen ? 'ignore' : options.verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
if (options.keepOpen) {
child.unref();
}
if (!options.verbose && !options.keepOpen) {
child.stdout?.on('data', data => process.stdout.write(`[code] ${data}`));
child.stderr?.on('data', data => process.stderr.write(`[code] ${data}`));
}
child.once('error', error => {
failBeforeConnect(new Error(`Failed to launch Code from ${codeScript}: ${error.message}`));
});
child.once('exit', (code, signal) => {
if (!connected && !terminating) {
failBeforeConnect(new Error(`Code exited before the script connected to CDP. code=${code} signal=${signal}`));
}
if (!options.reuse && code !== 0 && signal !== 'SIGTERM') {
console.error(`[code] exited with code ${code} signal ${signal}`);
}
});
return {
child,
failedBeforeConnect,
markConnected: () => connected = true,
terminate: signal => {
terminating = true;
return child.kill(signal);
},
};
}
async function connectToCode(port: number, launchFailure?: Promise<Error>): Promise<Browser> {
const endpoint = `http://127.0.0.1:${port}`;
for (let i = 0; i < 120; i++) {
try {
await raceLaunchFailure(waitForCDPEndpoint(port), launchFailure);
return await chromium.connectOverCDP(endpoint);
} catch {
await throwIfLaunchFailed(launchFailure);
await delay(500);
}
}
throw new Error(`Timed out waiting for Code to expose CDP on ${endpoint}`);
}
async function raceLaunchFailure<T>(promise: Promise<T>, launchFailure: Promise<Error> | undefined): Promise<T> {
if (!launchFailure) {
return promise;
}
return Promise.race([
promise,
launchFailure.then(error => Promise.reject(error)),
]);
}
async function throwIfLaunchFailed(launchFailure: Promise<Error> | undefined): Promise<void> {
const launchError = await Promise.race([
launchFailure,
new Promise<undefined>(resolve => queueMicrotask(() => resolve(undefined))),
]);
if (launchError) {
throw launchError;
}
}
function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve(true);
}
return new Promise(resolve => {
const timer = setTimeout(() => resolve(false), timeoutMs);
timer.unref();
child.once('exit', () => {
clearTimeout(timer);
resolve(true);
});
});
}
async function isCDPAvailable(port: number): Promise<boolean> {
return waitForCDPEndpoint(port).then(() => true, () => false);
}
async function waitForCDPEndpoint(port: number): Promise<void> {
await getJson(`http://127.0.0.1:${port}/json/version`);
}
async function findWorkbenchPage(browser: Browser): Promise<Page> {
for (let i = 0; i < 120; i++) {
const pages = browser.contexts().flatMap(context => context.pages());
for (const page of pages) {
const hasDriver = await page.evaluate(() => !!globalThis.driver?.whenWorkbenchRestored).catch(() => false);
if (hasDriver) {
return page;
}
}
await delay(500);
}
throw new Error('Timed out waiting for the workbench page and smoke-test driver');
}
// ---- Chat Interaction ----
async function openChat(page: Page): Promise<string> {
if (!await hasVisibleChatInputEditor(page)) {
const shortcut = process.platform === 'darwin' ? 'Control+Meta+I' : 'Control+Alt+I';
await page.keyboard.press(shortcut);
await waitForVisibleChatInputEditor(page);
}
let inputEditor = await markChatInputEditor(page);
await inputEditor.click({ force: true });
inputEditor = await markChatInputEditor(page);
await inputEditor.locator(chatInputElementSelector).first().waitFor({ state: 'attached', timeout: 30000 });
await waitForInputEditorFocus(inputEditor);
await markActiveChatInput(inputEditor);
return activeChatInputSelector;
}
async function hasVisibleChatInputEditor(page: Page): Promise<boolean> {
return page.evaluate(selector => Array.from(document.querySelectorAll(selector)).some(element => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}), chatInputEditorSelector);
}
async function waitForVisibleChatInputEditor(page: Page): Promise<void> {
await page.waitForFunction(selector => Array.from(document.querySelectorAll(selector)).some(element => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}), chatInputEditorSelector, { timeout: 30000 });
}
async function markChatInputEditor(page: Page): Promise<import('playwright-core').Locator> {
await page.evaluate(({ editorSelector, marker }) => {
document.querySelectorAll(`[${marker}]`).forEach(element => element.removeAttribute(marker));
const editors = Array.from(document.querySelectorAll(editorSelector)).filter((element): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const activeEditor = editors.find(editor => editor.classList.contains('focused') || editor.contains(document.activeElement));
const inputEditor = activeEditor ?? editors.toSorted((a, b) => a.getBoundingClientRect().bottom - b.getBoundingClientRect().bottom).at(-1);
if (!inputEditor) {
throw new Error('Visible Chat input editor not found');
}
inputEditor.setAttribute(marker, 'true');
}, { editorSelector: chatInputEditorSelector, marker: activeChatInputEditorMarker });
return page.locator(activeChatInputEditorSelector);
}
async function waitForInputEditorFocus(inputEditor: import('playwright-core').Locator): Promise<void> {
const stopWatch = StopWatch.create();
while (stopWatch.elapsed() < 5000) {
if (await inputEditor.evaluate(editor => editor.classList.contains('focused') || !!editor.querySelector(':focus'))) {
return;
}
await delay(50);
}
throw new Error('Timed out waiting for Chat input editor focus');
}
async function markActiveChatInput(inputEditor: import('playwright-core').Locator): Promise<void> {
await inputEditor.evaluate((editor, { inputSelector, marker }) => {
document.querySelectorAll(`[${marker}]`).forEach(element => element.removeAttribute(marker));
const input = editor.querySelector(inputSelector);
if (!input) {
throw new Error('Chat input not found');
}
input.setAttribute(marker, 'true');
}, { inputSelector: chatInputElementSelector, marker: activeChatInputMarker });
}
async function typeInChat(page: Page, message: string): Promise<void> {
const inputSelector = await openChat(page);
await page.evaluate(({ selector, text }) => {
if (!globalThis.driver) {
throw new Error('Smoke-test driver not found');
}
return globalThis.driver.typeInEditor(selector, text);
}, {
selector: inputSelector,
text: message.replace(/\r?\n/g, ' '),
});
}
async function clearChatInput(page: Page): Promise<void> {
await openChat(page);
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.press('Backspace');
}
async function createNewChatSession(page: Page): Promise<void> {
// Use the new chat keyboard shortcut
const shortcut = 'Control+L';
await page.keyboard.press(shortcut);
await delay(500);
await waitForVisibleChatInputEditor(page);
const inputEditor = await markChatInputEditor(page);
await inputEditor.click({ force: true });
await waitForInputEditorFocus(inputEditor);
await markActiveChatInput(inputEditor);
}
async function sendAndWaitForResponse(page: Page, message: string): Promise<string | undefined> {
const responseCountBefore = await page.locator(chatResponseSelector).count();
const textBefore = await getLatestChatResponseText(page);
await clearChatInput(page);
await typeInChat(page, message);
await page.keyboard.press('Enter');
// Wait for response to start
await page.waitForFunction(
({ responseSelector, loadingSelector, count, textBefore: tb }) => {
const getResponseText = (element: Element | undefined) => {
const contentElement = element?.querySelector(':scope > .value') ?? element;
return contentElement instanceof HTMLElement ? contentElement.innerText.trim() : contentElement?.textContent?.trim();
};
const responses = Array.from(document.querySelectorAll(responseSelector));
const latestText = getResponseText(responses.at(-1));
if (responses.length > count) {
return true;
}
const loadingResponse = document.querySelector(loadingSelector);
if (loadingResponse) {
return true;
}
if (!!latestText && latestText !== tb?.trim()) {
return true;
}
return false;
},
{ responseSelector: chatResponseSelector, loadingSelector: chatResponseLoadingSelector, count: responseCountBefore, textBefore },
{ timeout: options.firstResponseTimeout }
);
// Wait for response to complete
await page.waitForFunction(
({ completeSelector, loadingSelector, countBefore, textBefore: tb }) => {
const getResponseText = (element: Element | undefined) => {
const contentElement = element?.querySelector(':scope > .value') ?? element;
return contentElement instanceof HTMLElement ? contentElement.innerText.trim() : contentElement?.textContent?.trim();
};
const completedResponses = Array.from(document.querySelectorAll(completeSelector));
const loadingResponses = Array.from(document.querySelectorAll(loadingSelector));
const latestText = getResponseText(completedResponses.at(-1));
return !!latestText && latestText !== 'Working' && loadingResponses.length === 0 && (completedResponses.length > countBefore || latestText !== tb?.trim());
},
{ completeSelector: chatResponseCompleteSelector, loadingSelector: chatResponseLoadingSelector, countBefore: responseCountBefore, textBefore },
{ timeout: options.responseTimeout }
);
await delay(options.settleMs);
return getLatestChatResponseText(page);
}
async function getLatestChatResponseText(page: Page): Promise<string | undefined> {
return page.evaluate(responseSelector => {
const response = Array.from(document.querySelectorAll(responseSelector)).at(-1);
const contentElement = response?.querySelector(':scope > .value') ?? response;
return contentElement instanceof HTMLElement ? contentElement.innerText : contentElement?.textContent ?? undefined;
}, chatResponseSelector);
}
// ---- Session List Interaction ----
async function dismissDialogs(page: Page): Promise<void> {
const dialogBlocker = page.locator('.monaco-dialog-modal-block');
const stopWatch = StopWatch.create();
while (stopWatch.elapsed() < 3000) {
const visible = await dialogBlocker.count() > 0 && await dialogBlocker.first().isVisible().catch(() => false);
if (!visible) {
return;
}
console.log('[dialog] dismissing modal dialog via Escape');
await page.keyboard.press('Escape');
await delay(300);
}
}
async function showSessionsSidebar(page: Page): Promise<void> {
await dismissDialogs(page);
// Use command palette to show sessions sidebar
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+P' : 'Control+Shift+P');
await delay(300);
await page.keyboard.type('Show Sessions', { delay: 30 });
await delay(500);
// Press Enter to execute the first matching command
await page.keyboard.press('Enter');
await delay(1000);
// Dismiss any dialogs that appeared as a side effect
await dismissDialogs(page);
// Verify sessions list is visible
const sessionItems = await page.locator(sessionItemSelector).count();
console.log(`[sessions] sidebar shown, ${sessionItems} session items visible`);
}
async function clickSessionInList(page: Page, sessionIndex: number): Promise<void> {
// Dismiss any modal dialogs before clicking
await dismissDialogs(page);
// The sessions list shows the newest sessions at the top (under TODAY section).
// Our newly-created sessions are at the top. We need to click within
// the TODAY section, which are the first items in the list.
// sessionIndex 0 = first session we created (at top of TODAY section)
// Find session items that are in the TODAY section (most recent)
const sessionItems = page.locator(sessionItemSelector);
const count = await sessionItems.count();
if (count === 0) {
throw new Error('No session items found in the sessions list');
}
// Click directly by index (0 = first/newest)
const targetIndex = Math.min(sessionIndex, count - 1);
const targetItem = sessionItems.nth(targetIndex);
const title = await targetItem.textContent();
console.log(`[sessions] clicking session ${sessionIndex} (list index ${targetIndex}/${count}): "${title?.substring(0, 60)}..."`);
await targetItem.click();
await delay(300);
}
async function waitForSessionContentToLoad(page: Page): Promise<void> {
// Wait for the chat view to have at least one response and be settled
const stopWatch = StopWatch.create();
while (stopWatch.elapsed() < 15000) {
const isSettled = await page.evaluate(({ responseSelector, viewSelector, loadingSelector }) => {
const chatView = document.querySelector(viewSelector);
if (!chatView) {
return false;
}
// Check if there's at least one response in the view
const responses = chatView.querySelectorAll(responseSelector);
// Check no loading indicators
const loading = chatView.querySelectorAll(loadingSelector);
return responses.length > 0 && loading.length === 0;
}, {
responseSelector: '.interactive-item-container.interactive-response',
viewSelector: chatViewSelector,
loadingSelector: '.interactive-item-container.chat-response-loading',
});
if (isSettled) {
return;
}
await delay(100);
}
console.log('[sessions] warning: timed out waiting for session content to load');
}
// ---- Heap Sampling ----
async function sampleHeap(cdpSession: CDPSession, outputDir: string, label: string): Promise<HeapSample> {
await forceGarbageCollection(cdpSession);
const performanceMetrics = await getPerformanceMetrics(cdpSession);
const runtimeHeapUsage = await getRuntimeHeapUsage(cdpSession);
const snapshot = shouldTakeHeapSnapshot(label) ? path.join(outputDir, 'heap', `${label}.heapsnapshot`) : undefined;
const snapshotBytes = snapshot ? await takeHeapSnapshot(cdpSession, snapshot) : undefined;
const sample: HeapSample = {
label,
jsHeapUsedSize: performanceMetrics.get('JSHeapUsedSize'),
jsHeapTotalSize: performanceMetrics.get('JSHeapTotalSize'),
runtimeUsedSize: runtimeHeapUsage?.usedSize,
runtimeTotalSize: runtimeHeapUsage?.totalSize,
snapshot,
snapshotBytes,
};
console.log(`[heap] ${label}: ${formatBytes(sample.runtimeUsedSize ?? sample.jsHeapUsedSize)} used, ${snapshotBytes === undefined ? 'no snapshot' : `${formatBytes(snapshotBytes)} snapshot`}`);
return sample;
}
async function forceGarbageCollection(cdpSession: CDPSession): Promise<void> {
await cdpSession.send('HeapProfiler.collectGarbage');
await cdpSession.send('HeapProfiler.collectGarbage');
}
async function getPerformanceMetrics(cdpSession: CDPSession): Promise<Map<string, number>> {
const response = await cdpSession.send('Performance.getMetrics');
return new Map(response.metrics.map(metric => [metric.name, metric.value]));
}
async function getRuntimeHeapUsage(cdpSession: CDPSession): Promise<HeapUsage | undefined> {
return cdpSession.send('Runtime.getHeapUsage').catch(() => undefined);
}
async function takeHeapSnapshot(cdpSession: CDPSession, file: string): Promise<number> {
await mkdir(path.dirname(file), { recursive: true });
const stream = createWriteStream(file);
let snapshotBytes = 0;
let pendingWrite = Promise.resolve();
const onChunk = (event: { chunk: string }) => {
snapshotBytes += Buffer.byteLength(event.chunk);
pendingWrite = pendingWrite.then(() => new Promise((resolve, reject) => {
stream.write(event.chunk, error => error ? reject(error) : resolve());
}));
};
cdpSession.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
try {
await cdpSession.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
await pendingWrite;
} finally {
cdpSession.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
await new Promise<void>((resolve, reject) => {
stream.once('error', reject);
stream.end(resolve);
});
}
return snapshotBytes;
}
function shouldTakeHeapSnapshot(label: string): boolean {
return options.heapSnapshots && (!options.heapSnapshotLabels || options.heapSnapshotLabels.has(label));
}
// ---- Summary & Output ----
function printSummary(samples: HeapSample[], outputDir: string): void {
console.log(`\nSummary written to ${path.join(outputDir, 'summary.json')}`);
for (const sample of samples) {
const heapSize = sample.runtimeUsedSize ?? sample.jsHeapUsedSize;
console.log(`${sample.label.padEnd(32)} ${formatBytes(heapSize).padStart(10)} ${sample.snapshot ? path.relative(root, sample.snapshot) : ''}`);
}
const trend = analyzeMemoryTrend(samples);
if (trend.postFirstSwitchUsedBytes !== undefined) {
console.log(`Post-first-switch growth: ${formatSignedBytes(trend.postFirstSwitchUsedBytes)} (${formatSignedBytes(trend.postFirstSwitchUsedBytesPerIteration)}/iteration)`);
}
}
async function writeSummary(outputDir: string, samples: HeapSample[], sessions: SessionInfo[], switchIterations: number, error?: unknown): Promise<void> {
await writeFile(path.join(outputDir, 'summary.json'), JSON.stringify({
createdAt: new Date().toISOString(),
workspace: options.workspace,
switchIterations,
sessionsCreated: sessions.length,
sessions,
error: error === undefined ? undefined : String(error instanceof Error && error.stack ? error.stack : error),
analysis: analyzeMemoryTrend(samples),
samples,
}, undefined, '\t'));
}
function analyzeMemoryTrend(samples: HeapSample[]): MemoryTrend {
const sizedSamples = samples.map(sample => sample.runtimeUsedSize ?? sample.jsHeapUsedSize);
const firstSize = sizedSamples.at(0);
const lastSize = sizedSamples.at(-1);
const switchSamples = samples.filter(sample => sample.label.startsWith('04-switch-'));
const firstSwitchSize = switchSamples.at(0)?.runtimeUsedSize ?? switchSamples.at(0)?.jsHeapUsedSize;
const lastSwitchSize = switchSamples.at(-1)?.runtimeUsedSize ?? switchSamples.at(-1)?.jsHeapUsedSize;
const postFirstSwitchUsedBytes = firstSwitchSize !== undefined && lastSwitchSize !== undefined && switchSamples.length > 1 ? lastSwitchSize - firstSwitchSize : undefined;
return {
firstToLastUsedBytes: firstSize !== undefined && lastSize !== undefined && samples.length > 1 ? lastSize - firstSize : undefined,
postFirstSwitchUsedBytes,
postFirstSwitchUsedBytesPerIteration: postFirstSwitchUsedBytes !== undefined ? postFirstSwitchUsedBytes / (switchSamples.length - 1) : undefined,
};
}
// ---- Utilities ----
function formatBytes(bytes: number | undefined): string {
if (typeof bytes !== 'number') {
return 'unknown';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatSignedBytes(bytes: number | undefined): string {
if (bytes === undefined) {
return 'unknown';
}
return `${bytes >= 0 ? '+' : '-'}${formatBytes(Math.abs(bytes))}`;
}
function getJson(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const request = http.get(url, response => {
response.resume();
response.once('end', () => response.statusCode === 200 ? resolve(undefined) : reject(new Error(`HTTP ${response.statusCode}`)));
});
request.setTimeout(1000, () => {
request.destroy(new Error('Request timed out'));
});
request.once('error', reject);
});
}
// ---- Argument Parsing ----
function parseArgs(args: string[]): Options {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const parsed: Options = {
help: false,
verbose: false,
reuse: false,
skipPrelaunch: false,
heapSnapshots: true,
heapSnapshotLabels: undefined,
keepOpen: false,
temporaryUserData: false,
port: 9224,
switchIterations: 5,
responseTimeout: 120000,
firstResponseTimeout: 30000,
settleMs: 3000,
workspace: root,
outputDir: path.join(root, '.build', 'chat-session-switch-smoke', timestamp),
userDataDir: undefined,
seedUserDataDir: undefined,
extensionDir: undefined,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg === '--verbose') {
parsed.verbose = true;
} else if (arg === '--reuse') {
parsed.reuse = true;
} else if (arg === '--skip-prelaunch') {
parsed.skipPrelaunch = true;
} else if (arg === '--no-heap-snapshots') {
parsed.heapSnapshots = false;
} else if (arg.startsWith('--heap-snapshot-label=')) {
addHeapSnapshotLabels(parsed, arg.slice('--heap-snapshot-label='.length));
} else if (arg === '--heap-snapshot-label') {
addHeapSnapshotLabels(parsed, readArgValue(args, ++i, arg));
} else if (arg === '--keep-open') {
parsed.keepOpen = true;
} else if (arg === '--temporary-user-data') {
parsed.temporaryUserData = true;
} else if (arg.startsWith('--')) {
const [key, inlineValue] = splitArg(arg);
const value = inlineValue ?? readArgValue(args, ++i, key);
switch (key) {
case '--port': parsed.port = parseIntegerArg(key, value, 1); break;
case '--switch-iterations': parsed.switchIterations = parseIntegerArg(key, value, 1); break;
case '--response-timeout': parsed.responseTimeout = parseIntegerArg(key, value, 1); break;
case '--first-response-timeout': parsed.firstResponseTimeout = parseIntegerArg(key, value, 1); break;
case '--settle-ms': parsed.settleMs = parseIntegerArg(key, value, 0); break;
case '--workspace': parsed.workspace = path.resolve(value); break;
case '--output': parsed.outputDir = value; break;
case '--user-data-dir': parsed.userDataDir = value; break;
case '--seed-user-data-dir': parsed.seedUserDataDir = value; break;
case '--extensions-dir': parsed.extensionDir = value; break;
default: throw new Error(`Unknown argument: ${key}`);
}
} else {
throw new Error(`Unexpected positional argument: ${arg}`);
}
}
return parsed;
}
function addHeapSnapshotLabels(opts: Options, value: string): void {
opts.heapSnapshotLabels ??= new Set();
for (const label of value.split(',')) {
const trimmedLabel = label.trim();
if (trimmedLabel) {
opts.heapSnapshotLabels.add(trimmedLabel);
}
}
}
function splitArg(arg: string): [string, string | undefined] {
const index = arg.indexOf('=');
return index === -1 ? [arg, undefined] : [arg.slice(0, index), arg.slice(index + 1)];
}
function readArgValue(args: string[], index: number, flag: string): string {
const value = args[index];
if (typeof value !== 'string') {
throw new Error(`Missing value for ${flag}`);
}
return value;
}
function parseIntegerArg(flag: string, value: string, min: number): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min) {
throw new Error(`${flag} must be an integer greater than or equal to ${min}. Got: ${value}`);
}
return parsed;
}
function printHelp(): void {
console.log([
'Usage: node .github/skills/auto-perf-optimize/scripts/chat-session-switch-smoke.mts [options]',
'',
'Creates multiple Chat sessions with different content types, then repeatedly',
'switches between them via the sessions list to verify no memory leaks.',
'',
'Options:',
' --workspace <path> Workspace to open. Default: repo root',
' --output <path> Output folder. Default: .build/chat-session-switch-smoke/<timestamp>',
' --port <number> Remote debugging port. Default: 9224',
' --switch-iterations <number> Number of full switch cycles. Default: 5',
' --no-heap-snapshots Only record small heap-size metrics',
' --heap-snapshot-label <label> Only snapshot matching sample labels. Repeatable or comma-separated',
' --reuse Attach to an already-running --enable-smoke-test-driver window',
' --skip-prelaunch Set VSCODE_SKIP_PRELAUNCH=1',
' --temporary-user-data Use an output-local clean user-data-dir',
' --seed-user-data-dir <path> Copy a logged-in profile into a fresh target user-data-dir before launch',
' --keep-open Leave launched Code open',
' --verbose Show Code stdout/stderr',
'',
'Example:',
' node .github/skills/auto-perf-optimize/scripts/chat-session-switch-smoke.mts --switch-iterations 3 --no-heap-snapshots',
].join('\n'));
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { cp, mkdir, readdir, rm, stat } from 'node:fs/promises';
import path from 'node:path';
const skippedUserDataRootEntries = new Set([
'Backups',
'blob_storage',
'BrowserMetrics',
'Cache',
'CachedData',
'Code Cache',
'component_crx_cache',
'Crashpad',
'DawnGraphiteCache',
'DawnWebGPUCache',
'GPUCache',
'logs',
'ShaderCache',
'Session Storage',
'SingletonCookie',
'SingletonLock',
'SingletonSocket',
]);
export interface UserDataProfileOptions {
outputDir: string;
persistentUserDataDir: string;
temporaryUserData: boolean;
keepOpen: boolean;
keepUserData: boolean;
reuse: boolean;
userDataDir: string | undefined;
seedUserDataDir: string | undefined;
}
export interface UserDataProfile {
userDataDir: string;
ownsUserDataDir: boolean;
}
export async function prepareUserDataProfile(options: UserDataProfileOptions): Promise<UserDataProfile> {
const generatedUserDataDir = options.temporaryUserData ? path.join(options.outputDir, 'user-data') : options.persistentUserDataDir;
const userDataDir = path.resolve(options.userDataDir ?? generatedUserDataDir);
const ownsUserDataDir = !options.reuse && options.temporaryUserData && !options.keepOpen && !options.keepUserData && options.userDataDir === undefined;
if (options.seedUserDataDir !== undefined) {
if (options.reuse) {
throw new Error('--seed-user-data-dir cannot be used with --reuse');
}
await copySeedUserDataDir(path.resolve(options.seedUserDataDir), userDataDir);
}
return { userDataDir, ownsUserDataDir };
}
async function copySeedUserDataDir(seedUserDataDir: string, userDataDir: string): Promise<void> {
if (seedUserDataDir === userDataDir) {
throw new Error('--seed-user-data-dir must be different from the target user-data-dir');
}
if (!await isDirectory(seedUserDataDir)) {
throw new Error(`Seed user-data-dir does not exist or is not a directory: ${seedUserDataDir}`);
}
if (await pathExists(userDataDir)) {
const children = await readdir(userDataDir).catch(() => []);
if (children.length > 0) {
throw new Error(`Refusing to copy seed profile because the target user-data-dir already exists: ${userDataDir}. Choose a fresh --user-data-dir, pass --temporary-user-data, or delete the target first.`);
}
await rm(userDataDir, { recursive: true, force: true, maxRetries: 3 });
}
console.log(`[code] copying seed user-data-dir: ${seedUserDataDir}`);
console.log(`[code] seed copy target may contain auth secrets: ${userDataDir}`);
await mkdir(path.dirname(userDataDir), { recursive: true });
try {
await cp(seedUserDataDir, userDataDir, {
recursive: true,
errorOnExist: true,
force: false,
filter: source => shouldCopyUserDataPath(seedUserDataDir, source),
});
} catch (error) {
await rm(userDataDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined);
throw error;
}
}
function shouldCopyUserDataPath(seedUserDataDir: string, source: string): boolean {
const relativePath = path.relative(seedUserDataDir, source);
if (!relativePath) {
return true;
}
const [rootEntry] = relativePath.split(path.sep);
const basename = path.basename(relativePath);
return !skippedUserDataRootEntries.has(rootEntry) && !basename.endsWith('.sock') && !basename.endsWith('.lock');
}
async function isDirectory(file: string): Promise<boolean> {
return stat(file).then(value => value.isDirectory(), () => false);
}
async function pathExists(file: string): Promise<boolean> {
return stat(file).then(() => true, () => false);
}
Related skills
FAQ
What does auto-perf-optimize do?
Run agent-driven VS Code performance or memory investigations. Use when asked to launch Code OSS, automate a VS Code scenario, run the Chat memory smoke runner, capture renderer heap snap...
When should I use auto-perf-optimize?
Invoke when Run agent-driven VS Code performance or memory investigations. Use when asked to launch Code OSS, automate a VS Code scenario, run the Chat .
Is auto-perf-optimize safe to install?
Review the Security Audits panel on this page before installing in production.