Skip to content

virtio-fs (macOS): a file the host replaces via rename() reads as ENOENT in the guest for up to entry_timeout (5s) #888

Open

Description

@daaain

I've been having trouble with git being out of sync between host and containers in a Podman driven VM in my sandbox library foldyard and had to write pretty elaborate git shims, monitors and heal scripts. What led me to this bug is that with Lima / vz the issue was nowhere near as bad because the time windows are much shorter.

Questions:

  1. When a path-based operation on a tracked, non-root inode fails with ENOENT because its
    /.vol/{dev}/{ino} no longer resolves (and it has no unlinked_fd), would returning ESTALE
    be acceptable? The guest would then re-look-up the name straight away. It would find the new file
    if one exists, and get a real ENOENT if the file was deleted. NFS clients rely on the same
    semantics.
  2. Failing that, would you take a patch that makes entry_timeout configurable (libkrun API plus a
    krunkit virtio-fs option), so that users sharing a tree the host also writes could lower it?

Claude's detailed writeup:

Summary

On a macOS host, when the host atomically replaces a file in a shared directory (write a temp
file, then rename() it over the existing name), a Linux guest that has already looked up that name
gets ENOENT for it until the guest's dentry expires, which takes up to 5 seconds. New names show
up at once, and writes from the guest reach the host at once.

git updates HEAD, loose refs, packed-refs and .git/index in exactly this way. So git in the
guest sees a branch that the host just moved as missing. It falls back to packed-refs (an older
commit) or treats the branch as unborn, and a guest commit made in that window can rewind the branch.

Environment

  • macOS 26.7, Apple Silicon
  • podman 6.0.2 → krunkit 1.3.2 (/opt/podman/bin/krunkit) → the libkrun it bundles (≥ 1.19.3; it
    exports krun_add_virtiofs4)
  • Guest: podman machine's Fedora CoreOS 44.20260621.3.1, kernel 7.0.12-201.fc44.aarch64
  • The share is podman's default virtio-fs,sharedDir=…,mountTag=… with no
    permissionSemantics, so krunkit's default simplified applies (attr_timeout = 0,
    entry_timeout = 5 s)

Reproducer

Host (macOS), inside a directory the machine mounts, for example under $HOME:

# host.py: replace ./f with a new version every 2 s
import os, time
for i in range(30):
    with open("f.tmp", "w") as fh:
        fh.write(str(i))
    os.replace("f.tmp", "f")
    time.sleep(2)

Guest (podman machine ssh, same path):

# guest.py: stat ./f every 10 ms, print each ENOENT window
import os, time
t0, gone = time.monotonic(), None
while time.monotonic() - t0 < 60:
    now = time.monotonic() - t0
    try:
        os.stat("f")
        if gone is not None:
            print(f"ENOENT {gone:7.3f}s .. {now:7.3f}s ({(now - gone) * 1000:.0f} ms)")
            gone = None
    except FileNotFoundError:
        gone = now if gone is None else gone
    time.sleep(0.01)

Start guest.py, then host.py. Replaces then produce ENOENT windows in the guest.

Observed

  • ENOENT windows of 0.2–4.7 s after each host replace. The new version appears at t ≈ 5, 15, 20,
    30, 35 s, which is a 5 s grid rather than a fixed delay after each replace.
  • For comparison, on the same Mac, Apple's Virtualization.framework virtio-fs (Lima vz, and
    podman's applehv provider) shows the same shape but for only 22 ms to ~1 s.
  • A link()-family call on the name (for example ln -dT / f, which fails with EEXIST and creates
    nothing) makes the next read see the new file at once, for every process in the VM.

What the source suggests

Please correct me if I've misread any of this.

  • macos/passthrough.rs addresses a node by its volfs path /.vol/{dev}/{ino}
    (inode_to_handle). Once the host's rename() drops the old inode's last link, that path no
    longer resolves. do_getattr / open_inode then pass the host's ENOENT to the guest.
    fs/macos: preserve the inode a rename overwrites #700 fixed this for renames the guest makes, through unlinked_fd. A rename by the host
    isn't visible to libkrun, so the fix can't cover it.
  • The guest kernel treats ENOENT from GETATTR/OPEN as final for as long as the dentry is valid. The
    dentry lifetime is entry_timeout, which is Config::default()'s 5 s. Fs::new overrides only
    attr_timeout. As far as I can see, neither value is exposed through the C API or krunkit.
  • The ln workaround works because LOOKUP_EXCL forces fuse_dentry_revalidate to re-issue
    LOOKUP. LOOKUP_REVAL is in the same mask. The VFS retries a path walk with LOOKUP_REVAL when an
    operation returns ESTALE (vfs_statx's retry_estale, and the same in do_filp_open).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions