Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ AGENTS.local.md
.vscode-test/
apps/vscode-e2e/playwright-report-electron/
apps/vscode-e2e/test-results-electron/
apps/vscode-e2e/heap-results/

# Docs
docs/_site/
Expand Down
4 changes: 4 additions & 0 deletions apps/vscode-e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,7 @@ Always restore the default OpenRouter config in `suiteTeardown` so subsequent su
## Programmatic fixtures (regex matching)

For requests that can't be matched by a stable substring (e.g. "starts with `<environment_details>` but not preceded by a user message"), add a programmatic fixture in `src/runTest.ts` using `mock.addFixture()` with a `RegExp` match. These are only available in replay mode and are not recorded.

## Webview heap diagnostic

The opt-in suite under `src/heap/` measures retained renderer heap through real VS Code task hydration and switching. See [src/heap/README.md](src/heap/README.md) for build, run, and summarize commands. Keep base and candidate host builds identical, use isolated profiles, and preserve cleanup on failure. Heap snapshots and generated measurements stay in the git-ignored `heap-results/` directory. This diagnostic has no CI memory threshold and does not replace focused webview tests.
5 changes: 4 additions & 1 deletion apps/vscode-e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
"themes:test": "pnpm --filter @roo-code/types build && rimraf out && tsc -p tsconfig.json && node --test ./out/theme-fixtures/fixtures.test.js",
"visual:build": "pnpm themes:build",
"test:visual:electron": "pnpm visual:build && playwright test -c playwright-electron.config.ts",
"clean": "rimraf out .turbo"
"clean": "rimraf out .turbo",
"heap:build": "pnpm -w bundle && tsc -p tsconfig.json && node out/heap/prepare.js",
"test:heap": "playwright test -c playwright-heap.config.ts",
"heap:summarize": "node out/heap/summarize.js"
},
"devDependencies": {
"@playwright/test": "1.62.1",
Expand Down
16 changes: 16 additions & 0 deletions apps/vscode-e2e/playwright-heap.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import path from "path"
import { defineConfig } from "@playwright/test"

const results = process.env.HEAP_BENCHMARK_DIR ?? path.resolve(__dirname, "heap-results")
export default defineConfig({
testDir: "./src/heap",
testMatch: "electron.heap.ts",
outputDir: path.join(results, "runs"),
fullyParallel: false,
workers: 1,
repeatEach: Number(process.env.HEAP_REPEATS ?? 3),
timeout: 5 * 60_000,
expect: { timeout: 90_000 },
projects: [{ name: "base" }, { name: "candidate" }],
reporter: [["list"], ["json", { outputFile: path.join(results, "playwright.json") }]],
})
60 changes: 60 additions & 0 deletions apps/vscode-e2e/src/heap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Webview heap benchmark

See [RESULTS.md](./RESULTS.md) for the recorded comparison and its retention findings.

This opt-in diagnostic runs real VS Code, the extension host, task-history hydration,
extension/webview messaging and the production React webview. The model is a local
mock. It does not call a paid API or mutate your normal VS Code profile.

```sh
pnpm --dir apps/vscode-e2e heap:build
xvfb-run -a pnpm --dir apps/vscode-e2e test:heap
pnpm --dir apps/vscode-e2e heap:summarize
```

`heap:build` creates isolated base and candidate webview builds in `heap-results/builds`.
The extension host is identical for both. Base replaces `webview-ui/src/context/ExtensionStateContext.tsx`
with its version at `git merge-base HEAD origin/main`.
Override that reference with `HEAP_BASE_REF`. Source maps are disabled for both builds
to reduce build memory; production minification is preserved. Source files in your
checkout are untouched.

Each repetition creates two tasks through the exported API, evicts them, and stops
VS Code. With no extension host alive, it replaces the large task's persisted UI
transcript with 602 deterministic messages:
300 completed command/output pairs plus a title and completion. Each output contains
256 log lines (roughly 17 KiB). `repeated` uses 32 distinct output texts; `unique` uses 300. The isolated profile is restarted, and the normal task-resume path reads and posts
the transcript. This represents a
reproducible large saved task, not a recording of a human or live model session.

The driver waits for task-resume events, extension readiness files and rendered
completion markers. It requests a normal full-state refresh after each resume. It
then waits for fonts and two animation frames, forces GC through CDP attached to the
Zoo Code content frame, records `Runtime.getHeapUsage`, and streams a heap snapshot
to disk. This happens with no task loaded, with the large task loaded, and after
switching to a small task. A final snapshot can expose strings retained after the
task transition. This measures the webview renderer isolate, not extension-host heap
or whole-process RSS. External HTTP traffic is blocked (only the local mock model is reachable).
Playwright tracing is disabled to avoid retaining captured DOM
snapshots and message text during measurement.

The default is three fresh sessions per workload and variant (12 sessions). There is
no pass/fail memory threshold. Inspect distributions, baseline-adjusted differences,
and retaining paths before drawing conclusions. The summarizer reports median heap
sizes and counts large output string nodes in the loaded and switched snapshots.
It does not infer cache ownership solely from a surviving string.

Artifacts are ignored by git under `apps/vscode-e2e/heap-results/`: build provenance,
per-run `measurement.json`, three `.heapsnapshot` files, `summary.json` and `summary.md`.
Load snapshots in Chrome DevTools' Memory panel for retaining-path analysis. Temporary
profiles, workspaces and extension directories are removed on both success and failure;
failure logs and screenshots are copied to the run artifacts first.

Optional environment variables (use the same `HEAP_BENCHMARK_DIR` for all three commands):

- `HEAP_BENCHMARK_DIR`: absolute artifact/build directory.
- `HEAP_BASE_REF`: base commit or ref used by the comparison build.
- `HEAP_REPEATS`: repetitions per workload and variant (default 3).
- `HEAP_VSCODE_EXECUTABLE`: existing VS Code executable; otherwise download the pinned version.

For a smoke run: `HEAP_REPEATS=1 ... test:heap --project=candidate --grep=repeated`.
83 changes: 83 additions & 0 deletions apps/vscode-e2e/src/heap/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Webview heap results — 2026-10-27

Measured with real VS Code 1.100.0 under Xvfb, using CDP forced GC on the Zoo Code
webview renderer target. These are deterministic saved-task workloads loaded through
the real extension host and task-resume path, not an organic human/model session.
The extension host, dependencies and styles are identical between variants. Both
webview builds use production minification without source maps. External network
requests are blocked except for the local mock model used to create the seed tasks.

- Base webview modules: `97228c7ca9b032e5fe466e57e24fe72fbf405192`.
- Candidate webview modules: `b9a6ba03cb93c0f92f06cfb00c82ae7d488db7cb`.
- Three fresh measurement sessions per workload and variant: 12 successful samples.
- Each large task has 602 UI messages, including 300 command/output pairs. Repeated
uses 32 distinct large output strings; unique uses 300. Each output is 16.4–16.9 KiB.
A full-state refresh after
hydration exercises reuse across successive deserialized state snapshots.

These hashes identify the historical measured builds, not the current branch head.
The measurements below have not been rerun for subsequent review fixes.

Median forced-GC used heap, in MiB:

| Workload | Variant | Empty | Large task loaded | Loaded − empty | After switching to small task |
| -------- | --------- | ----: | ----------------: | -------------: | ----------------------------: |
| Repeated | Base | 26.46 | 41.42 | 14.71 | 36.19 |
| Repeated | Candidate | 26.57 | 31.75 | 5.19 | 31.85 |
| Unique | Base | 26.78 | 41.58 | 14.80 | 36.27 |
| Unique | Candidate | 26.57 | 36.63 | 10.07 | 36.26 |

`Loaded − empty` is the median of each session’s loaded-minus-empty difference;
it need not equal the difference of the two column medians.

Loaded heap is approximately **9.67 MiB lower (23%)** for repeated outputs and
**4.95 MiB lower (12%)** for unique outputs. Baseline-adjusted task growth falls by
about 9.52 MiB and 4.73 MiB respectively. The unique workload still benefits from
sharing unchanged message strings across full-state pushes via `Object.assign`,
even after FIFO eviction. The cache can retain only about 120 strings of 16 KiB
within its 2M-character budget. Repeated outputs fit within that budget and benefit
from cache interning as well. These mechanisms explain the observed difference,
but this comparison does not isolate their individual contributions. These results do not
measure CPU overhead, image-rich messages, or continuously streamed task execution.

Loaded-heap ranges across the three sessions:

| Workload | Base MiB | Candidate MiB |
| -------- | ----------- | ------------- |
| Repeated | 40.96–41.42 | 31.75–32.23 |
| Unique | 41.14–41.59 | 36.62–36.64 |

## Task-switch retention

The candidate's StringCache maps retain **zero old output strings after switching**
in all six candidate samples. Before switching, the message cache contains 32
output strings in the repeated case and 115 in the unique case, consistent with its
character budget. The history cache contains no tool outputs.

However, old task output strings remain reachable elsewhere in **both** variants:
300 output string nodes on base, versus 32 repeated or 300 unique on candidate.
A shortest strong retaining path inspected in both switched snapshots runs through:

```text
webview Window → DOM node → __reactProps… → children → props
→ onJumpToPreviousCheckpoint → closure contexts → message array → message.text
```

This confirms cache cleanup, but does **not** establish complete task-memory release.
The callback/React-props retaining path needs a separate investigation; this benchmark
only measures the observed post-switch state and does not establish indefinite growth.
No forced remounts or additional product behavior changes were introduced here.

## Evidence and reproduction

See [README.md](./README.md) for commands and methodology. The raw artifacts are local, git-ignored evidence and are not distributed with this
report; reproducing the run is required to independently inspect them. The ignored
`apps/vscode-e2e/heap-results/` directory contains the 36 successful snapshots,
per-run measurements, build provenance, generated summary, cache-map inventory,
and representative base/candidate retaining paths under `analysis/`.

The final sample set consists of 11 successful matrix cases and a successful narrow
rerun of the first base case after fixing the diagnostic's resume-readiness check.
The failed setup attempt has no measurement record and is excluded from the summary;
its failure artifacts remain available. Package type checks and ESLint pass with no
new suppressions. No memory threshold is imposed on CI.
205 changes: 205 additions & 0 deletions apps/vscode-e2e/src/heap/electron.heap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import fs from "fs/promises"
import { closeSync, openSync, readFileSync, writeSync } from "fs"
import os from "os"
import path from "path"
import { LLMock } from "@copilotkit/aimock"
import { downloadAndUnzipVSCode } from "@vscode/test-electron"
import { _electron, expect, test, type CDPSession, type ElectronApplication, type Frame } from "@playwright/test"
import { createTranscript, LARGE_TASK_MARKER, SMALL_TASK_MARKER, type Workload } from "./workload"

const packageRoot = path.resolve(__dirname, "../..")
const results = process.env.HEAP_BENCHMARK_DIR ?? path.join(packageRoot, "heap-results")
const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")) as {
devDependencies: Record<string, string>
}

async function waitForFile(file: string): Promise<void> {
await expect
.poll(
() =>
fs.access(file).then(
() => true,
() => false,
),
{ timeout: 120_000 },
)
.toBe(true)
}

async function capture(client: CDPSession, file: string) {
await client.send("HeapProfiler.collectGarbage")
const usage = await client.send("Runtime.getHeapUsage")
const descriptor = openSync(file, "w")
const receive = ({ chunk }: { chunk: string }) => {
writeSync(descriptor, chunk)
}
client.on("HeapProfiler.addHeapSnapshotChunk", receive)
try {
await client.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false })
} finally {
client.off("HeapProfiler.addHeapSnapshotChunk", receive)
closeSync(descriptor)
}
return usage
}

for (const workload of ["repeated", "unique"] satisfies Workload[]) {
test(`webview retained heap: ${workload} tool outputs`, async () => {
const testInfo = test.info()
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-heap-"))
const workspace = path.join(temporaryRoot, "workspace")
const userData = path.join(temporaryRoot, "user-data")
const extensions = path.join(temporaryRoot, "extensions")
await Promise.all([workspace, userData, extensions].map((directory) => fs.mkdir(directory)))
const mock = new LLMock({ port: 0 })
for (const marker of [SMALL_TASK_MARKER, "HEAP_TRANSCRIPT_SEED"]) {
mock.addFixture({
match: { userMessage: marker },
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: `${marker}_COMPLETED` }),
id: `call_${marker}`,
},
],
},
})
}
let app: ElectronApplication | undefined
let client: CDPSession | undefined
try {
await mock.start()
const executablePath =
process.env.HEAP_VSCODE_EXECUTABLE ??
(await downloadAndUnzipVSCode({ version: packageJson.devDependencies["@types/vscode"] }))
const launch = (phase: "seed" | "measure") =>
_electron.launch({
executablePath,
args: [
workspace,
`--extensionDevelopmentPath=${path.join(results, "builds", testInfo.project.name, "src")}`,
`--extensionTestsPath=${path.join(packageRoot, "out/heap/sceneController")}`,
`--user-data-dir=${userData}`,
`--extensions-dir=${extensions}`,
"--disable-workspace-trust",
"--skip-welcome",
"--skip-release-notes",
"--disable-updates",
"--disable-gpu",
"--force-device-scale-factor=1",
],
env: {
...process.env,
AIMOCK_URL: mock.url,
HEAP_RUN_DIR: temporaryRoot,
HEAP_WORKLOAD: workload,
HEAP_PHASE: phase,
HTTP_PROXY: "http://127.0.0.1:9",
HTTPS_PROXY: "http://127.0.0.1:9",
ALL_PROXY: "http://127.0.0.1:9",
NO_PROXY: "127.0.0.1,localhost",
},
locale: "en-US",
timezoneId: "UTC",
timeout: 60_000,
})

app = await launch("seed")
await waitForFile(path.join(temporaryRoot, "seed.ready"))
await fs.writeFile(path.join(temporaryRoot, "seed.close"), "close")
await app.close()
app = undefined
// No extension host is alive while replacing the persisted transcript.
const seed = JSON.parse(await fs.readFile(path.join(temporaryRoot, "seed.ready"), "utf8")) as {
largeTaskId: string
}
await fs.writeFile(
path.join(temporaryRoot, "storage/tasks", seed.largeTaskId, "ui_messages.json"),
JSON.stringify(createTranscript(workload)),
)
app = await launch("measure")
const page = await app.firstWindow()
await app.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.setBounds({ x: 0, y: 0, width: 1200, height: 800 }),
)
await waitForFile(path.join(temporaryRoot, "empty.ready"))
let content: Frame | undefined
await expect
.poll(() => {
const parent = page
.frames()
.find((frame) => frame.url().includes("extensionId=ZooCodeOrganization.zoo-code"))
content = parent?.childFrames().find((frame) => frame.url().includes("/fake.html"))
return Boolean(content)
})
.toBe(true)
if (!content) throw new Error("Zoo Code webview content frame not found")
// The content iframe may share its CDP target with the outer webview.
let targetFrame: Frame | null = content
while (targetFrame) {
try {
client = await app.context().newCDPSession(targetFrame)
break
} catch (error) {
if (!(error instanceof Error) || !error.message.includes("does not have a separate CDP session"))
throw error
targetFrame = targetFrame.parentFrame()
}
}
if (!client) throw new Error("No CDP target found for the webview")
await client.send("HeapProfiler.enable")
const target = await client.send("Target.getTargetInfo")
expect(target.targetInfo.url).toContain("vscode-webview")
const stages: Record<string, unknown> = {}
for (const stage of ["empty", "loaded", "switched"]) {
await waitForFile(path.join(temporaryRoot, `${stage}.ready`))
if (stage !== "empty") {
await expect(
content
.getByText(stage === "loaded" ? LARGE_TASK_MARKER : `${SMALL_TASK_MARKER}_COMPLETED`, {
exact: false,
})
.first(),
).toBeVisible()
}
await content.evaluate(async () => {
await document.fonts.ready
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
)
})
stages[stage] = await capture(client, testInfo.outputPath(`${stage}.heapsnapshot`))
await fs.writeFile(path.join(temporaryRoot, `${stage}.continue`), "continue")
}
await waitForFile(path.join(temporaryRoot, "done"))
const measurement = {
variant: testInfo.project.name,
workload,
repeat: testInfo.repeatEachIndex,
vscode: packageJson.devDependencies["@types/vscode"],
target,
stages,
}
await fs.writeFile(testInfo.outputPath("measurement.json"), JSON.stringify(measurement, null, 2))
await testInfo.attach("measurement", { body: JSON.stringify(measurement), contentType: "application/json" })
} catch (error) {
if (app)
await (
await app.firstWindow()
)
.screenshot({ path: testInfo.outputPath("failure.png") })
.catch(() => undefined)
await fs
.cp(path.join(userData, "logs"), testInfo.outputPath("logs"), { recursive: true })
.catch(() => undefined)
throw error
} finally {
await fs.writeFile(path.join(temporaryRoot, "close"), "close").catch(() => undefined)
await client?.detach().catch(() => undefined)
await app?.close().catch(() => undefined)
await mock.stop().catch(() => undefined)
await fs.rm(temporaryRoot, { recursive: true, force: true })
}
})
}
Loading
Loading