Skip to content

Docker Desktop (Docker VMM): fakeowner atomic_open binds newly created files to the backing virtiofs inode, breaking flock, POSIX locks, fchown, and inotify #765

Open

Description

@andrew-meter

Bug report

NOTE:
This problem surfaced when trying to use filesystem locking within a volume-mounted directory on Docker Desktop for Mac. The locks silently did not serialize access.
Claude and Gemini assisted with the detailed report.

Summary

On Docker Desktop for Mac using the Docker VMM virtual machine manager, host
bind mounts inside containers are served by two stacked instances of Docker's
out-of-tree fakeowner kernel module (statfs magic 0x6a656a63). For each
host share, the container-facing instance at /host_mnt/<share> sits on a
mark instance at /run/host_mark/<share>. The mark instance in turn names
a virtiofs mount of the host share (statfs magic 0x65735546,
FUSE_SUPER_MAGIC) at /run/host_virtiofs/<share> as its source.

When open(2) with O_CREAT actually creates a file on a fakeowner mount,
the returned descriptor bypasses both fakeowner layers: it is bound to the
backing virtiofs mount and inode instead of the container-facing
fakeowner mount and inode. Every later open of the same path is bound to the
container-facing fakeowner inode. The guest kernel therefore holds two
distinct struct inode objects for one file, and everything keyed on a
descriptor's inode or mount diverges between the creating descriptor and all
other descriptors:

  • flock(2): the creator and any later opener both obtain LOCK_EX.
    util-linux flock(1) and the Python filelock package inherit the failure
    whenever they create the lock file.
  • POSIX record locks (fcntl(F_SETLK), lockf(3)): the creating process
    and a second process both obtain exclusive locks.
  • fchown(2) on the creating descriptor returns success, but the
    ownership is not recorded: stat(2) still reports 0:0. fchmod(2) on the
    same descriptor works.
  • inotify watchers on the containing directory receive IN_CREATE but no
    IN_MODIFY or IN_CLOSE_WRITE for writes made through the creating
    descriptor.
  • readlink("/proc/self/fd/N") for the creating descriptor returns a
    /run/host_virtiofs/... path that does not exist in the container's mount
    namespace, nor in any mount namespace that a --pid=host container can see
    (step 3).

Scope: only the descriptor returned by the creating open is affected. Opening
an existing file, with or without O_CREAT, binds to the container-facing
fakeowner inode, and a second name for the same file (a hard link) shares
that inode. Unlinking and recreating the file reproduces the split on the new
creating descriptor.

Environment

Host and Docker Desktop (from step 1 below):

ProductName:		macOS
ProductVersion:		27.0.1
BuildVersion:		26A434
arm64
Docker Desktop: Docker Desktop 4.96.0 (243774)  Engine: 29.9.1
VM kernel: 7.0.14-linuxkit  OS: Docker Desktop
  "UseLibkrun": true,
  "UseVirtualizationFramework": false,
  "UseVirtualizationFrameworkRosetta": false,
  "UseVirtualizationFrameworkVirtioFS": true

Guest, as seen from inside a container on that host:

  • Kernel: Linux 7.0.14-linuxkit #1 SMP PREEMPT Fri Oct 16 11:55:50 UTC 2026 aarch64
  • /proc/modules: fakeowner 135168 - - Live 0x0000000000000000 (O)
    (an out-of-tree loadable module)
  • Container /proc/self/mountinfo entry for the step 2 bind mount:
    210 201 0:46 /myusername/fakeowner-repro /mnt rw,nosuid,nodev,relatime - fakeowner /run/host_mark/Users rw,fakeowner

VM-side view from step 3, which scans every mount namespace visible to a
privileged --pid=host container:

== mnt:[4026531832], first pid 1 (initd)
54 35 0:45 / /run/host_mark/Users rw,nosuid,nodev,relatime - fakeowner /run/host_virtiofs/Users rw,mark,fakeowner
55 37 0:46 / /host_mnt/Users rw,nosuid,nodev,relatime - fakeowner /run/host_mark/Users rw,fakeowner
57 35 0:48 / /run/host_mark/Volumes rw,nosuid,nodev,relatime - fakeowner /run/host_virtiofs/Volumes rw,mark,fakeowner
58 37 0:49 / /host_mnt/Volumes rw,nosuid,nodev,relatime - fakeowner /run/host_mark/Volumes rw,fakeowner
60 35 0:51 / /run/host_mark/private rw,nosuid,nodev,relatime - fakeowner /run/host_virtiofs/private rw,mark,fakeowner
61 37 0:52 / /host_mnt/private rw,nosuid,nodev,relatime - fakeowner /run/host_mark/private rw,fakeowner
63 35 0:54 / /run/host_mark/tmp rw,nosuid,nodev,relatime - fakeowner /run/host_virtiofs/tmp rw,mark,fakeowner
64 37 0:55 / /host_mnt/tmp rw,nosuid,nodev,relatime - fakeowner /run/host_mark/tmp rw,fakeowner
66 35 0:57 / /run/host_mark/var/folders rw,nosuid,nodev,relatime - fakeowner /run/host_virtiofs/var/folders rw,mark,fakeowner
67 37 0:58 / /host_mnt/var/folders rw,nosuid,nodev,relatime - fakeowner /run/host_mark/var/folders rw,fakeowner

