#944

virtio/fs/macos: unlink a closed file without opening it first

Openjansavwants to merge intofrom

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_open and create hold it for reading, from the open until the handle is in the table.
  • The fast unlink holds it for writing, from the check until unlinkat returns.

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.

BeforeAfterVirtualization.framework
rm -rf13.4 s10.4 s8.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 after rm, and fstat sees 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, and stat(".") still answers. With rmdir on 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:

RefResult
upstream main15/16 pass; virtiofs-misc fails in uid_gid_mapping_preexisting
this branch15/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.

Activity

2
slp1d ago

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.