Featured image of post FFmpeg thinks it has a disk

FFmpeg thinks it has a disk

FFmpeg assumes a disk it can seek around in. Giving it a convincing in-memory filesystem, and testing the behaviour that would hurt most first.

There’s a moment in every “FFmpeg in your own process” conversation where somebody asks where the files go. It’s a fair question. FFmpeg’s whole worldview is files: it opens paths, seeks around in them, writes headers and then goes back to fix them up. It assumes a disk is just there, the same way it assumes a clock is. Nobody ever wrote it to cope with either being a lie.

afmpeg doesn’t give it one. The FFmpeg inside is compiled to WebAssembly and the files it reads and writes are, by default, Go objects in memory. The guest never notices. This post is about the piece that does the lying: the vfs bridge.

Two filesystem interfaces, neither of them a filesystem

The requirement (R-AF-2 in the spec, one of the project’s founding rules) is that a media job can run with zero host filesystem access. Not “a temp dir we clean up afterwards”. None at all. The pipeline this library was built for generates a reel’s video in one step and posts it in the next, and there’s no reason the bytes moving between two steps of the same program should ever touch a disk.

So Go already has a beautiful answer to “a filesystem that isn’t”: afero, the de-facto standard filesystem abstraction, whose MemMapFs is a complete filesystem living in ordinary memory. And wazero, the wasm runtime, has its own answer from the opposite direction: a sys.FS interface describing what a guest expects a filesystem to look like, syscall by syscall.

The bridge is the adapter between those two worldviews:

sysCfg.WithSysFSMount(vfs.New(fs), "/")

vfs.New takes any afero.Fs and presents it to wazero as the guest’s root. When FFmpeg makes a WASI syscall (path_open, fd_read, fd_write, fd_seek), wazero routes it to the mounted sys.FS, and the bridge translates each one onto the corresponding afero operation (vfs.go at b9b7d5e). The comment on the constructor states the payoff plainly: hand it a MemMapFs and the whole pipeline stays in memory. Hand it a BasePathFs or a real directory and the same code runs against actual files, which is exactly how the integration tests double-check the abstraction isn’t cheating.

Test the scariest behaviour first

Spec 0003’s most opinionated decision wasn’t in the code, it was in the ordering: the first test written for the entire bridge was seek-on-write.

Here’s why that’s the scary one. When FFmpeg writes an mp4 with +faststart, it writes the file front to back, then seeks backwards into the finished output to relocate the moov atom (the index that lets a video start playing before it’s fully downloaded). Most toy filesystem shims survive sequential writes and die the moment something rewinds and patches the middle of a file. If afero’s in-memory files couldn’t take that punch, the whole project was a dead end, so that test was the go/no-go gate before anything else got built:

// Pwrite writes at an absolute offset without moving the file offset
// (fd_pwrite) — the path the mp4 muxer uses to patch the moov atom under
// +faststart.
func (f *file) Pwrite(buf []byte, off int64) (int, experimentalsys.Errno) {
	if len(buf) == 0 {
		return 0, 0
	}

	n, err := f.af.WriteAt(buf, off)

	return n, experimentalsys.UnwrapOSError(err)
}

(file.go at 661688d.) It passed, the gate opened, and the rest of the bridge is properly dull: EOF is reported the way WASI wants it (a zero-byte read with no error, not an error called EOF), errno values are mapped through wazero’s own error translation so the guest sees proper POSIX numbers, and anything the bridge doesn’t implement fails loudly with ENOSYS rather than pretending. Dull is the point. A filesystem is a contract and it’s always the clause you skimmed that bites, so the small print is where all the work went.

The paths that aren’t in your filesystem

The interesting wrinkle is the handful of paths a C program expects that no caller’s filesystem would ever contain. The bridge intercepts these in OpenFile before they reach afero (the dispatch):

  • /tmp routes to a separate in-memory scratch filesystem. FFmpeg is entitled to its temp files, but guest litter has no business appearing in the filesystem you handed in. Your MemMapFs stays exactly as clean as you gave it.
  • /dev/null is a discard sink, because portable C code will write to it and portable C code must be humoured.
  • /dev/urandom hands back real randomness from Go’s crypto/rand. That one arrived later and the hard way, after the Matroska muxer hung forever waiting for entropy that WASI never provides. The bridge was the natural home for the fix: not a patch to FFmpeg, just one more fixture the environment was expected to have.

There’s a theme in that list. WASI deliberately gives a guest almost nothing, and every one of these overlays is the bridge handing back a single, controlled piece of the POSIX world the guest was written for. The filesystem, the scratch space, the bit-bucket, the randomness. All of it fabricated, and all of it behaving exactly the way a real one would… which is the only thing the guest ever bothers to check.

What you get for the deceit

The practical upshot: afmpeg’s tests create no temp directories and need no cleanup, a crashed run leaves nothing on disk because nothing was ever on disk, and a server can process untrusted media without granting the decoder so much as a directory. It’s the same trick paying off twice over. Painless tests, and a sandbox you can hand somebody else’s video without flinching.

FFmpeg, for its part, never suspects a thing. It opens files, seeks around, patches its moov atoms, litters its /tmp… and every last byte of it is a Go map pretending, very carefully, to be a hard drive.

Built with Hugo · Theme Stack designed by Jimmy