Test CPU profiling & leak detection
SkillFiles & storageCPU-profile or leak-detect an existing vitest suite via env-gated instrumentation — DX_PROFILE_TESTS emits a V8 .cpuprofile, DX_DEBUG_LEAKS writes before/after heap snapshots plus a per-test heapUsed slope, with zero edits to the test file. Use when a suite is slow, memory grows across a run, or you need to find a hotspot/leak in node tests (echo/halo/mesh/sdk/composer/compute, incl. assistant + agentService).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Test CPU profiling & leak detection skill
What this skill tells your AI
The instructions your AI receives, as published by dxos/dxos in .agents/skills/test-perf-leaks/SKILL.md and read by ahel’s review.
Two opt-in env vars instrument the shared node vitest project (createNodeProject
in vite.base.config.ts). Unset → the config is byte-identical to a normal run, so
CI and everyday runs are unaffected. You point them at an existing suite; nothing
in the test file changes.
DX_PROFILE_TESTS[=dir]— emit a V8.cpuprofilevia Node--cpu-prof(dir default./profiles).DX_DEBUG_LEAKS— injecttools/vitest/leak-setup.ts:before/afterheap snapshots (warmed baseline, forced GC) + a per-testheapUsedsample inheap-samples.ndjson.DX_DEBUG_LEAKS_DIR— override the leak output dir (default./profiles).
Both force a single non-isolated fork (pool: 'forks', isolate:false,
fileParallelism:false, maxWorkers:1) so --cpu-prof / --expose-gc apply to the
thread the tests run on — under the default worker-per-file isolation they would not.
This works because vitest evaluates a setupFiles entry in the same isolate/heap as
the test file it precedes: the afterEach/afterAll that leak-setup.ts registers wrap
the real tests in the same realm and process, so process.memoryUsage() and
writeHeapSnapshot() observe exactly the heap the tests allocated in. (Verified: the
sampler sees the tests' globalThis mutations and their heap growth under one shared pid.)
Run it
Always pass --force. The env vars are declared as moon task inputs, so toggling
one busts the cache — but a repeat run with identical inputs would hydrate the cached
result and skip vitest, producing no fresh profile. --force re-executes unconditionally.
# CPU profile one suite → packages/<…>/<pkg>/profiles/CPU.*.cpuprofile
DX_PROFILE_TESTS=1 moon run <pkg>:test --force -- src/foo.test.ts
# Leak-check one suite → profiles/{before,after}.heapsnapshot + heap-samples.ndjson
DX_DEBUG_LEAKS=1 moon run <pkg>:test --force -- src/foo.test.ts
- Point at ONE
.test.tsfile. The leak model assumes a single suite in one process — one clean before/after, no "which file ran last?" ambiguity. The-- <file>filter works because the node test task forwards passthrough args to vitest (bash -c '… "$@"' --). <pkg>is the package directory name (moon project id), e.g.echo,credentials,messaging,app-graph,plugin-markdown,compute-runtime,assistant,agent-runtime.- Artifacts land relative to the fork cwd = the package dir, i.e.
packages/<…>/<pkg>/profiles/. They are gitignored (*.cpuprofile,*.heapsnapshot,heap-samples.ndjson,profiles/). - Pick a suite first with
--logHeapUsage(cheap) to see which one grows.
Read the CPU profile
Open the .cpuprofile in Chrome DevTools → Performance → load profile, or
speedscope. Look at self time: separate real product
hotspots from vitest runner/transform overhead (this approach profiles the whole process,
so the runner is in the trace — that's the tradeoff for needing zero code).
Read the leak snapshots
- Slope first.
heap-samples.ndjsonhas one{test, heapUsed}line per test, taken aftersettle()(GC ×3). The baseline is recorded after the first test, not at process start — first-run lazy init (module singletons, V8 code compilation, string interning) otherwise looks identical to a leak. A real leak is a monotonic rise across the suite; a flat/noisy line after test 1 is not a leak. - Then the diff. Load
before.heapsnapshotandafter.heapsnapshotin DevTools → Memory → load, select theaftersnapshot, and switch the dropdown to Comparison againstbefore. Read per-constructor deltas (# New,# Deleted,Size Delta) and the retainer chains of the growers to find what holds them. - Amplification comes from the suite's own test count (vitest 4 dropped config
repeats), so a many-test file gives a longer slope.heap-samples.ndjsonis truncated at the start of each run, so slopes never mix captures.
Triage
- Not a leak: growth confined to test 1 (lazy init), or fixtures/mocks the suite intentionally accumulates and never tears down. Note it and move on.
- A leak: a constructor whose count/size climbs with the slope and whose retainer
chain points at product code (a registry, cache, listener set,
FinalizationRegistrythat never fires, un-disposedContext). Fix at the source or file an issue with the retainer chain attached.
When NOT to use this
- Browser/storybook/workerd suites — this is node-only (
createNodeProject). The same moon-passthrough gap exists ontest-browser/test-workerdbut the instrumentation is not wired there. - Profiling the running Composer app (not tests) — that is the
memory-usageproject's domain; use the app-side heap tooling instead.
Reference
vite.base.config.ts(createNodeProject) — the env-gated block.tools/vitest/leak-setup.ts— the injected snapshot/sampling harness..moon/tasks/tag-ts-test.yml— env-var inputs + the"$@"passthrough..agents/projects/test-profiling-leaks/DESIGN.md— decisions, vitest-4 deltas, caveats.
Signals
- GitHub stars
- 520
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
test-perf-leaks- Source
- github.com/dxos/dxos