Skip to content

Silent data loss: virtiofs reports all workspace files as fully sparse, so cp out of the workspace writes only NUL bytes #526

Description

@amorey

Summary

On a macOS host, files on the virtiofs bind mount of the host workspace are reported as entirely sparse: st_blocks is 0, lseek(SEEK_DATA) returns EOF, and lseek(SEEK_HOLE) returns 0. read() returns the correct bytes, so most tools are fine — but any tool that trusts hole detection silently produces a file of the correct length containing only NUL bytes.

cp(1) is such a tool. cp workspace-file /tmp/backup is 100% silent data loss: the destination has the right size, the right mtime, and no content. I lost a source file this way — the copy was zeros, and later restoring "the backup" over the original destroyed it too.

This is a data-integrity bug, not a performance one, and it is silent: cp exits 0.

Reproduction

100% reproducible, 400/400 attempts. From inside the sandbox, with a host workspace bind-mounted at /Users/<user>/workspace/<repo>:

$ cp /Users/<user>/workspace/<repo>/some-file.go /tmp/copy.go
$ cmp /Users/<user>/workspace/<repo>/some-file.go /tmp/copy.go
/tmp/copy.go differs: byte 1, line 1
$ python3 -c "d=open('/tmp/copy.go','rb').read(); print(len(d), d.count(bytes(1)))"
22166 22166      # correct length, every byte NUL

The source is never damaged; only the copy.

Root cause

The virtiofs mount misreports sparseness. Probing the same file content on the bind mount vs. on the sandbox's overlay root:

virtiofs (/Users/<user>/workspace/<repo>/service.go):
  size=22166
  lseek(0, SEEK_DATA) = 22166      <-- EOF: "no data anywhere in this file"
  lseek(0, SEEK_HOLE) = 0          <-- "a hole begins at offset 0"
  st_blocks = 0

overlayfs (/tmp/ref.go, byte-identical content):
  size=22166
  lseek(0, SEEK_DATA) = 0
  lseek(0, SEEK_HOLE) = 22166
  st_blocks = 48

cp reads that as "the entire file is a hole" and writes a hole of the same length. Anything using plain read() is unaffected, which is why this hid for so long.

What is and isn't affected

Method Result
cp SRC DST all NUL
cp --sparse=never all NUL
cp --reflink=never all NUL
cp --sparse=always all NUL
cat SRC > DST correct
dd if=SRC of=DST correct
python3 shutil.copyfile correct
cp overlay → overlay correct

Direction matters: the source must be on the virtiofs mount. copy_file_range(2) is not involved — the kernel refuses it here with EXDEV, so coreutils already falls back.

Note that all four --sparse/--reflink variants fail, so there is no cp flag to work around this with.

Impact

Anything that copies out of the workspace: cp in build scripts, backup-before-edit patterns, Makefile install steps, packaging, and coding agents (which routinely snapshot a file before rewriting it). All get zeros, silently. rsync, install, and tar may share the same assumption — I did not test them.

Environment

  • Host: macOS (Apple silicon)
  • Sandbox: Linux 7.0.12 #1 SMP PREEMPT Wed Aug 12 16:05:38 UTC 2026 aarch64
  • Workspace mode: direct (host tree bind-mounted, not --clone)
  • Mount: TARGET=/Users/<user>/workspace/<repo> SOURCE=host[/Users/<user>/workspace/<repo>] FSTYPE=virtiofs OPTIONS=rw,nosuid,nodev,relatime
  • cp (GNU coreutils) 9.7

Suggested fix

Have the virtiofs server report real allocation for host files — st_blocks consistent with the data, and SEEK_DATA/SEEK_HOLE reflecting actual extents. If extent information cannot be obtained from the host filesystem, the safe fallback is to report the file as fully allocated (SEEK_DATA = offset, SEEK_HOLE = EOF, non-zero st_blocks) rather than fully sparse. Over-reporting allocation costs a little I/O; under-reporting it loses data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions