@motionvector/webcodecs-census
v0.3.1
Published
Find leaked VideoFrames, AudioData and codecs in WebCodecs apps — including inside Web Workers — and get the allocation stack that caused them.
Downloads
704
Readme
@motionvector/webcodecs-census
Find leaked VideoFrames, AudioData and codecs, and get the line of code that allocated them.
WebCodecs objects hold resources from a finite pool outside the JS heap. The
garbage collector never reclaims them — only close() does. Nothing in the
platform tells you that you leaked one, how many, or where from: the app
just gets slower, then quietly stops decoding.
This is the instrumentation core. It runs in any context — page, dedicated worker, shared worker — and has no dependencies.
Instrumenting a worker you did not write, from outside the app, needs
@motionvector/webcodecs-census-cdp. This package is what you reach for when you can edit the code being measured.
Install
npm install --save-dev @motionvector/webcodecs-censusUse
Install it as early as you can in every context that touches media — the main thread and each worker. Anything allocated before it installs is invisible to it.
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: [ … ],
// }leakSites is the part that matters: live objects grouped by where they entered
the context, worst first. A count alone cannot be acted on.
Make a leak fail the build
import { expectNoLeakedFrames } from '@motionvector/webcodecs-census';
test('the editor releases every frame it decodes', async () => {
await playThroughTimeline();
expectNoLeakedFrames([localCensus()], { minAgeMs: 1000 });
});checkLeaks() returns the same information without throwing.
minAgeMs ignores live objects younger than the threshold — a decode in flight
is not a leak. It is applied to the verdict, not only to the sites the report
prints, because the census carries the age of every live object in liveAges.
If a census predates 0.3.0 it has no ages; the report then leaves the counts
unfiltered, sets minAgeMsApplied to false and names the contexts it could not
filter, rather than letting an ignored option read as a clean bill of health.
The ages are capped, oldest first, and the cap travels in the payload as
liveAgesCap. 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. If every kept age does clear it, the count
is honest about being a lower bound — at least 256 VideoFrame still live …
9000 live in total — with the exact total in liveBounded.
types decides what counts as live too long. It defaults to the frame-like
types, because a long-lived decoder is normal and a long-lived frame almost
never is. Pass types: 'all' to hold the codecs to the same standard:
expectNoLeaks([localCensus()], { types: 'all' });What the platform does behind your back
A codec that fails is closed by the platform, not by your code: the spec closes
it before it invokes your error callback, so no close() call happens. The
census reconciles live codecs against their own state, records that as
closedByPlatform, and does not report it as a leak. Getting this wrong is how
a leak detector invents leaks in an error-heavy pipeline.
The mirror image is worth catching. Closing an already-closed codec throws
InvalidStateError — the four codec types throw, the three frame types are
idempotent — and from a floating .finally() that becomes an unhandled
rejection no application code can catch. Those calls are counted in
overCloses, with the line that made them:
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)It is a lifecycle defect, not a leak, so it never fails a check on its own.
Pass failOnOverClose: true for code you own.
Two things types deliberately does not do. It never hides an object the GC
collected while it was still open — that is the definitive leak, and it fails
the check whatever its type. And it never lets the report claim a clean bill of
health for a type it did not look at: an unenforced type with live objects is
named in the message.
No leaks in VideoFrame, AudioData, ImageBitmap — but VideoDecoder=47 still
live and not enforced. Pass types: 'all' to check those too.What it counts, and why that is not obvious
Tracked: VideoDecoder, VideoEncoder, AudioDecoder, AudioEncoder,
VideoFrame, AudioData, ImageBitmap — plus <video>/<audio> elements,
because Chrome caps WebMediaPlayers per frame and elements past the cap stall
at readyState 0 with no error event.
Each live object records how it entered the context, because provenance decides whether a leak is yours:
| Origin | Meaning |
| --- | --- |
| constructed | new VideoFrame(...) here |
| decoded | produced by a codec, attributed to that codec's construction site |
| cloned | .clone() — an independent handle needing its own close() |
| received | arrived over postMessage; this context owns it now |
decoded is the one that catches real bugs. Frames that leak in production
never pass through a JS constructor — the platform creates them and hands them
to the output callback you gave new VideoDecoder({output}). An instrument
that only traps the constructor reports a clean pipeline for an app losing every
frame. This wraps that callback, and since a platform callback has no
application frames above it, attributes each frame to the codec that produced
it.
Transfers are accounted for explicitly. Transferring a VideoFrame detaches
the sender's handle without calling close(), and the receiver gets it by
structured clone rather than a constructor. Counted naively that is a false leak
in one context and an invisible object in the other.
A FinalizationRegistry catches the unambiguous case: an object collected by GC
that was never closed. That is a leak, not a heuristic.
The timeline, and why a snapshot lies
A static count says how many are live. It cannot say whether the pipeline was
busy — and that difference is what tells a backlog apart from a wedge. Every
context keeps a rolling sample of live counts, codec throughput, queue depth and
media-element readyState:
t(s) liveVF dec/out queued
12 59 0/0 0
58 58 0/0 0
162 58 0/0 0Decoder idle, queue empty, frames frozen: wedged, not buffering. A snapshot alone reads that as normal.
Cost
Measured, not estimated: +5.6 µs per tracked allocation and ~284 bytes per live tracked object, the latter bounded by the size of the leak itself. At 60 fps that is 0.03% of a second. It only matters above roughly 100k allocations per second.
Suitable for development and CI. Not recommended enabled by default in production builds — not for speed, but because it patches global constructors and retains a stack per live object.
API
| | |
| --- | --- |
| installCensus(options?) | Patch this context. Safe to call more than once. |
| localCensus() | Snapshot this context. |
| timeline() | The rolling samples on their own. |
| checkLeaks(censuses, options?) | A pass/fail report, without throwing. |
| expectNoLeaks(censuses, options?) | Throws with the allocation sites. |
| expectNoLeakedFrames(censuses, options?) | The common case, named for what it means. |
| summarize(censuses) | A compact digest, sized for an agent to read. |
| totalLive(censuses, type) | Sum one type across contexts. |
| resetCensus() | Clear counters without unpatching. Tests only. |
installCensus accepts context, sampleIntervalMs, keepSamples,
stackDepth and warnOnCollect.
No network, no dependencies
The core makes no requests of any kind: no telemetry, no beacons, no reporting.
Verify with
grep -rE 'fetch\(|sendBeacon|WebSocket' node_modules/@motionvector/webcodecs-census/dist.
Published from GitHub Actions with provenance, so every release is traceable to the commit and workflow that built it.
Documentation
Full documentation, the two-phase worker injection, and the honest limits: github.com/motionvector-dev/webcodecs-census
MIT