5 mount namespaces scanned; 0 contain a virtiofs mount

The container's bind mount (0:46) is the /host_mnt/Users instance, whose
source is the mark instance (0:45) at /run/host_mark/Users. No scanned
namespace contains a virtiofs mount, so the backing mount is visible only
through the creating descriptor: its /proc/self/fdinfo mnt_id (53),
fstatfs magic, fstat device, and readlink path. Mount IDs 53, 56, 59,
62, and 65 are absent from the scan, and each directly precedes a share's
mark instance. That pattern is consistent with one backing virtiofs mount
per share, held somewhere a --pid=host container cannot see.

Layer How it is observed Device statfs magic
fakeowner, container-facing (/host_mnt/Users) step 2 mountinfo for /mnt; step 3; every non-creating descriptor 0:46 (00:2e in /proc/locks) 0x6a656a63
fakeowner, mark (/run/host_mark/Users) step 3 only; no descriptor ever lands on it 0:45 (00:2d; never appears) not observable
virtiofs, backing share (/run/host_virtiofs/Users) only the creating descriptor (mnt_id 53); absent from every scanned namespace 0:44 (00:2c in /proc/locks) 0x65735546

Steps to reproduce

Run these on the macOS host. Step 2 bind-mounts a new, empty host directory
under /Users (shared by default) at /mnt, runs the probes there, and
repeats them on the container's own /tmp (overlayfs) as a control. Each check
prints ok or BUG. Step 3 lists the fakeowner and virtiofs mounts in
every mount namespace visible to a privileged --pid=host container.

Step 1: host and Docker Desktop details

sw_vers
uname -m
docker version --format 'Docker Desktop: {{.Server.Platform.Name}}  Engine: {{.Server.Version}}'
docker info --format 'VM kernel: {{.KernelVersion}}  OS: {{.OperatingSystem}}'
grep -h -i -E 'libkrun|virtualizationframework' \
  ~/Library/Group\ Containers/group.com.docker/settings*.json

Step 2: reproducer

mkdir -p ~/fakeowner-repro && cd ~/fakeowner-repro
docker run --rm -i -v "$PWD":/mnt python:3.13-slim python3 -u - /mnt /tmp <<'PY'
import ctypes
import fcntl
import os
import re
import shutil
import struct
import subprocess
import sys
import time

libc = ctypes.CDLL(None, use_errno=True)
IN_NONBLOCK = os.O_NONBLOCK
IN_EVENTS = {0x100: "IN_CREATE", 0x2: "IN_MODIFY", 0x8: "IN_CLOSE_WRITE"}


def fdinfo(fd):
    fields = {}
    with open(f"/proc/self/fdinfo/{fd}") as fh:
        for line in fh:
            key, _, value = line.partition(":")
            fields.setdefault(key.strip(), value.strip())
    return fields


def fs_magic(fd):
    buf = ctypes.create_string_buffer(512)
    if libc.fstatfs(fd, buf) != 0:
        return "?"
    return hex(struct.unpack_from("l", buf)[0])


def describe(label, fd):
    info = fdinfo(fd)
    st = os.fstat(fd)
    print(f"    {label}: readlink={os.readlink(f'/proc/self/fd/{fd}')}")
    print(
        f"    {label}: mnt_id={info['mnt_id']} f_type={fs_magic(fd)} "
        f"st_dev={os.major(st.st_dev)}:{os.minor(st.st_dev)} st_ino={st.st_ino} "
        f"flags={info['flags']} lock={info.get('lock', '-')}"
    )


def mnt_id_of_dir(d):
    fd = os.open(d, os.O_RDONLY | os.O_DIRECTORY)
    try:
        return fdinfo(fd)["mnt_id"]
    finally:
        os.close(fd)


def flock_nb(fd):
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        return "blocked"
    return "ACQUIRED"


def verdict(ok):
    return "ok" if ok else "BUG"


def mount_line(d):
    best_point, best_line = "", ""
    with open("/proc/self/mountinfo") as fh:
        for line in fh:
            mount_point = line.split()[4]
            covers = d == mount_point or d.startswith(mount_point.rstrip("/") + "/")
            if covers and len(mount_point) >= len(best_point):
                best_point, best_line = mount_point, line.strip()
    return best_line


def probe_flock(d):
    print("  [1] flock(2): creator vs. later openers of a new file")
    path = f"{d}/repro-flock-{os.getpid()}.lock"
    flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
    fds = []
    try:
        fds = [os.open(path, flags, 0o644) for _ in range(3)]
        results = [flock_nb(fd) for fd in fds]
        print(f"    fd1 (created the file): {results[0]}")
        print(f"    fd2 (file existed):     {results[1]}  expected blocked -> {verdict(results[1] == 'blocked')}")
        print(f"    fd3 (file existed):     {results[2]}  expected blocked -> {verdict(results[2] == 'blocked')}")
        for label, fd in zip(("fd1", "fd2", "fd3"), fds):
            describe(label, fd)
        st = os.stat(path)
        print(f"    stat(path): st_dev={os.major(st.st_dev)}:{os.minor(st.st_dev)} st_ino={st.st_ino}")
        pid = str(os.getpid())
        with open("/proc/locks") as fh:
            for line in fh:
                if line.split()[4] == pid:
                    print(f"    /proc/locks: {line.strip()}")
    finally:
        for fd in fds:
            os.close(fd)
        os.unlink(path)


