WebCodecs Census docs

API reference

The assertion API

Everything exported by @motionvector/webcodecs-census. Two halves: the instrumentation that runs inside a context, and the verdict layer that turns a census into pass or fail.

import {
  installCensus, localCensus, timeline, resetCensus, VERSION,
  checkLeaks, expectNoLeaks, expectNoLeakedFrames, summarize, totalLive,
  TRACKED,
} from '@motionvector/webcodecs-census';

Verdicts

checkLeaks(censuses, options?)

Builds a LeakReport without throwing. checkLeaks(...).ok is the boolean form of every assertion below.

const report = checkLeaks(await session.census(), { types: 'all', allow: { VideoFrame: 2 } });
if (!report.ok) console.error(report.message);

LeakOptions

OptionTypeDefaultWhat it does
types TrackedType[] | 'all' ['VideoFrame', 'AudioData', 'ImageBitmap'] Which types count as live too long. The default is the frame-like types, because a long-lived decoder is normal and a long-lived frame almost never is. 'all' resolves to every entry in TRACKED.
allow Partial<Record<TrackedType, number>> {} Tolerated live count per type, summed across every context. A steady-state pipeline legitimately holds a few. Applies to live counts only — never to collectedUnclosed.
minAgeMs number 0 Ignores live objects younger than this — a decode in flight is not a leak. Decides the verdict, not just the printed attribution (v0.3.1; before that it filtered report.sites only).
failOnOverClose (v0.3.0) boolean false Fail when a close() threw. Off by default: the usual cause is a library closing a codec twice, which the app that would see the failure cannot fix. Turn it on for code you own.
How minAgeMs stays exact

The census carries liveAges: the age of every live object, per type, oldest first, capped at liveAgesCap (256). Keeping the oldest is what makes the count exact — anything dropped is younger than the youngest age kept, so it cannot clear a threshold the kept ages already fall below.

The one exception is saturation, where every kept age clears the bar. There the count is an honest lower bound — at least 256 VideoFrame still live … 9000 live in total — with the exact total in report.liveBounded. The verdict is the same either way; only the claim changes.

A census taken by a shim older than v0.3.1 carries no ages. Rather than ignore the option silently, the report leaves the counts unfiltered — never fewer than the truth — sets minAgeMsApplied to false, and names the contexts it could not filter.

LeakReport

FieldTypeMeaning
okbooleanThe verdict. See below for exactly what it is computed from.
livePartial<Record<TrackedType, number>>Live objects of the enforced types, summed across contexts. Age-filtered when minAgeMs asked for it.
liveBounded (v0.3.1)Partial<Record<TrackedType, number>>Types where live is "at least this many" because the age filter saturated the census cap. The value is the exact total live count.
minAgeMsApplied (v0.3.1)booleanWhether minAgeMs reached the verdict. False when it was not asked for, or when a census carries no ages.
unenforcedLivePartial<Record<TrackedType, number>>Live objects of the types types left out. Reported, never failed on.
collectedUnclosedPartial<Record<TrackedType, number>>GC'd without close(), across every tracked type. types cannot filter this away.
enforcedTrackedType[]What types resolved to.
sites(LeakSite & { context: string })[]Allocation sites holding live objects of the enforced types, worst first.
overCloses (v0.3.0)(OverCloseSite & { context: string })[]close() calls that threw, worst first. Always reported; only decides ok under failOnOverClose.
messagestringThe human-readable form. This is what expectNoLeaks throws.

How ok is decided

ok is true when all of these hold:

Two invariants the filter cannot break

A definitive leak is never filtered out. An object the GC collected while it was still open failed to release a resource for its entire lifetime. That fails the check whatever types says. A filter aimed at live frames must not hide a decoder that was dropped on the floor.

An unchecked type is never reported clean. If a type you left out still holds live objects, the message names it rather than printing an unqualified all-clear:

No leaks in VideoFrame, AudioData, ImageBitmap — but VideoDecoder=47 still
live and not enforced. Pass types: 'all' to check those too.

What the platform does behind your back (v0.3.0)

Two things happen to a codec that no close() call in your source explains. Getting either one wrong makes the report lie in a different direction.

A failing codec is closed by the platform

The spec's Close algorithm sets [[state]] to "closed" before it invokes your error callback, so no close() call ever reaches the instrumentation. Left alone, that codec stays counted live and, once collected, is filed as collectedUnclosed — "definitively leaked" for a resource the platform had already reclaimed.

The census reconciles live codecs against their own state, records the departure as the closedByPlatform fate, and does not call it a leak. Reconciliation runs on every sample tick and at the top of localCensus(), so a census taken straight after a decode error is already correct. A detector that invents leaks in an error-heavy pipeline is worse than one that stays quiet.

Closing an already-closed codec throws

Measured on Chrome 151: the four codec types throw InvalidStateError; the three frame types are idempotent and throw nothing. From a library closing out of a floating .finally(), that surfaces as an unhandled rejection no application code can catch — the platform reports it to nobody who can act on it.

Those calls are counted, grouped by type, message and calling line, in ContextCensus.overCloses and LeakReport.overCloses. The throw is observed, never swallowed: the caller still sees what the platform threw, or instrumenting the app would change how it behaves.

3 close() call(s) threw — a codec was closed twice:
  3x VideoDecoder: Cannot call 'close' on a closed codec (in worker)
      at decodePump (pipeline.js:812:20)

An over-close is a lifecycle defect, not a leak, so it never fails a check on its own. Pass failOnOverClose: true for code you own.

OverCloseSiteMeaning
typeWhich tracked type the call was made on.
messageWhat the platform threw, e.g. Cannot call 'close' on a closed codec.
stackThe line that called close().
countHow many times that exact site threw. Grouped because the thing doing it usually runs per frame.

expectNoLeaks(censuses, options?)

Runs checkLeaks and throws new Error(report.message) unless ok. The message carries the counts and up to five allocation sites with three stack frames each — enough to act on from a CI log.

expectNoLeaks(await session.census(), { types: 'all' });

expectNoLeakedFrames(censuses, options?)

The common case, named for what it means: expectNoLeaks with types: ['VideoFrame']. Note that options is spread after the default, so passing your own types overrides it.

expectNoLeakedFrames(await session.census());
expectNoLeakedFrames(await session.census(), { allow: { VideoFrame: 3 } });

Because only VideoFrame is enforced, a live AudioData shows up in unenforcedLive and in the message rather than failing the test.

summarize(censuses)

A compact digest, sized for an agent or a CI log to read in one go. One line per context plus the default verdict. Deliberately small: a full census is mostly stack strings.

2 context(s): main, worker
  main (3s): nothing live
  worker (3s): VideoDecoder=1 VideoFrame=5 | media 4 (1 stalled)

5 VideoFrame still live (allowed 0).

Held by:
  5x VideoFrame (decoded, oldest 100ms) in worker
      at VideoSample.toVideoFrame (pipeline.js:17261:14)
      (frame emitted by this VideoDecoder)

summarize uses the default types. If you are enforcing the codecs, call checkLeaks yourself and print its message.

totalLive(censuses, type)

Sums one type's live count across contexts. Returns a number.

const frames = totalLive(await session.census(), 'VideoFrame');

TRACKED

The seven types whose resources GC cannot reclaim, in order. TrackedType is the union of its members.

['VideoDecoder', 'VideoEncoder', 'AudioDecoder', 'AudioEncoder',
 'VideoFrame', 'AudioData', 'ImageBitmap']

Instrumentation

installCensus(options?)

Patches this context's globals. Safe to call more than once — a second call only updates context and returns, so re-running it after HMR or a second bundle chunk does not reset the counters or double-patch a constructor.

OptionTypeDefaultWhat it does
contextstringguessedName for this context in reports. The guess is main, worker, shared-worker, service-worker or unknown.
sampleIntervalMsnumber | false500Rolling timeline interval. false disables sampling.
keepSamplesnumber240How many samples to retain. Older ones are dropped.
stackDepthnumber8Frames of allocation stack to keep per object.
warnOnCollectbooleantrueLog a console warning when an object is GC'd without close().

Each patch step is wrapped separately. A global that is missing in this context costs you that one patch and is recorded in problems[] — it never aborts the install, and it is never swallowed.

installCensus also exposes globalThis.__webcodecsCensus: a function returning the local census, with .local(), .timeline(), .reset() and .version attached. That is how the CDP driver and the extension read a context they injected into.

localCensus()

A ContextCensus for this context only. Collecting a worker's census means running this inside that worker.

FieldTypeMeaning
contextstringThe context name.
uptimeMsnumberHow long the census has been installed. Makes rates computable.
enteredRecord<string, number>Keyed Type:origin, e.g. VideoFrame:decoded.
leftRecord<string, number>Keyed Type:fateclosed, transferred, or closedByPlatform (v0.3.0).
livePartial<Record<TrackedType, number>>Open right now, by type.
liveAges (v0.3.1)Partial<Record<TrackedType, number[]>>Ages in ms of the live objects, per type, oldest first and capped at liveAgesCap.
liveAgesCap (v0.3.1)numberThe cap liveAges was truncated at, carried so a saturated record says what it is.
collectedUnclosedPartial<Record<TrackedType, number>>Collected by GC without close(). Unambiguous.
closedUnseennumberClosed here but never seen entering — usually a receive path the message scanner missed.
overCloses (v0.3.0)OverCloseSite[]close() calls that threw, worst first. A lifecycle bug, not a leak.
leakSitesLeakSite[]Live objects grouped by allocation site, worst first.
oldestLiveLiveObject[]The ten oldest live objects, each with its stack and age.
mediaElementsMediaElementCensus{ total, stalled, byReadyState }.
timelineSample[]The rolling samples.
problemsstring[]Anything that could not be instrumented here. Non-empty means the numbers are a floor, not a total.

timeline()

The rolling samples on their own. Each Sample:

FieldMeaning
tms since install.
liveLive counts by type at that moment.
gained / lostEntered and left since the previous sample, by type.
activity{ decodeCalls, encodeCalls, outputs, errors, queued, configured }. queued is summed decodeQueueSize/encodeQueueSize across live codecs — backpressure.
mediaElementsElement totals and how many are stalled.

This is what separates a busy pipeline from a wedged one. Decoder idle, queue empty and the live count frozen means wedged; a snapshot alone reads it as normal.

Timers arrive late in a worker

A worker paused before its first line has VideoFrame but not setInterval. The sampler therefore starts on the first tracked allocation after the worker resumes, not at install time — so an early census can legitimately have an empty timeline.

resetCensus()

Clears the counters without unpatching. Tests need this; nothing else should. Objects that were live before the reset are forgotten, so a reset mid-run hides a real leak.

VERSION

The core's version string, stamped into every census payload and readable in-page as __webcodecsCensus.version. The release script rewrites it, and fails loudly if it ever stops matching its pattern.

Subpath exports

SpecifierWhat it gives you
@motionvector/webcodecs-censusThe ESM API above.
@motionvector/webcodecs-census/shimSHIM_SOURCE: the census bundled as a self-installing IIFE, as a string, for injecting into a context you do not control.
@motionvector/webcodecs-census/shim.txtThe same IIFE as a file, for serving or reading directly.

The injected build reads its options from globalThis.__webcodecsCensusOptions before installing, because an IIFE has no callable export. The CDP driver does this for you.