Quickstart
Get a leak report
Three ways in, depending on whether you can edit the code being measured, want to drive a browser from a script, or want an agent to do it.
The census only counts what it sees enter a context. Anything allocated before it installs is invisible to it. Install as early as you can — which is exactly why the CDP driver exists: it gets in before a worker's first line, which nothing running inside the page can do.
1. From your own code
Reach for this when you can edit the app or the test harness.
npm install --save-dev @motionvector/webcodecs-census
Install it at the top of every context that touches media — the main thread and each worker:
import { installCensus, localCensus } from '@motionvector/webcodecs-census';
installCensus({ context: 'decoder-worker' });
Then ask what is still open:
const census = localCensus();
// {
// live: { VideoFrame: 58, VideoDecoder: 1 },
// entered: { 'VideoFrame:decoded': 238, 'VideoDecoder:constructed': 1 },
// left: { 'VideoFrame:closed': 179 },
// leakSites: [ { count: 58, type: 'VideoFrame', origin: 'decoded', stack, oldestAgeMs } ],
// collectedUnclosed: { VideoFrame: 1 },
// mediaElements: { total: 4, stalled: 1, byReadyState: { 0: 1, 4: 3 } },
// timeline: [ … ],
// problems: [],
// }
localCensus() covers only the context it runs in. A worker's
census has to be collected in that worker and carried back to whoever is asking — the CDP
driver does this for you by querying each target on its own session.
2. From outside the app
No change to the code being measured, and workers are instrumented before their first line.
npm install --save-dev @motionvector/webcodecs-census-cdp
import { attach, launchChrome } from '@motionvector/webcodecs-census-cdp';
import { summarize } from '@motionvector/webcodecs-census';
const chrome = await launchChrome({ executablePath: CHROME });
const session = await attach({ browserURL: chrome.browserURL });
await session.navigate('http://localhost:5173/');
await session.evaluate('document.querySelector("#play").click()');
// …let it run…
console.log(summarize(await session.census()));
session.detach();
await chrome.kill();
To attach to a browser you already started with --remote-debugging-port, pass
its endpoint instead:
const session = await attach({ browserURL: 'http://127.0.0.1:9222' });
Full options on the CDP driver page.
3. From an agent
Register the MCP server with whatever runs your agent:
{
"mcpServers": {
"webcodecs-census": {
"command": "npx",
"args": ["-y", "@motionvector/webcodecs-census-mcp"]
}
}
}
webcodecs_attach { executablePath: "/path/to/chrome", url: "http://localhost:5173/" }
→ Instrumented 3 context(s): page, worker, worker
webcodecs_census
→ worker (20s): VideoDecoder=1 VideoFrame=58
1 VideoFrame garbage collected without close() — definitively leaked.
webcodecs_leak_sites { type: "VideoFrame" }
→ 58x VideoFrame — decoded, oldest 124609ms, in worker
at PackagerWorker.setupDecoder (worker.js:1756:21)
(frame emitted by this VideoDecoder)
Tool-by-tool detail on the MCP server page.
Reading the report
These mean different things, and the difference is worth internalising.
| Signal | What it means |
|---|---|
live |
Still open right now. Could be a leak, could be a pipeline mid-flight. Judge it against how long they have been live and how many you expect. |
collectedUnclosed |
Garbage collected while still open. The resource was held for the object's whole lifetime and nothing will ever release it. Not a heuristic — a leak. |
overCloses (v0.3.0) |
A close() that threw, because something closed an already-closed codec.
A lifecycle bug, not a leak — reported, and only fatal if you ask for it.
|
That is why collectedUnclosed fails a check for any tracked type, whatever you
passed for types — and why an over-close does not. A codec the platform closed
after an error is not counted as either. See the API reference.
Making it a test
import { expectNoLeakedFrames } from '@motionvector/webcodecs-census';
test('the editor releases every frame it decodes', async () => {
await playThroughTimeline();
expectNoLeakedFrames(await session.census());
});
It throws with the allocation sites attached. CI recipes covers running this against a real Chrome on a runner.