def probe_recreate(d):
    print("  [2] unlink + recreate repeats the split")
    path = f"{d}/repro-recreate-{os.getpid()}.lock"
    flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
    for round_no in (1, 2):
        fd1 = os.open(path, flags, 0o644)
        fd2 = os.open(path, flags, 0o644)
        first, second = flock_nb(fd1), flock_nb(fd2)
        print(f"    round {round_no}: fd1 {first}, fd2 {second}  expected blocked -> {verdict(second == 'blocked')}")
        os.close(fd1)
        os.close(fd2)
        os.unlink(path)


def probe_scope(d):
    print("  [3] scope: opens of an already-existing file (creator closed first), and a hard link")
    path = f"{d}/repro-scope-{os.getpid()}.lock"
    dir_mnt = mnt_id_of_dir(d)
    os.close(os.open(path, os.O_WRONLY | os.O_CREAT, 0o644))
    try:
        fd_creat = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
        fd_plain = os.open(path, os.O_RDWR)
        mnts = (fdinfo(fd_creat)["mnt_id"], fdinfo(fd_plain)["mnt_id"])
        print(f"    directory mnt_id={dir_mnt}; O_CREAT reopen mnt_id={mnts[0]}; O_RDWR reopen mnt_id={mnts[1]}")
        first, second = flock_nb(fd_creat), flock_nb(fd_plain)
        print(f"    flock: first {first}, second {second}  expected blocked -> {verdict(second == 'blocked')}")
        os.close(fd_creat)
        os.close(fd_plain)
        os.link(path, f"{path}.hardlink")
        fd_name, fd_link = os.open(path, os.O_RDWR), os.open(f"{path}.hardlink", os.O_RDWR)
        first, second = flock_nb(fd_name), flock_nb(fd_link)
        print(f"    hard link: via name {first}, via link {second}  expected blocked -> {verdict(second == 'blocked')}")
        os.close(fd_name)
        os.close(fd_link)
        os.unlink(f"{path}.hardlink")
    finally:
        os.unlink(path)


