Path: blob/main/scripts/chat-simulation/test-chat-mem-leaks.js
13379 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45// @ts-check67/**8* Chat memory leak checker — state-based approach.9*10* The idea: if you return to the same state you started from, memory should11* return to roughly the same level. Any residual growth is a potential leak.12*13* Each iteration:14* 1. Open a fresh chat (baseline state)15* 2. Measure heap + DOM nodes16* 3. Cycle through ALL registered perf scenarios (text, code blocks,17* tool calls, thinking, multi-turn, etc.)18* 4. Open a new chat (return to baseline state — clears previous session)19* 5. Measure heap + DOM nodes again20* 6. The delta is the "leaked" memory for that iteration21*22* Multiple iterations let us detect consistent leaks vs. one-time caching.23*24* Usage:25* npm run perf:chat-leak # defaults from config26* npm run perf:chat-leak -- --iterations 5 # more iterations27* npm run perf:chat-leak -- --threshold 5 # 5MB total threshold28* npm run perf:chat-leak -- --build 1.115.0 # test a specific build29*/3031const fs = require('fs');32const path = require('path');33const {34DATA_DIR, loadConfig,35resolveBuild, buildEnv, buildArgs, prepareRunDir,36launchVSCode,37} = require('./common/utils');38const {39CONTENT_SCENARIOS, TOOL_CALL_SCENARIOS, MULTI_TURN_SCENARIOS,40} = require('./common/perf-scenarios');41const {42getUserTurns, getModelTurnCount,43} = require('./common/mock-llm-server');4445// -- Config (edit config.jsonc to change defaults) ---------------------------4647const CONFIG = loadConfig('memLeaks');4849// -- CLI args ----------------------------------------------------------------5051function parseArgs() {52const args = process.argv.slice(2);53const opts = {54iterations: CONFIG.iterations ?? 3,55messages: CONFIG.messages ?? 5,56verbose: false,57ci: false,58/** @type {string | undefined} */59build: undefined,60leakThresholdMB: CONFIG.leakThresholdMB ?? 5,61/** @type {Record<string, any>} */62settingsOverrides: {},63};64for (let i = 0; i < args.length; i++) {65switch (args[i]) {66case '--iterations': opts.iterations = parseInt(args[++i], 10); break;67case '--messages': case '-n': opts.messages = parseInt(args[++i], 10); break;68case '--verbose': opts.verbose = true; break;69case '--ci': opts.ci = true; break;70case '--build': case '-b': opts.build = args[++i]; break;71case '--threshold': opts.leakThresholdMB = parseFloat(args[++i]); break;72case '--setting': {73const kv = args[++i];74const eq = kv.indexOf('=');75if (eq === -1) { console.error(`--setting requires key=value, got: ${kv}`); process.exit(1); }76const key = kv.slice(0, eq);77const raw = kv.slice(eq + 1);78const val = raw === 'true' ? true : raw === 'false' ? false : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;79opts.settingsOverrides[key] = val;80break;81}82case '--help': case '-h':83console.log([84'Chat memory leak checker (state-based)',85'',86'Options:',87' --iterations <n> Number of open→work→reset cycles (default: 3)',88' --messages <n> Messages to send per iteration (default: 5)',89' --ci CI mode: write Markdown summary to ci-summary.md',90' --build <path|ver> Path to VS Code build or version to download',91' --threshold <MB> Max total residual heap growth in MB (default: 5)',92' --setting <k=v> Set a VS Code setting override (repeatable)',93' --verbose Print per-step details',94].join('\n'));95process.exit(0);96}97}98return opts;99}100101// -- Scenario list -----------------------------------------------------------102103/**104* Build a flat list of scenario IDs to cycle through during leak testing.105* Includes all scenario types: content-only, tool-call, and multi-turn.106*107* Content scenarios exercise varied rendering (code blocks, markdown, etc.).108* Tool-call scenarios exercise the agent loop (model → tool → model → ...).109* Multi-turn scenarios exercise user follow-ups and thinking blocks.110*/111function getScenarioIds() {112return [113...Object.keys(CONTENT_SCENARIOS),114...Object.keys(TOOL_CALL_SCENARIOS),115...Object.keys(MULTI_TURN_SCENARIOS),116];117}118119// -- Helpers -----------------------------------------------------------------120121const CHAT_VIEW = 'div[id="workbench.panel.chat"]';122const CHAT_EDITOR_SEL = `${CHAT_VIEW} .interactive-input-part .monaco-editor[role="code"]`;123124/**125* Measure heap (MB) and DOM node count after forced GC.126* @param {any} cdp127* @param {import('playwright').Page} page128*/129async function measure(cdp, page) {130await cdp.send('HeapProfiler.collectGarbage');131await new Promise(r => setTimeout(r, 500));132await cdp.send('HeapProfiler.collectGarbage');133await new Promise(r => setTimeout(r, 300));134const heapInfo = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage'));135const heapMB = Math.round(heapInfo.usedSize / 1024 / 1024 * 100) / 100;136const domNodes = await page.evaluate(() => document.querySelectorAll('*').length);137return { heapMB, domNodes };138}139140/**141* Open a new chat session via the command palette.142* @param {import('playwright').Page} page143*/144async function openNewChat(page) {145// Use keyboard shortcut to open a new chat (clears previous session)146const newChatShortcut = process.platform === 'darwin' ? 'Meta+KeyL' : 'Control+KeyL';147await page.keyboard.press(newChatShortcut);148await new Promise(r => setTimeout(r, 1000));149150// Verify the chat view is visible and ready151await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 });152await page.waitForFunction(153(sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0),154CHAT_EDITOR_SEL, { timeout: 15_000 },155);156await new Promise(r => setTimeout(r, 500));157}158159/**160* Send a single message and wait for the response to complete.161* For multi-turn scenarios where the model makes multiple tool-call rounds162* before producing content, `modelTurns` controls how many completions to163* wait for.164* @param {import('playwright').Page} page165* @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer166* @param {string} text167* @param {number} [modelTurns=1] - number of model completions to wait for168*/169async function sendMessage(page, mockServer, text, modelTurns = 1) {170await page.click(CHAT_EDITOR_SEL);171await new Promise(r => setTimeout(r, 200));172173const inputSel = await page.evaluate((editorSel) => {174const ed = document.querySelector(editorSel);175if (!ed) { throw new Error('no editor'); }176return ed.querySelector('.native-edit-context') ? editorSel + ' .native-edit-context' : editorSel + ' textarea';177}, CHAT_EDITOR_SEL);178179const hasDriver = await page.evaluate(() =>180// @ts-ignore181!!globalThis.driver?.typeInEditor182).catch(() => false);183184if (hasDriver) {185await page.evaluate(({ selector, t }) => {186// @ts-ignore187return globalThis.driver.typeInEditor(selector, t);188}, { selector: inputSel, t: text });189} else {190await page.click(inputSel);191await new Promise(r => setTimeout(r, 200));192await page.locator(inputSel).pressSequentially(text, { delay: 0 });193}194195const compBefore = mockServer.completionCount();196await page.keyboard.press('Enter');197try { await mockServer.waitForCompletion(compBefore + modelTurns, 60_000); } catch { }198199const responseSelector = `${CHAT_VIEW} .interactive-item-container.interactive-response`;200await page.waitForFunction(201(sel) => {202const responses = document.querySelectorAll(sel);203if (responses.length === 0) { return false; }204return !responses[responses.length - 1].classList.contains('chat-response-loading');205},206responseSelector, { timeout: 30_000 },207);208await new Promise(r => setTimeout(r, 500));209}210211/**212* Run a full scenario: send the initial message, then handle any user213* follow-up turns for multi-turn scenarios.214*215* - Content-only scenarios: single message, 1 model turn.216* - Tool-call scenarios (no user turns): single message, N model turns217* (the extension automatically relays tool results back to the model).218* - Multi-turn with user turns: send initial message, wait for response,219* then for each user turn send the follow-up message and wait again.220*221* @param {import('playwright').Page} page222* @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer223* @param {string} scenarioId224* @param {string} label - prefix for the message (e.g. "Warmup" or "Iteration 2")225*/226async function runScenario(page, mockServer, scenarioId, label) {227const userTurns = getUserTurns(scenarioId);228const totalModelTurns = getModelTurnCount(scenarioId);229230if (userTurns.length === 0) {231// Content-only or tool-call scenario: one message, wait for all model turns232await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, totalModelTurns);233} else {234// Multi-turn with user follow-ups: send initial message and wait for235// the model turns before the first user turn, then alternate.236let modelTurnsSoFar = 0;237const firstUserAfter = userTurns[0].afterModelTurn;238const turnsBeforeFirstUser = firstUserAfter - modelTurnsSoFar;239await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, turnsBeforeFirstUser);240modelTurnsSoFar = firstUserAfter;241242for (let u = 0; u < userTurns.length; u++) {243const nextModelStop = u + 1 < userTurns.length244? userTurns[u + 1].afterModelTurn245: totalModelTurns;246const turnsUntilNext = nextModelStop - modelTurnsSoFar;247248// Send the user follow-up message249await sendMessage(page, mockServer, userTurns[u].message, turnsUntilNext);250modelTurnsSoFar = nextModelStop;251}252}253}254255// -- Leak check --------------------------------------------------------------256257/**258* @param {string} electronPath259* @param {{ url: string, requestCount: () => number, waitForRequests: (n: number, ms: number) => Promise<void>, completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer260* @param {{ iterations: number, verbose: boolean, settingsOverrides?: Record<string, any> }} opts261*/262async function runLeakCheck(electronPath, mockServer, opts) {263const { iterations, verbose } = opts;264const { userDataDir, extDir, logsDir } = prepareRunDir('leak-check', mockServer, opts.settingsOverrides);265const isDevBuild = !electronPath.includes('.vscode-test');266267const vscode = await launchVSCode(268electronPath,269buildArgs(userDataDir, extDir, logsDir, { isDevBuild }),270buildEnv(mockServer, { isDevBuild }),271{ verbose },272);273const page = vscode.page;274275try {276await page.waitForSelector('.monaco-workbench', { timeout: 60_000 });277278const cdp = await page.context().newCDPSession(page);279await cdp.send('HeapProfiler.enable');280281// Open chat panel282const chatShortcut = process.platform === 'darwin' ? 'Control+Meta+KeyI' : 'Control+Alt+KeyI';283await page.keyboard.press(chatShortcut);284await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 });285await page.waitForFunction(286(sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0),287CHAT_EDITOR_SEL, { timeout: 15_000 },288);289290// Wait for extension activation291const reqsBefore = mockServer.requestCount();292try { await mockServer.waitForRequests(reqsBefore + 4, 30_000); } catch { }293await new Promise(r => setTimeout(r, 3000));294295const scenarioIds = getScenarioIds();296297// --- Baseline measurement (fresh chat) ---298const baseline = await measure(cdp, page);299if (verbose) {300console.log(` [leak] Baseline: heap=${baseline.heapMB}MB, domNodes=${baseline.domNodes}`);301}302303/** @type {{ beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[]} */304const iterationResults = [];305306for (let iter = 0; iter < iterations; iter++) {307// Measure at start of iteration (should be in "clean" state)308const before = await measure(cdp, page);309310if (verbose) {311console.log(` [leak] Iteration ${iter + 1}/${iterations}: start heap=${before.heapMB}MB, domNodes=${before.domNodes}`);312}313314// Do work: cycle through all scenarios315for (let m = 0; m < scenarioIds.length; m++) {316const sid = scenarioIds[m];317await runScenario(page, mockServer, sid, `Iteration ${iter + 1}`);318if (verbose) {319console.log(` [leak] Sent ${sid} (${m + 1}/${scenarioIds.length})`);320}321}322323// Return to clean state: open a new empty chat324await openNewChat(page);325await new Promise(r => setTimeout(r, 1000));326327// Measure after returning to clean state328const after = await measure(cdp, page);329const deltaHeapMB = Math.round((after.heapMB - before.heapMB) * 100) / 100;330const deltaDomNodes = after.domNodes - before.domNodes;331332iterationResults.push({333beforeHeapMB: before.heapMB,334afterHeapMB: after.heapMB,335deltaHeapMB,336beforeDomNodes: before.domNodes,337afterDomNodes: after.domNodes,338deltaDomNodes,339});340341if (verbose) {342console.log(` [leak] Iteration ${iter + 1}/${iterations}: end heap=${after.heapMB}MB (delta=${deltaHeapMB}MB), domNodes=${after.domNodes} (delta=${deltaDomNodes})`);343}344}345346// Final measurement347const final = await measure(cdp, page);348const totalResidualMB = Math.round((final.heapMB - baseline.heapMB) * 100) / 100;349const totalResidualNodes = final.domNodes - baseline.domNodes;350351return {352baseline,353final: { heapMB: final.heapMB, domNodes: final.domNodes },354totalResidualMB,355totalResidualNodes,356iterations: iterationResults,357};358} finally {359await vscode.close();360}361}362363// -- Main --------------------------------------------------------------------364365async function main() {366const opts = parseArgs();367const electronPath = await resolveBuild(opts.build);368369if (!fs.existsSync(electronPath)) {370console.error(`Electron not found at: ${electronPath}`);371process.exit(1);372}373374const { startServer } = require('./common/mock-llm-server');375const { registerPerfScenarios } = require('./common/perf-scenarios');376registerPerfScenarios();377const mockServer = await startServer(0);378379console.log(`[chat-simulation] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB total`);380console.log(`[chat-simulation] Build: ${electronPath}`);381console.log('');382383const result = await runLeakCheck(electronPath, mockServer, opts);384385console.log('[chat-simulation] =================== Leak Check Results ===================');386console.log('');387console.log(` Baseline: heap=${result.baseline.heapMB}MB, domNodes=${result.baseline.domNodes}`);388console.log(` Final: heap=${result.final.heapMB}MB, domNodes=${result.final.domNodes}`);389console.log('');390for (let i = 0; i < result.iterations.length; i++) {391const it = result.iterations[i];392console.log(` Iteration ${i + 1}: ${it.beforeHeapMB}MB → ${it.afterHeapMB}MB (residual: ${it.deltaHeapMB > 0 ? '+' : ''}${it.deltaHeapMB}MB, DOM: ${it.deltaDomNodes > 0 ? '+' : ''}${it.deltaDomNodes} nodes)`);393}394console.log('');395console.log(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB`);396console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`);397console.log('');398399// Write JSON400const jsonPath = path.join(DATA_DIR, 'chat-simulation-leak-results.json');401fs.writeFileSync(jsonPath, JSON.stringify({402timestamp: new Date().toISOString(),403leakThresholdMB: opts.leakThresholdMB,404iterationCount: opts.iterations,405scenarioCount: getScenarioIds().length,406...result,407}, null, 2));408console.log(`[chat-simulation] Results written to ${jsonPath}`);409410const leaked = result.totalResidualMB > opts.leakThresholdMB;411console.log('');412if (leaked) {413console.log(`[chat-simulation] LEAK DETECTED — ${result.totalResidualMB}MB residual exceeds ${opts.leakThresholdMB}MB threshold`);414} else {415console.log(`[chat-simulation] No leak detected (${result.totalResidualMB}MB residual < ${opts.leakThresholdMB}MB threshold)`);416}417418if (opts.ci) {419const summary = generateLeakCISummary(result, opts);420const summaryPath = path.join(DATA_DIR, 'ci-summary-leak.md');421fs.writeFileSync(summaryPath, summary);422console.log(`[chat-simulation] CI summary written to ${summaryPath}`);423}424425await mockServer.close();426process.exit(leaked ? 1 : 0);427}428429/**430* Generate a Markdown summary for CI, matching the perf script pattern.431* @param {{ baseline: { heapMB: number, domNodes: number }, final: { heapMB: number, domNodes: number }, totalResidualMB: number, totalResidualNodes: number, iterations: { beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[] }} result432* @param {{ leakThresholdMB: number, iterations: number }} opts433*/434function generateLeakCISummary(result, opts) {435const leaked = result.totalResidualMB > opts.leakThresholdMB;436const verdict = leaked ? '\u274C **LEAK DETECTED**' : '\u2705 **No leak detected**';437const lines = [];438lines.push('## Memory Leak Check');439lines.push('');440lines.push('| | |');441lines.push('|---|---|');442lines.push(`| **Verdict** | ${verdict} |`);443lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB |`);444lines.push(`| **Iterations** | ${opts.iterations} |`);445lines.push(`| **Scenarios per iteration** | ${getScenarioIds().length} |`);446lines.push('');447lines.push('| Phase | Heap (MB) | DOM Nodes |');448lines.push('|-------|----------:|----------:|');449lines.push(`| Baseline | ${result.baseline.heapMB} | ${result.baseline.domNodes} |`);450for (let i = 0; i < result.iterations.length; i++) {451const it = result.iterations[i];452const sign = it.deltaHeapMB > 0 ? '+' : '';453const domSign = it.deltaDomNodes > 0 ? '+' : '';454lines.push(`| Iteration ${i + 1} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`);455}456lines.push(`| **Final** | **${result.final.heapMB}** | **${result.final.domNodes}** |`);457lines.push('');458const sign = result.totalResidualMB > 0 ? '+' : '';459const domSign = result.totalResidualNodes > 0 ? '+' : '';460lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes`);461lines.push('');462return lines.join('\n');463}464465main().catch(err => { console.error(err); process.exit(1); });466467468