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
| Option | Type | Default | What 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.
|
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
| Field | Type | Meaning |
|---|---|---|
ok | boolean | The verdict. See below for exactly what it is computed from. |
live | Partial<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) | boolean | Whether minAgeMs reached the verdict. False when it was not asked for, or when a census carries no ages. |
unenforcedLive | Partial<Record<TrackedType, number>> | Live objects of the types types left out. Reported, never failed on. |
collectedUnclosed | Partial<Record<TrackedType, number>> | GC'd without close(), across every tracked type. types cannot filter this away. |
enforced | TrackedType[] | 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. |
message | string | The human-readable form. This is what expectNoLeaks throws. |
How ok is decided
ok is true when all of these hold:
- no enforced type has more live objects than its
allowentry (default 0), - no tracked type has any
collectedUnclosedat all, and - if
failOnOverCloseis on, noclose()threw.
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.
OverCloseSite | Meaning |
|---|---|
type | Which tracked type the call was made on. |
message | What the platform threw, e.g. Cannot call 'close' on a closed codec. |
stack | The line that called close(). |
count | How 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.
| Option | Type | Default | What it does |
|---|---|---|---|
context | string | guessed | Name for this context in reports. The guess is main, worker, shared-worker, service-worker or unknown. |
sampleIntervalMs | number | false | 500 | Rolling timeline interval. false disables sampling. |
keepSamples | number | 240 | How many samples to retain. Older ones are dropped. |
stackDepth | number | 8 | Frames of allocation stack to keep per object. |
warnOnCollect | boolean | true | Log 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.
| Field | Type | Meaning |
|---|---|---|
context | string | The context name. |
uptimeMs | number | How long the census has been installed. Makes rates computable. |
entered | Record<string, number> | Keyed Type:origin, e.g. VideoFrame:decoded. |
left | Record<string, number> | Keyed Type:fate — closed, transferred, or closedByPlatform (v0.3.0). |
live | Partial<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) | number | The cap liveAges was truncated at, carried so a saturated record says what it is. |
collectedUnclosed | Partial<Record<TrackedType, number>> | Collected by GC without close(). Unambiguous. |
closedUnseen | number | Closed 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. |
leakSites | LeakSite[] | Live objects grouped by allocation site, worst first. |
oldestLive | LiveObject[] | The ten oldest live objects, each with its stack and age. |
mediaElements | MediaElementCensus | { total, stalled, byReadyState }. |
timeline | Sample[] | The rolling samples. |
problems | string[] | 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:
| Field | Meaning |
|---|---|
t | ms since install. |
live | Live counts by type at that moment. |
gained / lost | Entered and left since the previous sample, by type. |
activity | { decodeCalls, encodeCalls, outputs, errors, queued, configured }. queued is summed decodeQueueSize/encodeQueueSize across live codecs — backpressure. |
mediaElements | Element 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.
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
| Specifier | What it gives you |
|---|---|
@motionvector/webcodecs-census | The ESM API above. |
@motionvector/webcodecs-census/shim | SHIM_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.txt | The 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.