def probe_posix(d):
    print("  [4] POSIX fcntl(F_SETLK)/lockf(3): creator process vs. second process")
    path = f"{d}/repro-posix-{os.getpid()}.lock"
    fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
    try:
        fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        describe("parent", fd)
        read_end, write_end = os.pipe()
        child = os.fork()
        if child == 0:
            os.close(read_end)
            child_fd = os.open(path, os.O_RDWR)
            try:
                fcntl.lockf(child_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                info = fdinfo(child_fd)
                message = f"ACQUIRED (mnt_id={info['mnt_id']} lock={info.get('lock', '-')})"
            except OSError:
                message = "blocked"
            os.write(write_end, message.encode())
            os._exit(0)
        os.close(write_end)
        os.waitpid(child, 0)
        message = os.read(read_end, 4096).decode()
        os.close(read_end)
        print(f"    child: {message}  expected blocked -> {verdict(message == 'blocked')}")
    finally:
        os.close(fd)
        os.unlink(path)


def probe_ownership(d):
    print("  [5] fchown(2)/fchmod(2) through the creating descriptor vs. a later descriptor")
    path = f"{d}/repro-chown-{os.getpid()}.txt"
    fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
    try:
        os.fchown(fd, 1000, 1000)
        os.fchmod(fd, 0o751)
        st = os.stat(path)
        owner_ok = (st.st_uid, st.st_gid) == (1000, 1000)
        print(f"    creating fd: fchown(1000:1000) -> stat {st.st_uid}:{st.st_gid} {verdict(owner_ok)}")
        mode_ok = st.st_mode & 0o7777 == 0o751
        print(f"    creating fd: fchmod(0751)      -> stat {oct(st.st_mode & 0o7777)} {verdict(mode_ok)}")
        os.close(fd)
        fd = os.open(path, os.O_RDWR)
        os.fchown(fd, 1001, 1001)
        st = os.stat(path)
        owner_ok = (st.st_uid, st.st_gid) == (1001, 1001)
        print(f"    later fd:    fchown(1001:1001) -> stat {st.st_uid}:{st.st_gid} {verdict(owner_ok)}")
    finally:
        os.close(fd)
        os.unlink(path)


def drain_inotify(ifd):
    names = []
    try:
        buf = os.read(ifd, 65536)
    except BlockingIOError:
        return names
    offset = 0
    while offset < len(buf):
        _wd, mask, _cookie, name_len = struct.unpack_from("iIII", buf, offset)
        names.extend(name for bit, name in IN_EVENTS.items() if mask & bit)
        offset += 16 + name_len
    return names


def probe_inotify(d):
    print("  [6] inotify on the directory: create+write+close, then reopen+write+close")
    ifd = libc.inotify_init1(IN_NONBLOCK)
    libc.inotify_add_watch(ifd, d.encode(), sum(IN_EVENTS))
    path = f"{d}/repro-inotify-{os.getpid()}.txt"
    try:
        fd = os.open(path, os.O_WRONLY | os.O_CREAT, 0o644)
        os.write(fd, b"data")
        os.close(fd)
        created = drain_inotify(ifd)
        ok = created == ["IN_CREATE", "IN_MODIFY", "IN_CLOSE_WRITE"]
        print(f"    creating fd: {created}  expected IN_CREATE, IN_MODIFY, IN_CLOSE_WRITE -> {verdict(ok)}")
        fd = os.open(path, os.O_WRONLY | os.O_APPEND)
        os.write(fd, b"more")
        os.close(fd)
        later = drain_inotify(ifd)
        ok = later == ["IN_MODIFY", "IN_CLOSE_WRITE"]
        print(f"    later fd:    {later}  expected IN_MODIFY, IN_CLOSE_WRITE -> {verdict(ok)}")
    finally:
        os.close(ifd)
        os.unlink(path)


def probe_workaround(d):
    print("  [7] workaround: create and close first, then open the lock without O_CREAT")
    path = f"{d}/repro-workaround-{os.getpid()}.lock"
    os.close(os.open(path, os.O_WRONLY | os.O_CREAT, 0o644))
    try:
        fd1 = os.open(path, os.O_RDWR)
        fd2 = os.open(path, os.O_RDWR)
        first, second = flock_nb(fd1), flock_nb(fd2)
        print(f"    fd1 {first}, fd2 {second}  expected blocked -> {verdict(second == 'blocked')}")
        os.close(fd1)
        os.close(fd2)
    finally:
        os.unlink(path)


def holds_flock(pid):
    with open("/proc/locks") as fh:
        return any(line.split()[1] == "FLOCK" and line.split()[4] == str(pid) for line in fh)


def probe_flock_cli(d):
    print("  [8] util-linux flock(1): first invocation creates and holds the lock file, second tries it")
    if shutil.which("flock") is None:
        print("    skipped: flock(1) not installed")
        return
    path = f"{d}/repro-cli-{os.getpid()}.lock"
    holder = subprocess.Popen(["flock", "-n", path, "sleep", "10"])
    try:
        deadline = time.monotonic() + 5
        while not holds_flock(holder.pid) and time.monotonic() < deadline:
            time.sleep(0.05)
        if not holds_flock(holder.pid):
            print("    inconclusive: first flock(1) never took its lock")
            return
        second = subprocess.run(["flock", "-n", path, "true"], check=False).returncode
        outcome = "blocked" if second == 1 else f"ACQUIRED (exit {second})"
        print(f"    second invocation: {outcome}  expected blocked -> {verdict(second == 1)}")
    finally:
        holder.terminate()
        holder.wait()
        os.unlink(path)


def environment():
    print("== Environment")
    print(f"  uname: {' '.join(os.uname())}")
    print(f"  python: {sys.version.split()[0]}")
    with open("/proc/modules") as fh:
        for line in fh:
            if line.startswith("fakeowner "):
                print(f"  /proc/modules: {line.strip()}")
    symbols = re.compile(
        r"\sfakeowner_(atomic_open|open|open_realfile|lookup|create|inode_test|inode_set|flock|lock)\s"
    )
    found = []
    try:
        with open("/proc/kallsyms") as fh:
            found.extend(line.strip() for line in fh if symbols.search(line))
    except OSError as exc:
        print(f"  /proc/kallsyms unreadable: {exc}")
    for line in found:
        print(f"  /proc/kallsyms: {line}")
    lock_ops = [line for line in found if re.search(r"\sfakeowner_(flock|lock)\s", line)]
    print(f"  fakeowner_flock/fakeowner_lock symbols present: {bool(lock_ops)}")


environment()
for directory in sys.argv[1:]:
    print(f"\n== {directory} (directory mnt_id={mnt_id_of_dir(directory)})")
    print(f"  mountinfo: {mount_line(directory)}")
    for probe in (
        probe_flock,
        probe_recreate,
        probe_scope,
        probe_posix,
        probe_ownership,
        probe_inotify,
        probe_workaround,
        probe_flock_cli,
    ):
        probe(directory)
PY

Step 3 (privileged): VM-side mount namespaces

docker run --rm -i --privileged --pid=host python:3.13-slim python3 - <<'PY'
import os

namespaces = {}
for pid in sorted((p for p in os.listdir("/proc") if p.isdigit()), key=int):
    try:
        ns = os.readlink(f"/proc/{pid}/ns/mnt")
        if ns in namespaces:
            continue
        with open(f"/proc/{pid}/comm") as fh:
            comm = fh.read().strip()
        with open(f"/proc/{pid}/mountinfo") as fh:
            lines = [line.rstrip() for line in fh if " - virtiofs " in line or " - fakeowner " in line]
    except OSError:
        continue
    namespaces[ns] = (pid, comm, lines)

with_virtiofs = 0
for ns, (pid, comm, lines) in namespaces.items():
    has_virtiofs = any(" - virtiofs " in line for line in lines)
    with_virtiofs += has_virtiofs
    if pid == "1" or has_virtiofs:
        print(f"== {ns}, first pid {pid} ({comm})")
        print("\n".join(lines) if lines else "  (no virtiofs or fakeowner mounts)")
print(f"\n{len(namespaces)} mount namespaces scanned; {with_virtiofs} contain a virtiofs mount")
PY

Expected result

On the container's /tmp, every check prints ok:

  • [1] fd2 and fd3 are blocked; all three descriptors report the same
    mnt_id and f_type; /proc/locks lists a single lock.
  • [2] fd2 is blocked in both rounds.
  • [3] Reopens report the directory's mnt_id; the second lock and the lock
    through the hard link are blocked.
  • [4] The child process is blocked.
  • [5] stat reports 1000:1000 and mode 0751 after the creating
    descriptor's fchown/fchmod, then 1001:1001.
  • [6] The creating descriptor produces IN_CREATE, IN_MODIFY,
    IN_CLOSE_WRITE.
  • [7] fd2 is blocked.
  • [8] The second flock(1) invocation is blocked.

The fakeowner bind mount at /mnt should behave identically.

Actual result

On /mnt, these checks print BUG: [1] fd2, [2] both rounds, [4], the
fchown line of [5], the creating-descriptor line of [6], and [8]. The
remaining checks print ok: [1] fd3, [3], the fchmod and later-descriptor
lines of [5], the later-descriptor line of [6], and [7].

The descriptor details in [1] show the split. fd1 reports the backing
mount's mnt_id, f_type=0x65735546, a readlink under
/run/host_virtiofs/Users/, and its lock on 00:2c:<ino>. fd2 and fd3
report the bind mount's mnt_id, f_type=0x6a656a63, the /mnt/... path, and
fd2's lock on 00:2e:<ino>. fd3 conflicts with fd2, so only fd1 is
isolated.

Observed output of step 2 (image python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f;
docker pull progress omitted):

== Environment
  uname: Linux e01ee54b67b0 7.0.14-linuxkit #1 SMP PREEMPT Fri Oct 16 11:55:50 UTC 2026 aarch64
  python: 3.13.16
  /proc/modules: fakeowner 135168 - - Live 0x0000000000000000 (O)
  /proc/kallsyms: 0000000000000000 t fakeowner_inode_test	[fakeowner]
  /proc/kallsyms: 0000000000000000 t fakeowner_inode_set	[fakeowner]
  /proc/kallsyms: 0000000000000000 t fakeowner_lookup	[fakeowner]
  /proc/kallsyms: 0000000000000000 t fakeowner_create	[fakeowner]
  /proc/kallsyms: 0000000000000000 t fakeowner_open	[fakeowner]
  /proc/kallsyms: 0000000000000000 t fakeowner_atomic_open	[fakeowner]
  /proc/kallsyms: 0000000000000000 t fakeowner_open_realfile	[fakeowner]
  fakeowner_flock/fakeowner_lock symbols present: False

== /mnt (directory mnt_id=210)
  mountinfo: 210 201 0:46 /myusername/fakeowner-repro /mnt rw,nosuid,nodev,relatime - fakeowner /run/host_mark/Users rw,fakeowner
  [1] flock(2): creator vs. later openers of a new file
    fd1 (created the file): ACQUIRED
    fd2 (file existed):     ACQUIRED  expected blocked -> BUG
    fd3 (file existed):     blocked  expected blocked -> ok
    fd1: readlink=/run/host_virtiofs/Users/myusername/fakeowner-repro/repro-flock-1.lock
    fd1: mnt_id=53 f_type=0x65735546 st_dev=0:44 st_ino=79847 flags=02402001 lock=1: FLOCK  ADVISORY  WRITE 1 00:2c:79847 0 EOF
    fd2: readlink=/mnt/repro-flock-1.lock
    fd2: mnt_id=210 f_type=0x6a656a63 st_dev=0:44 st_ino=79847 flags=02402001 lock=1: FLOCK  ADVISORY  WRITE 1 00:2e:79847 0 EOF
    fd3: readlink=/mnt/repro-flock-1.lock
    fd3: mnt_id=210 f_type=0x6a656a63 st_dev=0:44 st_ino=79847 flags=02402001 lock=-
    stat(path): st_dev=0:44 st_ino=79847
    /proc/locks: 15: FLOCK  ADVISORY  WRITE 1 00:2e:79847 0 EOF
    /proc/locks: 16: FLOCK  ADVISORY  WRITE 1 00:2c:79847 0 EOF
  [2] unlink + recreate repeats the split
    round 1: fd1 ACQUIRED, fd2 ACQUIRED  expected blocked -> BUG
    round 2: fd1 ACQUIRED, fd2 ACQUIRED  expected blocked -> BUG
  [3] scope: opens of an already-existing file (creator closed first), and a hard link
    directory mnt_id=210; O_CREAT reopen mnt_id=210; O_RDWR reopen mnt_id=210
    flock: first ACQUIRED, second blocked  expected blocked -> ok
    hard link: via name ACQUIRED, via link blocked  expected blocked -> ok
  [4] POSIX fcntl(F_SETLK)/lockf(3): creator process vs. second process
    parent: readlink=/run/host_virtiofs/Users/myusername/fakeowner-repro/repro-posix-1.lock
    parent: mnt_id=53 f_type=0x65735546 st_dev=0:44 st_ino=79851 flags=02400002 lock=1: POSIX  ADVISORY  WRITE 1 00:2c:79851 0 EOF
    child: ACQUIRED (mnt_id=210 lock=1: POSIX  ADVISORY  WRITE 7 00:2e:79851 0 EOF)  expected blocked -> BUG
  [5] fchown(2)/fchmod(2) through the creating descriptor vs. a later descriptor
    creating fd: fchown(1000:1000) -> stat 0:0 BUG
    creating fd: fchmod(0751)      -> stat 0o751 ok
    later fd:    fchown(1001:1001) -> stat 1001:1001 ok
  [6] inotify on the directory: create+write+close, then reopen+write+close
    creating fd: ['IN_CREATE']  expected IN_CREATE, IN_MODIFY, IN_CLOSE_WRITE -> BUG
    later fd:    ['IN_MODIFY', 'IN_CLOSE_WRITE']  expected IN_MODIFY, IN_CLOSE_WRITE -> ok
  [7] workaround: create and close first, then open the lock without O_CREAT
    fd1 ACQUIRED, fd2 blocked  expected blocked -> ok
  [8] util-linux flock(1): first invocation creates and holds the lock file, second tries it
    second invocation: ACQUIRED (exit 0)  expected blocked -> BUG

== /tmp (directory mnt_id=201)
  mountinfo: 201 116 0:65 / / rw,relatime - overlay overlay rw,lowerdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/404/fs:/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/403/fs:/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/402/fs:/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/401/fs:/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/400/fs,upperdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/405/fs,workdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/405/work,nouserxattr
  [1] flock(2): creator vs. later openers of a new file
    fd1 (created the file): ACQUIRED
    fd2 (file existed):     blocked  expected blocked -> ok
    fd3 (file existed):     blocked  expected blocked -> ok
    fd1: readlink=/tmp/repro-flock-1.lock
    fd1: mnt_id=201 f_type=0x794c7630 st_dev=0:65 st_ino=14641 flags=02402001 lock=1: FLOCK  ADVISORY  WRITE 1 00:41:14641 0 EOF
    fd2: readlink=/tmp/repro-flock-1.lock
    fd2: mnt_id=201 f_type=0x794c7630 st_dev=0:65 st_ino=14641 flags=02402001 lock=-
    fd3: readlink=/tmp/repro-flock-1.lock
    fd3: mnt_id=201 f_type=0x794c7630 st_dev=0:65 st_ino=14641 flags=02402001 lock=-
    stat(path): st_dev=0:65 st_ino=14641
    /proc/locks: 1: FLOCK  ADVISORY  WRITE 1 00:41:14641 0 EOF
  [2] unlink + recreate repeats the split
    round 1: fd1 ACQUIRED, fd2 blocked  expected blocked -> ok
    round 2: fd1 ACQUIRED, fd2 blocked  expected blocked -> ok
  [3] scope: opens of an already-existing file (creator closed first), and a hard link
    directory mnt_id=201; O_CREAT reopen mnt_id=201; O_RDWR reopen mnt_id=201
    flock: first ACQUIRED, second blocked  expected blocked -> ok
    hard link: via name ACQUIRED, via link blocked  expected blocked -> ok
  [4] POSIX fcntl(F_SETLK)/lockf(3): creator process vs. second process
    parent: readlink=/tmp/repro-posix-1.lock
    parent: mnt_id=201 f_type=0x794c7630 st_dev=0:65 st_ino=14641 flags=02400002 lock=1: POSIX  ADVISORY  WRITE 1 00:41:14641 0 EOF
    child: blocked  expected blocked -> ok
  [5] fchown(2)/fchmod(2) through the creating descriptor vs. a later descriptor
    creating fd: fchown(1000:1000) -> stat 1000:1000 ok
    creating fd: fchmod(0751)      -> stat 0o751 ok
    later fd:    fchown(1001:1001) -> stat 1001:1001 ok
  [6] inotify on the directory: create+write+close, then reopen+write+close
    creating fd: ['IN_CREATE', 'IN_MODIFY', 'IN_CLOSE_WRITE']  expected IN_CREATE, IN_MODIFY, IN_CLOSE_WRITE -> ok
    later fd:    ['IN_MODIFY', 'IN_CLOSE_WRITE']  expected IN_MODIFY, IN_CLOSE_WRITE -> ok
  [7] workaround: create and close first, then open the lock without O_CREAT
    fd1 ACQUIRED, fd2 blocked  expected blocked -> ok
  [8] util-linux flock(1): first invocation creates and holds the lock file, second tries it
    second invocation: blocked  expected blocked -> ok

Diagnostics

What the evidence shows

  1. One path, two inodes. The creating descriptor and every later
    descriptor differ in mnt_id, statfs magic, and the device recorded with
    their locks in /proc/locks (00:2c vs 00:2e), while sharing the same
    inode number. From step 2's output for /mnt:

    Descriptor mnt_id f_type /proc/locks device fstat st_dev
    creating O_CREAT open 53 (backing virtiofs) 0x65735546 00:2c 0:44
    any later open 210 (container bind mount) 0x6a656a63 00:2e 0:44

    The mark instance's device (0:45, 00:2d) appears for neither
    descriptor, so the creating descriptor skips both fakeowner layers.

  2. Locks attach to each descriptor's own inode. The later descriptor's
    lock is recorded on the container-facing fakeowner inode (00:2e). That
    is the VFS default (file_inode(filp)) when a filesystem supplies no
    ->flock or ->lock operation, and no fakeowner_flock or
    fakeowner_lock symbol exists. Locks taken through the two descriptors
    therefore never meet on a common inode.

  3. The fakeowner inode is otherwise canonical. Reopening an existing
    file, with or without O_CREAT, binds to the bind mount ([3]). Two hard
    links to one file share lock state ([3]), consistent with fakeowner
    hashing its inodes per backing inode (fakeowner_inode_test /
    fakeowner_inode_set). In the reporter's environment, opening 12,000
    existing files in long-idle directories (most of them likely not in the
    dentry cache), with and without O_CREAT, bound every descriptor to the
    bind mount. Only the creating path escapes.

  4. fstat() cannot see the split, but mnt_id can. fstat reports
    st_dev=0:44 and the same st_ino for both descriptors and for
    stat(path). For the creating descriptor this is because it is the
    virtiofs inode; for the others it is because the fakeowner layers
    report the backing device in getattr. The common lock-file check that
    compares (st_dev, st_ino) of the descriptor and the path cannot detect
    the problem. The descriptor's mnt_id (/proc/self/fdinfo/<fd>, or
    statx(STATX_MNT_ID)) does.

