Problem
On a macOS host, deleting a large tree on a virtio-fs share is slow: rm -rf of a 63,224-file node_modules takes 13.4 s, against 8.6 s on Apple's Virtualization.framework. Clean builds and package installs delete such trees all the time.
Cause
Every unlink pays for a case that is almost never true. Once a file is unlinked, its /.vol/<dev>/<ino> path no longer resolves, so before each unlink do_unlink opens the file and keeps the descriptor, in case the guest still uses the inode. Each unlink therefore costs an open of the parent, an open of the file, an fstat and two closes, on top of the unlink itself.
For a file, the kept descriptor is only needed while the guest holds it open. A file with no open handle cannot be reached after the unlink, and in rm -rf no file is open.
Fix
When no open handle refers to a file, unlink it with one unlinkat on its /.vol path. When a handle does refer to it, or when the check is in doubt (the lstat fails), take the existing path, which keeps the descriptor.
rmdir always takes the existing path. A directory can stay reachable without any handle, as a process's working directory, and Linux lets that process keep using . after another process removes the directory. Directories are a small share of a real tree (7,385 of the 70,701 entries below), so this costs little.
An open could otherwise slip in between the check and the unlink, and leave an open file whose inode the server can no longer reach. A new open_unlink lock closes that window:
do_openandcreatehold it for reading, from the open until the handle is in the table.- The fast unlink holds it for writing, from the check until
unlinkatreturns.
Opens of different files do not wait for each other.
Evidence
Setup: M4 Pro, macOS 26.7, aarch64 Linux guest with 2 vCPUs and 2 GiB, rm -rf of a node_modules tree with 63,224 files, 7,385 directories and 92 symlinks on a virtio-fs share; medians of 3. The measurement ran in a downstream build that serves the share with a fixed owner and has #943 applied.
| Before | After | Virtualization.framework | |
|---|---|---|---|
rm -rf | 13.4 s | 10.4 s | 8.6 s |
Neither the lock nor keeping rmdir on the existing path made a difference beyond run-to-run noise (10.2 s with rmdir on the fast path).
Two new virtiofs_misc subtests:
unlink_open_and_closed_files: a file the guest holds open stays readable and writable afterrm, andfstatsees the write; a closed file is gone with its name, and the name can be created again.rmdir_working_directory: the guest removes its own working directory, andstat(".")still answers. Withrmdiron the fast path, this subtest fails.
The race the lock closes cannot be triggered reliably from a guest, so no test covers it.
Tests
make test, M4 Pro, macOS 26.7, dynamic linking:
| Ref | Result |
|---|---|
upstream main | 15/16 pass; virtiofs-misc fails in uid_gid_mapping_preexisting |
| this branch | 15/16 pass; the same failure, nothing new; both new subtests pass |
The 17 skipped tests need FreeBSD assets, pjdfstest, iperf, TAP or passt, none of which this Mac has. This PR and #943 both add a subtest at the end of test_virtiofs_misc.rs, so whichever merges second needs a trivial rebase.
Related work
#933 (experimental) cuts the latency of each virtio-fs request. This PR cuts the number of requests, so the two add up.
Sadly, this optimization is broken. The guest might still have an entry in its lookup cache even if we don't have an entry in
self.handles.BTW, does the use case you're optimizing for require guest-to-host or guest-to-guest consistency for the contents shared via virtio-fs? Because in case it doesn't, I'm preparing a feature that drastically improves the file system performance and avoids complex syscall juggling.