Summary
On a Windows host, ls inside any sandbox with a bind mount fails with Too many levels of symbolic links (errno 40), and ls -la / shows d????????? for every virtiofs mount — including msb's own /.msb runtime share.
The mount itself is fine: find, cat via shell redirect, and [ -d ] all work on the same mount at the same moment.
The cause is that the host→Linux errno translation module is stubbed out on Windows, so the device layer sends host errno values into the guest's Linux errno space. The specific fatal collision: an unimplemented opcode is reported with libc::ENOSYS, evaluated in the host's errno space:
|
ENOSYS |
ELOOP |
| Linux (the guest, always) |
38 |
40 |
Windows (libc::windows) |
40 |
114 |
macOS (libc::apple) |
78 |
62 |
So on Windows the guest receives 40 and interprets it as ELOOP. The unimplemented opcode is FUSE_STATX (52, Linux 6.6+) — the Opcode enum stops at RemoveMapping = 49. Because the guest kernel only sets fc->no_statx and falls back to GETATTR on -ENOSYS, it never falls back; the bogus ELOOP goes straight to userspace, permanently.
Ubuntu 25.10+ ships uutils coreutils (Rust), so ls and cat are broken there. GNU coreutils (Debian) use fstatat and work fine.
Reproduction
Windows 11 host, msb 0.6.7 (also reproduces on 0.6.6):
# Repro: statx -> ELOOP on virtiofs mounts, Windows hosts.
# Run on a Windows host with `msb` on PATH. Nothing is installed or modified
# outside the temp directory this creates.
$ErrorActionPreference = "Continue"
function Section($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
Section "environment"
msb --version
Write-Host "host: $([System.Environment]::OSVersion.VersionString)"
Section "1. no mount required - msb's own /.msb share is unstattable"
Write-Host "expect: '.msb' listed as d????????? (statx failed)"
msb run ubuntu -- ls -la /
$dir = Join-Path $env:TEMP "msb-repro-statx"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Set-Content -Path (Join-Path $dir "f.txt") -Value "hello-from-host" -NoNewline
Section "2. same mount, four syscall paths (ubuntu / uutils / statx)"
Write-Host "expect: the first three succeed, 'ls' fails with FilesystemLoop"
msb run --mount-dir "${dir}:/w" ubuntu -- sh -c "[ -d /w ] && echo 'newfstatat: OK'"
msb run --mount-dir "${dir}:/w" ubuntu -- find /w -maxdepth 1
msb run --mount-dir "${dir}:/w" ubuntu -- sh -c "read l < /w/f.txt; echo `"read: `$l`""
msb run --mount-dir "${dir}:/w" ubuntu -- ls -la /w
Section "3. same mount, GNU coreutils instead of uutils"
Write-Host "expect: debian lists the directory cleanly - the mount was always fine"
msb run --mount-dir "${dir}:/w" debian -- ls -la /w
Section "done"
Write-Host "Mount used: $dir"
Write-Host "If step 3 works and step 2's 'ls' does not, the mount is healthy and only statx fails."
What should you see?
=== environment ===
msb 0.6.7
host: Microsoft Windows NT 10.0.26200.0
=== 1. no mount required - msb's own /.msb share is unstattable ===
expect: '.msb' listed as d????????? (statx failed)
total 76
drwxr-xr-x 1 root root 4096 Jan 1 2020 .
drwxr-xr-x 1 root root 4096 Jan 1 2020 ..
d????????? ? ? ? ? ? .msb
drwxr-xr-x 2 root root 52 Jul 13 16:06 .rock
lrwxrwxrwx 1 root root 7 Apr 20 08:46 bin -> usr/bin
drwxr-xr-x 2 root root 27 Apr 20 08:46 boot
drwxr-xr-x 8 root root 2360 Jan 1 2020 dev
drwxr-xr-x 1 root root 4096 Jul 13 16:06 etc
drwxr-xr-x 3 root root 45 Jul 13 16:06 home
lrwxrwxrwx 1 root root 7 Apr 20 08:46 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Apr 20 08:46 lib64 -> usr/lib64
drwxr-xr-x 2 root root 27 Jul 13 16:05 media
drwxr-xr-x 2 root root 27 Jul 13 16:05 mnt
drwxr-xr-x 2 root root 27 Jul 13 16:05 opt
dr-xr-xr-x 98 root root 0 Jan 1 2020 proc
drwx------ 2 root root 66 Jul 13 16:06 root
drwxr-xr-x 1 root root 4096 Jan 1 2020 run
lrwxrwxrwx 1 root root 8 Apr 20 08:46 sbin -> usr/sbin
drwxr-xr-x 2 root root 27 Jul 13 16:05 srv
dr-xr-xr-x 11 root root 0 Jan 1 2020 sys
drwxrwxrwt 2 root root 40 Jan 1 2020 tmp
drwxr-xr-x 12 root root 194 Jul 13 16:05 usr
drwxr-xr-x 11 root root 204 Jul 13 16:06 var
=== 2. same mount, four syscall paths (ubuntu / uutils / statx) ===
expect: the first three succeed, 'ls' fails with FilesystemLoop
newfstatat: OK
/w
/w/f.txt
read:
ls: unknown io error: '/w', 'Os { code: 40, kind: FilesystemLoop, message: "Too many levels of symbolic links" }'
=== 3. same mount, GNU coreutils instead of uutils ===
expect: debian lists the directory cleanly - the mount was always fine
total 5
drwxrwxrwx 1 root root 0 Jul 27 08:50 .
drwxr-xr-x 1 root root 4096 Jan 1 2020 ..
-rwxrwxrwx 1 root root 15 Jul 27 08:50 f.txt
=== done ===
Mount used: C:\Users\<snip>\AppData\Local\Temp\msb-repro-statx
If step 3 works and step 2's 'ls' does not, the mount is healthy and only statx fails.
Root cause
**In the libkrun fork (msb_krun_devices 0.1.25 on crates.io, superradcompany/libkrun). The Windows FUSE path and the stub below are additions in the fork.
virtio/mod.rs:31-40 compiles the errno translation table on every platform except Windows, where it is replaced by a no-op stub:
#[cfg(not(target_os = "windows"))]
pub mod linux_errno; // real host -> Linux table
// (maps libc::ENOSYS => LINUX_ENOSYS = 38)
#[cfg(target_os = "windows")]
pub mod linux_errno {
pub const LINUX_ERANGE: i32 = 34;
pub fn linux_error(error: std::io::Error) -> std::io::Error {
std::io::Error::from_raw_os_error(error.raw_os_error().unwrap_or(5))
}
}
So on Windows every linux_error(...) call in the FUSE server returns the host value unchanged. The fatal one is virtio/fs/server.rs:198:
_ => reply_error(
linux_error(io::Error::from_raw_os_error(libc::ENOSYS)),
in_header.unique,
w,
),
libc::ENOSYS is 40 on Windows (libc-0.2.186/src/windows/mod.rs:182) and 40 is ELOOP on Linux. On a Linux host the real table maps it to 38 and the kernel's documented fallback works, which is why this has never been seen.
Opcode (virtio/fs/fuse.rs:683-731) ends at RemoveMapping = 49 and has no Statx = 52, so every statx from the guest takes that fallback arm.
The blast radius is wider than statx.
Every errno the device layer sends is in the wrong number space on Windows. Values 1-32 happen to agree between the Windows CRT and Linux, which is why ordinary I/O works and only the high range is scrambled — e.g. a genuine ENAMETOOLONG (38 on Windows) would reach the guest as ENOSYS, and ELOOP (114 on Windows) as EALREADY.
Worse, io::Error::raw_os_error() on Windows usually carries a Win32 code rather than a CRT errno, so error paths that propagate a real API failure send values from a third numbering entirely:
ERROR_ACCESS_DENIED (5) arrives as EIO, ERROR_INVALID_DATA (13) as EACCES, ERROR_SHARING_VIOLATION (32) as EPIPE.
Summary
On a Windows host,
lsinside any sandbox with a bind mount fails withToo many levels of symbolic links(errno 40), andls -la /showsd?????????for every virtiofs mount — including msb's own/.msbruntime share.The mount itself is fine:
find,catvia shell redirect, and[ -d ]all work on the same mount at the same moment.The cause is that the host→Linux errno translation module is stubbed out on Windows, so the device layer sends host errno values into the guest's Linux errno space. The specific fatal collision: an unimplemented opcode is reported with
libc::ENOSYS, evaluated in the host's errno space:libc::windows)libc::apple)So on Windows the guest receives 40 and interprets it as
ELOOP. The unimplemented opcode isFUSE_STATX(52, Linux 6.6+) — theOpcodeenum stops atRemoveMapping = 49. Because the guest kernel only setsfc->no_statxand falls back toGETATTRon-ENOSYS, it never falls back; the bogus ELOOP goes straight to userspace, permanently.Ubuntu 25.10+ ships uutils coreutils (Rust), so
lsandcatare broken there. GNU coreutils (Debian) usefstatatand work fine.Reproduction
Windows 11 host,
msb0.6.7 (also reproduces on 0.6.6):What should you see?
Root cause
**In the libkrun fork (
msb_krun_devices0.1.25 on crates.io,superradcompany/libkrun). The Windows FUSE path and the stub below are additions in the fork.virtio/mod.rs:31-40compiles the errno translation table on every platform except Windows, where it is replaced by a no-op stub:So on Windows every
linux_error(...)call in the FUSE server returns the host value unchanged. The fatal one isvirtio/fs/server.rs:198:libc::ENOSYSis 40 on Windows (libc-0.2.186/src/windows/mod.rs:182) and 40 isELOOPon Linux. On a Linux host the real table maps it to 38 and the kernel's documented fallback works, which is why this has never been seen.Opcode(virtio/fs/fuse.rs:683-731) ends atRemoveMapping = 49and has noStatx = 52, so everystatxfrom the guest takes that fallback arm.The blast radius is wider than statx.
Every errno the device layer sends is in the wrong number space on Windows. Values 1-32 happen to agree between the Windows CRT and Linux, which is why ordinary I/O works and only the high range is scrambled — e.g. a genuine
ENAMETOOLONG(38 on Windows) would reach the guest asENOSYS, andELOOP(114 on Windows) asEALREADY.Worse,
io::Error::raw_os_error()on Windows usually carries a Win32 code rather than a CRT errno, so error paths that propagate a real API failure send values from a third numbering entirely:ERROR_ACCESS_DENIED(5) arrives asEIO,ERROR_INVALID_DATA(13) asEACCES,ERROR_SHARING_VIOLATION(32) asEPIPE.