CI recipes
Make a leak a test failure
A leaked frame is easy to ship and hard to notice. Asserted in CI, it becomes a red build on the pull request that introduced it instead of a mystery six months later.
The shape of it
- Install a pinned Chrome for Testing on the runner.
- Serve the app, launch Chrome, attach the census.
- Drive the workload you care about.
- Assert. Fail with the allocation site in the log.
Pin the browser version. The injection path depends on undocumented Chrome behaviour, so an unpinned browser turns a Chrome release into a mysterious failure on an unrelated pull request. This repository pins one version for its blocking job and runs a separate, non-blocking job against stable on a schedule — a browser change is then news, not a blocker.
GitHub Actions
name: leak check
on: [push, pull_request]
jobs:
leaks:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install Chrome for Testing
run: |
# Prints "chrome@<version> <path>"; the path is the last field.
OUT=$(npx --yes @puppeteer/browsers install chrome@151.0.7922.71)
echo "$OUT"
echo "CHROME_PATH=${OUT##* }" >> "$GITHUB_ENV"
- run: npm run build
- run: node --test --test-timeout=120000 test/leaks.test.mjs
Use Node 22. The CDP client uses Node's built-in global WebSocket and imports
no WebSocket library.
The test
import { test, after } from 'node:test';
import { attach, launchChrome } from '@motionvector/webcodecs-census-cdp';
import { expectNoLeaks, summarize } from '@motionvector/webcodecs-census';
const chrome = await launchChrome({
executablePath: process.env.CHROME_PATH,
// Chrome's sandbox needs privileges a CI container usually will not grant,
// and the failure is an opaque early exit rather than a message about it.
args: process.env.CI ? ['--no-sandbox', '--disable-dev-shm-usage'] : [],
});
const session = await attach({ browserURL: chrome.browserURL });
after(async () => {
session.detach();
await chrome.kill();
});
test('the editor releases every frame it decodes', async () => {
await session.navigate('http://127.0.0.1:5173/');
await session.evaluate('window.playThroughTimeline()');
const censuses = await session.census();
console.log(summarize(censuses)); // useful even when it passes
expectNoLeaks(censuses, { types: 'all' });
});
Print the summary whether or not it fails. A passing run that quietly drifts from 2 live frames to 200 is the thing you want to catch before it crosses a threshold.
census.problems lists anything a context could not instrument. Non-empty
means the counts below it are a floor, not a total. Assert on it too, or a build that
instrumented nothing passes for the same reason an app with no leaks does.
for (const c of censuses) assert.deepEqual(c.problems, [], `${c.context} was not fully instrumented`);
Without a driver
If your suite already runs in a browser — a Karma, Vitest browser-mode or Playwright component test — install the core directly and assert on the local census. Remember it covers only the context it runs in, so a worker's census has to be collected inside that worker.
import { installCensus, localCensus, expectNoLeakedFrames } from '@motionvector/webcodecs-census';
installCensus({ context: 'test' });
test('decoding a clip leaks nothing', async () => {
await decodeClip();
expectNoLeakedFrames([localCensus()]);
});
Tuning the threshold
A steady-state pipeline legitimately holds a few objects open. Say so explicitly.
| Want | Do |
|---|---|
| Frames only, which is the usual first gate | expectNoLeakedFrames(censuses) |
| Hold the codecs to the same standard | expectNoLeaks(censuses, { types: 'all' }) |
| Tolerate a small steady-state pool | expectNoLeaks(censuses, { allow: { VideoFrame: 4 } }) |
| Fail when something closes a codec twice (v0.3.0) | expectNoLeaks(censuses, { failOnOverClose: true }) |
| Report rather than fail | const { ok, message } = checkLeaks(censuses) |
Whatever you pass, an object the GC collected while it was still open fails the check, and
a type left out of types is named in the message rather than quietly reported
clean. Both are deliberate — see the API reference.
Reading a failure
Error: 3 VideoFrame garbage collected without close() — definitively leaked.
58 VideoFrame still live (allowed 0).
Not enforced, and still live: VideoDecoder=1.
Held by:
58x VideoFrame (decoded, oldest 124609ms) in worker
at PackagerWorker.setupDecoder (worker.js:1756:21)
(frame emitted by this VideoDecoder)
| Line | Read it as |
|---|---|
garbage collected without close() |
Certain. The resource was held for the object's whole lifetime. Fix this first. |
still live (allowed 0) |
Open at census time. Check the age — oldest 124609ms is not a pipeline mid-flight. |
decoded |
The platform made this frame and handed it to a decoder's output callback. The stack is that decoder's construction site, which is the line to look at. |
Not enforced, and still live |
A type outside types holding objects. Not a failure here, but it is why you might want types: 'all'. |
Where the leak usually is
- An
outputcallback that returns early — on a seek, an abort, a queue that is already full — without closing the frame it was handed. - A
.clone()whose second handle nobody closes. Cloning is cheap to write and easy to forget. - A library contract: mediabunny's
VideoSample.toVideoFrame()hands back a frame that must be closed separately from the sample. Close every sample diligently, forget the frames, and you leak silently. - A worker that is terminated while frames are in flight. Nothing runs your cleanup.