WebCodecs Census docs

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

  1. Install a pinned Chrome for Testing on the runner.
  2. Serve the app, launch Chrome, attach the census.
  3. Drive the workload you care about.
  4. 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.

Watch the problems array

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.

WantDo
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)
LineRead 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