Module symbols

Verbatim from /proc/kallsyms in an unprivileged container (addresses are
hidden by the kernel's pointer policy). Step 2 prints the same lines:

0000000000000000 t fakeowner_inode_test	[fakeowner]
0000000000000000 t fakeowner_inode_set	[fakeowner]
0000000000000000 t fakeowner_lookup	[fakeowner]
0000000000000000 t fakeowner_create	[fakeowner]
0000000000000000 t fakeowner_open	[fakeowner]
0000000000000000 t fakeowner_atomic_open	[fakeowner]
0000000000000000 t fakeowner_open_realfile	[fakeowner]

fakeowner_flock and fakeowner_lock are absent. On this kernel, kallsyms
lists only the module's text symbols, so its operation tables are not visible.
Both stacked layers are instances of this one module. The module also carries
a fakeowner_atomic_open tracepoint, which should let maintainers confirm the
path below directly.

Interpretation (inferred; the reporter does not have the fakeowner source)

The behavior is consistent with the create branch of fakeowner_atomic_open()
returning a struct file opened on the backing virtiofs path. That leaves
f_path and f_inode belonging to the virtiofs mount, instead of the
instance instantiating its own dentry with its own inode and finishing the
open on it, as fakeowner_open() does for existing files. User space cannot
tell which stacked instance lets the backing file escape. The container-facing
instance may pass the create down to the mark instance and return that
result, or a single instance may resolve straight to the backing file. Either
way the escape spans both layers, because the mark instance's device never
appears (point 1). The VFS calls ->atomic_open from lookup_open() when the
final dentry is negative or not yet cached. Because existing-file opens bind
correctly even when cold (point 3), the divergence is confined to the branch
that creates the file.

Impact

  • Lock files created on first use. Mutual exclusion silently fails
    whenever the lock file did not exist before the first locker opened it with
    O_CREAT; every later locker uses a different inode. This covers
    per-operation or per-session lock files, flock(1) in shell scripts (check
    [8]), and the Python filelock package: with filelock 4.2.2, a second
    process acquired a FileLock on a freshly created path while the first still
    held it, and /tmp blocked as expected.
  • Ownership set after creation. Root processes that create a file and then
    fchown the returned descriptor leave the file at 0:0 in the container's
    view. Examples include archive extraction, package installation, and copy
    tools that preserve ownership.
  • File watchers. inotify-based watchers on a bind mount (dev servers, test
    runners, build tools) see IN_CREATE for a new file, often while it is
    still empty, but no IN_MODIFY or IN_CLOSE_WRITE for content written
    through the creating descriptor.
  • Diagnostics. /proc/self/fd, lsof, and error messages derived from
    descriptor paths show /run/host_virtiofs/... paths that do not exist in the
    container, or in any other mount namespace visible to a --pid=host
    container (step 3).

The reporter has reproduced this only with Docker VMM and has not tested the
other virtual machine manager options.

Workarounds

Application level (lock files)

Create the file and close that descriptor first, then open the lock descriptor
without O_CREAT:

lock_path.touch(exist_ok=True)
handle = lock_path.open("r+", encoding="utf-8")  # O_RDWR without O_CREAT
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

The creating descriptor never holds a lock, so racing creators are safe, and
every lock holder opens an existing file and receives the fakeowner inode
(check [7]). This protects only processes that all follow the pattern: a
process that still creates and locks in one open(O_CREAT) (older code,
flock(1), filelock) holds an isolated lock that the others cannot see. For
fchown and inotify, close and reopen a newly created file before changing
ownership or writing its content.

A descriptor is affected exactly when its mnt_id in
/proc/self/fdinfo/<fd> differs from the mnt_id of its directory.

Suggested fix

In every stacked fakeowner instance, on the create branch of
fakeowner_atomic_open(), create the file in the layer below. Then
instantiate the instance's own dentry with its own inode for the new file (the
inode fakeowner_lookup() would return), and finish the open on that dentry
with the instance's own open path, for example
finish_open(file, dentry, fakeowner_open). The struct file returned to the
container then belongs to the container-facing mount and inode, exactly as
when an existing file is opened. fuse_create_open() in fs/fuse/dir.c is an
in-tree example of finishing an atomic create-open on the filesystem's own
dentry. Alternatively, if an instance does not provide ->atomic_open, the
VFS creates through ->create (fakeowner_create already exists) and opens
through ->open.

