Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/scripts/chat-simulation/test-chat-mem-leaks.js
13379 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
// @ts-check
7
8
/**
9
* Chat memory leak checker — state-based approach.
10
*
11
* The idea: if you return to the same state you started from, memory should
12
* return to roughly the same level. Any residual growth is a potential leak.
13
*
14
* Each iteration:
15
* 1. Open a fresh chat (baseline state)
16
* 2. Measure heap + DOM nodes
17
* 3. Cycle through ALL registered perf scenarios (text, code blocks,
18
* tool calls, thinking, multi-turn, etc.)
19
* 4. Open a new chat (return to baseline state — clears previous session)
20
* 5. Measure heap + DOM nodes again
21
* 6. The delta is the "leaked" memory for that iteration
22
*
23
* Multiple iterations let us detect consistent leaks vs. one-time caching.
24
*
25
* Usage:
26
* npm run perf:chat-leak # defaults from config
27
* npm run perf:chat-leak -- --iterations 5 # more iterations
28
* npm run perf:chat-leak -- --threshold 5 # 5MB total threshold
29
* npm run perf:chat-leak -- --build 1.115.0 # test a specific build
30
*/
31
32
const fs = require('fs');
33
const path = require('path');
34
const {
35
DATA_DIR, loadConfig,
36
resolveBuild, buildEnv, buildArgs, prepareRunDir,
37
launchVSCode,
38
} = require('./common/utils');
39
const {
40
CONTENT_SCENARIOS, TOOL_CALL_SCENARIOS, MULTI_TURN_SCENARIOS,
41
} = require('./common/perf-scenarios');
42
const {
43
getUserTurns, getModelTurnCount,
44
} = require('./common/mock-llm-server');
45
46
// -- Config (edit config.jsonc to change defaults) ---------------------------
47
48
const CONFIG = loadConfig('memLeaks');
49
50
// -- CLI args ----------------------------------------------------------------
51
52
function parseArgs() {
53
const args = process.argv.slice(2);
54
const opts = {
55
iterations: CONFIG.iterations ?? 3,
56
messages: CONFIG.messages ?? 5,
57
verbose: false,
58
ci: false,
59
/** @type {string | undefined} */
60
build: undefined,
61
leakThresholdMB: CONFIG.leakThresholdMB ?? 5,
62
/** @type {Record<string, any>} */
63
settingsOverrides: {},
64
};
65
for (let i = 0; i < args.length; i++) {
66
switch (args[i]) {
67
case '--iterations': opts.iterations = parseInt(args[++i], 10); break;
68
case '--messages': case '-n': opts.messages = parseInt(args[++i], 10); break;
69
case '--verbose': opts.verbose = true; break;
70
case '--ci': opts.ci = true; break;
71
case '--build': case '-b': opts.build = args[++i]; break;
72
case '--threshold': opts.leakThresholdMB = parseFloat(args[++i]); break;
73
case '--setting': {
74
const kv = args[++i];
75
const eq = kv.indexOf('=');
76
if (eq === -1) { console.error(`--setting requires key=value, got: ${kv}`); process.exit(1); }
77
const key = kv.slice(0, eq);
78
const raw = kv.slice(eq + 1);
79
const val = raw === 'true' ? true : raw === 'false' ? false : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
80
opts.settingsOverrides[key] = val;
81
break;
82
}
83
case '--help': case '-h':
84
console.log([
85
'Chat memory leak checker (state-based)',
86
'',
87
'Options:',
88
' --iterations <n> Number of open→work→reset cycles (default: 3)',
89
' --messages <n> Messages to send per iteration (default: 5)',
90
' --ci CI mode: write Markdown summary to ci-summary.md',
91
' --build <path|ver> Path to VS Code build or version to download',
92
' --threshold <MB> Max total residual heap growth in MB (default: 5)',
93
' --setting <k=v> Set a VS Code setting override (repeatable)',
94
' --verbose Print per-step details',
95
].join('\n'));
96
process.exit(0);
97
}
98
}
99
return opts;
100
}
101
102
// -- Scenario list -----------------------------------------------------------
103
104
/**
105
* Build a flat list of scenario IDs to cycle through during leak testing.
106
* Includes all scenario types: content-only, tool-call, and multi-turn.
107
*
108
* Content scenarios exercise varied rendering (code blocks, markdown, etc.).
109
* Tool-call scenarios exercise the agent loop (model → tool → model → ...).
110
* Multi-turn scenarios exercise user follow-ups and thinking blocks.
111
*/
112
function getScenarioIds() {
113
return [
114
...Object.keys(CONTENT_SCENARIOS),
115
...Object.keys(TOOL_CALL_SCENARIOS),
116
...Object.keys(MULTI_TURN_SCENARIOS),
117
];
118
}
119
120
// -- Helpers -----------------------------------------------------------------
121
122
const CHAT_VIEW = 'div[id="workbench.panel.chat"]';
123
const CHAT_EDITOR_SEL = `${CHAT_VIEW} .interactive-input-part .monaco-editor[role="code"]`;
124
125
/**
126
* Measure heap (MB) and DOM node count after forced GC.
127
* @param {any} cdp
128
* @param {import('playwright').Page} page
129
*/
130
async function measure(cdp, page) {
131
await cdp.send('HeapProfiler.collectGarbage');
132
await new Promise(r => setTimeout(r, 500));
133
await cdp.send('HeapProfiler.collectGarbage');
134
await new Promise(r => setTimeout(r, 300));
135
const heapInfo = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage'));
136
const heapMB = Math.round(heapInfo.usedSize / 1024 / 1024 * 100) / 100;
137
const domNodes = await page.evaluate(() => document.querySelectorAll('*').length);
138
return { heapMB, domNodes };
139
}
140
141
/**
142
* Open a new chat session via the command palette.
143
* @param {import('playwright').Page} page
144
*/
145
async function openNewChat(page) {
146
// Use keyboard shortcut to open a new chat (clears previous session)
147
const newChatShortcut = process.platform === 'darwin' ? 'Meta+KeyL' : 'Control+KeyL';
148
await page.keyboard.press(newChatShortcut);
149
await new Promise(r => setTimeout(r, 1000));
150
151
// Verify the chat view is visible and ready
152
await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 });
153
await page.waitForFunction(
154
(sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0),
155
CHAT_EDITOR_SEL, { timeout: 15_000 },
156
);
157
await new Promise(r => setTimeout(r, 500));
158
}
159
160
/**
161
* Send a single message and wait for the response to complete.
162
* For multi-turn scenarios where the model makes multiple tool-call rounds
163
* before producing content, `modelTurns` controls how many completions to
164
* wait for.
165
* @param {import('playwright').Page} page
166
* @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer
167
* @param {string} text
168
* @param {number} [modelTurns=1] - number of model completions to wait for
169
*/
170
async function sendMessage(page, mockServer, text, modelTurns = 1) {
171
await page.click(CHAT_EDITOR_SEL);
172
await new Promise(r => setTimeout(r, 200));
173
174
const inputSel = await page.evaluate((editorSel) => {
175
const ed = document.querySelector(editorSel);
176
if (!ed) { throw new Error('no editor'); }
177
return ed.querySelector('.native-edit-context') ? editorSel + ' .native-edit-context' : editorSel + ' textarea';
178
}, CHAT_EDITOR_SEL);
179
180
const hasDriver = await page.evaluate(() =>
181
// @ts-ignore
182
!!globalThis.driver?.typeInEditor
183
).catch(() => false);
184
185
if (hasDriver) {
186
await page.evaluate(({ selector, t }) => {
187
// @ts-ignore
188
return globalThis.driver.typeInEditor(selector, t);
189
}, { selector: inputSel, t: text });
190
} else {
191
await page.click(inputSel);
192
await new Promise(r => setTimeout(r, 200));
193
await page.locator(inputSel).pressSequentially(text, { delay: 0 });
194
}
195
196
const compBefore = mockServer.completionCount();
197
await page.keyboard.press('Enter');
198
try { await mockServer.waitForCompletion(compBefore + modelTurns, 60_000); } catch { }
199
200
const responseSelector = `${CHAT_VIEW} .interactive-item-container.interactive-response`;
201
await page.waitForFunction(
202
(sel) => {
203
const responses = document.querySelectorAll(sel);
204
if (responses.length === 0) { return false; }
205
return !responses[responses.length - 1].classList.contains('chat-response-loading');
206
},
207
responseSelector, { timeout: 30_000 },
208
);
209
await new Promise(r => setTimeout(r, 500));
210
}
211
212
/**
213
* Run a full scenario: send the initial message, then handle any user
214
* follow-up turns for multi-turn scenarios.
215
*
216
* - Content-only scenarios: single message, 1 model turn.
217
* - Tool-call scenarios (no user turns): single message, N model turns
218
* (the extension automatically relays tool results back to the model).
219
* - Multi-turn with user turns: send initial message, wait for response,
220
* then for each user turn send the follow-up message and wait again.
221
*
222
* @param {import('playwright').Page} page
223
* @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer
224
* @param {string} scenarioId
225
* @param {string} label - prefix for the message (e.g. "Warmup" or "Iteration 2")
226
*/
227
async function runScenario(page, mockServer, scenarioId, label) {
228
const userTurns = getUserTurns(scenarioId);
229
const totalModelTurns = getModelTurnCount(scenarioId);
230
231
if (userTurns.length === 0) {
232
// Content-only or tool-call scenario: one message, wait for all model turns
233
await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, totalModelTurns);
234
} else {
235
// Multi-turn with user follow-ups: send initial message and wait for
236
// the model turns before the first user turn, then alternate.
237
let modelTurnsSoFar = 0;
238
const firstUserAfter = userTurns[0].afterModelTurn;
239
const turnsBeforeFirstUser = firstUserAfter - modelTurnsSoFar;
240
await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, turnsBeforeFirstUser);
241
modelTurnsSoFar = firstUserAfter;
242
243
for (let u = 0; u < userTurns.length; u++) {
244
const nextModelStop = u + 1 < userTurns.length
245
? userTurns[u + 1].afterModelTurn
246
: totalModelTurns;
247
const turnsUntilNext = nextModelStop - modelTurnsSoFar;
248
249
// Send the user follow-up message
250
await sendMessage(page, mockServer, userTurns[u].message, turnsUntilNext);
251
modelTurnsSoFar = nextModelStop;
252
}
253
}
254
}
255
256
// -- Leak check --------------------------------------------------------------
257
258
/**
259
* @param {string} electronPath
260
* @param {{ url: string, requestCount: () => number, waitForRequests: (n: number, ms: number) => Promise<void>, completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer
261
* @param {{ iterations: number, verbose: boolean, settingsOverrides?: Record<string, any> }} opts
262
*/
263
async function runLeakCheck(electronPath, mockServer, opts) {
264
const { iterations, verbose } = opts;
265
const { userDataDir, extDir, logsDir } = prepareRunDir('leak-check', mockServer, opts.settingsOverrides);
266
const isDevBuild = !electronPath.includes('.vscode-test');
267
268
const vscode = await launchVSCode(
269
electronPath,
270
buildArgs(userDataDir, extDir, logsDir, { isDevBuild }),
271
buildEnv(mockServer, { isDevBuild }),
272
{ verbose },
273
);
274
const page = vscode.page;
275
276
try {
277
await page.waitForSelector('.monaco-workbench', { timeout: 60_000 });
278
279
const cdp = await page.context().newCDPSession(page);
280
await cdp.send('HeapProfiler.enable');
281
282
// Open chat panel
283
const chatShortcut = process.platform === 'darwin' ? 'Control+Meta+KeyI' : 'Control+Alt+KeyI';
284
await page.keyboard.press(chatShortcut);
285
await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 });
286
await page.waitForFunction(
287
(sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0),
288
CHAT_EDITOR_SEL, { timeout: 15_000 },
289
);
290
291
// Wait for extension activation
292
const reqsBefore = mockServer.requestCount();
293
try { await mockServer.waitForRequests(reqsBefore + 4, 30_000); } catch { }
294
await new Promise(r => setTimeout(r, 3000));
295
296
const scenarioIds = getScenarioIds();
297
298
// --- Baseline measurement (fresh chat) ---
299
const baseline = await measure(cdp, page);
300
if (verbose) {
301
console.log(` [leak] Baseline: heap=${baseline.heapMB}MB, domNodes=${baseline.domNodes}`);
302
}
303
304
/** @type {{ beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[]} */
305
const iterationResults = [];
306
307
for (let iter = 0; iter < iterations; iter++) {
308
// Measure at start of iteration (should be in "clean" state)
309
const before = await measure(cdp, page);
310
311
if (verbose) {
312
console.log(` [leak] Iteration ${iter + 1}/${iterations}: start heap=${before.heapMB}MB, domNodes=${before.domNodes}`);
313
}
314
315
// Do work: cycle through all scenarios
316
for (let m = 0; m < scenarioIds.length; m++) {
317
const sid = scenarioIds[m];
318
await runScenario(page, mockServer, sid, `Iteration ${iter + 1}`);
319
if (verbose) {
320
console.log(` [leak] Sent ${sid} (${m + 1}/${scenarioIds.length})`);
321
}
322
}
323
324
// Return to clean state: open a new empty chat
325
await openNewChat(page);
326
await new Promise(r => setTimeout(r, 1000));
327
328
// Measure after returning to clean state
329
const after = await measure(cdp, page);
330
const deltaHeapMB = Math.round((after.heapMB - before.heapMB) * 100) / 100;
331
const deltaDomNodes = after.domNodes - before.domNodes;
332
333
iterationResults.push({
334
beforeHeapMB: before.heapMB,
335
afterHeapMB: after.heapMB,
336
deltaHeapMB,
337
beforeDomNodes: before.domNodes,
338
afterDomNodes: after.domNodes,
339
deltaDomNodes,
340
});
341
342
if (verbose) {
343
console.log(` [leak] Iteration ${iter + 1}/${iterations}: end heap=${after.heapMB}MB (delta=${deltaHeapMB}MB), domNodes=${after.domNodes} (delta=${deltaDomNodes})`);
344
}
345
}
346
347
// Final measurement
348
const final = await measure(cdp, page);
349
const totalResidualMB = Math.round((final.heapMB - baseline.heapMB) * 100) / 100;
350
const totalResidualNodes = final.domNodes - baseline.domNodes;
351
352
return {
353
baseline,
354
final: { heapMB: final.heapMB, domNodes: final.domNodes },
355
totalResidualMB,
356
totalResidualNodes,
357
iterations: iterationResults,
358
};
359
} finally {
360
await vscode.close();
361
}
362
}
363
364
// -- Main --------------------------------------------------------------------
365
366
async function main() {
367
const opts = parseArgs();
368
const electronPath = await resolveBuild(opts.build);
369
370
if (!fs.existsSync(electronPath)) {
371
console.error(`Electron not found at: ${electronPath}`);
372
process.exit(1);
373
}
374
375
const { startServer } = require('./common/mock-llm-server');
376
const { registerPerfScenarios } = require('./common/perf-scenarios');
377
registerPerfScenarios();
378
const mockServer = await startServer(0);
379
380
console.log(`[chat-simulation] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB total`);
381
console.log(`[chat-simulation] Build: ${electronPath}`);
382
console.log('');
383
384
const result = await runLeakCheck(electronPath, mockServer, opts);
385
386
console.log('[chat-simulation] =================== Leak Check Results ===================');
387
console.log('');
388
console.log(` Baseline: heap=${result.baseline.heapMB}MB, domNodes=${result.baseline.domNodes}`);
389
console.log(` Final: heap=${result.final.heapMB}MB, domNodes=${result.final.domNodes}`);
390
console.log('');
391
for (let i = 0; i < result.iterations.length; i++) {
392
const it = result.iterations[i];
393
console.log(` Iteration ${i + 1}: ${it.beforeHeapMB}MB → ${it.afterHeapMB}MB (residual: ${it.deltaHeapMB > 0 ? '+' : ''}${it.deltaHeapMB}MB, DOM: ${it.deltaDomNodes > 0 ? '+' : ''}${it.deltaDomNodes} nodes)`);
394
}
395
console.log('');
396
console.log(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB`);
397
console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`);
398
console.log('');
399
400
// Write JSON
401
const jsonPath = path.join(DATA_DIR, 'chat-simulation-leak-results.json');
402
fs.writeFileSync(jsonPath, JSON.stringify({
403
timestamp: new Date().toISOString(),
404
leakThresholdMB: opts.leakThresholdMB,
405
iterationCount: opts.iterations,
406
scenarioCount: getScenarioIds().length,
407
...result,
408
}, null, 2));
409
console.log(`[chat-simulation] Results written to ${jsonPath}`);
410
411
const leaked = result.totalResidualMB > opts.leakThresholdMB;
412
console.log('');
413
if (leaked) {
414
console.log(`[chat-simulation] LEAK DETECTED — ${result.totalResidualMB}MB residual exceeds ${opts.leakThresholdMB}MB threshold`);
415
} else {
416
console.log(`[chat-simulation] No leak detected (${result.totalResidualMB}MB residual < ${opts.leakThresholdMB}MB threshold)`);
417
}
418
419
if (opts.ci) {
420
const summary = generateLeakCISummary(result, opts);
421
const summaryPath = path.join(DATA_DIR, 'ci-summary-leak.md');
422
fs.writeFileSync(summaryPath, summary);
423
console.log(`[chat-simulation] CI summary written to ${summaryPath}`);
424
}
425
426
await mockServer.close();
427
process.exit(leaked ? 1 : 0);
428
}
429
430
/**
431
* Generate a Markdown summary for CI, matching the perf script pattern.
432
* @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 }[] }} result
433
* @param {{ leakThresholdMB: number, iterations: number }} opts
434
*/
435
function generateLeakCISummary(result, opts) {
436
const leaked = result.totalResidualMB > opts.leakThresholdMB;
437
const verdict = leaked ? '\u274C **LEAK DETECTED**' : '\u2705 **No leak detected**';
438
const lines = [];
439
lines.push('## Memory Leak Check');
440
lines.push('');
441
lines.push('| | |');
442
lines.push('|---|---|');
443
lines.push(`| **Verdict** | ${verdict} |`);
444
lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB |`);
445
lines.push(`| **Iterations** | ${opts.iterations} |`);
446
lines.push(`| **Scenarios per iteration** | ${getScenarioIds().length} |`);
447
lines.push('');
448
lines.push('| Phase | Heap (MB) | DOM Nodes |');
449
lines.push('|-------|----------:|----------:|');
450
lines.push(`| Baseline | ${result.baseline.heapMB} | ${result.baseline.domNodes} |`);
451
for (let i = 0; i < result.iterations.length; i++) {
452
const it = result.iterations[i];
453
const sign = it.deltaHeapMB > 0 ? '+' : '';
454
const domSign = it.deltaDomNodes > 0 ? '+' : '';
455
lines.push(`| Iteration ${i + 1} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`);
456
}
457
lines.push(`| **Final** | **${result.final.heapMB}** | **${result.final.domNodes}** |`);
458
lines.push('');
459
const sign = result.totalResidualMB > 0 ? '+' : '';
460
const domSign = result.totalResidualNodes > 0 ? '+' : '';
461
lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes`);
462
lines.push('');
463
return lines.join('\n');
464
}
465
466
main().catch(err => { console.error(err); process.exit(1); });
467
468