For the last couple of weeks, I’ve been working on garbage collection for composefs repositories. A composefs repository is a content-addressed object store, and it needs a garbage collector to reclaim space from images that are no longer referenced. The hard part is deciding what “referenced” actually means when an image has no ref in the repository but is mounted somewhere, possibly from another process, possibly in a different mount namespace, possibly by the running system itself. On composefs-rs#346 was suggested to use flock() on the EROFS backing file. Mounters take a shared lock, the GC probes with an exclusive one, and if the exclusive lock fails, the image is still in use.

The problem#

Named refs under images/refs/ point at EROFS images. Everything reachable from a ref, meaning the EROFS image itself plus every object referenced by its trusted.overlay.redirect xattrs, is live. That works fine as long as the only consumers are named refs.

It stops working the moment a caller mounts an image that has no ref, or deletes a ref while the image is still mounted somewhere. The mount itself holds no reference that the GC can see. The EROFS superblock keeps the backing file open in the kernel, but from userspace the file is just sitting in objects/, looking like any other candidate for pruning.

The obvious first attempt is a per-image lockfile: whoever mounts an image writes a marker under refs/mounted/<digest>, the GC checks for markers, the marker is removed on unmount. It doesn’t really work. Cleaning up on abnormal exit needs its own mechanism, and the bootc root mount has no long-lived userspace process to keep a marker alive to begin with: EROFS is mounted in the initramfs, and then systemd pivots into it. Writing state at mount time also races with a concurrent GC, so we would need locking on top of the marker file anyway.

Flatpak solved a very similar problem for deploy directories using fcntl locks on a .ref file inside each deploy dir. Every app that bind-mounts the directory takes a shared lock, and the GC only removes a directory if it can take an exclusive one. The kernel tracks lifetime for us. The same idea maps cleanly onto composefs if we lock the EROFS image file directly.

The flock protocol#

The rule is symmetric. Mounters take flock(fd, LOCK_SH) on the EROFS image before mounting. Multiple mounters can hold the shared lock at the same time. The GC takes flock(fd, LOCK_EX | LOCK_NB) on each object it is about to delete, and if the non-blocking exclusive lock fails, at least one mounter is still holding a shared lock and the object is live, so the GC skips it.

On the mount side, the code takes the shared lock right before handing the fd to fsconfig:

1
2
3
4
5
6
7
8
let image = make_erofs_mountable(image)?;
rustix::fs::flock(&image, rustix::fs::FlockOperation::LockShared)?;
let erofs = FsHandle::open("erofs")?;
fsconfig_set_flag(erofs.as_fd(), "ro")?;
if fsconfig_set_fd(erofs.as_fd(), "source", image.as_fd()).is_err() {
    fsconfig_set_string(erofs.as_fd(), "source", proc_self_fd(&image))?;
}
fsconfig_create(erofs.as_fd())?;

On the GC side, before deleting an object we try to grab the exclusive lock on it:

1
2
3
4
5
6
if let Ok(obj_fd) = openat(&dirfd, filename, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()) {
    if flock(&obj_fd, FlockOperation::NonBlockingLockExclusive).is_err() {
        debug!("objects/{first_byte:02x}/{filename:?} is locked, skipping");
        continue;
    }
}

flock is attached to the open file description, so once the kernel holds a reference to the EROFS image for the lifetime of the superblock, it keeps the lock alive for us. The lock lifetime tracks the mount lifetime, not the lifetime of any particular process. That is exactly what we want.

The kernel piece#

There is one issue left. EROFS has historically been mounted by path, so the fd the userspace mounter locked on is not the fd the kernel ends up holding on to. Once the mounting process exits, the lock goes away with it, and the mount is no longer protected by anything.

Passing the backing file to EROFS as an fd instead of a path was suggested on the issue as the cleanest way to make the mount lifetime and the userspace lock lifetime line up. I picked up the idea and turned it into a small EROFS patch:

[PATCH 1/2] erofs: accept source file descriptor via fsconfig https://www.mail-archive.com/[email protected]/msg16338.html

The patch adds fsparam_fd("source") so that userspace can pass an already-open fd via fsconfig(FSCONFIG_SET_FD, "source", NULL, fd). The kernel stores that fd directly in sbi->dif0.file and skips filp_open(). It boils down to a handful of lines in erofs_fc_parse_param:

1
2
3
4
5
6
7
8
9
case Opt_source_fd:
    if (!IS_ENABLED(CONFIG_EROFS_FS_BACKED_BY_FILE)) {
        errorfc(fc, "source fd option not supported");
        return -EINVAL;
    }
    if (sbi->dif0.file)
        fput(sbi->dif0.file);
    sbi->dif0.file = get_file(param->file);
    break;

With this in place, the flock(LOCK_SH) taken in userspace is attached to the same open file description that the kernel is holding for the mount. The lock survives even after the mounting process exits, and it is released when the mount goes away.

The userspace code tries the fd path first and falls back to the path-based source on older kernels:

1
2
3
if fsconfig_set_fd(erofs.as_fd(), "source", image.as_fd()).is_err() {
    fsconfig_set_string(erofs.as_fd(), "source", proc_self_fd(&image))?;
}

The fallback works, but on old kernels the lock lifetime is tied to whoever holds the userspace fd, which is exactly what I am trying to avoid.

Cross-namespace correctness#

flock is namespace-agnostic, so a shared lock held from one mount namespace blocks an exclusive lock attempted from another. The GC does not need to enumerate mount namespaces or use statmount(2)/listmount(2) to decide whether an object is safe to delete: it tries the exclusive lock and lets the kernel answer.

I also have code that enumerates mounts in composefs-rs to reuse an existing EROFS mount as the overlay lower layer, so that mounting the same image twice does not create two identical EROFS superblocks. That path relies on a couple of extra kernel patches (OVL_IOC_OPEN_LAYER on the overlay side, and EROFS_IOC_GET_SOURCE_FD on the erofs side, to walk from the overlay mount back to the backing image file and compare its fs-verity digest), but those patches are currently stalled on some design discussions upstream, so let’s ignore them. For now the only piece I actually need in the kernel is the ability to mount an EROFS image by fd, and everything in this post works on top of that alone. Locking and mount reuse are independent problems anyway: locking protects objects from being deleted while they are in use, reuse allows a better sharing of resources since the same superblock is shared among different mounts.

What this does not solve#

flock is advisory: a stray rm -rf objects/ still deletes a mounted image out from under the kernel.

Nothing here binds the lock to a specific mounter identity either. Any process with read access to the EROFS file can take a shared lock and pin an object. For the system-wide composefs repository that is fine, only privileged components mount from it. A shared repository serving unprivileged callers would need to combine this with the file permission model still under discussion in the upstream thread.

Conclusion#

flock on the EROFS backing file is the smallest change I could come up with that gives the GC a correct answer to “is this object in use?” without persisting state anywhere. Shared on the mount side, non-blocking exclusive on the GC side, and the kernel does the accounting for us through the open file description attached to the mount. No daemon, no fd stash, no directory of markers under /run.