Bug report
Summary
On Docker Desktop 4.87.0 for Mac with Docker VMM (libkrun) virtualization,
lstat() on symbolic links inside host bind mounts returns wrong, unstable
metadata. Freshly created symlinks can report st_size=0 (POSIX requires a
symlink's st_size to equal the length of its target path), longer-lived
symlinks decay to st_size=0 and st_nlink=0, and the values served for a
single unchanged path flap between correct and zeroed across consecutive
observations. readlink() usually returns the correct target, but on
symlinks in the decayed st_nlink=0 state a process can also observe
zero-length readlink content while another process reads the correct
target moments later, so data reads flap as well.
git caches lstat metadata (including st_size) per index entry to detect
modifications, so any repository bind-mounted from the host shows phantom
symlink modifications:
- Right after container start or a branch checkout,
git status lists
every symlink as modified although nothing touched them.
git restore . appears to do nothing: the first git status afterwards
still lists the symlinks, and only the second run shows a clean tree,
regardless of how much time passes between the two runs.
git pull --rebase fails on "local edits" to files that git status
did not list; repeated git rebase --continue gradually "heals" it.
This is a regression from Docker Desktop 4.86 and is specific to Docker
VMM. The same host, image, volume, and workflow behave correctly on 4.86
with VMM, and on 4.87 with Apple Virtualization Framework using either
VirtioFS or gRPC FUSE.
Environment
- Host: macOS 26.6, Apple Silicon (arm64)
- Docker Desktop: 4.87.0 (236836), client/engine 29.7.2, API 1.55,
containerd v2.2.5, runc 1.3.6
- Virtualization: Docker VMM (
"UseLibkrun": true)
- Guest kernel (4.87):
7.0.12-linuxkit #1 SMP PREEMPT Wed Aug 12 20:18:49 UTC 2026 aarch64
- Guest kernel (4.86, not affected):
6.12.76-linuxkit #1 SMP Mon Jul 27 16:56:11 UTC 2026 aarch64
- Bind mount inside the container, from
/proc/mounts:
host /simpletest virtiofs rw,nosuid,nodev,relatime,ignore_atime,no_xattr 0 0
- git inside the container: 2.54.0
Backend matrix
Captured by toggling the Docker Desktop UI and reading
~/Library/Group Containers/group.com.docker/settings-store.json:
| Configuration (Docker Desktop 4.87 unless noted) |
Result |
4.86, Docker VMM (UseLibkrun=true) |
OK |
Docker VMM (UseLibkrun=true, VirtioFS keys present) |
broken |
Docker VMM (UseLibkrun=true, gRPC FUSE keys present) |
broken |
AVF + VirtioFS (UseVirtualizationFramework=true, UseVirtualizationFrameworkVirtioFS=true) |
OK |
AVF + gRPC FUSE (UseVirtualizationFramework=true, UseGrpcfuse=true) |
OK |
In VMM mode the file-sharing selector is not shown in the UI; the
UseGrpcfuse / UseVirtualizationFrameworkVirtioFS keys remain in the
settings file but appear to be ignored, and both recorded VMM combinations
misbehave identically. Since AVF + VirtioFS on 4.87 is healthy, the guest
virtiofs client alone does not explain it; the defect is in the VMM
backend's virtiofs device (or its interaction with the 7.0.12 guest).
Expected result
A symlink's st_size equals the length of its target path and stays
stable, on every filesystem:
$ ln -s 0123456789abcde /mnt/probe_link && stat -c 'st_size=%s st_nlink=%h' /mnt/probe_link
st_size=15 st_nlink=1
The same probe against a non-virtiofs filesystem in the same container
(/tmp, overlayfs) returns exactly that.
Actual result
On the VMM virtiofs bind mount, observed from inside the container:
$ python3 -c 'import os; os.symlink("0123456789abcde", "link_a")'
$ # lstat sampled at t=0, 0.3s, 1s, 2s, 4s, 8s, 15s, 25s after creation:
t=0 size=0 nlink=1 ino=5371 readlink_len=15
t=25 size=0 nlink=1 ino=5371 readlink_len=15 (all samples identical)
readlink() is correct (15 bytes) while st_size stays 0. Other symlinks
in the same mount show the opposite transition, from correct to zeroed and
back, with no guest-side activity on those paths in between:
$ ls -la GEMINI.md # observation 1
lrwxrwxrwx 0 root root 0 Aug 19 16:22 GEMINI.md -> AGENTS.md (size 0, nlink 0)
$ stat -c 'size=%s nlink=%h' GEMINI.md # ~1 minute later
size=9 nlink=1 (healed)
$ # minutes later, git status reports GEMINI.md as modified again (decayed)
A two-day-old symlink whose target is a directory stayed zeroed across
every observation over several minutes (.agents/skills/bar, target
../../.claude/skills/bar, real length 29):
$ stat -c 'size=%s nlink=%h' .agents/skills/bar
size=0 nlink=0 # st_nlink=0 for an existing, listable path
For a symlink in that decayed state, git's own readlink() observed zero
bytes of content while a shell readlink on the same path immediately
afterwards returned the correct 9-byte target:
$ stat -c '%n size=%s nlink=%h' CLAUDE.md && readlink CLAUDE.md
CLAUDE.md size=0 nlink=0
AGENTS.md
$ git diff CLAUDE.md # rendered from a zero-length readlink by git
diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
One-shot reproducer
Prerequisite: Docker Desktop 4.87.x on macOS with Virtual Machine Manager
selected as the virtualization backend. Save as reproduce-vmm-symlink.sh
and run from the host. The metadata corruption is timing-dependent; if a
run comes out clean, re-run it (in our environment it fires nearly every
time, in one of the two probes).
#!/usr/bin/env bash
set -euo pipefail
workdir="$(mktemp -d "$HOME/vmm-symlink-repro.XXXXXX")"
trap 'rm -rf "$workdir"' EXIT
echo "== probe 1: symlink lstat metadata (every st_size should be 15) =="
docker run --rm -v "$workdir":/mnt alpine:3.22 sh -c '
for i in 1 2 3 4 5; do ln -s 0123456789abcde /mnt/link$i; done
stat -c "%n st_size=%s st_nlink=%h" /mnt/link*
sleep 3
echo "--- after 3s:"
stat -c "%n st_size=%s st_nlink=%h" /mnt/link*
'
echo "== probe 2: git phantom symlink modification (both runs should be empty) =="
docker run --rm -v "$workdir":/mnt alpine:3.22 sh -c '
apk add --quiet git
cd /mnt && git init -q repo && cd repo
git config user.email repro@example.com && git config user.name repro
echo hello > tgt.txt && ln -s tgt.txt link1
git add -A && git commit -qm init
rm link1 && git checkout -- link1
sleep 3
echo "--- git status run 1:"; git status --porcelain
echo "--- git status run 2:"; git status --porcelain
'
Observed output for probe 2 on 4.87 VMM (a from-scratch repository):
--- git status run 1:
M link1
--- git status run 2:
The same script under Docker Desktop 4.86, or under 4.87 with Apple
Virtualization Framework (VirtioFS or gRPC FUSE), prints st_size=15 for
every link and two empty status runs.
Diagnostics
Two different inode numbering ranges for one path
Deleting and recreating a symlink at the same path moved it between two
distinct inode-number ranges with different metadata quality, suggesting
two attribute sources inside the backend:
$ ln -s tgt.txt link1 && git add -A && git commit -qm init
$ stat -c 'size=%s nlink=%h ino=%i' link1
size=7 nlink=1 ino=47438504 # correct size, "high" inode range
$ rm link1 && git restore .
$ stat -c 'size=%s nlink=%h ino=%i' link1
size=0 nlink=1 ino=5437 # zeroed size, "low" inode range
Why git needs exactly two git status runs
git stores each index entry's last-seen lstat data (size, mtime, ctime,
inode). For a symlink the blob content is the target string, so a correct
st_size equals the target length. Two code paths matter:
- If the current
st_size differs from a nonzero cached size, git
reports the entry as modified without reading the link at all.
- If only timestamps/inode changed, or the cached size is 0, git does a
full readlink() compare; on a match it silently rewrites the index
entry with the freshly observed stat data ("racily clean" repair).
Each git status therefore re-rolls the dice on whatever metadata the
virtiofs device serves at that instant: one run records flapped values
into the index or reports a phantom diff, and the next run, observing a
different flap state, repairs it. Because readlink() content can itself
come back empty in the decayed state (see above), even git's full content
re-check can confirm a phantom modification instead of clearing it. Captured on the from-scratch repository
above (git ls-files --debug): the index recorded size: 7 at commit
time, size: 0 after git restore ., run 1 printed M link1, run 2
was clean. Waiting 15+ seconds between the runs does not change the
pattern, so this is not a fixed-interval cache timeout.
The same mechanism explains the git pull --rebase failures on files that
git status never listed: each rebase step re-stats the tree and can
observe a different flap state than the status run that preceded it.
Impact on a real repository
On a bind-mounted repository whose index was written while metadata was
correct, consecutive git status runs seconds apart, with zero writes in
between, disagree and even grow:
--- status run 1: 12 phantom entries
M .agents/skills/foo-analysis
M .agents/skills/foo-debug
... (12 symlinks, all actually unmodified)
--- status run 2: 14 phantom entries
M .agents/skills/... (same 12)
M CLAUDE.md (symlink, healed earlier, decayed again)
M GEMINI.md (symlink, healed earlier, decayed again)
git ls-files --debug confirms every phantom entry is a symlink whose
index-recorded size is the true target length while live lstat() returns
0 (e.g. .agents/skills/bar: index size: 29, live size=0 nlink=0).
Workaround
Switch the virtualization backend from Docker VMM to Apple Virtualization
Framework with VirtioFS (or gRPC FUSE):
"UseGrpcfuse": false,
"UseLibkrun": false,
"UseVirtualizationFramework": true,
"UseVirtualizationFrameworkRosetta": false,
"UseVirtualizationFrameworkVirtioFS": true
Reverting to Docker Desktop 4.86 with VMM also avoids the problem.
Platform
macOS
Version information
Client:
Version: 29.7.2
API version: 1.55
Go version: go1.26.5
Git commit: a7dcaa6
Built: Wed Aug 5 18:27:50 2026
OS/Arch: darwin/arm64
Context: desktop-linux
Server: Docker Desktop 4.88.1 (237512)
Engine:
Version: 29.7.2
API version: 1.55 (minimum version 1.40)
Go version: go1.26.5
Git commit: 6a43e3d
Built: Wed Aug 5 18:28:35 2026
OS/Arch: linux/arm64
Experimental: false
containerd:
Version: v2.3.3
GitCommit: aad11006b869517fcd3009450b6f82da282e1a9b
runc:
Version: 1.4.3
GitCommit: v1.4.3-0-gbb14dabe
docker-init:
Version: 0.19.0
GitCommit: de40ad0
Client:
Version: 29.7.2
Context: desktop-linux
Debug Mode: false
Plugins:
agent: Docker AI Agent Runner (Docker Inc.)
Version: v1.124.0
Path: /Users/username/.docker/cli-plugins/docker-agent
ai: Docker AI Agent - Ask Gordon (Docker Inc.)
Version: v1.30.0
Path: /Users/username/.docker/cli-plugins/docker-ai
buildx: Docker Buildx (Docker Inc.)
Version: v0.36.1-desktop.1
Path: /Users/username/.docker/cli-plugins/docker-buildx
compose: Docker Compose (Docker Inc.)
Version: v5.4.0
Path: /Users/username/.docker/cli-plugins/docker-compose
debug: Get a shell into any image or container (Docker Inc.)
Version: 0.0.47
Path: /Users/username/.docker/cli-plugins/docker-debug
desktop: Docker Desktop commands (Docker Inc.)
Version: v0.4.3
Path: /Users/username/.docker/cli-plugins/docker-desktop
dhi: CLI for managing Docker Hardened Images (Docker Inc.)
Version: v0.0.7
Path: /Users/username/.docker/cli-plugins/docker-dhi
extension: Manages Docker extensions (Docker Inc.)
Version: v0.2.31
Path: /Users/username/.docker/cli-plugins/docker-extension
init: Creates Docker-related starter files for your project (Docker Inc.)
Version: v1.4.0
Path: /Users/username/.docker/cli-plugins/docker-init
mcp: Docker MCP Plugin (Docker Inc.)
Version: v0.43.3
Path: /Users/username/.docker/cli-plugins/docker-mcp
model: Docker Model Runner (Docker Inc.)
Version: v1.2.6
Path: /Users/username/.docker/cli-plugins/docker-model
offload: Docker Offload (Docker Inc.)
Version: v0.6.13
Path: /Users/username/.docker/cli-plugins/docker-offload
pass: Docker Pass Secrets Manager Plugin (beta) (Docker Inc.)
Version: v0.2.1
Path: /Users/username/.docker/cli-plugins/docker-pass
sandbox: "docker sandbox" is deprecated, use Docker Sandboxes instead (Docker Inc.)
Version: v0.13.0
Path: /Users/username/.docker/cli-plugins/docker-sandbox
scout: Docker Scout (Docker Inc.)
Version: v1.24.0
Path: /Users/username/.docker/cli-plugins/docker-scout
Server:
Containers: 2
Running: 2
Paused: 0
Stopped: 0
Images: 2
Server Version: 29.7.2
Storage Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
Logging Driver: json-file
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: aad11006b869517fcd3009450b6f82da282e1a9b
runc version: v1.4.3-0-gbb14dabe
init version: de40ad0
Security Options:
seccomp
Profile: builtin
cgroupns
Kernel Version: 7.0.12-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/username/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
E11D28AD-A7A2-4E9B-8B5A-BDC6145BCEF6
Bug report
Summary
On Docker Desktop 4.87.0 for Mac with Docker VMM (libkrun) virtualization,
lstat()on symbolic links inside host bind mounts returns wrong, unstablemetadata. Freshly created symlinks can report
st_size=0(POSIX requires asymlink's
st_sizeto equal the length of its target path), longer-livedsymlinks decay to
st_size=0andst_nlink=0, and the values served for asingle unchanged path flap between correct and zeroed across consecutive
observations.
readlink()usually returns the correct target, but onsymlinks in the decayed
st_nlink=0state a process can also observezero-length readlink content while another process reads the correct
target moments later, so data reads flap as well.
git caches lstat metadata (including
st_size) per index entry to detectmodifications, so any repository bind-mounted from the host shows phantom
symlink modifications:
git statuslistsevery symlink as
modifiedalthough nothing touched them.git restore .appears to do nothing: the firstgit statusafterwardsstill lists the symlinks, and only the second run shows a clean tree,
regardless of how much time passes between the two runs.
git pull --rebasefails on "local edits" to files thatgit statusdid not list; repeated
git rebase --continuegradually "heals" it.This is a regression from Docker Desktop 4.86 and is specific to Docker
VMM. The same host, image, volume, and workflow behave correctly on 4.86
with VMM, and on 4.87 with Apple Virtualization Framework using either
VirtioFS or gRPC FUSE.
Environment
containerd v2.2.5, runc 1.3.6
"UseLibkrun": true)7.0.12-linuxkit #1 SMP PREEMPT Wed Aug 12 20:18:49 UTC 2026 aarch646.12.76-linuxkit #1 SMP Mon Jul 27 16:56:11 UTC 2026 aarch64/proc/mounts:host /simpletest virtiofs rw,nosuid,nodev,relatime,ignore_atime,no_xattr 0 0Backend matrix
Captured by toggling the Docker Desktop UI and reading
~/Library/Group Containers/group.com.docker/settings-store.json:UseLibkrun=true)UseLibkrun=true, VirtioFS keys present)UseLibkrun=true, gRPC FUSE keys present)UseVirtualizationFramework=true,UseVirtualizationFrameworkVirtioFS=true)UseVirtualizationFramework=true,UseGrpcfuse=true)In VMM mode the file-sharing selector is not shown in the UI; the
UseGrpcfuse/UseVirtualizationFrameworkVirtioFSkeys remain in thesettings file but appear to be ignored, and both recorded VMM combinations
misbehave identically. Since AVF + VirtioFS on 4.87 is healthy, the guest
virtiofs client alone does not explain it; the defect is in the VMM
backend's virtiofs device (or its interaction with the 7.0.12 guest).
Expected result
A symlink's
st_sizeequals the length of its target path and staysstable, on every filesystem:
The same probe against a non-virtiofs filesystem in the same container
(
/tmp, overlayfs) returns exactly that.Actual result
On the VMM virtiofs bind mount, observed from inside the container:
readlink()is correct (15 bytes) whilest_sizestays 0. Other symlinksin the same mount show the opposite transition, from correct to zeroed and
back, with no guest-side activity on those paths in between:
A two-day-old symlink whose target is a directory stayed zeroed across
every observation over several minutes (
.agents/skills/bar, target../../.claude/skills/bar, real length 29):For a symlink in that decayed state, git's own
readlink()observed zerobytes of content while a shell
readlinkon the same path immediatelyafterwards returned the correct 9-byte target:
One-shot reproducer
Prerequisite: Docker Desktop 4.87.x on macOS with Virtual Machine Manager
selected as the virtualization backend. Save as
reproduce-vmm-symlink.shand run from the host. The metadata corruption is timing-dependent; if a
run comes out clean, re-run it (in our environment it fires nearly every
time, in one of the two probes).
Observed output for probe 2 on 4.87 VMM (a from-scratch repository):
The same script under Docker Desktop 4.86, or under 4.87 with Apple
Virtualization Framework (VirtioFS or gRPC FUSE), prints
st_size=15forevery link and two empty status runs.
Diagnostics
Two different inode numbering ranges for one path
Deleting and recreating a symlink at the same path moved it between two
distinct inode-number ranges with different metadata quality, suggesting
two attribute sources inside the backend:
Why git needs exactly two
git statusrunsgit stores each index entry's last-seen lstat data (size, mtime, ctime,
inode). For a symlink the blob content is the target string, so a correct
st_sizeequals the target length. Two code paths matter:st_sizediffers from a nonzero cached size, gitreports the entry as modified without reading the link at all.
full
readlink()compare; on a match it silently rewrites the indexentry with the freshly observed stat data ("racily clean" repair).
Each
git statustherefore re-rolls the dice on whatever metadata thevirtiofs device serves at that instant: one run records flapped values
into the index or reports a phantom diff, and the next run, observing a
different flap state, repairs it. Because
readlink()content can itselfcome back empty in the decayed state (see above), even git's full content
re-check can confirm a phantom modification instead of clearing it. Captured on the from-scratch repository
above (
git ls-files --debug): the index recordedsize: 7at committime,
size: 0aftergit restore ., run 1 printedM link1, run 2was clean. Waiting 15+ seconds between the runs does not change the
pattern, so this is not a fixed-interval cache timeout.
The same mechanism explains the
git pull --rebasefailures on files thatgit statusnever listed: each rebase step re-stats the tree and canobserve a different flap state than the status run that preceded it.
Impact on a real repository
On a bind-mounted repository whose index was written while metadata was
correct, consecutive
git statusruns seconds apart, with zero writes inbetween, disagree and even grow:
git ls-files --debugconfirms every phantom entry is a symlink whoseindex-recorded size is the true target length while live
lstat()returns0 (e.g.
.agents/skills/bar: indexsize: 29, livesize=0 nlink=0).Workaround
Switch the virtualization backend from Docker VMM to Apple Virtualization
Framework with VirtioFS (or gRPC FUSE):
Reverting to Docker Desktop 4.86 with VMM also avoids the problem.
Platform
macOS
Version information
Diagnostics ID
E11D28AD-A7A2-4E9B-8B5A-BDC6145BCEF6