Forwarding ->flock and ->lock to the backing file would address only
locking and leave fchown, inotify, and descriptor paths wrong. POSIX-lock
forwarding would also need to preserve close-time lock release semantics, so it
is not a substitute for fixing the open.

A fix is verified when step 2 prints ok for every check on /mnt.

Platform

macOS

Version information

Client:
 Version:           29.9.1
 API version:       1.56
 Go version:        go1.26.9
 Git commit:        dfc520d
 Built:             Wed Oct 14 18:31:22 2026
 OS/Arch:           darwin/arm64
 Context:           desktop-linux

Server: Docker Desktop 4.96.0 (243774)
 Engine:
  Version:          29.9.1
  API version:      1.56 (minimum version 1.40)
  Go version:       go1.26.9
  Git commit:       b26fb77
  Built:            Wed Oct 14 18:31:50 2026
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          v2.4.2
  GitCommit:        b7a25261e029d68978bbf290df0a62fc819b3c51
 runc:
  Version:          1.5.2
  GitCommit:        v1.5.2-0-g29dd3dc2
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
Client:
 Version:    29.9.1
 Context:    desktop-linux
 Debug Mode: false
 Plugins:
  agent: Docker AI Agent Runner (Docker Inc.)
    Version:  v1.149.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-agent
  ai: Docker AI Agent - Ask Gordon (Docker Inc.)
    Version:  v1.31.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-ai
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.38.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v5.6.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-compose
  debug: Get a shell into any image or container (Docker Inc.)
    Version:  0.0.47
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-debug
  desktop: Docker Desktop commands (Docker Inc.)
    Version:  4.96.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-desktop
  dhi: CLI for managing Docker Hardened Images (Docker Inc.)
    Version:  v0.0.7
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-dhi
  extension: Manages Docker extensions (Docker Inc.)
    Version:  v0.2.31
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-extension
  init: Creates Docker-related starter files for your project (Docker Inc.)
    Version:  v1.4.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-init
  mcp: Docker MCP Plugin (Docker Inc.)
    Version:  v0.44.1
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-mcp
  model: Docker Model Runner (Docker Inc.)
    Version:  v1.2.6
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-model
  offload: Docker Offload (Docker Inc.)
    Version:  v0.7.4
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-offload
  pass: Docker Pass Secrets Manager Plugin (beta) (Docker Inc.)
    Version:  v0.3.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-pass
  sandbox: "docker sandbox" is deprecated, use Docker Sandboxes instead (Docker Inc.)
    Version:  v0.13.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-sandbox
  scout: Docker Scout (Docker Inc.)
    Version:  v1.26.0
    Path:     /Users/andrewlester/.docker/cli-plugins/docker-scout

