The vfs bridge¶
The vfs bridge (internal/vfs) is the heart of afmpeg and the one thing every
other Go ffmpeg binding lacks: it presents the caller's
afero.Fs to the WebAssembly guest as wazero's
experimental/sys.FS,
so the guest ffmpeg's WASI filesystem syscalls read and write the caller's
filesystem — including a fully in-memory MemMapFs — without touching the host
disk. It implements spec 0003.
Where it sits¶
caller's afero.Fs ──► internal/vfs (sys.FS adapter) ──► wazero ──► ffmpeg.wasm
MemMapFs / OsFs path_open, fd_read, fd_write, fd_seek, … (guest)
The guest issues POSIX-shaped WASI calls; wazero routes them to a mounted
sys.FS; afmpeg's adapter turns each one into the equivalent afero.Fs /
afero.File operation. The adapter holds no runtime state and knows nothing
about ffmpeg — it is a pure translation layer, which is what makes it testable
in isolation against the sys.FS contract.
Faithful syscall semantics¶
The adapter mirrors wazero's own os.File behaviour rather than inventing its
own, so the guest cannot tell it apart from a real filesystem:
- EOF is
n == 0with a zeroErrno, not an error — matching the POSIXreadconvention wazero expects. - Zero-length reads and writes short-circuit to
(0, 0). - Errors are mapped through wazero's own
UnwrapOSError, so an afero*os.PathErrorbecomes the correctErrno(ENOENT,EEXIST,EISDIR, …) the guest would see on a host filesystem. UnlinkandRmdirare POSIX-faithful: unlinking a directory returnsEISDIR, removing a non-directory with rmdir returnsENOTDIR.
Seek-on-write: the de-risking case¶
The highest-risk behaviour, and the first test written, is seek-on-write.
The mp4 muxer under -movflags +faststart writes the media data, then seeks
back and overwrites the moov atom header with its final size. If the bridge
could not seek backwards and overwrite over an afero.Fs, the whole approach
would be unworkable. It can, and the round-trip — write placeholder → append
payload → seek back → overwrite → read back — is verified against MemMapFs,
BasePathFs, and OsFs. This is gate G1 in the
execution plan.
The synthetic overlays (/tmp and the device files)¶
A guest ffmpeg expects a few POSIX locations that a bare afero.Fs does not
provide. The bridge overlays them on top of the caller's filesystem:
/tmpis routed to an isolated in-memory scratch filesystem, freshly created for each invocation, so the guest's temporary writes never pollute the caller'safero.Fs. There is no public option to supply your own or to read it back afterwards — anything you want to keep has to be written outside/tmp./dev/nullis a discard sink: writes succeed and vanish, reads report EOF./dev/urandom(and/dev/random) serve cryptographically-random bytes from the host'scrypto/rand. This one is not a convenience — it is load-bearing, and the reason is a genuine WASI gotcha (below)./dev/afmpeg-progressis a write-only sink the engine streams live progress records to. Unlike the others it is conditional — see below.
Everything else resolves against the caller's filesystem.
Why /dev/urandom is load-bearing¶
Several libav muxers need a random identifier — the Matroska muxer, for
instance, gives each track a random UID, which means seeding a PRNG during muxer
init. That PRNG is seeded by libavutil's av_get_random_seed().
In the wasm build, av_get_random_seed() has exactly one compiled entropy
source: reading /dev/urandom (the Windows, arc4random, gcrypt and openssl paths
are all configured out). When that read fails, it falls back to
get_generic_seed() — a loop that harvests entropy from clock() jitter and
only exits once the process clock has advanced far enough. Under WASI there is no
/dev/urandom, and clock() does not advance, so the fallback never
returns. The symptom is stark: any attempt to write a Matroska/WebM file hangs
forever — the muxer stalls in init, seeding, before it emits its first byte (the
loop that would consume the random UID is never even reached).
Serving /dev/urandom from the vfs closes the hole at its source: the very first
entropy read succeeds with real randomness, av_get_random_seed() returns
immediately, and the fallback loop is never reached. It fixes not just Matroska
but every format that needs a random seed. This is the recurring shape of WASI
work — it is a smaller world than POSIX, and each missing device is a place a
"portable" C fallback can quietly misbehave (see also ffmpeg-wasi's
build shims, which
fill the link-time gaps the same spirit fills here at runtime).
The progress device is the bridge run backwards¶
The overlays above exist so the guest can read something the caller's
afero.Fs cannot provide. /dev/afmpeg-progress inverts that: it exists so the
guest can tell the host something, using the only channel a sandboxed WASI
module reliably has — a file write.
The engine opens it write-only and streams newline-delimited JSON records
(frame, out_time_us, total_size, and optionally duration_us) as it
encodes. The bridge splits that byte stream on \n and hands each complete line
to a sink the host installed, buffering a trailing partial line until its newline
arrives in a later write. Those records become the Frame / OutTime / Speed
fields — and the authoritative Fraction — on the
WithProgress channel (specs
0032,
0034).
Two properties are worth calling out, because both are deliberate:
It is only mounted when someone is listening. The other overlays are
unconditional; this one appears only when the caller attached a progress sink —
which happens only when WithProgress is active. A job with no progress channel
has no such path, so /dev/afmpeg-progress resolves against the caller's
filesystem like any other name. That is what keeps the device from being a
surprise: it cannot shadow a real file unless you asked for it.
It cannot fail the job. Writes always report success, the sink must not block, and a malformed stream that never emits a newline has its partial-line buffer dropped at 64 KiB rather than growing without bound. Progress is best-effort by construction: the worst a broken side-channel can do is report nothing.
The no-host-filesystem guarantee¶
The adapter never calls the os package directly — it only invokes methods on
the injected afero.Fs. With a MemMapFs and no host preopens, a guest write
resolves entirely in memory; a test asserts a canary write reaches the in-memory
fs and is absent from the host disk. This is the property (R-AF-2) that lets
keryx hand afmpeg an in-memory worktree and render without a local checkout.
What lives elsewhere¶
The bridge is deliberately runtime-agnostic. Mounting it into a wazero module
(WithSysFSMount) and driving an actual guest is the job of afmpeg's wasm backend,
which composes this package with the ffmpeg.wasm the caller supplied — the module
is never embedded. The end-to-end test that exercises
the bridge through a real WASI host therefore lands with that runtime; the
contract tests here drive the exact sys.FS / sys.File methods wazero
invokes.
The bridge is an internal package, so it has no published Go API. What a caller can
actually observe of it — path resolution, the synthetic locations, which operations
are supported and which return ENOSYS — is listed in
the guest filesystem.