Repository navigation
Docker Desktop (Docker VMM): fakeowner atomic_open binds newly created files to the backing virtiofs inode, breaking flock, POSIX locks, fchown, and inotify #765
Description
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 obtainLOCK_EX.
util-linuxflock(1)and the Pythonfilelockpackage 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 reports0:0.fchmod(2)on the
same descriptor works.- inotify watchers on the containing directory receive
IN_CREATEbut no
IN_MODIFYorIN_CLOSE_WRITEfor 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=hostcontainer 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/mountinfoentry 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*.jsonStep 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)
PYStep 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")
PYExpected result
On the container's /tmp, every check prints ok:
- [1]
fd2andfd3areblocked; all three descriptors report the same
mnt_idandf_type;/proc/lockslists a single lock. - [2]
fd2isblockedin both rounds. - [3] Reopens report the directory's
mnt_id; the second lock and the lock
through the hard link areblocked. - [4] The child process is
blocked. - [5]
statreports1000:1000and mode0751after the creating
descriptor'sfchown/fchmod, then1001:1001. - [6] The creating descriptor produces
IN_CREATE,IN_MODIFY,
IN_CLOSE_WRITE. - [7]
fd2isblocked. - [8] The second
flock(1)invocation isblocked.
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
-
One path, two inodes. The creating descriptor and every later
descriptor differ inmnt_id,statfsmagic, and the device recorded with
their locks in/proc/locks(00:2cvs00:2e), while sharing the same
inode number. From step 2's output for/mnt:Descriptor mnt_idf_type/proc/locksdevicefstatst_devcreating O_CREATopen53(backingvirtiofs)0x6573554600:2c0:44any later open 210(container bind mount)0x6a656a6300:2e0:44The
markinstance's device (0:45,00:2d) appears for neither
descriptor, so the creating descriptor skips bothfakeownerlayers. -
Locks attach to each descriptor's own inode. The later descriptor's
lock is recorded on the container-facingfakeownerinode (00:2e). That
is the VFS default (file_inode(filp)) when a filesystem supplies no
->flockor->lockoperation, and nofakeowner_flockor
fakeowner_locksymbol exists. Locks taken through the two descriptors
therefore never meet on a common inode. -
The
fakeownerinode is otherwise canonical. Reopening an existing
file, with or withoutO_CREAT, binds to the bind mount ([3]). Two hard
links to one file share lock state ([3]), consistent withfakeowner
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 withoutO_CREAT, bound every descriptor to the
bind mount. Only the creating path escapes. -
fstat()cannot see the split, butmnt_idcan.fstatreports
st_dev=0:44and the samest_inofor both descriptors and for
stat(path). For the creating descriptor this is because it is the
virtiofsinode; for the others it is because thefakeownerlayers
report the backing device ingetattr. The common lock-file check that
compares(st_dev, st_ino)of the descriptor and the path cannot detect
the problem. The descriptor'smnt_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 Pythonfilelockpackage: withfilelock4.2.2, a second
process acquired aFileLockon a freshly created path while the first still
held it, and/tmpblocked as expected. - Ownership set after creation. Root processes that create a file and then
fchownthe returned descriptor leave the file at0:0in 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) seeIN_CREATEfor a new file, often while it is
still empty, but noIN_MODIFYorIN_CLOSE_WRITEfor 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: iptablesDiagnostics ID
No response
Activity