Server:
 Containers: 2
  Running: 2
  Paused: 0
  Stopped: 0
 Images: 3
 Server Version: 29.9.1
 Storage Driver: overlayfs
  driver-type: io.containerd.snapshotter.v1
 Logging Driver: local
 Cgroup Driver: cgroupfs
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 CDI spec directories:
  /etc/cdi
  /var/run/cdi
 Discovered Devices:
  cdi: docker.com/gpu=webgpu
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: b7a25261e029d68978bbf290df0a62fc819b3c51
 runc version: v1.5.2-0-g29dd3dc2
 init version: de40ad0
 Security Options:
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 7.0.14-linuxkit
 Operating System: Docker Desktop
 OSType: linux
 Architecture: aarch64
 CPUs: 12
 Total Memory: 11.67GiB
 Name: docker-desktop
 ID: 69d91972-450c-4f06-91ad-3ef9b1314e5a
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 HTTP Proxy: http.docker.internal:3128
 HTTPS Proxy: http.docker.internal:3128
 No Proxy: hubproxy.docker.internal
 Labels:
  com.docker.desktop.address=unix:///Users/andrewlester/Library/Containers/com.docker.docker/Data/docker-cli.sock
 Experimental: false
 Insecure Registries:
  hubproxy.docker.internal:5555
  127.0.0.0/8
  ::1/128
 Live Restore Enabled: false
 Firewall Backend: iptables

Diagnostics ID

No response

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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions