Add performance tests (#309700)

This commit is contained in:
Paul
2026-04-17 14:23:43 -07:00
committed by GitHub
parent a947515f45
commit ec992baa49
20 changed files with 7040 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
---
name: chat-perf
description: Run chat perf benchmarks and memory leak checks against the local dev build or any published VS Code version. Use when investigating chat rendering regressions, validating perf-sensitive changes to chat UI, or checking for memory leaks in the chat response pipeline.
---
# Chat Performance Testing
## When to use
- Before/after modifying chat rendering code (`chatListRenderer.ts`, `chatInputPart.ts`, markdown rendering)
- When changing the streaming response pipeline or SSE processing
- When modifying disposable/lifecycle patterns in chat components
- To compare performance between two VS Code releases
- In CI to gate PRs that touch chat UI code
## Quick start
```bash
# Run perf regression test (compares local dev build vs VS Code 1.115.0):
npm run perf:chat -- --scenario text-only --runs 3
# Run all scenarios with no baseline (just measure):
npm run perf:chat -- --no-baseline --runs 3
# Compare two local builds (apples-to-apples):
npm run perf:chat -- --build /path/to/build-A --baseline-build /path/to/build-B --runs 5
# Build a local production package and compare against a release:
npm run perf:chat -- --production-build --baseline-build 1.115.0 --runs 5
# Run memory leak check (10 messages in one session):
npm run perf:chat-leak
# Run leak check with more messages for accuracy:
npm run perf:chat-leak -- --messages 20 --verbose
```
## Perf regression test
**Script:** `scripts/chat-simulation/test-chat-perf-regression.js`
**npm:** `npm run perf:chat`
Launches VS Code via Playwright Electron, opens the chat panel, sends a message with a mock LLM response, and measures timing, layout, and rendering metrics. By default, downloads VS Code 1.115.0 as a baseline, benchmarks it, then benchmarks the local dev build and compares.
### Key flags
| Flag | Default | Description |
|---|---|---|
| `--runs <n>` | `5` | Runs per scenario. More = more stable. Use 5+ for CI. |
| `--scenario <id>` / `-s` | all | Scenario to test (repeatable). See `common/perf-scenarios.js`. |
| `--build <path\|ver>` / `-b` | local dev | Build to test. Accepts path or version (`1.110.0`, `insiders`, commit hash). |
| `--baseline <path>` | — | Compare against a previously saved baseline JSON file. |
| `--baseline-build <path\|ver>` | `1.115.0` | Version or local path to benchmark as baseline. |
| `--no-baseline` | — | Skip baseline comparison entirely. |
| `--save-baseline` | — | Save results as the new baseline (requires `--baseline <path>`). |
| `--resume <path>` | — | Resume a previous run, adding more iterations to increase confidence. |
| `--threshold <frac>` | `0.2` | Regression threshold (0.2 = flag if 20% slower). |
| `--production-build` | — | Build a local bundled package via `gulp vscode` for comparison against a release baseline. |
| `--no-cache` | — | Ignore cached baseline data, always run fresh. |
| `--force` | — | Skip build mode mismatch confirmation prompt. |
| `--ci` | — | CI mode: write Markdown summary to `ci-summary.md` (implies `--no-cache`). |
| `--setting <k=v>` | — | Set a VS Code setting override for all builds (repeatable). |
| `--test-setting <k=v>` | — | Set a VS Code setting override for the test build only. |
| `--baseline-setting <k=v>` | — | Set a VS Code setting override for the baseline build only. |
| `--verbose` | — | Print per-run details including response content. |
### Comparing two remote builds
```bash
# Compare 1.110.0 against 1.115.0 (no local build needed):
npm run perf:chat -- --build 1.110.0 --baseline-build 1.115.0 --runs 5
```
### Comparing two local builds
Both `--build` and `--baseline-build` accept local paths to VS Code executables. This enables apples-to-apples comparisons between any two builds:
```bash
# Compare two dev builds (e.g. feature branch vs main):
npm run perf:chat -- \
--build .build/electron/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--baseline-build /path/to/other/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--runs 5
# Compare two production builds:
npm run perf:chat -- \
--build ../VSCode-darwin-arm64-feature/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--baseline-build ../VSCode-darwin-arm64-main/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \
--runs 5
```
Local path baselines are never cached (the build may change between runs). Version string baselines are cached for reuse.
### Build modes and mismatch detection
The tool classifies builds into three modes based on the executable path:
| Mode | Source | Characteristics |
|---|---|---|
| `dev` | `.build/electron/` (local dev) | Unbundled sources, `VSCODE_DEV=1`, `NODE_ENV=development`. Higher memory and startup overhead. |
| `production` | `../VSCode-<platform>-<arch>/` (from `gulp vscode`) | Bundled JS, no dev flags. Matches release characteristics but uses local source. |
| `release` | `.vscode-test/` (downloaded via `@vscode/test-electron`) | Official published build. |
When test and baseline builds have different modes (e.g. dev vs release), the tool shows a warning and prompts for confirmation. Use `--force` or `--ci` to skip the prompt.
Using `--production-build` builds a local bundled package via `gulp vscode` for fair comparison against a release baseline. This eliminates dev-mode overhead while still testing your local changes.
```bash
# Production build vs release baseline (fair comparison):
npm run perf:chat -- --production-build --baseline-build 1.115.0 --runs 5
```
### Settings overrides
Use `--setting`, `--test-setting`, and `--baseline-setting` to inject VS Code settings into the launched instance. This is useful for A/B testing experimental features:
```bash
# Enable a feature for the test build only:
npm run perf:chat -- --test-setting chat.experimental.incrementalRendering.enabled=true --runs 3
# Compare two builds with different settings:
npm run perf:chat -- \
--baseline-build "../vscode2/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS" \
--baseline-setting chat.experimental.incrementalRendering.enabled=true \
--test-setting chat.experimental.incrementalRendering.enabled=false \
--runs 3
# Set a value for both builds:
npm run perf:chat -- --setting chat.mcp.enabled=false --runs 3
```
Precedence: `--test-setting` / `--baseline-setting` override `--setting` for the same key. Values are auto-parsed: `true`/`false` become booleans, numbers become numbers, everything else stays a string.
### Resuming a run for more confidence
When results exceed the threshold but aren't statistically significant, the tool prints a `--resume` hint. Use it to add more iterations to an existing run:
```bash
# Initial run with 3 iterations — may be inconclusive:
npm run perf:chat -- --scenario text-only --runs 3
# Add 3 more runs to the same results file (both test + baseline):
npm run perf:chat -- --resume .chat-simulation-data/2026-04-14T02-15-14/results.json --runs 3
# Keep adding until confidence is reached:
npm run perf:chat -- --resume .chat-simulation-data/2026-04-14T02-15-14/results.json --runs 5
```
`--resume` loads the previous `results.json` and its associated `baseline-*.json`, runs N more iterations for both builds, merges rawRuns, recomputes stats, and re-runs the comparison. The updated files are written back in-place. You can resume multiple times — samples accumulate.
### Statistical significance
Regression detection uses **Welch's t-test** to avoid false positives from noisy measurements. A metric is only flagged as `REGRESSION` when it both exceeds the threshold AND is statistically significant (p < 0.05). Otherwise it's reported as `(likely noise — p=X, not significant)`.
With typical variance (cv ≈ 20%), you need:
- **n ≥ 5** per build to detect a 35% regression at 95% confidence
- **n ≥ 10** per build to detect a 20% regression reliably
Confidence levels reported: `high` (p < 0.01), `medium` (p < 0.05), `low` (p < 0.1), `none`.
### Exit codes
- `0` — all metrics within threshold, or exceeding threshold but not statistically significant
- `1` — statistically significant regression detected, or all runs failed
### Scenarios
Scenarios are defined in `scripts/chat-simulation/common/perf-scenarios.js` and registered via `registerPerfScenarios()`. There are three categories:
- **Content-only** — plain streaming responses (e.g. `text-only`, `large-codeblock`, `rapid-stream`)
- **Tool-call** — multi-turn scenarios with tool invocations (e.g. `tool-read-file`, `tool-edit-file`)
- **Multi-turn user** — multi-turn conversations with user follow-ups, thinking blocks (e.g. `thinking-response`, `multi-turn-user`, `long-conversation`)
Run `npm run perf:chat -- --help` to see the full list of registered scenario IDs.
### Metrics collected
- **Timing:** time to first token, time to complete, time to render complete (includes typewriter animation)
- **Rendering:** layout count, layout duration (ms), style recalculation count, forced reflows, long tasks (>50ms), long animation frame count and duration
- **Memory:** heap before/after, heap delta post-GC (informational, noisy for single requests)
- **Extension host:** heap before/after/delta via CDP inspector
### Regression triggers vs informational metrics
Only these metrics trigger a regression failure (when they exceed the threshold with statistical significance):
- `timeToFirstToken`, `timeToComplete` — user-perceived latency
- `forcedReflowCount` — forced synchronous layouts are always bad
- `longTaskCount`, `longAnimationFrameCount` — main thread jank
These are reported but **informational only** (won't fail CI):
- `layoutCount` — inflated by CSS animations; use `layoutDurationMs` instead
- `layoutDurationMs` — total layout time from trace (more meaningful than count)
- `recalcStyleCount` — inflated by CSS animations (compositor-driven, cheap)
- `timeToRenderComplete` — includes typewriter animation tail
- Memory/heap metrics — too noisy for single-request benchmarks
### Statistics
Results use **IQR-based outlier removal** and **median** (not mean) to handle startup jitter. The **coefficient of variation (cv)** is reported — under 15% is stable, over 15% gets a ⚠ warning. Baseline comparison uses **Welch's t-test** on raw run values to determine statistical significance before flagging regressions. Use 5+ runs to get stable results.
## Memory leak check
**Script:** `scripts/chat-simulation/test-chat-mem-leaks.js`
**npm:** `npm run perf:chat-leak`
Launches one VS Code session, sends N messages sequentially, forces GC between each, and measures renderer heap and DOM node count. Uses **linear regression** on the samples to compute per-message growth rate, which is compared against a threshold.
### Key flags
| Flag | Default | Description |
|---|---|---|
| `--messages <n>` / `-n` | `10` | Number of messages to send. More = more accurate slope. |
| `--build <path\|ver>` / `-b` | local dev | Build to test. |
| `--threshold <MB>` | `2` | Max per-message heap growth in MB. |
| `--setting <k=v>` | — | Set a VS Code setting override (repeatable). |
| `--verbose` | — | Print per-message heap/DOM counts. |
### What it measures
- **Heap growth slope** (MB/message) — linear regression over forced-GC heap samples. A leak shows as sustained positive slope.
- **DOM node growth** (nodes/message) — catches rendering leaks where elements aren't cleaned up. Healthy chat virtualizes old messages so node count plateaus.
### Interpreting results
- `0.31.0 MB/msg` — normal (V8 internal overhead, string interning)
- `>2.0 MB/msg` — likely leak, investigate retained objects
- DOM nodes stable after first message — normal (chat list virtualization working)
- DOM nodes growing linearly — rendering leak, check disposable cleanup
## Architecture
```
scripts/chat-simulation/
├── common/
│ ├── mock-llm-server.js # Mock CAPI server matching @vscode/copilot-api URL structure
│ ├── perf-scenarios.js # Built-in scenario definitions (content, tool-call, multi-turn)
│ └── utils.js # Shared: paths, env setup, stats, launch helpers
├── config.jsonc # Default config (baseline version, runs, thresholds)
├── fixtures/ # TypeScript fixture files used by tool-call scenarios
├── test-chat-perf-regression.js
└── test-chat-mem-leaks.js
```
### Mock server
The mock LLM server (`common/mock-llm-server.js`) implements the full CAPI URL structure from `@vscode/copilot-api`'s `DomainService`:
- `GET /models` — returns model metadata
- `POST /models/session` — returns `AutoModeAPIResponse` with `available_models` and `session_token`
- `POST /models/session/intent` — model router
- `POST /chat/completions` — SSE streaming response matching the scenario
- Agent, session, telemetry, and token endpoints
The copilot extension connects to this server via `IS_SCENARIO_AUTOMATION=1` mode with `overrideCapiUrl` and `overrideProxyUrl` settings. The `vscode-api-tests` extension must be disabled (`--disable-extension=vscode.vscode-api-tests`) because it contributes a duplicate `copilot` vendor that blocks the real extension's language model provider registration.
### Adding a scenario
1. Add a new entry to the appropriate object (`CONTENT_SCENARIOS`, `TOOL_CALL_SCENARIOS`, or `MULTI_TURN_SCENARIOS`) in `common/perf-scenarios.js` using the `ScenarioBuilder` API from `common/mock-llm-server.js`
2. The scenario is auto-registered by `registerPerfScenarios()` — no manual ID list to update
3. Run: `npm run perf:chat -- --scenario your-new-scenario --runs 1 --no-baseline --verbose`
## Related skills
- **heap-snapshot-analysis** — When a perf regression or leak check identifies high memory growth, use the heap-snapshot-analysis skill to dig deeper. It can parse `.heapsnapshot` files, compare before/after snapshots, group object deltas, and trace retainer paths to find what keeps disposed objects alive. The chat-perf leak check measures overall heap slope; heap-snapshot-analysis finds the specific objects responsible.
- **auto-perf-optimize** — For launching VS Code, driving a scenario, and capturing heap snapshots or CPU profiles automatically before doing low-level analysis.
+467
View File
@@ -0,0 +1,467 @@
name: Chat Performance Comparison
on:
workflow_dispatch:
inputs:
baseline_build:
description: "Baseline version or commit SHA (e.g. \"1.116.0\", \"insiders\", \"abc1234\"). Default: config.jsonc baselineBuild."
required: false
type: string
test_build:
description: "Branch, PR ref, commit SHA, or version to test (e.g. \"my-feature\", \"refs/pull/12345/head\", \"1.115.0\"). Default: current pipeline branch (probably main)."
required: false
type: string
runs:
description: "Runs per scenario"
required: false
type: number
default: 7
scenarios:
description: "Comma-separated scenario list. Default: all registered scenarios."
required: false
type: string
default: ""
threshold:
description: "Regression threshold fraction (0.2 = 20%)"
required: false
type: number
default: 0.2
skip_leak_check:
description: "Skip the memory leak check step"
required: false
type: boolean
default: false
test_settings:
description: 'JSON object of VS Code settings for the test build (e.g. {"chat.experimental.smoothStreaming.enabled": true})'
required: false
type: string
default: ""
baseline_settings:
description: 'JSON object of VS Code settings for the baseline build'
required: false
type: string
default: ""
permissions:
contents: read
concurrency:
group: chat-perf-${{ github.run_id }}
cancel-in-progress: true
env:
# Only set when explicitly provided; otherwise scripts read config.jsonc
BASELINE_BUILD_INPUT: ${{ inputs.baseline_build || '' }}
TEST_BUILD_INPUT: ${{ inputs.test_build || '' }}
PERF_RUNS: ${{ inputs.runs || '' }}
PERF_THRESHOLD: ${{ inputs.threshold || '' }}
SCENARIOS_INPUT: ${{ inputs.scenarios || '' }}
TEST_SETTINGS_INPUT: ${{ inputs.test_settings || '' }}
BASELINE_SETTINGS_INPUT: ${{ inputs.baseline_settings || '' }}
jobs:
# ── Shared setup: build once, cache everything ──────────────────────
setup:
name: Build & Cache
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
test_is_version: ${{ steps.resolve.outputs.is_version }}
test_build_arg: ${{ steps.resolve.outputs.build_arg }}
steps:
- name: Resolve test build type
id: resolve
run: |
INPUT="$TEST_BUILD_INPUT"
if [[ -z "$INPUT" ]]; then
echo "is_version=false" >> "$GITHUB_OUTPUT"
echo "build_arg=" >> "$GITHUB_OUTPUT"
elif [[ "$INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || [[ "$INPUT" == "insiders" ]] || [[ "$INPUT" == "stable" ]]; then
echo "test_build is a version string: $INPUT (will download)"
echo "is_version=true" >> "$GITHUB_OUTPUT"
echo "build_arg=$INPUT" >> "$GITHUB_OUTPUT"
else
echo "test_build is a git ref: $INPUT (will checkout and build from source)"
echo "is_version=false" >> "$GITHUB_OUTPUT"
echo "build_arg=" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve.outputs.is_version != 'true' && inputs.test_build || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install system dependencies
run: |
sudo apt update -y
sudo apt install -y \
build-essential pkg-config \
libx11-dev libx11-xcb-dev libxkbfile-dev \
libnotify-bin libkrb5-dev \
xvfb sqlite3 \
libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2t64 libdrm2 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
libasound2t64 libxshmfence1 libgtk-3-0
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install build dependencies
run: npm ci
working-directory: build
- name: Transpile source
run: npm run transpile-client
- name: Build copilot extension
run: npm run compile
working-directory: extensions/copilot
- name: Download Electron
run: node build/lib/preLaunch.ts
- name: Cache Electron
uses: actions/cache/save@v4
with:
path: ~/.cache/electron
key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }}
- name: Install Playwright Chromium
run: npx playwright install chromium
- name: Cache Playwright
uses: actions/cache/save@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: Upload build output
uses: actions/upload-artifact@v7
with:
name: build-output
path: |
out/
extensions/copilot/dist/
retention-days: 1
# ── Perf comparison (split across matrix groups) ─────────────────────
chat-perf:
name: Chat Perf (${{ matrix.group }})
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
group: [ 1, 2, 3, 4 ]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install system dependencies
run: |
sudo apt update -y
sudo apt install -y \
build-essential pkg-config \
libx11-dev libx11-xcb-dev libxkbfile-dev \
libnotify-bin libkrb5-dev \
xvfb sqlite3 \
libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2t64 libdrm2 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
libasound2t64 libxshmfence1 libgtk-3-0
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download build output
uses: actions/download-artifact@v7
with:
name: build-output
- name: Restore Electron cache
uses: actions/cache/restore@v4
with:
path: ~/.cache/electron
key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }}
- name: Download Electron
run: node build/lib/preLaunch.ts
- name: Restore Playwright cache
uses: actions/cache/restore@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: Install Playwright Chromium
run: npx playwright install chromium
- name: Resolve scenario group
id: scenarios
run: |
node -e "
const fs = require('fs');
require('./scripts/chat-simulation/common/perf-scenarios').registerPerfScenarios();
const { getScenarioIds } = require('./scripts/chat-simulation/common/mock-llm-server');
const userInput = process.env.SCENARIOS_INPUT || '';
const allScens = userInput
? userInput.split(',').map(s => s.trim()).filter(Boolean)
: getScenarioIds();
const groups = 4;
const group = parseInt(process.env.MATRIX_GROUP, 10);
// Distribute scenarios round-robin across groups
const groupScens = allScens.filter((_, i) => (i % groups) + 1 === group);
if (groupScens.length === 0) {
console.log('No scenarios for group ' + group);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip=true\n');
} else {
const args = groupScens.map(s => '--scenario ' + s).join(' ');
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip=false\n');
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'args=' + args + '\n');
console.log('Group ' + group + ' (' + groupScens.length + '/' + allScens.length + '): ' + groupScens.join(', '));
}
"
env:
MATRIX_GROUP: ${{ matrix.group }}
- name: Run chat perf comparison
id: perf
if: steps.scenarios.outputs.skip != 'true'
env:
SCENARIO_ARGS: ${{ steps.scenarios.outputs.args }}
run: |
PERF_ARGS=("--ci")
if [[ -n "$BASELINE_BUILD_INPUT" ]]; then
PERF_ARGS+=("--baseline-build" "$BASELINE_BUILD_INPUT")
fi
TEST_BUILD_ARG="${{ needs.setup.outputs.test_build_arg }}"
if [[ -n "$TEST_BUILD_ARG" ]]; then
PERF_ARGS+=("--build" "$TEST_BUILD_ARG")
fi
if [[ -n "$PERF_RUNS" ]]; then
PERF_ARGS+=("--runs" "$PERF_RUNS")
fi
if [[ -n "$PERF_THRESHOLD" ]]; then
PERF_ARGS+=("--threshold" "$PERF_THRESHOLD")
fi
PERF_ARGS+=("--production-build")
# Convert JSON settings objects to --test-setting / --baseline-setting flags
if [[ -n "$TEST_SETTINGS_INPUT" ]]; then
while IFS='=' read -r key value; do
PERF_ARGS+=("--test-setting" "$key=$value")
done < <(node -e "const s=JSON.parse(process.env.TEST_SETTINGS_INPUT); for (const [k,v] of Object.entries(s)) console.log(k+'='+v)")
fi
if [[ -n "$BASELINE_SETTINGS_INPUT" ]]; then
while IFS='=' read -r key value; do
PERF_ARGS+=("--baseline-setting" "$key=$value")
done < <(node -e "const s=JSON.parse(process.env.BASELINE_SETTINGS_INPUT); for (const [k,v] of Object.entries(s)) console.log(k+'='+v)")
fi
# Split SCENARIO_ARGS on whitespace into array elements
read -ra SCENARIO_ARR <<< "$SCENARIO_ARGS"
set +eo pipefail
xvfb-run node scripts/chat-simulation/test-chat-perf-regression.js \
"${PERF_ARGS[@]}" \
"${SCENARIO_ARR[@]}" \
2>&1 | tee perf-output.log
echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Upload perf results
if: always() && steps.scenarios.outputs.skip != 'true'
uses: actions/upload-artifact@v7
with:
name: perf-results-${{ matrix.group }}
include-hidden-files: true
path: |
perf-output.log
.chat-simulation-data/
retention-days: 30
- name: Check for regressions
if: always() && steps.perf.outputs.exit_code != '' &&
steps.perf.outputs.exit_code != '0'
run: |
echo "::error::Chat perf regression detected (exit code ${{ steps.perf.outputs.exit_code }}). See perf-output.log for details."
exit 1
# ── Memory leak check (runs in parallel with perf) ──────────────────
leak-check:
name: Leak Check
needs: setup
if: inputs.skip_leak_check != true
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install system dependencies
run: |
sudo apt update -y
sudo apt install -y \
build-essential pkg-config \
libx11-dev libx11-xcb-dev libxkbfile-dev \
libnotify-bin libkrb5-dev \
xvfb \
libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2t64 libdrm2 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
libasound2t64 libxshmfence1 libgtk-3-0
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download build output
uses: actions/download-artifact@v7
with:
name: build-output
- name: Restore Electron cache
uses: actions/cache/restore@v4
with:
path: ~/.cache/electron
key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }}
- name: Download Electron
run: node build/lib/preLaunch.ts
- name: Restore Playwright cache
uses: actions/cache/restore@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: Install Playwright Chromium
run: npx playwright install chromium
- name: Run memory leak check
id: leak
run: |
LEAK_ARGS="--verbose --ci"
TEST_BUILD_ARG="${{ needs.setup.outputs.test_build_arg }}"
if [[ -n "$TEST_BUILD_ARG" ]]; then
LEAK_ARGS="$LEAK_ARGS --build $TEST_BUILD_ARG"
fi
set +eo pipefail
xvfb-run node scripts/chat-simulation/test-chat-mem-leaks.js \
$LEAK_ARGS \
2>&1 | tee leak-output.log
echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Upload leak results
if: always()
uses: actions/upload-artifact@v7
with:
name: leak-results
include-hidden-files: true
path: |
leak-output.log
.chat-simulation-data/chat-simulation-leak-results.json
.chat-simulation-data/ci-summary-leak.md
retention-days: 30
- name: Check for leaks
if: always() && steps.leak.outputs.exit_code != '' &&
steps.leak.outputs.exit_code != '0'
run: |
echo "::error::Chat memory leak detected (exit code ${{ steps.leak.outputs.exit_code }}). See leak-output.log for details."
exit 1
# ── Report: collect results, write summary, fail on regression ──────
report:
name: Report
needs: [ chat-perf, leak-check ]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Download all perf results
uses: actions/download-artifact@v7
with:
pattern: perf-results-*
path: perf-results
- name: Download leak results
if: inputs.skip_leak_check != true && needs.leak-check.result != 'skipped'
uses: actions/download-artifact@v7
with:
name: leak-results
path: leak-results
continue-on-error: true
- name: Generate unified summary
run: |
LEAK_ARG=""
if [[ -f leak-results/.chat-simulation-data/ci-summary-leak.md ]]; then
LEAK_ARG="--leak-summary leak-results/.chat-simulation-data/ci-summary-leak.md"
fi
node scripts/chat-simulation/merge-ci-summary.js \
--results-dir perf-results \
--output ci-summary.md \
$LEAK_ARG
cat ci-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload CI summary
if: always()
uses: actions/upload-artifact@v7
with:
name: chat-perf-summary
path: ci-summary.md
retention-days: 30
- name: Fail on regression
id: regression
if: needs.chat-perf.result == 'failure' || (inputs.skip_leak_check != true &&
needs.leak-check.result == 'failure')
run: |
if [[ "${{ needs.chat-perf.result }}" == "failure" ]]; then
echo "::error::Chat performance regression detected. See job summary for details."
fi
if [[ "${{ inputs.skip_leak_check }}" != "true" && "${{ needs.leak-check.result }}" == "failure" ]]; then
echo "::error::Chat memory leak detected. See leak-output.log for details."
fi
exit 1
+1
View File
@@ -25,6 +25,7 @@ product.overrides.json
*.snap.actual
*.tsbuildinfo
.vscode-test
.chat-simulation-data
vscode-telemetry-docs/
test-output.json
test/componentFixtures/.screenshots/*
+1
View File
@@ -163,6 +163,7 @@ export const copyrightFilter = Object.freeze<string[]>([
'**',
'!**/*.desktop',
'!**/*.json',
'!**/*.jsonc',
'!**/*.jsonl',
'!**/*.html',
'!**/*.template',
+2
View File
@@ -79,6 +79,8 @@
"extensions-ci": "npm run gulp extensions-ci",
"extensions-ci-pr": "npm run gulp extensions-ci-pr",
"perf": "node scripts/code-perf.js",
"perf:chat": "node scripts/chat-simulation/test-chat-perf-regression.js",
"perf:chat-leak": "node scripts/chat-simulation/test-chat-mem-leaks.js",
"copilot:setup": "npm --prefix extensions/copilot run setup",
"copilot:get_token": "npm --prefix extensions/copilot run get_token",
"update-build-ts-version": "npm install -D typescript@next && npm install -D @typescript/native-preview && (cd build && npm run typecheck)",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,800 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// @ts-check
/**
* Built-in scenario definitions for chat performance benchmarks and leak checks.
*
* Each test file imports this module and calls `registerScenario()` for the
* scenarios it needs, keeping scenario ownership close to the test that uses it.
*/
const path = require('path');
const { ScenarioBuilder, registerScenario } = require('./mock-llm-server');
const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures');
/**
* @typedef {{
* description: string,
* chunks: import('./mock-llm-server').StreamChunk[],
* }} ContentScenarioDef
*
* @typedef {{
* description: string,
* scenario: import('./mock-llm-server').MultiTurnScenario,
* }} MultiTurnScenarioDef
*/
// -- Content-only scenarios ---------------------------------------------------
/** @type {Record<string, ContentScenarioDef>} */
const CONTENT_SCENARIOS = {
'text-only': {
description: 'Plain text, 4 paragraphs',
chunks: new ScenarioBuilder()
.stream([
'Here is an explanation of the code you selected:\n\n',
'The function `processItems` iterates over the input array and applies a transformation to each element. ',
'It uses a `Map` to track previously seen values, which allows it to deduplicate results efficiently in O(n) time.\n\n',
'The algorithm works in a single pass: for every element, it computes the transformed value, ',
'checks membership in the set, and conditionally appends to the output array. ',
'This is a common pattern in data processing pipelines where uniqueness constraints must be maintained.\n\n',
'Edge cases to consider include empty arrays, duplicate transformations that produce the same key, ',
'and items where the transform function itself is expensive.\n\n',
'The time complexity is **O(n)** and the space complexity is **O(n)** in the worst case when all items are unique.\n',
], 20)
.build(),
},
'large-codeblock': {
description: 'Single large TypeScript code block',
chunks: new ScenarioBuilder()
.stream([
'Here is the refactored implementation:\n\n',
'```typescript\n',
'import { EventEmitter } from "events";\n\n',
'interface CacheEntry<T> {\n value: T;\n expiresAt: number;\n accessCount: number;\n}\n\n',
'export class LRUCache<K, V> {\n',
' private readonly _map = new Map<K, CacheEntry<V>>();\n',
' private readonly _emitter = new EventEmitter();\n\n',
' constructor(\n private readonly _maxSize: number,\n private readonly _ttlMs: number = 60_000,\n ) {}\n\n',
' get(key: K): V | undefined {\n const entry = this._map.get(key);\n if (!entry) { return undefined; }\n',
' if (Date.now() > entry.expiresAt) {\n this._map.delete(key);\n this._emitter.emit("evict", key);\n return undefined;\n }\n',
' entry.accessCount++;\n this._map.delete(key);\n this._map.set(key, entry);\n return entry.value;\n }\n\n',
' set(key: K, value: V): void {\n if (this._map.size >= this._maxSize) {\n',
' const oldest = this._map.keys().next().value;\n if (oldest !== undefined) {\n this._map.delete(oldest);\n this._emitter.emit("evict", oldest);\n }\n }\n',
' this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs, accessCount: 0 });\n }\n\n',
' clear(): void { this._map.clear(); this._emitter.emit("clear"); }\n',
' get size(): number { return this._map.size; }\n',
' onEvict(listener: (key: K) => void): void { this._emitter.on("evict", listener); }\n}\n',
'```\n\n',
'The key changes:\n- Added TTL-based expiry with configurable timeout\n- LRU eviction uses Map insertion order\n- EventEmitter notifies on evictions for cache observability\n',
], 20)
.build(),
},
'many-small-chunks': {
description: '200 word-level chunks at 5ms',
chunks: (() => {
const words = ['Generating detailed analysis:\n\n'];
for (let i = 0; i < 200; i++) { words.push(`Word${i} `); }
words.push('\n\nAnalysis complete.\n');
const b = new ScenarioBuilder();
b.stream(words, 5);
return b.build();
})(),
},
'mixed-content': {
description: 'Markdown + code block + fix suggestion',
chunks: new ScenarioBuilder()
.stream([
'## Issue Found\n\n',
'The `DisposableStore` is not being disposed in the `deactivate` path, ',
'which can lead to memory leaks.\n\n',
'### Current Code\n\n',
'```typescript\nclass MyService {\n private store = new DisposableStore();\n // missing dispose!\n}\n```\n\n',
'### Suggested Fix\n\n',
'```typescript\nclass MyService extends Disposable {\n',
' private readonly store = this._register(new DisposableStore());\n\n',
' override dispose(): void {\n this.store.dispose();\n super.dispose();\n }\n}\n```\n\n',
'This ensures the store is cleaned up when the service is disposed via the workbench lifecycle.\n',
], 20)
.build(),
},
// -- Stress-test scenarios --------------------------------------------
'many-codeblocks': {
description: '10 code blocks, 60 lines each',
chunks: (() => {
const b = new ScenarioBuilder();
b.emit('Here are the implementations for each module:\n\n');
for (let i = 0; i < 10; i++) {
b.wait(10, `### Module ${i + 1}: \`handler${i}.ts\`\n\n`);
b.emit('```typescript\n');
const lines = [];
for (let j = 0; j < 15; j++) {
lines.push(`export function handle${i}_${j}(input: string): string {\n`);
lines.push(` const result = input.trim().split('').reverse().join('');\n`);
lines.push(` return \`[\${result}] processed by handler ${i}_${j}\`;\n`);
lines.push('}\n\n');
}
b.stream(lines, 5);
b.emit('```\n\n');
}
b.emit('All modules implement the same pattern with unique handler IDs.\n');
return b.build();
})(),
},
'long-prose': {
description: '15 sections, ~3000 words of prose',
chunks: (() => {
const sentences = [
'The architecture follows a layered dependency injection pattern where each service declares its dependencies through constructor parameters. ',
'This approach ensures that circular dependencies are detected at compile time rather than at runtime, which significantly reduces debugging overhead. ',
'When a service is instantiated, the instantiation service resolves all of its dependencies recursively, creating a directed acyclic graph of service instances. ',
'Each service is a singleton within its scope, meaning that multiple consumers of the same service interface receive the same instance. ',
'The workbench lifecycle manages the creation and disposal of these services through well-defined phases: creation, restoration, and eventual shutdown. ',
'During the restoration phase, services that persist state across sessions reload their data from storage, which may involve asynchronous operations. ',
'Contributors register their functionality through extension points, which are processed during the appropriate lifecycle phase. ',
'This contribution model allows features to be added without modifying the core workbench code, maintaining a clean separation of concerns. ',
];
const b = new ScenarioBuilder();
b.emit('# Detailed Architecture Analysis\n\n');
for (let para = 0; para < 15; para++) {
b.wait(15, `## Section ${para + 1}: ${['Overview', 'Design Patterns', 'Service Layer', 'Event System', 'State Management', 'Error Handling', 'Performance', 'Testing', 'Deployment', 'Monitoring', 'Security', 'Extensibility', 'Compatibility', 'Migration', 'Future Work'][para]}\n\n`);
const paraSentences = [];
for (let s = 0; s < 25; s++) { paraSentences.push(sentences[s % sentences.length]); }
b.stream(paraSentences, 8);
b.emit('\n\n');
}
return b.build();
})(),
},
'rich-markdown': {
description: '6 sections × 5 items, bold/links/code spans',
chunks: (() => {
const b = new ScenarioBuilder();
b.emit('# Comprehensive Code Review Report\n\n');
b.wait(15, '> **Summary**: Found 12 issues across 4 severity levels.\n\n');
for (let section = 0; section < 6; section++) {
b.wait(10, `## ${section + 1}. ${['Critical Issues', 'Performance Concerns', 'Code Style', 'Documentation Gaps', 'Test Coverage', 'Security Review'][section]}\n\n`);
for (let item = 0; item < 5; item++) {
b.stream([
`${item + 1}. **Issue ${section * 5 + item + 1}**: \`${['useState', 'useEffect', 'useMemo', 'useCallback', 'useRef'][item]}\` in \`src/components/Widget${item}.tsx\`\n`,
` - Severity: ${['[Critical]', '[Warning]', '[Info]', '[Suggestion]', '[Note]'][item]}\n`,
` - The current implementation uses *unnecessary re-renders* due to missing dependency arrays.\n`,
` - See [React docs](https://react.dev/reference) and the [\`useMemo\` guide](https://react.dev/reference/react/useMemo).\n`,
` - Fix: wrap in \`useCallback\` or extract to a ***separate memoized component***.\n\n`,
], 10);
}
b.emit('---\n\n');
}
b.emit('> *Report generated automatically. Please review all suggestions before applying.*\n');
return b.build();
})(),
},
'giant-codeblock': {
description: '40 classes in one fenced code block',
chunks: (() => {
const b = new ScenarioBuilder();
b.emit('Here is the complete implementation:\n\n```typescript\n');
b.stream([
'import { Disposable, DisposableStore } from "vs/base/common/lifecycle";\n',
'import { Emitter, Event } from "vs/base/common/event";\n',
'import { URI } from "vs/base/common/uri";\n\n',
], 10);
for (let i = 0; i < 40; i++) {
b.stream([
`export class Service${i} extends Disposable {\n`,
` private readonly _onDidChange = this._register(new Emitter<void>());\n`,
` readonly onDidChange: Event<void> = this._onDidChange.event;\n\n`,
` private _value: string = '';\n`,
` get value(): string { return this._value; }\n\n`,
` async update(uri: URI): Promise<void> {\n`,
` this._value = uri.toString();\n`,
` this._onDidChange.fire();\n`,
` }\n`,
'}\n\n',
], 5);
}
b.emit('```\n\nThis defines 40 service classes following the standard VS Code pattern.\n');
return b.build();
})(),
},
'rapid-stream': {
description: '1000 tokens at 2ms (streaming stress test)',
chunks: (() => {
const b = new ScenarioBuilder();
const words = [];
for (let i = 0; i < 1000; i++) { words.push(`w${i} `); }
// Very fast inter-chunk delay to stress the streaming pipeline
b.stream(words, 2);
return b.build();
})(),
},
'file-links': {
description: '32 file references with line links',
chunks: (() => {
const files = [
'src/vs/workbench/contrib/chat/browser/chatListRenderer.ts',
'src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts',
'src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts',
'src/vs/workbench/contrib/chat/common/chatPerf.ts',
'src/vs/base/common/lifecycle.ts',
'src/vs/base/common/event.ts',
'src/vs/platform/instantiation/common/instantiation.ts',
'src/vs/workbench/services/extensions/common/abstractExtensionService.ts',
'src/vs/workbench/api/common/extHostLanguageModels.ts',
'src/vs/workbench/contrib/chat/common/languageModels.ts',
'src/vs/editor/browser/widget/codeEditor/editor.ts',
'src/vs/workbench/browser/parts/editor/editorGroupView.ts',
];
const b = new ScenarioBuilder();
b.emit('I found references to the disposable pattern across the following files:\n\n');
for (let i = 0; i < files.length; i++) {
const line = Math.floor(Math.random() * 500) + 1;
b.stream([
`${i + 1}. [${files[i]}](${files[i]}#L${line}) -- `,
`Line ${line}: uses \`DisposableStore\` with ${Math.floor(Math.random() * 10) + 1} registrations\n`,
], 15);
}
b.wait(10, '\nAdditionally, the following files import from `vs/base/common/lifecycle`:\n\n');
for (let i = 0; i < 20; i++) {
const depth = ['base', 'platform', 'editor', 'workbench'][i % 4];
const area = ['common', 'browser', 'node', 'electron-browser'][i % 4];
const name = ['service', 'provider', 'contribution', 'handler', 'manager'][i % 5];
const file = `src/vs/${depth}/${area}/${name}${i}.ts`;
b.stream([
`- [${file}](${file}#L${i * 10 + 5})`,
` -- imports \`Disposable\`, \`DisposableStore\`\n`,
], 12);
}
b.emit('\nTotal: 32 files reference the disposable pattern.\n');
return b.build();
})(),
},
};
// -- Tool call scenarios ------------------------------------------------------
/** @type {Record<string, MultiTurnScenarioDef>} */
const TOOL_CALL_SCENARIOS = {
'tool-read-file': {
description: 'Read 8 files across 2 tool-call rounds',
scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ((() => {
const filesToRead = [
'_chatperf_lifecycle.ts',
'_chatperf_event.ts',
'_chatperf_uri.ts',
'_chatperf_errors.ts',
'_chatperf_async.ts',
'_chatperf_strings.ts',
'_chatperf_arrays.ts',
'_chatperf_types.ts',
];
// Round 1: parallel read of first 4 files
// Round 2: parallel read of next 4 files
// Round 3: final content response
return {
type: 'multi-turn',
turns: [
{
kind: 'tool-calls',
toolCalls: filesToRead.slice(0, 4).map(f => ({
toolNamePattern: /read.?file/i,
arguments: { filePath: path.join(FIXTURES_DIR, f), startLine: 1, endLine: 50 },
})),
},
{
kind: 'tool-calls',
toolCalls: filesToRead.slice(4).map(f => ({
toolNamePattern: /read.?file/i,
arguments: { filePath: path.join(FIXTURES_DIR, f), startLine: 1, endLine: 50 },
})),
},
{
kind: 'content',
chunks: new ScenarioBuilder()
.wait(20, '## Analysis of VS Code Base Utilities\n\n')
.stream([
'I read 8 core utility files from `src/vs/base/common/`. Here is a summary:\n\n',
'### lifecycle.ts\n',
'The `Disposable` base class provides the standard lifecycle pattern. Components register cleanup ',
'handlers via `this._register()` which are automatically disposed when the parent is disposed.\n\n',
'### event.ts\n',
'The `Emitter` class implements the observer pattern. `Event.once()`, `Event.map()`, and `Event.filter()` ',
'provide functional combinators for composing event streams.\n\n',
'### uri.ts\n',
'`URI` is an immutable representation of a resource identifier with scheme, authority, path, query, and fragment.\n\n',
'### errors.ts\n',
'Central error handling with `onUnexpectedError()` and `isCancellationError()` for distinguishing user cancellation.\n\n',
'### async.ts\n',
'`Throttler`, `Delayer`, `RunOnceScheduler`, and `Queue` manage async operation scheduling and deduplication.\n\n',
'### strings.ts\n',
'String utilities including `format()`, `escape()`, `startsWith()`, and `endsWith()` for common string operations.\n\n',
'### arrays.ts\n',
'Array helpers like `coalesce()`, `groupBy()`, `distinct()`, and binary search implementations.\n\n',
'### types.ts\n',
'Type guards and assertion helpers: `isString()`, `isNumber()`, `assertType()`, `assertIsDefined()`.\n',
], 15)
.build(),
},
],
};
})()),
},
'tool-edit-file': {
description: 'Read 3 files, edit 2 (read + write rounds)',
scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ((() => {
const readFiles = [
'_chatperf_lifecycle.ts',
'_chatperf_event.ts',
'_chatperf_errors.ts',
];
return {
type: 'multi-turn',
turns: [
// Round 1: read all 3 files in parallel
{
kind: 'tool-calls',
toolCalls: readFiles.map(f => ({
toolNamePattern: /read.?file/i,
arguments: { filePath: path.join(FIXTURES_DIR, f), startLine: 1, endLine: 40 },
})),
},
// Round 2: edit 2 files in parallel
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /insert.?edit|replace.?string|apply.?patch/i,
arguments: {
filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'),
explanation: 'Update the benchmark marker comment in lifecycle.ts',
code: '// perf-benchmark-marker (updated)',
},
},
{
toolNamePattern: /insert.?edit|replace.?string|apply.?patch/i,
arguments: {
filePath: path.join(FIXTURES_DIR, '_chatperf_event.ts'),
explanation: 'Update the benchmark marker comment in event.ts',
code: '// perf-benchmark-marker (updated)',
},
},
],
},
// Round 3: final content
{
kind: 'content',
chunks: new ScenarioBuilder()
.wait(20, '## Edits Applied\n\n')
.stream([
'I read 3 files and applied edits to 2 of them:\n\n',
'### Files read:\n',
'1. `src/vs/base/common/lifecycle.ts` — Disposable pattern and lifecycle management\n',
'2. `src/vs/base/common/event.ts` — Event emitter and observer pattern\n',
'3. `src/vs/base/common/errors.ts` — Error handling utilities\n\n',
'### Edits applied:\n',
'1. **lifecycle.ts** — Updated the benchmark marker comment\n',
'2. **event.ts** — Updated the benchmark marker comment\n\n',
'Both files follow the standard VS Code pattern of using `Disposable` as a base class ',
'with `_register()` for lifecycle management. The edits were minimal and localized.\n',
], 20)
.build(),
},
],
};
})()),
},
'tool-terminal': {
description: 'Run commands, read output, fix + rerun',
scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ({
type: 'multi-turn',
turns: [
// Round 1: run initial commands (install + build)
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /run.?in.?terminal|execute.?command/i,
arguments: {
command: 'echo "Installing dependencies..." && echo "added 1631 packages in 6m"',
explanation: 'Install project dependencies',
goal: 'Install dependencies',
mode: 'sync',
timeout: 30000,
},
},
],
},
// Round 2: run test command
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /run.?in.?terminal|execute.?command/i,
arguments: {
command: 'echo "Running unit tests..." && echo " 42 passing (3s)" && echo " 2 failing" && echo "" && echo " 1) ChatService should dispose listeners" && echo " AssertionError: expected 0 to equal 1" && echo " 2) ChatModel should clear on new session" && echo " TypeError: Cannot read property dispose of undefined"',
explanation: 'Run the unit test suite to check for failures',
goal: 'Run tests',
mode: 'sync',
timeout: 60000,
},
},
],
},
// Round 3: read the failing test file for context
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /read.?file/i,
arguments: { filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'), startLine: 1, endLine: 50 },
},
],
},
// Round 4: fix the issue with an edit
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /insert.?edit|replace.?string|apply.?patch/i,
arguments: {
filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'),
explanation: 'Fix the dispose call in the test',
code: '// perf-benchmark-marker (fixed)',
},
},
],
},
// Round 5: re-run tests to confirm
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /run.?in.?terminal|execute.?command/i,
arguments: {
command: 'echo "Running unit tests..." && echo " 44 passing (3s)" && echo " 0 failing"',
explanation: 'Re-run tests to verify the fix',
goal: 'Verify fix',
mode: 'sync',
timeout: 60000,
},
},
],
},
// Round 6: final summary
{
kind: 'content',
chunks: new ScenarioBuilder()
.wait(20, '## Test Failures Fixed\n\n')
.stream([
'I found and fixed 2 test failures:\n\n',
'### Root Cause\n',
'The `ChatService` was not properly disposing event listeners when a session was cleared. ',
'The `dispose()` method was missing a call to `this._store.dispose()`.\n\n',
'### Changes Made\n',
'Updated `lifecycle.ts` to properly chain disposal:\n\n',
'```typescript\n',
'override dispose(): void {\n',
' this._store.dispose();\n',
' super.dispose();\n',
'}\n',
'```\n\n',
'### Test Results\n',
'- **Before**: 42 passing, 2 failing\n',
'- **After**: 44 passing, 0 failing\n\n',
'All tests pass now. The fix ensures listeners are cleaned up during session transitions.\n',
], 15)
.build(),
},
],
}),
},
};
// -- Multi-turn user conversation scenarios -----------------------------------
/** @type {Record<string, MultiTurnScenarioDef>} */
const MULTI_TURN_SCENARIOS = {
'thinking-response': {
description: 'Thinking block before content response',
scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ({
type: 'multi-turn',
turns: [
{
kind: 'thinking',
thinkingChunks: new ScenarioBuilder()
.stream([
'Let me analyze this code carefully. ',
'The user is asking about the lifecycle pattern in VS Code. ',
'I should look at the Disposable base class and how it manages cleanup. ',
'The key methods are _register(), dispose(), and the DisposableStore pattern. ',
'I need to read the file first to give an accurate explanation.',
], 15)
.build(),
chunks: new ScenarioBuilder()
.wait(20, 'I\'ll start by reading the file to understand its structure.\n\n')
.stream([
'The `Disposable` base class in `lifecycle.ts` provides a standard pattern ',
'for managing resources. It uses a `DisposableStore` internally to track ',
'all registered disposables and clean them up on `dispose()`.\n',
], 20)
.build(),
},
],
}),
},
'multi-turn-user': {
description: '2 user follow-ups with thinking + code',
scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ({
type: 'multi-turn',
turns: [
// Turn 1: Model reads a file
{
kind: 'tool-calls',
toolCalls: [
{
toolNamePattern: /read.?file/i,
arguments: {
filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'),
offset: 1,
limit: 50,
},
},
],
},
// Turn 2: Model responds with analysis
{
kind: 'content',
chunks: new ScenarioBuilder()
.wait(20, 'I\'ve read the file. Here\'s what I found:\n\n')
.stream([
'The `Disposable` class is the base for lifecycle management. ',
'It internally holds a `DisposableStore` via `this._store`. ',
'Subclasses call `this._register()` to track their own disposables.\n\n',
'Would you like me to explain any specific part in more detail?\n',
], 20)
.build(),
},
// Turn 3: User follow-up (injected by test harness, not served by mock)
{
kind: 'user',
message: 'Yes, explain the MutableDisposable pattern',
},
// Turn 4: Model responds with thinking, then content
{
kind: 'thinking',
thinkingChunks: new ScenarioBuilder()
.stream([
'The user wants to understand MutableDisposable specifically. ',
'Let me recall the key aspects: it holds a single disposable that can be swapped. ',
'When a new value is set, the old one is automatically disposed. ',
'This is useful for things like event listener subscriptions that need to be replaced.',
], 10)
.build(),
chunks: new ScenarioBuilder()
.wait(15, '## MutableDisposable\n\n')
.stream([
'`MutableDisposable<T>` holds a **single disposable** that can be swapped at any time. ',
'When you set a new value via `.value = newDisposable`, the previous value is automatically disposed.\n\n',
'This is perfect for:\n',
'- **Event listeners** that need to be re-subscribed when configuration changes\n',
'- **Editor decorations** that are replaced when content updates\n',
'- **Watchers** that switch targets dynamically\n\n',
'```typescript\n',
'class MyService extends Disposable {\n',
' private readonly _listener = this._register(new MutableDisposable());\n\n',
' updateTarget(editor: ICodeEditor): void {\n',
' // Old listener is automatically disposed\n',
' this._listener.value = editor.onDidChangeModel(() => {\n',
' this._handleModelChange();\n',
' });\n',
' }\n',
'}\n',
'```\n\n',
'The key benefit is that you never forget to dispose the old subscription.\n',
], 15)
.build(),
},
// Turn 5: Second user follow-up
{
kind: 'user',
message: 'Can you also show me DisposableMap?',
},
// Turn 6: Final response
{
kind: 'content',
chunks: new ScenarioBuilder()
.wait(20, '## DisposableMap\n\n')
.stream([
'`DisposableMap<K, V>` extends `Map` with automatic disposal semantics:\n\n',
'- When a key is **overwritten**, the old value is disposed\n',
'- When a key is **deleted**, the value is disposed\n',
'- When the map itself is **disposed**, all values are disposed\n\n',
'```typescript\n',
'class ToolManager extends Disposable {\n',
' private readonly _tools = this._register(new DisposableMap<string, IDisposable>());\n\n',
' registerTool(id: string, tool: IDisposable): void {\n',
' this._tools.set(id, tool); // auto-disposes previous tool with same id\n',
' }\n',
'}\n',
'```\n\n',
'This is commonly used for managing collections of disposable resources keyed by ID.\n',
], 15)
.build(),
},
],
}),
},
'long-conversation': {
description: '10 user turns, mixed content types',
scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ((() => {
const topics = [
{ question: 'How does the Disposable pattern work?', heading: 'Disposable Pattern', content: 'The `Disposable` base class provides lifecycle management. Subclasses call `this._register()` to track child disposables that are cleaned up automatically when `dispose()` is called.' },
{ question: 'What about DisposableStore?', heading: 'DisposableStore', content: '`DisposableStore` aggregates multiple `IDisposable` instances and disposes them all at once. It tracks whether it has already been disposed and throws if you try to add after disposal.' },
{ question: 'How does the Event system work?', heading: 'Event System', content: 'The `Emitter<T>` class implements the observer pattern. `Event.once()`, `Event.map()`, `Event.filter()`, and `Event.debounce()` provide functional combinators for composing event streams.' },
{ question: 'Explain dependency injection', heading: 'Dependency Injection', content: 'Services are injected through constructor parameters decorated with service identifiers. The `IInstantiationService` resolves dependencies recursively, creating singletons within each scope.' },
{ question: 'What is the contribution model?', heading: 'Contribution Model', content: 'Features register functionality through extension points like `Registry.as<IWorkbenchContributionsRegistry>()`. Contributions are instantiated during specific lifecycle phases.' },
{ question: 'How does the editor handle text models?', heading: 'Text Models', content: 'The `TextModel` class manages document content with line-based storage. It supports undo/redo stacks, bracket matching, tokenization, and change tracking via edit operations.' },
{ question: 'Explain the extension host architecture', heading: 'Extension Host', content: 'Extensions run in a separate process (or worker) called the extension host. Communication happens via an RPC protocol over `IPC`. The main process proxies API calls back to the workbench.' },
{ question: 'How does file watching work?', heading: 'File Watching', content: 'The `IFileService` supports correlated and shared file watchers. Correlated watchers are preferred as they track specific resources. The underlying implementation uses `chokidar` or `parcel/watcher`.' },
{ question: 'What about the tree widget?', heading: 'Tree Widget', content: 'The `AsyncDataTree` and `ObjectTree` provide virtualized tree rendering. They support filtering, sorting, keyboard navigation, and accessibility. The `ITreeRenderer` interface handles element rendering.' },
{ question: 'How does the settings editor work?', heading: 'Settings Editor', content: 'Settings are declared in `package.json` contribution points. The settings editor reads the configuration registry, groups settings by category, and renders appropriate input controls for each type.' },
];
/** @type {import('./mock-llm-server').ScenarioTurn[]} */
const turns = [];
// Turn 1: Initial model response (no user turn needed before the first)
const firstTopic = topics[0];
turns.push({
kind: 'content',
chunks: new ScenarioBuilder()
.wait(20, `## ${firstTopic.heading}\n\n`)
.stream([
`${firstTopic.content}\n\n`,
'Here is a typical example:\n\n',
'```typescript\n',
'class MyService extends Disposable {\n',
' private readonly _onDidChange = this._register(new Emitter<void>());\n',
' readonly onDidChange: Event<void> = this._onDidChange.event;\n\n',
' constructor(@IFileService private readonly fileService: IFileService) {\n',
' super();\n',
' this._register(fileService.onDidFilesChange(e => this._handleChange(e)));\n',
' }\n',
'}\n',
'```\n\n',
'Would you like to know more about any specific aspect?\n',
], 15)
.build(),
});
// Turns 2..N: alternating user follow-up + model response
for (let i = 1; i < topics.length; i++) {
const topic = topics[i];
// User follow-up
turns.push({ kind: 'user', message: topic.question });
// Model response — vary content type to stress different renderers
const b = new ScenarioBuilder();
b.wait(20, `## ${topic.heading}\n\n`);
// Main explanation
const sentences = topic.content.split('. ');
b.stream(sentences.map(s => s.endsWith('.') ? s + ' ' : s + '. '), 12);
b.emit('\n\n');
if (i % 3 === 0) {
// Every 3rd response: large code block
b.emit('```typescript\n');
for (let j = 0; j < 8; j++) {
b.stream([
`export class ${topic.heading.replace(/\s/g, '')}Part${j} extends Disposable {\n`,
` private readonly _state = new Map<string, unknown>();\n\n`,
` process(input: string): string {\n`,
` const cached = this._state.get(input);\n`,
` if (cached) { return String(cached); }\n`,
` const result = input.split('').reverse().join('');\n`,
` this._state.set(input, result);\n`,
` return result;\n`,
` }\n`,
'}\n\n',
], 5);
}
b.emit('```\n\n');
} else if (i % 3 === 1) {
// Every 3rd+1 response: bullet list with bold + inline code
b.emit('Key points to remember:\n\n');
for (let j = 0; j < 6; j++) {
b.stream([
`${j + 1}. **Point ${j + 1}**: The \`${topic.heading.replace(/\s/g, '')}${j}\` `,
`component uses the standard pattern with \`_register()\` for lifecycle. `,
`It handles edge cases like ${['empty input', 'null references', 'concurrent access', 'circular deps', 'timeout expiry', 'disposal races'][j]}.\n`,
], 10);
}
b.emit('\n');
} else {
// Every 3rd+2 response: mixed prose + small code snippet
b.stream([
'This pattern is used extensively throughout the codebase. ',
'The key insight is that resources are always tracked from creation, ',
'ensuring no leaks even in error paths. ',
'The ownership chain is explicit and follows the component hierarchy.\n\n',
], 12);
b.emit('Quick example:\n\n```typescript\n');
b.stream([
`const store = new DisposableStore();\n`,
`store.add(event.on(() => { /* handler */ }));\n`,
`store.add(watcher.watch(uri));\n`,
`// Later: store.dispose(); // cleans up everything\n`,
], 8);
b.emit('```\n\n');
}
b.stream([
`That covers the essentials of **${topic.heading}**. `,
'Let me know if you want to dive deeper into any of these concepts.\n',
], 15);
turns.push({
kind: 'content',
chunks: b.build(),
});
}
return { type: 'multi-turn', turns };
})()),
},
};
// -- Registration helper ------------------------------------------------------
/**
* Get a brief description of a scenario by ID.
* @param {string} id
* @returns {string}
*/
function getScenarioDescription(id) {
const content = CONTENT_SCENARIOS[id];
if (content) { return content.description; }
const tool = TOOL_CALL_SCENARIOS[id];
if (tool) { return tool.description; }
const multi = MULTI_TURN_SCENARIOS[id];
if (multi) { return multi.description; }
return '';
}
/**
* Register all built-in perf scenarios into the mock LLM server.
* Call this from your test file before starting the server.
*/
function registerPerfScenarios() {
for (const [id, def] of Object.entries(CONTENT_SCENARIOS)) {
registerScenario(id, def.chunks);
}
for (const [id, def] of Object.entries(TOOL_CALL_SCENARIOS)) {
registerScenario(id, def.scenario);
}
for (const [id, def] of Object.entries(MULTI_TURN_SCENARIOS)) {
registerScenario(id, def.scenario);
}
}
module.exports = { registerPerfScenarios, getScenarioDescription, CONTENT_SCENARIOS, TOOL_CALL_SCENARIOS, MULTI_TURN_SCENARIOS };
+835
View File
@@ -0,0 +1,835 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// @ts-check
/**
* Shared utilities for chat performance benchmarks and leak checks.
*
* Platform: macOS and Linux only. Windows is not supported — several
* utilities (`sqlite3`, `sleep`, `pkill`) are Unix-specific.
* CI runs on ubuntu-latest.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const http = require('http');
const { execSync, execFileSync, spawn } = require('child_process');
const ROOT = path.join(__dirname, '..', '..', '..');
const DATA_DIR = path.join(ROOT, '.chat-simulation-data');
// -- Config loading ----------------------------------------------------------
/** @param {string} text */
function stripJsoncComments(text) { return text.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); }
/**
* Load a namespaced section from config.jsonc.
* @param {string} section - Top-level key (e.g. 'perfRegression', 'memLeaks')
* @returns {Record<string, any>}
*/
function loadConfig(section) {
const raw = fs.readFileSync(path.join(__dirname, '..', 'config.jsonc'), 'utf-8');
const config = JSON.parse(stripJsoncComments(raw));
return config[section] ?? {};
}
// -- Electron path resolution ------------------------------------------------
/**
* Derive the VS Code repo root from an Electron executable path.
* Dev builds live at `<repo>/.build/electron/<app>/`, so we walk up
* from the path to find the directory containing `.build`.
* Returns `undefined` if the path doesn't look like a dev build.
* @param {string} electronPath
* @returns {string | undefined}
*/
function getRepoRoot(electronPath) {
const buildIdx = electronPath.indexOf(`${path.sep}.build${path.sep}`);
if (buildIdx === -1) {
// Also check for posix separators (path may be user-supplied)
const posixIdx = electronPath.indexOf('/.build/');
if (posixIdx === -1) { return undefined; }
return electronPath.slice(0, posixIdx);
}
return electronPath.slice(0, buildIdx);
}
function getElectronPath() {
const product = require(path.join(ROOT, 'product.json'));
if (process.platform === 'darwin') {
return path.join(ROOT, '.build', 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', product.nameShort);
} else if (process.platform === 'linux') {
return path.join(ROOT, '.build', 'electron', product.applicationName);
} else {
return path.join(ROOT, '.build', 'electron', `${product.nameShort}.exe`);
}
}
/**
* Returns true if the string looks like a VS Code version or commit hash
* rather than a file path.
* @param {string} value
*/
function isVersionString(value) {
if (value === 'insiders' || value === 'stable') { return true; }
if (/^\d+\.\d+\.\d+/.test(value)) { return true; }
if (/^[0-9a-f]{7,40}$/.test(value)) { return true; }
return false;
}
/**
* Get the built-in extensions directory for a VS Code executable.
* @param {string} exePath
* @returns {string | undefined}
*/
function getBuiltinExtensionsDir(exePath) {
if (process.platform === 'darwin') {
const appDir = exePath.split('/Contents/')[0];
return path.join(appDir, 'Contents', 'Resources', 'app', 'extensions');
} else if (process.platform === 'linux') {
return path.join(path.dirname(exePath), 'resources', 'app', 'extensions');
} else {
return path.join(path.dirname(exePath), 'resources', 'app', 'extensions');
}
}
/**
* Resolve a build arg to an executable path.
* Version strings are downloaded via @vscode/test-electron.
* @param {string | undefined} buildArg
* @returns {Promise<string>}
*/
async function resolveBuild(buildArg) {
if (!buildArg) {
return getElectronPath();
}
if (isVersionString(buildArg)) {
console.log(`[chat-simulation] Downloading VS Code ${buildArg}...`);
const { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } = require('@vscode/test-electron');
const exePath = await downloadAndUnzipVSCode(buildArg);
console.log(`[chat-simulation] Downloaded: ${exePath}`);
// Check if copilot is already bundled as a built-in extension
// (recent Insiders/Stable builds ship it in the app's extensions/ dir).
const builtinExtDir = getBuiltinExtensionsDir(exePath);
const hasCopilotBuiltin = builtinExtDir && fs.existsSync(builtinExtDir)
&& fs.readdirSync(builtinExtDir).some(e => e === 'copilot');
if (hasCopilotBuiltin) {
console.log(`[chat-simulation] Copilot is bundled as a built-in extension`);
} else {
// Install copilot-chat from the marketplace into our shared
// extensions dir so it's available when we launch with
// --extensions-dir=DATA_DIR/extensions.
const extDir = path.join(DATA_DIR, 'extensions');
fs.mkdirSync(extDir, { recursive: true });
const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(exePath);
const extId = 'GitHub.copilot-chat';
console.log(`[chat-simulation] Installing ${extId} into ${extDir}...`);
const { spawnSync } = require('child_process');
const result = spawnSync(cli, [...cliArgs, '--extensions-dir', extDir, '--install-extension', extId], {
encoding: 'utf-8',
stdio: 'pipe',
shell: process.platform === 'win32',
timeout: 120_000,
});
if (result.status !== 0) {
console.warn(`[chat-simulation] Extension install exited with ${result.status}: ${(result.stderr || '').substring(0, 500)}`);
} else {
console.log(`[chat-simulation] ${extId} installed`);
}
}
return exePath;
}
return path.resolve(buildArg);
}
// -- Storage pre-seeding -----------------------------------------------------
/**
* Pre-seed the VS Code storage database to prevent the
* BuiltinChatExtensionEnablementMigration from disabling the copilot
* extension on fresh user data directories.
*
* Requires `sqlite3` on PATH (pre-installed on macOS and Ubuntu).
* @param {string} userDataDir
*/
function preseedStorage(userDataDir) {
const globalStorageDir = path.join(userDataDir, 'User', 'globalStorage');
fs.mkdirSync(globalStorageDir, { recursive: true });
const dbPath = path.join(globalStorageDir, 'state.vscdb');
const sql = [
'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);',
'INSERT INTO ItemTable (key, value) VALUES (\'builtinChatExtensionEnablementMigration\', \'true\');',
'INSERT INTO ItemTable (key, value) VALUES (\'chat.tools.global.autoApprove.optIn\', \'true\');',
].join(' ');
execFileSync('sqlite3', [dbPath, sql]);
}
// -- Launch helpers ----------------------------------------------------------
/**
* Build the environment variables for launching VS Code with the mock server.
* @param {{ url: string }} mockServer
* @param {{ isDevBuild?: boolean }} [opts]
* @returns {Record<string, string>}
*/
function buildEnv(mockServer, { isDevBuild = true } = {}) {
/** @type {Record<string, string>} */
const env = {
...process.env,
ELECTRON_ENABLE_LOGGING: '1',
IS_SCENARIO_AUTOMATION: '1',
GITHUB_PAT: 'perf-benchmark-fake-pat',
VSCODE_COPILOT_CHAT_TOKEN: Buffer.from(JSON.stringify({
token: 'perf-benchmark-fake-token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
refresh_in: 1800,
sku: 'free_limited_copilot',
individual: true,
isNoAuthUser: true,
copilot_plan: 'free',
organization_login_list: [],
endpoints: { api: mockServer.url, proxy: mockServer.url },
})).toString('base64'),
};
// Dev-only flags — these tell Electron to load the app from source (out/)
// instead of the packaged app. Setting them on a stable build causes it
// to fail to show a window.
if (isDevBuild) {
env.NODE_ENV = 'development';
env.VSCODE_DEV = '1';
env.VSCODE_CLI = '1';
}
return env;
}
/**
* Build the default VS Code launch args.
* @param {string} userDataDir
* @param {string} extDir
* @param {string} logsDir
* @returns {string[]}
*/
function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT } = {}) {
// Chromium switches must come BEFORE the app path (ROOT) — Chromium
// only processes switches that precede the first non-switch argument.
const chromiumFlags = [];
if (traceFile) {
chromiumFlags.push(`--enable-tracing=v8.gc,devtools.timeline,blink.user_timing`);
chromiumFlags.push(`--trace-startup-file=${traceFile}`);
chromiumFlags.push(`--enable-tracing-format=json`);
}
const args = [
...chromiumFlags,
appRoot,
'--skip-release-notes',
'--skip-welcome',
'--disable-telemetry',
'--disable-updates',
'--disable-workspace-trust',
`--user-data-dir=${userDataDir}`,
`--extensions-dir=${extDir}`,
`--logsPath=${logsDir}`,
'--enable-smoke-test-driver',
'--disable-extensions',
];
// vscode-api-tests only exists in the dev build
if (isDevBuild) {
args.push('--disable-extension=vscode.vscode-api-tests');
}
if (process.platform !== 'darwin') {
args.push('--disable-gpu');
}
if (process.env.CI && process.platform === 'linux') {
args.push('--no-sandbox');
}
// Enable extension host inspector for profiling/heap snapshots
if (extHostInspectPort > 0) {
args.push(`--inspect-extensions=${extHostInspectPort}`);
}
return args;
}
/**
* Write VS Code settings that point the copilot extension at the mock server.
* @param {string} userDataDir
* @param {{ url: string }} mockServer
* @param {Record<string, any>} [overrides]
*/
function writeSettings(userDataDir, mockServer, overrides) {
const settingsDir = path.join(userDataDir, 'User');
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({
'github.copilot.advanced.debug.overrideProxyUrl': mockServer.url,
'github.copilot.advanced.debug.overrideCapiUrl': mockServer.url,
'chat.allowAnonymousAccess': true,
// Disable MCP servers — they start async and add unpredictable
// delay that pollutes perf measurements.
'chat.mcp.discovery.enabled': false,
'chat.mcp.enabled': false,
'github.copilot.chat.githubMcpServer.enabled': false,
'github.copilot.chat.cli.mcp.enabled': false,
// Auto-approve all tool invocations (YOLO mode) so tool call
// scenarios don't block on confirmation dialogs.
'chat.tools.global.autoApprove': true,
...overrides,
}, null, '\t'));
}
/**
* Prepare a fresh run directory (clean, create, preseed, write settings).
* @param {string} runId
* @param {{ url: string }} mockServer
* @param {Record<string, any>} [settingsOverrides]
* @returns {{ userDataDir: string, extDir: string, logsDir: string }}
*/
function prepareRunDir(runId, mockServer, settingsOverrides) {
const tmpBase = path.join(os.tmpdir(), 'vscode-chat-simulation');
const userDataDir = path.join(tmpBase, `run-${runId}`);
const extDir = path.join(DATA_DIR, 'extensions');
const logsDir = path.join(tmpBase, 'logs', `run-${runId}`);
// Retry rmSync to handle ENOTEMPTY race conditions from Electron cache locks
for (let attempt = 0; attempt < 3; attempt++) {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
break;
} catch (err) {
const error = /** @type {NodeJS.ErrnoException} */ (err);
if (attempt < 2 && error.code === 'ENOTEMPTY') {
require('child_process').execSync(`sleep 0.5`);
} else {
throw error;
}
}
}
fs.mkdirSync(userDataDir, { recursive: true });
fs.mkdirSync(extDir, { recursive: true });
fs.mkdirSync(logsDir, { recursive: true });
preseedStorage(userDataDir);
writeSettings(userDataDir, mockServer, settingsOverrides);
return { userDataDir, extDir, logsDir };
}
// -- VS Code launch via CDP --------------------------------------------------
// -- Extension host inspector ------------------------------------------------
/** @type {number} */
let nextExtHostPort = 29222;
/** @returns {number} */
function getNextExtHostInspectPort() {
return nextExtHostPort++;
}
/**
* Connect to the extension host's Node inspector via WebSocket.
* The extension host must be started with `--inspect-extensions=<port>`.
*
* @param {number} port
* @param {{ verbose?: boolean, timeoutMs?: number }} [opts]
* @returns {Promise<{ send: (method: string, params?: any) => Promise<any>, on: (event: string, listener: (params: any) => void) => void, close: () => void, port: number }>}
*/
async function connectToExtHostInspector(port, opts = {}) {
const { verbose = false, timeoutMs = 30_000 } = opts;
// Wait for the inspector endpoint to be available
const deadline = Date.now() + timeoutMs;
/** @type {any} */
let wsUrl;
while (Date.now() < deadline) {
try {
const targets = await getJson(`http://127.0.0.1:${port}/json`);
if (targets.length > 0 && targets[0].webSocketDebuggerUrl) {
wsUrl = targets[0].webSocketDebuggerUrl;
break;
}
} catch { }
await new Promise(r => setTimeout(r, 500));
}
if (!wsUrl) {
throw new Error(`Timed out waiting for extension host inspector on port ${port}`);
}
if (verbose) {
console.log(` [ext-host] Connected to inspector: ${wsUrl}`);
}
const WebSocket = require('ws');
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.once('open', resolve);
ws.once('error', reject);
});
let msgId = 1;
/** @type {Map<number, { resolve: (v: any) => void, reject: (e: Error) => void }>} */
const pending = new Map();
/** @type {Map<string, ((params: any) => void)[]>} */
const eventListeners = new Map();
ws.on('message', (/** @type {Buffer} */ data) => {
const msg = JSON.parse(data.toString());
if (msg.id !== undefined) {
const p = pending.get(msg.id);
if (p) {
pending.delete(msg.id);
if (msg.error) { p.reject(new Error(msg.error.message)); }
else { p.resolve(msg.result); }
}
} else if (msg.method) {
const listeners = eventListeners.get(msg.method) || [];
for (const listener of listeners) { listener(msg.params); }
}
});
return {
port,
/**
* @param {string} method
* @param {any} [params]
* @returns {Promise<any>}
*/
send(method, params) {
return new Promise((resolve, reject) => {
const id = msgId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`Inspector call timed out: ${method}`));
}
}, 30_000);
});
},
/**
* @param {string} event
* @param {(params: any) => void} listener
*/
on(event, listener) {
const list = eventListeners.get(event) || [];
list.push(listener);
eventListeners.set(event, list);
},
close() {
ws.close();
},
};
}
/**
* Fetch JSON from a URL. Used to probe the CDP endpoint.
* @param {string} url
* @returns {Promise<any>}
*/
function getJson(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch { reject(new Error(`Invalid JSON from ${url}`)); }
});
}).on('error', reject);
});
}
/**
* Wait until VS Code exposes its CDP endpoint.
* @param {number} port
* @param {number} timeoutMs
* @returns {Promise<void>}
*/
async function waitForCDP(port, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await getJson(`http://127.0.0.1:${port}/json/version`);
return;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
throw new Error(`Timed out waiting for CDP on port ${port}`);
}
/**
* Find the workbench page among all CDP pages.
* For dev builds this checks for `globalThis.driver` (smoke-test driver).
* For stable builds it checks for `.monaco-workbench` in the DOM.
* @param {import('playwright').Browser} browser
* @param {number} timeoutMs
* @returns {Promise<import('playwright').Page>}
*/
async function findWorkbenchPage(browser, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const pages = browser.contexts().flatMap(ctx => ctx.pages());
for (const page of pages) {
const hasWorkbench = await page.evaluate(() =>
// @ts-ignore
!!globalThis.driver?.whenWorkbenchRestored || !!document.querySelector('.monaco-workbench')
).catch(() => false);
if (hasWorkbench) {
return page;
}
}
await new Promise(r => setTimeout(r, 500));
}
throw new Error('Timed out waiting for the workbench page');
}
/** @type {number} */
let nextPort = 19222;
/**
* Launch VS Code via child_process and connect via CDP.
* Works with dev builds, insiders, and stable releases.
*
* @param {string} executable - Path to the VS Code executable (Electron binary or CLI)
* @param {string[]} launchArgs - Arguments to pass to the executable
* @param {Record<string, string>} env - Environment variables
* @param {{ verbose?: boolean }} [opts]
* @returns {Promise<{ page: import('playwright').Page, browser: import('playwright').Browser, close: () => Promise<void> }>}
*/
async function launchVSCode(executable, launchArgs, env, opts = {}) {
const { chromium } = require('playwright');
const port = nextPort++;
const args = [`--remote-debugging-port=${port}`, ...launchArgs];
const isShell = process.platform === 'win32';
if (opts.verbose) {
console.log(` [launch] ${executable} ${args.slice(0, 3).join(' ')} ... (port ${port})`);
}
const child = spawn(executable, args, {
cwd: ROOT,
env,
shell: isShell,
stdio: opts.verbose ? 'inherit' : ['ignore', 'ignore', 'ignore'],
});
// Track early exit
let exitError = /** @type {Error | null} */ (null);
child.once('exit', (code, signal) => {
if (!exitError) {
exitError = new Error(`VS Code exited before CDP connected (code=${code} signal=${signal})`);
}
});
// Wait for CDP
try {
await waitForCDP(port);
} catch (e) {
if (exitError) { throw exitError; }
throw e;
}
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
const page = await findWorkbenchPage(browser);
return {
page,
browser,
close: async () => {
// Trigger app.quit() so Chromium flushes trace buffers and
// writes --trace-startup-file. Using Cmd+Q / Alt+F4 triggers
// the full Electron quit lifecycle including trace flush.
// window.close() only closes the BrowserWindow without
// triggering app-level quit.
try {
const quitKey = process.platform === 'darwin' ? 'Meta+KeyQ' : 'Alt+F4';
await page.keyboard.press(quitKey);
} catch {
// Page may already be closed
}
const pid = child.pid;
// Wait for graceful exit (up to 30s for trace flush)
await new Promise(resolve => {
const timer = setTimeout(() => {
if (pid) {
try { execSync(`pkill -9 -P ${pid}`, { stdio: 'ignore' }); }
catch { }
}
child.kill('SIGKILL');
resolve(undefined);
}, 30_000);
child.once('exit', () => { clearTimeout(timer); resolve(undefined); });
});
// Disconnect CDP after the process has exited
await browser.close().catch(() => { });
// Kill crashpad handler — it self-daemonizes and outlives the
// parent. Wait briefly for it to detach, then kill by pattern.
await new Promise(r => setTimeout(r, 500));
try { execSync('pkill -9 -f crashpad_handler.*vscode-chat-simulation', { stdio: 'ignore' }); }
catch { }
},
};
}
// -- Statistics --------------------------------------------------------------
/**
* @param {number[]} values
*/
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
/**
* Remove outliers using IQR method.
* @param {number[]} values
* @returns {number[]}
*/
function removeOutliers(values) {
if (values.length < 4) { return values; }
const sorted = [...values].sort((a, b) => a - b);
const q1 = sorted[Math.floor(sorted.length * 0.25)];
const q3 = sorted[Math.floor(sorted.length * 0.75)];
const iqr = q3 - q1;
const lo = q1 - 1.5 * iqr;
const hi = q3 + 1.5 * iqr;
return sorted.filter(v => v >= lo && v <= hi);
}
/**
* Regularized incomplete beta function I_x(a, b) via continued fraction.
* Used for computing t-distribution CDF / p-values.
* @param {number} x
* @param {number} a
* @param {number} b
* @returns {number}
*/
function betaIncomplete(x, a, b) {
if (x <= 0) { return 0; }
if (x >= 1) { return 1; }
// Use symmetry relation when x > (a+1)/(a+b+2) for better convergence
if (x > (a + 1) / (a + b + 2)) {
return 1 - betaIncomplete(1 - x, b, a);
}
// Log-beta via Stirling: lnBeta(a,b) = lnGamma(a)+lnGamma(b)-lnGamma(a+b)
const lnBeta = lnGamma(a) + lnGamma(b) - lnGamma(a + b);
const front = Math.exp(Math.log(x) * a + Math.log(1 - x) * b - lnBeta) / a;
// Lentz's continued fraction
const maxIter = 200;
const eps = 1e-14;
let c = 1, d = 1 - (a + b) * x / (a + 1);
if (Math.abs(d) < eps) { d = eps; }
d = 1 / d;
let result = d;
for (let m = 1; m <= maxIter; m++) {
// Even step
let num = m * (b - m) * x / ((a + 2 * m - 1) * (a + 2 * m));
d = 1 + num * d; if (Math.abs(d) < eps) { d = eps; } d = 1 / d;
c = 1 + num / c; if (Math.abs(c) < eps) { c = eps; }
result *= d * c;
// Odd step
num = -(a + m) * (a + b + m) * x / ((a + 2 * m) * (a + 2 * m + 1));
d = 1 + num * d; if (Math.abs(d) < eps) { d = eps; } d = 1 / d;
c = 1 + num / c; if (Math.abs(c) < eps) { c = eps; }
const delta = d * c;
result *= delta;
if (Math.abs(delta - 1) < eps) { break; }
}
return front * result;
}
/**
* Log-gamma via Lanczos approximation.
* @param {number} z
* @returns {number}
*/
function lnGamma(z) {
const g = 7;
const coef = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
if (z < 0.5) {
return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z);
}
z -= 1;
let x = coef[0];
for (let i = 1; i < g + 2; i++) { x += coef[i] / (z + i); }
const t = z + g + 0.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
/**
* Two-tailed p-value from t-distribution.
* @param {number} t - t-statistic
* @param {number} df - degrees of freedom
* @returns {number}
*/
function tDistPValue(t, df) {
const x = df / (df + t * t);
return betaIncomplete(x, df / 2, 0.5);
}
/**
* Welch's t-test for two independent samples (unequal variance).
* @param {number[]} a - Sample 1 (e.g., baseline values)
* @param {number[]} b - Sample 2 (e.g., current values)
* @returns {{ t: number, df: number, pValue: number, significant: boolean, confidence: string } | null}
*/
function welchTTest(a, b) {
if (a.length < 2 || b.length < 2) { return null; }
const meanA = a.reduce((s, v) => s + v, 0) / a.length;
const meanB = b.reduce((s, v) => s + v, 0) / b.length;
const varA = a.reduce((s, v) => s + (v - meanA) ** 2, 0) / (a.length - 1);
const varB = b.reduce((s, v) => s + (v - meanB) ** 2, 0) / (b.length - 1);
const seA = varA / a.length;
const seB = varB / b.length;
const seDiff = Math.sqrt(seA + seB);
if (seDiff === 0) { return null; }
const t = (meanB - meanA) / seDiff;
// Welch-Satterthwaite degrees of freedom
const df = (seA + seB) ** 2 / ((seA ** 2) / (a.length - 1) + (seB ** 2) / (b.length - 1));
const pValue = tDistPValue(t, df);
const significant = pValue < 0.05;
let confidence;
if (pValue < 0.01) { confidence = 'high'; }
else if (pValue < 0.05) { confidence = 'medium'; }
else if (pValue < 0.1) { confidence = 'low'; }
else { confidence = 'none'; }
return { t: Math.round(t * 100) / 100, df: Math.round(df * 10) / 10, pValue: Math.round(pValue * 1000) / 1000, significant, confidence };
}
/**
* Compute robust stats for a metric array.
* @param {number[]} raw
*/
function robustStats(raw) {
const valid = raw.filter(v => v >= 0);
if (valid.length === 0) { return null; }
const cleaned = removeOutliers(valid);
if (cleaned.length === 0) { return null; }
const sorted = [...cleaned].sort((a, b) => a - b);
const med = median(sorted);
const p95 = sorted[Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1)];
const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
const variance = sorted.reduce((a, b) => a + (b - mean) ** 2, 0) / sorted.length;
const stddev = Math.sqrt(variance);
const cv = mean > 0 ? stddev / mean : 0;
return {
median: Math.round(med * 100) / 100,
p95: Math.round(p95 * 100) / 100,
min: sorted[0],
max: sorted[sorted.length - 1],
mean: Math.round(mean * 100) / 100,
stddev: Math.round(stddev * 100) / 100,
cv: Math.round(cv * 1000) / 1000,
n: sorted.length,
nOutliers: valid.length - cleaned.length,
};
}
/**
* Simple linear regression slope (y per unit x).
* @param {number[]} values
*/
function linearRegressionSlope(values) {
const n = values.length;
if (n < 2) { return 0; }
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += values[i];
sumXY += i * values[i];
sumX2 += i * i;
}
return (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
}
/**
* Format a single metric line for console output.
* @param {number[]} values
* @param {string} label
* @param {string} unit
*/
function summarize(values, label, unit) {
const s = robustStats(values);
if (!s) { return ` ${label}: (no data)`; }
const cv = s.cv > 0.15 ? ` cv=${(s.cv * 100).toFixed(0)}%⚠` : ` cv=${(s.cv * 100).toFixed(0)}%`;
const outliers = s.nOutliers > 0 ? ` (${s.nOutliers} outlier${s.nOutliers > 1 ? 's' : ''} removed)` : '';
return ` ${label}: median=${s.median}${unit}, p95=${s.p95}${unit},${cv}${outliers} [n=${s.n}]`;
}
/**
* Compute duration between two chat perf marks.
* @param {Array<{name: string, startTime: number}>} marks
* @param {string} from
* @param {string} to
*/
function markDuration(marks, from, to) {
const fromMark = marks.find(m => m.name.endsWith('/' + from));
const toMark = marks.find(m => m.name.endsWith('/' + to));
if (fromMark && toMark) {
return toMark.startTime - fromMark.startTime;
}
return -1;
}
/** @type {Array<[string, string, string]>} */
const METRIC_DEFS = [
['timeToFirstToken', 'timing', 'ms'],
['timeToComplete', 'timing', 'ms'],
['timeToRenderComplete', 'timing', 'ms'],
['timeToUIUpdated', 'timing', 'ms'],
['instructionCollectionTime', 'timing', 'ms'],
['agentInvokeTime', 'timing', 'ms'],
['heapDelta', 'memory', 'MB'],
['heapDeltaPostGC', 'memory', 'MB'],
['gcDurationMs', 'memory', 'ms'],
['layoutCount', 'rendering', ''],
['layoutDurationMs', 'rendering', 'ms'],
['recalcStyleCount', 'rendering', ''],
['forcedReflowCount', 'rendering', ''],
['longTaskCount', 'rendering', ''],
['longAnimationFrameCount', 'rendering', ''],
['longAnimationFrameTotalMs', 'rendering', 'ms'],
['frameCount', 'rendering', ''],
['compositeLayers', 'rendering', ''],
['paintCount', 'rendering', ''],
['extHostHeapUsedBefore', 'extHost', 'MB'],
['extHostHeapUsedAfter', 'extHost', 'MB'],
['extHostHeapDelta', 'extHost', 'MB'],
['extHostHeapDeltaPostGC', 'extHost', 'MB'],
];
module.exports = {
ROOT,
DATA_DIR,
METRIC_DEFS,
loadConfig,
getElectronPath,
getRepoRoot,
isVersionString,
resolveBuild,
preseedStorage,
buildEnv,
buildArgs,
writeSettings,
prepareRunDir,
median,
removeOutliers,
robustStats,
welchTTest,
linearRegressionSlope,
summarize,
markDuration,
launchVSCode,
getNextExtHostInspectPort,
connectToExtHostInspector,
};
+37
View File
@@ -0,0 +1,37 @@
{
"perfRegression": {
// VS Code version, "insiders", or a commit hash (7-40 hex chars)
"baselineBuild": "1.116.0",
// Number of benchmark iterations per scenario
"runsPerScenario": 5,
// Default fraction above baseline that triggers a regression (0.2 = 20%).
// Per-metric overrides below take precedence when set.
"regressionThreshold": 0.2,
// Per-metric regression thresholds.
// - A plain number (0-1) is a fraction, e.g. 0.2 = 20% above baseline.
// - A string ending in the metric's unit (e.g. "100ms") is an absolute delta.
// Metrics not listed here use regressionThreshold above.
"metricThresholds": {
"timeToFirstToken": "100ms",
"timeToComplete": 0.2,
"layoutCount": 0.2,
"recalcStyleCount": 0.2,
"forcedReflowCount": 0.2,
"longTaskCount": 0.2,
"longAnimationFrameCount": 0.2
}
},
"memLeaks": {
// Number of open→work→reset cycles
"iterations": 3,
// Max acceptable total residual heap growth in MB.
// Each iteration cycles through ALL scenarios (text, code blocks,
// tool calls, thinking, terminal, multi-turn, etc.), so this needs
// to account for V8 internal caches that aren't immediately reclaimed.
"leakThresholdMB": 10
}
}
@@ -0,0 +1,84 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/arrays.ts for stable perf testing.
*/
export function coalesce<T>(array: ReadonlyArray<T | undefined | null>): T[] {
return array.filter((e): e is T => e !== undefined && e !== null);
}
export function groupBy<T>(data: ReadonlyArray<T>, groupFn: (element: T) => string): { [key: string]: T[] } {
const result: { [key: string]: T[] } = {};
for (const element of data) {
const key = groupFn(element);
(result[key] ??= []).push(element);
}
return result;
}
export function distinct<T>(array: ReadonlyArray<T>, keyFn: (t: T) => any = t => t): T[] {
const seen = new Set<any>();
return array.filter(element => {
const key = keyFn(element);
if (seen.has(key)) { return false; }
seen.add(key);
return true;
});
}
export function firstOrDefault<T>(array: ReadonlyArray<T>): T | undefined;
export function firstOrDefault<T>(array: ReadonlyArray<T>, defaultValue: T): T;
export function firstOrDefault<T>(array: ReadonlyArray<T>, defaultValue?: T): T | undefined {
return array.length > 0 ? array[0] : defaultValue;
}
export function lastOrDefault<T>(array: ReadonlyArray<T>): T | undefined;
export function lastOrDefault<T>(array: ReadonlyArray<T>, defaultValue: T): T;
export function lastOrDefault<T>(array: ReadonlyArray<T>, defaultValue?: T): T | undefined {
return array.length > 0 ? array[array.length - 1] : defaultValue;
}
export function binarySearch<T>(array: ReadonlyArray<T>, key: T, comparator: (a: T, b: T) => number): number {
let low = 0;
let high = array.length - 1;
while (low <= high) {
const mid = ((low + high) / 2) | 0;
const comp = comparator(array[mid], key);
if (comp < 0) { low = mid + 1; }
else if (comp > 0) { high = mid - 1; }
else { return mid; }
}
return -(low + 1);
}
export function insertSorted<T>(array: T[], element: T, comparator: (a: T, b: T) => number): void {
const idx = binarySearch(array, element, comparator);
const insertIdx = idx < 0 ? ~idx : idx;
array.splice(insertIdx, 0, element);
}
export function flatten<T>(arr: T[][]): T[] {
return ([] as T[]).concat(...arr);
}
export function range(to: number): number[];
export function range(from: number, to: number): number[];
export function range(arg: number, to?: number): number[] {
const from = to !== undefined ? arg : 0;
const end = to !== undefined ? to : arg;
const result: number[] = [];
for (let i = from; i < end; i++) { result.push(i); }
return result;
}
export function tail<T>(array: T[]): [T[], T] {
if (array.length === 0) { throw new Error('Invalid tail call'); }
return [array.slice(0, array.length - 1), array[array.length - 1]];
}
@@ -0,0 +1,132 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/async.ts for stable perf testing.
*/
import { IDisposable } from './lifecycle';
import { CancellationError } from './errors';
export class Throttler {
private activePromise: Promise<any> | null = null;
private queuedPromiseFactory: (() => Promise<any>) | null = null;
queue<T>(promiseFactory: () => Promise<T>): Promise<T> {
if (this.activePromise) {
this.queuedPromiseFactory = promiseFactory;
return this.activePromise as Promise<T>;
}
this.activePromise = promiseFactory();
return this.activePromise.finally(() => {
this.activePromise = null;
if (this.queuedPromiseFactory) {
const factory = this.queuedPromiseFactory;
this.queuedPromiseFactory = null;
return this.queue(factory);
}
});
}
}
export class Delayer<T> implements IDisposable {
private timeout: any;
private task: (() => T | Promise<T>) | null = null;
constructor(public defaultDelay: number) { }
trigger(task: () => T | Promise<T>, delay: number = this.defaultDelay): Promise<T> {
this.task = task;
this.cancelTimeout();
return new Promise<T>((resolve, reject) => {
this.timeout = setTimeout(() => {
this.timeout = null;
try { resolve(this.task!()); } catch (e) { reject(e); }
this.task = null;
}, delay);
});
}
private cancelTimeout(): void {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
dispose(): void {
this.cancelTimeout();
}
}
export class RunOnceScheduler implements IDisposable {
private runner: (() => void) | null;
private timeout: any;
constructor(runner: () => void, private delay: number) {
this.runner = runner;
}
schedule(delay = this.delay): void {
this.cancel();
this.timeout = setTimeout(() => {
this.timeout = null;
this.runner?.();
}, delay);
}
cancel(): void {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
isScheduled(): boolean { return this.timeout !== null; }
dispose(): void {
this.cancel();
this.runner = null;
}
}
export class Queue<T> {
private readonly queue: Array<() => Promise<T>> = [];
private running = false;
async enqueue(factory: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push(() => factory().then(resolve, reject));
if (!this.running) { this.processQueue(); }
});
}
private async processQueue(): Promise<void> {
this.running = true;
while (this.queue.length > 0) {
const task = this.queue.shift()!;
await task();
}
this.running = false;
}
get size(): number { return this.queue.length; }
}
export function timeout(millis: number): Promise<void> {
return new Promise<void>(resolve => setTimeout(resolve, millis));
}
export async function retry<T>(task: () => Promise<T>, delay: number, retries: number): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < retries; i++) {
try { return await task(); }
catch (error) { lastError = error as Error; await timeout(delay); }
}
throw lastError;
}
@@ -0,0 +1,88 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/errors.ts for stable perf testing.
*/
export interface ErrorListenerCallback {
(error: any): void;
}
export interface ErrorListenerUnbind {
(): void;
}
const _errorListeners: ErrorListenerCallback[] = [];
export function setUnexpectedErrorHandler(handler: ErrorListenerCallback): void {
_errorListeners.length = 0;
_errorListeners.push(handler);
}
export function onUnexpectedError(e: any): void {
if (!isCancellationError(e)) {
for (const listener of _errorListeners) {
try { listener(e); } catch { }
}
}
}
export function onUnexpectedExternalError(e: any): void {
if (!isCancellationError(e)) {
for (const listener of _errorListeners) {
try { listener(e); } catch { }
}
}
}
export function transformErrorForSerialization(error: any): any {
if (error instanceof Error) {
const { name, message, stack } = error;
return { $isError: true, name, message, stack };
}
return error;
}
const canceledName = 'Canceled';
export function isCancellationError(error: any): boolean {
if (error instanceof CancellationError) { return true; }
return error instanceof Error && error.name === canceledName && error.message === canceledName;
}
export class CancellationError extends Error {
constructor() {
super(canceledName);
this.name = this.message;
}
}
export class NotSupportedError extends Error {
constructor(message?: string) {
super(message || 'NotSupported');
}
}
export class NotImplementedError extends Error {
constructor(message?: string) {
super(message || 'NotImplemented');
}
}
export class IllegalArgumentError extends Error {
constructor(message?: string) {
super(message || 'Illegal argument');
}
}
export class BugIndicatingError extends Error {
constructor(message?: string) {
super(message || 'Bug Indicating Error');
}
}
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/event.ts for stable perf testing.
*/
import { IDisposable, DisposableStore } from './lifecycle';
export interface Event<T> {
(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable;
}
export namespace Event {
export const None: Event<any> = () => ({ dispose() { } });
export function once<T>(event: Event<T>): Event<T> {
return (listener, thisArgs?, disposables?) => {
let didFire = false;
const result = event(e => {
if (didFire) { return; }
didFire = true;
return listener.call(thisArgs, e);
}, null, disposables);
if (didFire) { result.dispose(); }
return result;
};
}
export function map<I, O>(event: Event<I>, map: (i: I) => O): Event<O> {
return (listener, thisArgs?, disposables?) =>
event(i => listener.call(thisArgs, map(i)), null, disposables);
}
export function filter<T>(event: Event<T>, filter: (e: T) => boolean): Event<T> {
return (listener, thisArgs?, disposables?) =>
event(e => filter(e) && listener.call(thisArgs, e), null, disposables);
}
export function debounce<T>(event: Event<T>, merge: (last: T | undefined, e: T) => T, delay: number = 100): Event<T> {
let subscription: IDisposable;
let output: T | undefined;
let handle: any;
return (listener, thisArgs?, disposables?) => {
subscription = event(cur => {
output = merge(output, cur);
clearTimeout(handle);
handle = setTimeout(() => {
const e = output!;
output = undefined;
listener.call(thisArgs, e);
}, delay);
});
return { dispose() { subscription.dispose(); clearTimeout(handle); } };
};
}
}
export class Emitter<T> {
private readonly _listeners = new Set<(e: T) => void>();
private _disposed = false;
readonly event: Event<T> = (listener: (e: T) => void) => {
if (this._disposed) { return { dispose() { } }; }
this._listeners.add(listener);
return {
dispose: () => { this._listeners.delete(listener); }
};
};
fire(event: T): void {
if (this._disposed) { return; }
for (const listener of [...this._listeners]) {
try { listener(event); } catch { }
}
}
dispose(): void {
if (this._disposed) { return; }
this._disposed = true;
this._listeners.clear();
}
get hasListeners(): boolean { return this._listeners.size > 0; }
}
export class PauseableEmitter<T> extends Emitter<T> {
private _isPaused = false;
private _queue: T[] = [];
pause(): void { this._isPaused = true; }
resume(): void {
this._isPaused = false;
while (this._queue.length > 0) {
super.fire(this._queue.shift()!);
}
}
override fire(event: T): void {
if (this._isPaused) { this._queue.push(event); }
else { super.fire(event); }
}
}
@@ -0,0 +1,127 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/lifecycle.ts for stable perf testing.
*/
export interface IDisposable {
dispose(): void;
}
export function isDisposable<T extends object>(thing: T): thing is T & IDisposable {
return typeof (thing as IDisposable).dispose === 'function'
&& (thing as IDisposable).dispose.length === 0;
}
export function dispose<T extends IDisposable>(disposable: T): T;
export function dispose<T extends IDisposable>(disposable: T | undefined): T | undefined;
export function dispose<T extends IDisposable>(disposables: T[]): T[];
export function dispose<T extends IDisposable>(disposables: readonly T[]): readonly T[];
export function dispose<T extends IDisposable>(arg: T | T[] | undefined): any {
if (Array.isArray(arg)) {
const errors: any[] = [];
for (const d of arg) {
try { d.dispose(); } catch (e) { errors.push(e); }
}
if (errors.length > 0) { throw new Error(`Dispose errors: ${errors.length}`); }
return arg;
} else if (arg) {
arg.dispose();
return arg;
}
}
export class DisposableStore implements IDisposable {
private readonly _toDispose = new Set<IDisposable>();
private _isDisposed = false;
dispose(): void {
if (this._isDisposed) { return; }
this._isDisposed = true;
this.clear();
}
clear(): void {
if (this._toDispose.size === 0) { return; }
const iter = this._toDispose.values();
this._toDispose.clear();
for (const item of iter) {
try { item.dispose(); } catch { }
}
}
add<T extends IDisposable>(o: T): T {
if (this._isDisposed) {
console.warn('Adding to a disposed DisposableStore');
return o;
}
this._toDispose.add(o);
return o;
}
get size(): number { return this._toDispose.size; }
}
export abstract class Disposable implements IDisposable {
private readonly _store = new DisposableStore();
dispose(): void {
this._store.dispose();
}
protected _register<T extends IDisposable>(o: T): T {
return this._store.add(o);
}
}
export class MutableDisposable<T extends IDisposable> implements IDisposable {
private _value?: T;
private _isDisposed = false;
get value(): T | undefined { return this._isDisposed ? undefined : this._value; }
set value(value: T | undefined) {
if (this._isDisposed || value === this._value) { return; }
this._value?.dispose();
this._value = value;
}
dispose(): void {
this._isDisposed = true;
this._value?.dispose();
this._value = undefined;
}
}
export class DisposableMap<K, V extends IDisposable> implements IDisposable {
private readonly _map = new Map<K, V>();
private _isDisposed = false;
set(key: K, value: V): void {
const existing = this._map.get(key);
if (existing !== value) {
existing?.dispose();
this._map.set(key, value);
}
}
get(key: K): V | undefined { return this._map.get(key); }
delete(key: K): void {
this._map.get(key)?.dispose();
this._map.delete(key);
}
dispose(): void {
if (this._isDisposed) { return; }
this._isDisposed = true;
for (const [, v] of this._map) { v.dispose(); }
this._map.clear();
}
}
@@ -0,0 +1,75 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/strings.ts for stable perf testing.
*/
export function format(value: string, ...args: any[]): string {
return value.replace(/{(\d+)}/g, (match, index) => {
const i = parseInt(index, 10);
return i >= 0 && i < args.length ? `${args[i]}` : match;
});
}
export function escape(value: string): string {
return value.replace(/[<>&"']/g, ch => {
switch (ch) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '"': return '&quot;';
case '\'': return '&#39;';
default: return ch;
}
});
}
export function trim(value: string, ch: string = ' '): string {
let start = 0;
let end = value.length;
while (start < end && value[start] === ch) { start++; }
while (end > start && value[end - 1] === ch) { end--; }
return value.substring(start, end);
}
export function equalsIgnoreCase(a: string, b: string): boolean {
return a.length === b.length && a.toLowerCase() === b.toLowerCase();
}
export function startsWithIgnoreCase(str: string, candidate: string): boolean {
if (str.length < candidate.length) { return false; }
return str.substring(0, candidate.length).toLowerCase() === candidate.toLowerCase();
}
export function commonPrefixLength(a: string, b: string): number {
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
if (a.charCodeAt(i) !== b.charCodeAt(i)) { return i; }
}
return len;
}
export function commonSuffixLength(a: string, b: string): number {
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
if (a.charCodeAt(a.length - 1 - i) !== b.charCodeAt(b.length - 1 - i)) { return i; }
}
return len;
}
export function splitLines(str: string): string[] {
return str.split(/\r\n|\r|\n/);
}
export function regExpLeadsToEndlessLoop(regexp: RegExp): boolean {
if (regexp.source === '^' || regexp.source === '^$' || regexp.source === '$') {
return false;
}
return !regexp.exec('')?.length;
}
@@ -0,0 +1,92 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/types.ts for stable perf testing.
*/
export function isString(thing: unknown): thing is string {
return typeof thing === 'string';
}
export function isNumber(thing: unknown): thing is number {
return typeof thing === 'number' && !isNaN(thing);
}
export function isBoolean(thing: unknown): thing is boolean {
return thing === true || thing === false;
}
export function isUndefined(thing: unknown): thing is undefined {
return typeof thing === 'undefined';
}
export function isDefined<T>(thing: T | undefined | null): thing is T {
return !isUndefinedOrNull(thing);
}
export function isUndefinedOrNull(thing: unknown): thing is undefined | null {
return isUndefined(thing) || thing === null;
}
export function isFunction(thing: unknown): thing is Function {
return typeof thing === 'function';
}
export function isObject(thing: unknown): thing is object {
return typeof thing === 'object'
&& thing !== null
&& !Array.isArray(thing)
&& !(thing instanceof RegExp)
&& !(thing instanceof Date);
}
export function isArray(thing: unknown): thing is unknown[] {
return Array.isArray(thing);
}
export function assertType(condition: unknown, type?: string): asserts condition {
if (!condition) {
throw new Error(type ? `Unexpected type, expected '${type}'` : 'Unexpected type');
}
}
export function assertIsDefined<T>(thing: T | undefined | null): T {
if (isUndefinedOrNull(thing)) {
throw new Error('Assertion failed: value is undefined or null');
}
return thing;
}
export function assertAllDefined<T1, T2>(t1: T1 | undefined | null, t2: T2 | undefined | null): [T1, T2] {
return [assertIsDefined(t1), assertIsDefined(t2)];
}
export type TypeConstraint = string | Function;
export function validateConstraints(args: unknown[], constraints: Array<TypeConstraint | undefined>): void {
const len = Math.min(args.length, constraints.length);
for (let i = 0; i < len; i++) {
validateConstraint(args[i], constraints[i]);
}
}
export function validateConstraint(arg: unknown, constraint: TypeConstraint | undefined): void {
if (isString(constraint)) {
if (typeof arg !== constraint) {
throw new Error(`argument does not match constraint: typeof ${constraint}`);
}
} else if (isFunction(constraint)) {
try {
if (arg instanceof constraint) { return; }
} catch { }
if (!isUndefinedOrNull(arg) && (arg as any).constructor === constraint) { return; }
if (constraint.length === 1 && constraint.call(undefined, arg) === true) { return; }
throw new Error('argument does not match one of these constraints: arg instanceof constraint, arg.constructor === constraint, nor constraint(arg) === true');
}
}
@@ -0,0 +1,85 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// perf-benchmark-marker
/**
* Fixture for chat-simulation benchmarks.
* Simplified from src/vs/base/common/uri.ts for stable perf testing.
*/
const _empty = '';
const _slash = '/';
export class URI {
readonly scheme: string;
readonly authority: string;
readonly path: string;
readonly query: string;
readonly fragment: string;
private constructor(scheme: string, authority: string, path: string, query: string, fragment: string) {
this.scheme = scheme;
this.authority = authority || _empty;
this.path = path || _empty;
this.query = query || _empty;
this.fragment = fragment || _empty;
}
static file(path: string): URI {
let authority = _empty;
if (path.length >= 2 && path.charCodeAt(0) === 47 /* / */ && path.charCodeAt(1) === 47 /* / */) {
const idx = path.indexOf(_slash, 2);
if (idx === -1) {
authority = path.substring(2);
path = _slash;
} else {
authority = path.substring(2, idx);
path = path.substring(idx) || _slash;
}
}
return new URI('file', authority, path, _empty, _empty);
}
static parse(value: string): URI {
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/.exec(value);
if (!match) { return new URI(_empty, _empty, _empty, _empty, _empty); }
return new URI(match[1], match[2], match[3], match[4]?.substring(1) || _empty, match[5]?.substring(1) || _empty);
}
with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): URI {
return new URI(
change.scheme ?? this.scheme,
change.authority ?? this.authority,
change.path ?? this.path,
change.query ?? this.query,
change.fragment ?? this.fragment,
);
}
toString(): string {
let result = '';
if (this.scheme) { result += this.scheme + '://'; }
if (this.authority) { result += this.authority; }
if (this.path) { result += this.path; }
if (this.query) { result += '?' + this.query; }
if (this.fragment) { result += '#' + this.fragment; }
return result;
}
get fsPath(): string {
return this.path;
}
toJSON(): object {
return {
scheme: this.scheme,
authority: this.authority,
path: this.path,
query: this.query,
fragment: this.fragment,
};
}
}
+535
View File
@@ -0,0 +1,535 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// @ts-check
/**
* Merge per-group perf results into a single unified CI summary.
*
* Called by the CI report job after all matrix groups have finished.
* Reads results.json and baseline-*.json from each group directory,
* merges all scenarios into one combined report, and writes a single
* ci-summary.md file.
*
* Usage:
* node scripts/chat-simulation/merge-ci-summary.js \
* --results-dir perf-results \
* --output ci-summary.md \
* [--leak-summary leak-results/.chat-simulation-data/ci-summary-leak.md] \
* [--threshold 0.2]
*/
const fs = require('fs');
const path = require('path');
const { welchTTest, loadConfig } = require('./common/utils');
// -- CLI args ----------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
resultsDir: '',
output: '',
/** @type {string | undefined} */
leakSummary: undefined,
threshold: 0.2,
/** @type {Record<string, number | string>} */
metricThresholds: {},
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--results-dir': opts.resultsDir = args[++i]; break;
case '--output': opts.output = args[++i]; break;
case '--leak-summary': opts.leakSummary = args[++i]; break;
case '--threshold': opts.threshold = parseFloat(args[++i]); break;
case '--help': case '-h':
console.log([
'Merge per-group perf results into a single CI summary.',
'',
'Options:',
' --results-dir <dir> Directory containing perf-results-* subdirs',
' --output <path> Output path for ci-summary.md',
' --leak-summary <path> Path to ci-summary-leak.md (optional)',
' --threshold <frac> Regression threshold fraction (default: 0.2)',
].join('\n'));
process.exit(0);
}
}
if (!opts.resultsDir || !opts.output) {
console.error('Required: --results-dir and --output');
process.exit(1);
}
return opts;
}
// -- Merge logic -------------------------------------------------------------
/**
* Find all results.json and baseline-*.json files across group directories,
* merge scenarios into a single combined report.
* @param {string} resultsDir
*/
function mergeResults(resultsDir) {
const groupDirs = fs.readdirSync(resultsDir)
.filter(d => d.startsWith('perf-results-'))
.map(d => path.join(resultsDir, d))
.filter(d => fs.statSync(d).isDirectory());
if (groupDirs.length === 0) {
console.error(`No perf-results-* directories found in ${resultsDir}`);
return null;
}
/** @type {Record<string, any>} */
const mergedScenarios = {};
/** @type {Record<string, any>} */
const mergedBaselineScenarios = {};
let runsPerScenario = 0;
let platform = 'linux';
/** @type {string | undefined} */
let baselineBuildVersion;
/** @type {string | undefined} */
let threshold;
// Read per-metric thresholds from config.jsonc (same source as the perf script)
const perfConfig = loadConfig('perfRegression');
/** @type {Record<string, number | string>} */
const metricThresholds = perfConfig.metricThresholds ?? {};
for (const groupDir of groupDirs) {
// Find results.json (may be in a timestamped subdir under .chat-simulation-data)
const simDataDir = path.join(groupDir, '.chat-simulation-data');
if (!fs.existsSync(simDataDir)) { continue; }
// Search for results.json in timestamped subdirs
const subdirs = fs.readdirSync(simDataDir).filter(d => {
const full = path.join(simDataDir, d);
return fs.statSync(full).isDirectory() && /^\d{4}-/.test(d);
});
for (const subdir of subdirs) {
const resultsPath = path.join(simDataDir, subdir, 'results.json');
if (fs.existsSync(resultsPath)) {
const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
runsPerScenario = results.runsPerScenario || runsPerScenario;
platform = results.platform || platform;
for (const [scenario, data] of Object.entries(results.scenarios || {})) {
mergedScenarios[scenario] = data;
}
}
// Find baseline-*.json in the same dir
const baselineFiles = fs.readdirSync(path.join(simDataDir, subdir))
.filter(f => f.startsWith('baseline-') && f.endsWith('.json'));
for (const bf of baselineFiles) {
const baseline = JSON.parse(fs.readFileSync(path.join(simDataDir, subdir, bf), 'utf-8'));
baselineBuildVersion = baseline.baselineBuildVersion || baselineBuildVersion;
for (const [scenario, data] of Object.entries(baseline.scenarios || {})) {
mergedBaselineScenarios[scenario] = data;
}
}
}
// Also check for baseline cached at top-level .chat-simulation-data
const topBaselines = fs.readdirSync(simDataDir)
.filter(f => f.startsWith('baseline-') && f.endsWith('.json'));
for (const bf of topBaselines) {
const baseline = JSON.parse(fs.readFileSync(path.join(simDataDir, bf), 'utf-8'));
baselineBuildVersion = baseline.baselineBuildVersion || baselineBuildVersion;
for (const [scenario, data] of Object.entries(baseline.scenarios || {})) {
mergedBaselineScenarios[scenario] = data;
}
}
// Read threshold/metricThresholds from the group's ci-summary or config
const ciSummaryPath = path.join(simDataDir, 'ci-summary.md');
if (fs.existsSync(ciSummaryPath)) {
const content = fs.readFileSync(ciSummaryPath, 'utf-8');
const thresholdMatch = content.match(/Regression threshold\*\* \| (\d+)%/);
if (thresholdMatch) {
threshold = thresholdMatch[1];
}
}
}
const mergedReport = {
timestamp: new Date().toISOString(),
platform,
runsPerScenario,
scenarios: mergedScenarios,
};
const mergedBaseline = Object.keys(mergedBaselineScenarios).length > 0
? { baselineBuildVersion, scenarios: mergedBaselineScenarios }
: null;
return { report: mergedReport, baseline: mergedBaseline, baselineBuildVersion, threshold: threshold ? parseInt(threshold, 10) / 100 : undefined, metricThresholds };
}
// -- Summary generation (unified, single-header format) ----------------------
const GITHUB_REPO = 'https://github.com/microsoft/vscode';
/** @param {string} label */
function formatBuildLink(label) {
if (/^[0-9a-f]{7,40}$/.test(label)) {
return `[\`${label.substring(0, 7)}\`](${GITHUB_REPO}/commit/${label})`;
}
if (/^\d+\.\d+\.\d+/.test(label)) {
return `[\`${label}\`](${GITHUB_REPO}/releases/tag/${label})`;
}
return `\`${label}\``;
}
/**
* @param {string} base
* @param {string} test
*/
function formatCompareLink(base, test) {
const isRef = (/** @type {string} */ v) => /^[0-9a-f]{7,40}$/.test(v) || /^\d+\.\d+\.\d+/.test(v);
if (!isRef(base) || !isRef(test)) { return ''; }
return `[compare](${GITHUB_REPO}/compare/${base}...${test})`;
}
/**
* @param {{ type: string, value: number }} threshold
* @param {number} change
* @param {number} absoluteDelta
*/
function exceedsThreshold(threshold, change, absoluteDelta) {
if (threshold.type === 'absolute') { return absoluteDelta > threshold.value; }
return change > threshold.value;
}
/**
* @param {{ threshold: number, metricThresholds?: Record<string, number | string> }} opts
* @param {string} metric
*/
function getMetricThreshold(opts, metric) {
const raw = opts.metricThresholds?.[metric];
if (raw !== undefined) {
const num = typeof raw === 'number' ? raw : parseFloat(/** @type {string} */(raw));
return typeof raw === 'number' ? { type: 'fraction', value: num } : { type: 'absolute', value: num };
}
return { type: 'fraction', value: opts.threshold };
}
/** @param {number} v */
function round2(v) { return Math.round(v * 100) / 100; }
/**
* Generate a unified Markdown summary for all scenarios.
*
* @param {Record<string, any>} jsonReport
* @param {Record<string, any> | null} baseline
* @param {{ threshold: number, metricThresholds?: Record<string, number | string>, runs: number, baselineBuild?: string, build?: string }} opts
*/
function generateUnifiedSummary(jsonReport, baseline, opts) {
const baseLabel = opts.baselineBuild || 'baseline';
const testLabel = opts.build || 'dev (local)';
const baseLink = formatBuildLink(baseLabel);
const testLink = formatBuildLink(testLabel);
const compareLink = formatCompareLink(baseLabel, testLabel);
const allMetrics = [
['timeToFirstToken', 'timing', 'ms'],
['timeToComplete', 'timing', 'ms'],
['layoutCount', 'rendering', ''],
['recalcStyleCount', 'rendering', ''],
['forcedReflowCount', 'rendering', ''],
['longTaskCount', 'rendering', ''],
['longAnimationFrameCount', 'rendering', ''],
['longAnimationFrameTotalMs', 'rendering', 'ms'],
['frameCount', 'rendering', ''],
['compositeLayers', 'rendering', ''],
['paintCount', 'rendering', ''],
['heapDelta', 'memory', 'MB'],
['heapDeltaPostGC', 'memory', 'MB'],
['gcDurationMs', 'memory', 'ms'],
['extHostHeapDelta', 'extHost', 'MB'],
['extHostHeapDeltaPostGC', 'extHost', 'MB'],
];
const regressionMetricNames = new Set([
'timeToFirstToken', 'timeToComplete', 'layoutCount', 'recalcStyleCount',
'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount',
]);
const lines = [];
const scenarios = Object.keys(jsonReport.scenarios);
// -- Collect verdicts ------------------------------------------------
/** @type {Map<string, { metric: string, verdict: string, change: number, pValue: string, basStr: string, curStr: string }[]>} */
const scenarioVerdicts = new Map();
let totalRegressions = 0;
let totalImprovements = 0;
for (const scenario of scenarios) {
const current = jsonReport.scenarios[scenario];
const base = baseline?.scenarios?.[scenario];
/** @type {{ metric: string, verdict: string, change: number, pValue: string, basStr: string, curStr: string }[]} */
const verdicts = [];
if (base) {
for (const [metric, group, unit] of allMetrics) {
const cur = current[group]?.[metric];
const bas = base[group]?.[metric];
if (!cur || !bas || bas.median === null || bas.median === undefined) { continue; }
const change = bas.median !== 0 ? (cur.median - bas.median) / bas.median : 0;
const isRegressionMetric = regressionMetricNames.has(metric);
const curRaw = (current.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0);
const basRaw = (base.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0);
const ttest = welchTTest(basRaw, curRaw);
const pStr = ttest ? `${ttest.pValue}` : 'n/a';
const metricThreshold = getMetricThreshold(opts, metric);
const absoluteDelta = cur.median - bas.median;
let verdict = '';
if (isRegressionMetric) {
if (exceedsThreshold(metricThreshold, change, absoluteDelta)) {
if (!ttest || ttest.significant) {
verdict = 'REGRESSION';
totalRegressions++;
} else {
verdict = 'noise';
}
} else if (exceedsThreshold(metricThreshold, -change, -absoluteDelta) && ttest?.significant) {
verdict = 'improved';
totalImprovements++;
} else {
verdict = 'ok';
}
} else {
verdict = 'info';
}
const basStr = `${bas.median}${unit} \xb1${bas.stddev}${unit}`;
const curStr = `${cur.median}${unit} \xb1${cur.stddev}${unit}`;
verdicts.push({ metric, verdict, change, pValue: pStr, basStr, curStr });
}
}
scenarioVerdicts.set(scenario, verdicts);
}
// -- Header ----------------------------------------------------------
const hasRegressions = totalRegressions > 0;
const verdictIcon = hasRegressions ? '\u274C' : '\u2705';
let verdictText;
if (hasRegressions && totalImprovements > 0) {
verdictText = `${totalRegressions} regression(s), ${totalImprovements} improvement(s)`;
} else if (hasRegressions) {
verdictText = `${totalRegressions} regression(s) detected`;
} else if (totalImprovements > 0) {
verdictText = `No regressions \u2014 ${totalImprovements} improvement(s)`;
} else {
verdictText = 'No significant changes';
}
lines.push(`# ${verdictIcon} Chat Performance: ${verdictText}`);
lines.push('');
lines.push(`| | |`);
lines.push(`|---|---|`);
lines.push(`| **Baseline** | ${baseLink} |`);
lines.push(`| **Test** | ${testLink} |`);
if (compareLink) {
lines.push(`| **Diff** | ${compareLink} |`);
}
lines.push(`| **Runs per scenario** | ${opts.runs} |`);
const overrides = Object.entries(opts.metricThresholds || {}).filter(([, v]) => {
const parsed = typeof v === 'number' ? { type: 'fraction', value: v } : { type: 'absolute', value: parseFloat(/** @type {string} */(v)) };
return parsed.type !== 'fraction' || parsed.value !== opts.threshold;
});
if (overrides.length > 0) {
const overrideStr = overrides.map(([k, v]) => {
if (typeof v === 'number') {
return `${k}: ${(v * 100).toFixed(0)}%`;
}
return `${k}: ${v}`;
}).join(', ');
lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% (${overrideStr}) |`);
} else {
lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% |`);
}
lines.push(`| **Scenarios** | ${scenarios.length} |`);
lines.push(`| **Platform** | ${jsonReport.platform || 'linux'} / x64 |`);
lines.push('');
// -- Overview table --------------------------------------------------
lines.push('## Overview');
lines.push('');
lines.push('| Scenario | TTFT | Complete | Layouts | Styles | LoAF | Verdict |');
lines.push('|----------|-----:|---------:|--------:|-------:|-----:|:-------:|');
for (const scenario of scenarios) {
const verdicts = scenarioVerdicts.get(scenario) || [];
const get = (/** @type {string} */ m) => verdicts.find(v => v.metric === m);
const ttft = get('timeToFirstToken');
const complete = get('timeToComplete');
const layouts = get('layoutCount');
const styles = get('recalcStyleCount');
const loaf = get('longAnimationFrameCount');
const fmtCell = (/** @type {{ change: number } | undefined} */ v) => {
if (!v) { return '\u2014'; }
return `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(0)}%`;
};
const keyVerdicts = [ttft, complete, layouts, styles, loaf].filter(Boolean);
const hasRegression = keyVerdicts.some(v => v?.verdict === 'REGRESSION');
const hasImproved = keyVerdicts.some(v => v?.verdict === 'improved');
const rowVerdict = hasRegression ? '\u274C' : hasImproved ? '\u2B06\uFE0F' : '\u2705';
lines.push(`| ${scenario} | ${fmtCell(ttft)} | ${fmtCell(complete)} | ${fmtCell(layouts)} | ${fmtCell(styles)} | ${fmtCell(loaf)} | ${rowVerdict} |`);
}
lines.push('');
// -- Regressions & improvements (compact table) ----------------------
const notableRows = [];
for (const scenario of scenarios) {
const verdicts = scenarioVerdicts.get(scenario) || [];
for (const v of verdicts) {
if (v.verdict === 'REGRESSION' || v.verdict === 'improved') {
notableRows.push({ scenario, ...v });
}
}
}
if (notableRows.length > 0) {
lines.push('## Regressions & Improvements');
lines.push('');
lines.push('| Scenario | Metric | Baseline | Test | Change | p-value | |');
lines.push('|----------|--------|----------|------|-------:|--------:|:-:|');
for (const r of notableRows) {
const pct = `${r.change > 0 ? '+' : ''}${(r.change * 100).toFixed(1)}%`;
const icon = r.verdict === 'REGRESSION' ? '\u274C' : '\u2B06\uFE0F';
lines.push(`| ${r.scenario} | ${r.metric} | ${r.basStr} | ${r.curStr} | ${pct} | ${r.pValue} | ${icon} |`);
}
lines.push('');
}
// -- Full details (collapsible) --------------------------------------
lines.push('<details><summary>Full metric details per scenario</summary>');
lines.push('');
for (const scenario of scenarios) {
const verdicts = scenarioVerdicts.get(scenario) || [];
const base = baseline?.scenarios?.[scenario];
lines.push(`### ${scenario}`);
lines.push('');
if (!base) {
const current = jsonReport.scenarios[scenario];
lines.push('> No baseline data for this scenario.');
lines.push('');
lines.push('| Metric | Value | StdDev | CV | n |');
lines.push('|--------|------:|-------:|---:|--:|');
for (const [metric, group, unit] of allMetrics) {
const cur = current[group]?.[metric];
if (!cur) { continue; }
lines.push(`| ${metric} | ${cur.median}${unit} | \xb1${cur.stddev}${unit} | ${(cur.cv * 100).toFixed(0)}% | ${cur.n} |`);
}
lines.push('');
continue;
}
lines.push('| Metric | Baseline | Test | Change | p-value | Verdict |');
lines.push('|--------|----------|------|--------|---------|---------|');
for (const v of verdicts) {
const pct = `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(1)}%`;
let verdictDisplay = v.verdict;
if (v.verdict === 'REGRESSION') { verdictDisplay = '\u274C REGRESSION'; }
else if (v.verdict === 'improved') { verdictDisplay = '\u2B06\uFE0F improved'; }
else if (v.verdict === 'ok') { verdictDisplay = '\u2705 ok'; }
else if (v.verdict === 'noise') { verdictDisplay = '\uD83C\uDF2B\uFE0F noise'; }
else if (v.verdict === 'info') { verdictDisplay = '\u2139\uFE0F'; }
lines.push(`| ${v.metric} | ${v.basStr} | ${v.curStr} | ${pct} | ${v.pValue} | ${verdictDisplay} |`);
}
lines.push('');
}
lines.push('</details>');
lines.push('');
// -- Raw run data (collapsible) --------------------------------------
lines.push('<details><summary>Raw run data</summary>');
lines.push('');
for (const scenario of scenarios) {
const current = jsonReport.scenarios[scenario];
lines.push(`### ${scenario}`);
lines.push('');
lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) |');
lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|');
const runs = current.rawRuns || [];
for (let i = 0; i < runs.length; i++) {
const r = runs[i];
lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${round2(r.longAnimationFrameTotalMs ?? 0) || '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} |`);
}
lines.push('');
}
if (baseline) {
for (const scenario of scenarios) {
const base = baseline.scenarios?.[scenario];
if (!base) { continue; }
lines.push(`### ${scenario} (baseline)`);
lines.push('');
lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) |');
lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|');
const runs = base.rawRuns || [];
for (let i = 0; i < runs.length; i++) {
const r = runs[i];
lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${round2(r.longAnimationFrameTotalMs ?? 0) || '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} |`);
}
lines.push('');
}
}
lines.push('</details>');
lines.push('');
return lines.join('\n');
}
// -- Main --------------------------------------------------------------------
function main() {
const opts = parseArgs();
const merged = mergeResults(opts.resultsDir);
if (!merged) {
const fallback = '\u26A0\uFE0F No perf results found to merge. Check perf-output.log artifacts.\n';
fs.writeFileSync(opts.output, fallback);
console.log('[merge] No results found.');
process.exit(0);
}
const { report, baseline, baselineBuildVersion } = merged;
const scenarioCount = Object.keys(report.scenarios).length;
console.log(`[merge] Merged ${scenarioCount} scenarios from ${fs.readdirSync(opts.resultsDir).filter(d => d.startsWith('perf-results-')).length} groups`);
if (baseline) {
console.log(`[merge] Baseline: ${baselineBuildVersion || 'unknown'} (${Object.keys(baseline.scenarios).length} scenarios)`);
}
const summary = generateUnifiedSummary(report, baseline, {
threshold: merged.threshold || opts.threshold,
metricThresholds: merged.metricThresholds,
runs: report.runsPerScenario,
baselineBuild: baselineBuildVersion,
build: process.env.TEST_COMMIT || undefined,
});
// Append leak summary if available
let fullSummary = summary;
if (opts.leakSummary && fs.existsSync(opts.leakSummary)) {
fullSummary += '\n' + fs.readFileSync(opts.leakSummary, 'utf-8');
console.log('[merge] Appended leak summary');
}
fs.writeFileSync(opts.output, fullSummary);
console.log(`[merge] Summary written to ${opts.output}`);
}
main();
@@ -0,0 +1,466 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// @ts-check
/**
* Chat memory leak checker — state-based approach.
*
* The idea: if you return to the same state you started from, memory should
* return to roughly the same level. Any residual growth is a potential leak.
*
* Each iteration:
* 1. Open a fresh chat (baseline state)
* 2. Measure heap + DOM nodes
* 3. Cycle through ALL registered perf scenarios (text, code blocks,
* tool calls, thinking, multi-turn, etc.)
* 4. Open a new chat (return to baseline state — clears previous session)
* 5. Measure heap + DOM nodes again
* 6. The delta is the "leaked" memory for that iteration
*
* Multiple iterations let us detect consistent leaks vs. one-time caching.
*
* Usage:
* npm run perf:chat-leak # defaults from config
* npm run perf:chat-leak -- --iterations 5 # more iterations
* npm run perf:chat-leak -- --threshold 5 # 5MB total threshold
* npm run perf:chat-leak -- --build 1.115.0 # test a specific build
*/
const fs = require('fs');
const path = require('path');
const {
DATA_DIR, loadConfig,
resolveBuild, buildEnv, buildArgs, prepareRunDir,
launchVSCode,
} = require('./common/utils');
const {
CONTENT_SCENARIOS, TOOL_CALL_SCENARIOS, MULTI_TURN_SCENARIOS,
} = require('./common/perf-scenarios');
const {
getUserTurns, getModelTurnCount,
} = require('./common/mock-llm-server');
// -- Config (edit config.jsonc to change defaults) ---------------------------
const CONFIG = loadConfig('memLeaks');
// -- CLI args ----------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
iterations: CONFIG.iterations ?? 3,
messages: CONFIG.messages ?? 5,
verbose: false,
ci: false,
/** @type {string | undefined} */
build: undefined,
leakThresholdMB: CONFIG.leakThresholdMB ?? 5,
/** @type {Record<string, any>} */
settingsOverrides: {},
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--iterations': opts.iterations = parseInt(args[++i], 10); break;
case '--messages': case '-n': opts.messages = parseInt(args[++i], 10); break;
case '--verbose': opts.verbose = true; break;
case '--ci': opts.ci = true; break;
case '--build': case '-b': opts.build = args[++i]; break;
case '--threshold': opts.leakThresholdMB = parseFloat(args[++i]); break;
case '--setting': {
const kv = args[++i];
const eq = kv.indexOf('=');
if (eq === -1) { console.error(`--setting requires key=value, got: ${kv}`); process.exit(1); }
const key = kv.slice(0, eq);
const raw = kv.slice(eq + 1);
const val = raw === 'true' ? true : raw === 'false' ? false : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
opts.settingsOverrides[key] = val;
break;
}
case '--help': case '-h':
console.log([
'Chat memory leak checker (state-based)',
'',
'Options:',
' --iterations <n> Number of open→work→reset cycles (default: 3)',
' --messages <n> Messages to send per iteration (default: 5)',
' --ci CI mode: write Markdown summary to ci-summary.md',
' --build <path|ver> Path to VS Code build or version to download',
' --threshold <MB> Max total residual heap growth in MB (default: 5)',
' --setting <k=v> Set a VS Code setting override (repeatable)',
' --verbose Print per-step details',
].join('\n'));
process.exit(0);
}
}
return opts;
}
// -- Scenario list -----------------------------------------------------------
/**
* Build a flat list of scenario IDs to cycle through during leak testing.
* Includes all scenario types: content-only, tool-call, and multi-turn.
*
* Content scenarios exercise varied rendering (code blocks, markdown, etc.).
* Tool-call scenarios exercise the agent loop (model → tool → model → ...).
* Multi-turn scenarios exercise user follow-ups and thinking blocks.
*/
function getScenarioIds() {
return [
...Object.keys(CONTENT_SCENARIOS),
...Object.keys(TOOL_CALL_SCENARIOS),
...Object.keys(MULTI_TURN_SCENARIOS),
];
}
// -- Helpers -----------------------------------------------------------------
const CHAT_VIEW = 'div[id="workbench.panel.chat"]';
const CHAT_EDITOR_SEL = `${CHAT_VIEW} .interactive-input-part .monaco-editor[role="code"]`;
/**
* Measure heap (MB) and DOM node count after forced GC.
* @param {any} cdp
* @param {import('playwright').Page} page
*/
async function measure(cdp, page) {
await cdp.send('HeapProfiler.collectGarbage');
await new Promise(r => setTimeout(r, 500));
await cdp.send('HeapProfiler.collectGarbage');
await new Promise(r => setTimeout(r, 300));
const heapInfo = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage'));
const heapMB = Math.round(heapInfo.usedSize / 1024 / 1024 * 100) / 100;
const domNodes = await page.evaluate(() => document.querySelectorAll('*').length);
return { heapMB, domNodes };
}
/**
* Open a new chat session via the command palette.
* @param {import('playwright').Page} page
*/
async function openNewChat(page) {
// Use keyboard shortcut to open a new chat (clears previous session)
const newChatShortcut = process.platform === 'darwin' ? 'Meta+KeyL' : 'Control+KeyL';
await page.keyboard.press(newChatShortcut);
await new Promise(r => setTimeout(r, 1000));
// Verify the chat view is visible and ready
await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 });
await page.waitForFunction(
(sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0),
CHAT_EDITOR_SEL, { timeout: 15_000 },
);
await new Promise(r => setTimeout(r, 500));
}
/**
* Send a single message and wait for the response to complete.
* For multi-turn scenarios where the model makes multiple tool-call rounds
* before producing content, `modelTurns` controls how many completions to
* wait for.
* @param {import('playwright').Page} page
* @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer
* @param {string} text
* @param {number} [modelTurns=1] - number of model completions to wait for
*/
async function sendMessage(page, mockServer, text, modelTurns = 1) {
await page.click(CHAT_EDITOR_SEL);
await new Promise(r => setTimeout(r, 200));
const inputSel = await page.evaluate((editorSel) => {
const ed = document.querySelector(editorSel);
if (!ed) { throw new Error('no editor'); }
return ed.querySelector('.native-edit-context') ? editorSel + ' .native-edit-context' : editorSel + ' textarea';
}, CHAT_EDITOR_SEL);
const hasDriver = await page.evaluate(() =>
// @ts-ignore
!!globalThis.driver?.typeInEditor
).catch(() => false);
if (hasDriver) {
await page.evaluate(({ selector, t }) => {
// @ts-ignore
return globalThis.driver.typeInEditor(selector, t);
}, { selector: inputSel, t: text });
} else {
await page.click(inputSel);
await new Promise(r => setTimeout(r, 200));
await page.locator(inputSel).pressSequentially(text, { delay: 0 });
}
const compBefore = mockServer.completionCount();
await page.keyboard.press('Enter');
try { await mockServer.waitForCompletion(compBefore + modelTurns, 60_000); } catch { }
const responseSelector = `${CHAT_VIEW} .interactive-item-container.interactive-response`;
await page.waitForFunction(
(sel) => {
const responses = document.querySelectorAll(sel);
if (responses.length === 0) { return false; }
return !responses[responses.length - 1].classList.contains('chat-response-loading');
},
responseSelector, { timeout: 30_000 },
);
await new Promise(r => setTimeout(r, 500));
}
/**
* Run a full scenario: send the initial message, then handle any user
* follow-up turns for multi-turn scenarios.
*
* - Content-only scenarios: single message, 1 model turn.
* - Tool-call scenarios (no user turns): single message, N model turns
* (the extension automatically relays tool results back to the model).
* - Multi-turn with user turns: send initial message, wait for response,
* then for each user turn send the follow-up message and wait again.
*
* @param {import('playwright').Page} page
* @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer
* @param {string} scenarioId
* @param {string} label - prefix for the message (e.g. "Warmup" or "Iteration 2")
*/
async function runScenario(page, mockServer, scenarioId, label) {
const userTurns = getUserTurns(scenarioId);
const totalModelTurns = getModelTurnCount(scenarioId);
if (userTurns.length === 0) {
// Content-only or tool-call scenario: one message, wait for all model turns
await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, totalModelTurns);
} else {
// Multi-turn with user follow-ups: send initial message and wait for
// the model turns before the first user turn, then alternate.
let modelTurnsSoFar = 0;
const firstUserAfter = userTurns[0].afterModelTurn;
const turnsBeforeFirstUser = firstUserAfter - modelTurnsSoFar;
await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, turnsBeforeFirstUser);
modelTurnsSoFar = firstUserAfter;
for (let u = 0; u < userTurns.length; u++) {
const nextModelStop = u + 1 < userTurns.length
? userTurns[u + 1].afterModelTurn
: totalModelTurns;
const turnsUntilNext = nextModelStop - modelTurnsSoFar;
// Send the user follow-up message
await sendMessage(page, mockServer, userTurns[u].message, turnsUntilNext);
modelTurnsSoFar = nextModelStop;
}
}
}
// -- Leak check --------------------------------------------------------------
/**
* @param {string} electronPath
* @param {{ url: string, requestCount: () => number, waitForRequests: (n: number, ms: number) => Promise<void>, completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise<void> }} mockServer
* @param {{ iterations: number, verbose: boolean, settingsOverrides?: Record<string, any> }} opts
*/
async function runLeakCheck(electronPath, mockServer, opts) {
const { iterations, verbose } = opts;
const { userDataDir, extDir, logsDir } = prepareRunDir('leak-check', mockServer, opts.settingsOverrides);
const isDevBuild = !electronPath.includes('.vscode-test');
const vscode = await launchVSCode(
electronPath,
buildArgs(userDataDir, extDir, logsDir, { isDevBuild }),
buildEnv(mockServer, { isDevBuild }),
{ verbose },
);
const page = vscode.page;
try {
await page.waitForSelector('.monaco-workbench', { timeout: 60_000 });
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.enable');
// Open chat panel
const chatShortcut = process.platform === 'darwin' ? 'Control+Meta+KeyI' : 'Control+Alt+KeyI';
await page.keyboard.press(chatShortcut);
await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 });
await page.waitForFunction(
(sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0),
CHAT_EDITOR_SEL, { timeout: 15_000 },
);
// Wait for extension activation
const reqsBefore = mockServer.requestCount();
try { await mockServer.waitForRequests(reqsBefore + 4, 30_000); } catch { }
await new Promise(r => setTimeout(r, 3000));
const scenarioIds = getScenarioIds();
// --- Baseline measurement (fresh chat) ---
const baseline = await measure(cdp, page);
if (verbose) {
console.log(` [leak] Baseline: heap=${baseline.heapMB}MB, domNodes=${baseline.domNodes}`);
}
/** @type {{ beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[]} */
const iterationResults = [];
for (let iter = 0; iter < iterations; iter++) {
// Measure at start of iteration (should be in "clean" state)
const before = await measure(cdp, page);
if (verbose) {
console.log(` [leak] Iteration ${iter + 1}/${iterations}: start heap=${before.heapMB}MB, domNodes=${before.domNodes}`);
}
// Do work: cycle through all scenarios
for (let m = 0; m < scenarioIds.length; m++) {
const sid = scenarioIds[m];
await runScenario(page, mockServer, sid, `Iteration ${iter + 1}`);
if (verbose) {
console.log(` [leak] Sent ${sid} (${m + 1}/${scenarioIds.length})`);
}
}
// Return to clean state: open a new empty chat
await openNewChat(page);
await new Promise(r => setTimeout(r, 1000));
// Measure after returning to clean state
const after = await measure(cdp, page);
const deltaHeapMB = Math.round((after.heapMB - before.heapMB) * 100) / 100;
const deltaDomNodes = after.domNodes - before.domNodes;
iterationResults.push({
beforeHeapMB: before.heapMB,
afterHeapMB: after.heapMB,
deltaHeapMB,
beforeDomNodes: before.domNodes,
afterDomNodes: after.domNodes,
deltaDomNodes,
});
if (verbose) {
console.log(` [leak] Iteration ${iter + 1}/${iterations}: end heap=${after.heapMB}MB (delta=${deltaHeapMB}MB), domNodes=${after.domNodes} (delta=${deltaDomNodes})`);
}
}
// Final measurement
const final = await measure(cdp, page);
const totalResidualMB = Math.round((final.heapMB - baseline.heapMB) * 100) / 100;
const totalResidualNodes = final.domNodes - baseline.domNodes;
return {
baseline,
final: { heapMB: final.heapMB, domNodes: final.domNodes },
totalResidualMB,
totalResidualNodes,
iterations: iterationResults,
};
} finally {
await vscode.close();
}
}
// -- Main --------------------------------------------------------------------
async function main() {
const opts = parseArgs();
const electronPath = await resolveBuild(opts.build);
if (!fs.existsSync(electronPath)) {
console.error(`Electron not found at: ${electronPath}`);
process.exit(1);
}
const { startServer } = require('./common/mock-llm-server');
const { registerPerfScenarios } = require('./common/perf-scenarios');
registerPerfScenarios();
const mockServer = await startServer(0);
console.log(`[chat-simulation] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB total`);
console.log(`[chat-simulation] Build: ${electronPath}`);
console.log('');
const result = await runLeakCheck(electronPath, mockServer, opts);
console.log('[chat-simulation] =================== Leak Check Results ===================');
console.log('');
console.log(` Baseline: heap=${result.baseline.heapMB}MB, domNodes=${result.baseline.domNodes}`);
console.log(` Final: heap=${result.final.heapMB}MB, domNodes=${result.final.domNodes}`);
console.log('');
for (let i = 0; i < result.iterations.length; i++) {
const it = result.iterations[i];
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)`);
}
console.log('');
console.log(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB`);
console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`);
console.log('');
// Write JSON
const jsonPath = path.join(DATA_DIR, 'chat-simulation-leak-results.json');
fs.writeFileSync(jsonPath, JSON.stringify({
timestamp: new Date().toISOString(),
leakThresholdMB: opts.leakThresholdMB,
iterationCount: opts.iterations,
scenarioCount: getScenarioIds().length,
...result,
}, null, 2));
console.log(`[chat-simulation] Results written to ${jsonPath}`);
const leaked = result.totalResidualMB > opts.leakThresholdMB;
console.log('');
if (leaked) {
console.log(`[chat-simulation] LEAK DETECTED — ${result.totalResidualMB}MB residual exceeds ${opts.leakThresholdMB}MB threshold`);
} else {
console.log(`[chat-simulation] No leak detected (${result.totalResidualMB}MB residual < ${opts.leakThresholdMB}MB threshold)`);
}
if (opts.ci) {
const summary = generateLeakCISummary(result, opts);
const summaryPath = path.join(DATA_DIR, 'ci-summary-leak.md');
fs.writeFileSync(summaryPath, summary);
console.log(`[chat-simulation] CI summary written to ${summaryPath}`);
}
await mockServer.close();
process.exit(leaked ? 1 : 0);
}
/**
* Generate a Markdown summary for CI, matching the perf script pattern.
* @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
* @param {{ leakThresholdMB: number, iterations: number }} opts
*/
function generateLeakCISummary(result, opts) {
const leaked = result.totalResidualMB > opts.leakThresholdMB;
const verdict = leaked ? '\u274C **LEAK DETECTED**' : '\u2705 **No leak detected**';
const lines = [];
lines.push('## Memory Leak Check');
lines.push('');
lines.push('| | |');
lines.push('|---|---|');
lines.push(`| **Verdict** | ${verdict} |`);
lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB |`);
lines.push(`| **Iterations** | ${opts.iterations} |`);
lines.push(`| **Scenarios per iteration** | ${getScenarioIds().length} |`);
lines.push('');
lines.push('| Phase | Heap (MB) | DOM Nodes |');
lines.push('|-------|----------:|----------:|');
lines.push(`| Baseline | ${result.baseline.heapMB} | ${result.baseline.domNodes} |`);
for (let i = 0; i < result.iterations.length; i++) {
const it = result.iterations[i];
const sign = it.deltaHeapMB > 0 ? '+' : '';
const domSign = it.deltaDomNodes > 0 ? '+' : '';
lines.push(`| Iteration ${i + 1} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`);
}
lines.push(`| **Final** | **${result.final.heapMB}** | **${result.final.domNodes}** |`);
lines.push('');
const sign = result.totalResidualMB > 0 ? '+' : '';
const domSign = result.totalResidualNodes > 0 ? '+' : '';
lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes`);
lines.push('');
return lines.join('\n');
}
main().catch(err => { console.error(err); process.exit(1); });
File diff suppressed because it is too large Load Diff