Skip to content

Commit 6ebfa03

Browse files
sjmiller609claude
andauthored
forkvm: clone fork files via FICLONE reflink with sparse-copy fallback (#212)
* forkvm: clone fork files via FICLONE reflink with sparse-copy fallback CopyGuestDirectory now attempts an FICLONE per regular file before falling back to the existing SEEK_DATA/SEEK_HOLE sparse copy. On btrfs/xfs+reflink the per-fork file copy collapses to a metadata operation, leaving the existing sparse-copy path intact for filesystems that don't support copy-on-write. The reflink path short-circuits after the first observed "unsupported"-class error from FICLONE so we don't repay the kernel rejection on every file. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: force sparse path in seek-unsupported test The reflink fast path now succeeds before sparse seeking is invoked, so the test must disable reflink to exercise the sparse fallback. * test: assert FICLONE actually reflinked in snapshot scenario Walks the snapshot guest dir after ForkSnapshot, FIEMAPs each regular file against its counterpart under the fork instance dir, and fails if no pair shares a physical extent. A successful FICLONE leaves both files pointing at the same extents, so disjoint physical offsets across every inspected file means the fast path silently fell back to a full byte copy. Requires a reflink-capable scratch filesystem (XFS reflink=1, btrfs, ZFS). Linux-only; non-Linux platforms get a no-op stub since they only exercise the sparse-copy fallback today. --------- Co-authored-by: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ba4a02d commit 6ebfa03

8 files changed

Lines changed: 349 additions & 4 deletions

lib/forkvm/copy.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,37 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"sync/atomic"
1011
)
1112

12-
var ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
13+
var (
14+
ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
15+
ErrReflinkUnsupported = errors.New("reflink unsupported")
16+
)
17+
18+
// reflinkDisabled, when nonzero, forces CopyGuestDirectory to skip the FICLONE
19+
// fast path entirely. Tests set this; production code leaves it untouched.
20+
var reflinkDisabled atomic.Bool
21+
22+
// SetReflinkDisabled toggles the FICLONE fast path. Intended for tests that
23+
// need to exercise the sparse-copy fallback explicitly.
24+
func SetReflinkDisabled(disabled bool) {
25+
reflinkDisabled.Store(disabled)
26+
}
27+
28+
// reflinkUnsupportedSticky tracks whether reflink has already been observed to
29+
// fail with an "unsupported" signal for this destination filesystem. Once set,
30+
// we skip subsequent FICLONE attempts within the same CopyGuestDirectory call
31+
// to avoid re-paying the rejection on every file.
32+
type copyState struct {
33+
reflinkDead bool
34+
}
1335

1436
// CopyGuestDirectory recursively copies a guest directory to a new destination.
15-
// Regular files are copied using sparse extent copy only (SEEK_DATA/SEEK_HOLE).
16-
// Runtime sockets and logs are skipped because they are host-runtime artifacts.
37+
// Regular files are cloned via reflink (FICLONE) when the underlying filesystem
38+
// supports it; otherwise we fall back to a sparse extent copy
39+
// (SEEK_DATA/SEEK_HOLE). Runtime sockets and logs are skipped because they are
40+
// host-runtime artifacts.
1741
func CopyGuestDirectory(srcDir, dstDir string) error {
1842
srcInfo, err := os.Stat(srcDir)
1943
if err != nil {
@@ -27,6 +51,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
2751
return fmt.Errorf("create destination directory: %w", err)
2852
}
2953

54+
state := &copyState{}
55+
if reflinkDisabled.Load() {
56+
state.reflinkDead = true
57+
}
58+
3059
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
3160
if walkErr != nil {
3261
return walkErr
@@ -61,7 +90,7 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
6190
return nil
6291

6392
case mode.IsRegular():
64-
if err := copyRegularFileSparse(path, dstPath, mode.Perm()); err != nil {
93+
if err := copyRegularFile(state, path, dstPath, mode.Perm()); err != nil {
6594
return fmt.Errorf("copy file %s: %w", path, err)
6695
}
6796
return nil
@@ -86,6 +115,26 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
86115
})
87116
}
88117

118+
// copyRegularFile clones path to dstPath, preferring FICLONE reflink and
119+
// falling back to sparse extent copy. The state object lets us short-circuit
120+
// future reflink attempts once we observe an "unsupported" signal from the
121+
// destination filesystem in the current copy.
122+
func copyRegularFile(state *copyState, srcPath, dstPath string, perms fs.FileMode) error {
123+
if state == nil || !state.reflinkDead {
124+
err := copyRegularFileReflink(srcPath, dstPath, perms)
125+
if err == nil {
126+
return nil
127+
}
128+
if !errors.Is(err, ErrReflinkUnsupported) {
129+
return err
130+
}
131+
if state != nil {
132+
state.reflinkDead = true
133+
}
134+
}
135+
return copyRegularFileSparse(srcPath, dstPath, perms)
136+
}
137+
89138
func shouldSkipDirectory(relPath string) bool {
90139
return relPath == "logs"
91140
}

lib/forkvm/copy_reflink_linux.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//go:build linux
2+
3+
package forkvm
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"io/fs"
9+
"os"
10+
11+
"golang.org/x/sys/unix"
12+
)
13+
14+
// copyRegularFileReflink attempts to clone srcPath to dstPath via FICLONE
15+
// (reflink). On filesystems that support copy-on-write at the block layer
16+
// (btrfs, xfs with reflink=1, zfs, bcachefs), this is effectively
17+
// instantaneous and consumes no additional space until pages diverge.
18+
//
19+
// Returns ErrReflinkUnsupported when the filesystem or kernel rejects the
20+
// operation; callers should fall back to a full-copy path.
21+
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) (retErr error) {
22+
src, err := os.Open(srcPath)
23+
if err != nil {
24+
return err
25+
}
26+
defer src.Close()
27+
28+
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perms)
29+
if err != nil {
30+
return err
31+
}
32+
defer func() {
33+
if cerr := dst.Close(); retErr == nil && cerr != nil {
34+
retErr = cerr
35+
}
36+
if retErr != nil {
37+
_ = os.Remove(dstPath)
38+
}
39+
}()
40+
41+
if err := unix.IoctlFileClone(int(dst.Fd()), int(src.Fd())); err != nil {
42+
if isReflinkUnsupportedError(err) {
43+
return fmt.Errorf("%w: FICLONE rejected for %s: %v", ErrReflinkUnsupported, srcPath, err)
44+
}
45+
return fmt.Errorf("FICLONE %s -> %s: %w", srcPath, dstPath, err)
46+
}
47+
return nil
48+
}
49+
50+
// isReflinkUnsupportedError returns true when an FICLONE failure indicates the
51+
// operation cannot be served by the filesystem and the caller should fall
52+
// back. Real errors (EIO, ENOSPC) propagate as-is.
53+
func isReflinkUnsupportedError(err error) bool {
54+
switch {
55+
case errors.Is(err, unix.EINVAL),
56+
errors.Is(err, unix.ENOTSUP),
57+
errors.Is(err, unix.EOPNOTSUPP),
58+
errors.Is(err, unix.EXDEV),
59+
errors.Is(err, unix.ETXTBSY),
60+
errors.Is(err, unix.EISDIR),
61+
errors.Is(err, unix.ENOTTY):
62+
return true
63+
}
64+
return false
65+
}

lib/forkvm/copy_reflink_other.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//go:build !linux
2+
3+
package forkvm
4+
5+
import (
6+
"fmt"
7+
"io/fs"
8+
)
9+
10+
// copyRegularFileReflink is unavailable on non-Linux platforms. On macOS APFS
11+
// supports clonefile(2) and could be wired up here, but we currently only
12+
// rely on the sparse-copy fallback off-Linux.
13+
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) error {
14+
_ = dstPath
15+
_ = perms
16+
return fmt.Errorf("%w: reflink unsupported on this platform: %s", ErrReflinkUnsupported, srcPath)
17+
}

lib/forkvm/copy_reflink_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package forkvm
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestCopyGuestDirectory_ReflinkFallback exercises the sparse-copy fallback
13+
// path. The reflink fast path is fs-dependent and not portable across CI
14+
// runners; this test forces it off and verifies copy correctness.
15+
func TestCopyGuestDirectory_ReflinkFallback(t *testing.T) {
16+
SetReflinkDisabled(true)
17+
t.Cleanup(func() { SetReflinkDisabled(false) })
18+
19+
src := filepath.Join(t.TempDir(), "src")
20+
dst := filepath.Join(t.TempDir(), "dst")
21+
22+
require.NoError(t, os.MkdirAll(src, 0755))
23+
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))
24+
require.NoError(t, os.WriteFile(filepath.Join(src, "config.json"), []byte(`{"x":1}`), 0644))
25+
26+
require.NoError(t, CopyGuestDirectory(src, dst))
27+
28+
got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
29+
require.NoError(t, err)
30+
assert.Equal(t, "rootfs-bytes", string(got))
31+
32+
got, err = os.ReadFile(filepath.Join(dst, "config.json"))
33+
require.NoError(t, err)
34+
assert.Equal(t, `{"x":1}`, string(got))
35+
}
36+
37+
// TestCopyGuestDirectory_ReflinkAttempted verifies that with reflink enabled
38+
// (the default), the copy still produces a correct destination on filesystems
39+
// where FICLONE either succeeds or falls back transparently. This is the
40+
// happy-path smoke test for the new fast path; on filesystems that don't
41+
// support FICLONE the fallback handles correctness.
42+
func TestCopyGuestDirectory_ReflinkAttempted(t *testing.T) {
43+
SetReflinkDisabled(false)
44+
45+
src := filepath.Join(t.TempDir(), "src")
46+
dst := filepath.Join(t.TempDir(), "dst")
47+
48+
require.NoError(t, os.MkdirAll(src, 0755))
49+
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))
50+
51+
require.NoError(t, CopyGuestDirectory(src, dst))
52+
53+
got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
54+
require.NoError(t, err)
55+
assert.Equal(t, "rootfs-bytes", string(got))
56+
}

lib/forkvm/copy_sparse_unix_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ func TestCopyGuestDirectory_PreservesSparseFiles(t *testing.T) {
4646
}
4747

4848
func TestCopyGuestDirectory_FailsWhenSparseSeekingUnsupported(t *testing.T) {
49+
SetReflinkDisabled(true)
50+
t.Cleanup(func() { SetReflinkDisabled(false) })
51+
4952
src := filepath.Join(t.TempDir(), "src")
5053
dst := filepath.Join(t.TempDir(), "dst")
5154
require.NoError(t, os.MkdirAll(src, 0755))
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//go:build linux
2+
3+
package instances
4+
5+
import (
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
"unsafe"
11+
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/sys/unix"
14+
)
15+
16+
const (
17+
fsIOCFiemap = 0xC020660B
18+
fiemapFlagSync = 0x1
19+
fiemapMaxExtents = 64
20+
)
21+
22+
type fiemapHeader struct {
23+
Start uint64
24+
Length uint64
25+
Flags uint32
26+
MappedExtents uint32
27+
ExtentCount uint32
28+
Reserved uint32
29+
}
30+
31+
type fiemapExtent struct {
32+
Logical uint64
33+
Physical uint64
34+
Length uint64
35+
Reserved64 [2]uint64
36+
Flags uint32
37+
Reserved [3]uint32
38+
}
39+
40+
type fiemapRequest struct {
41+
Header fiemapHeader
42+
Extents [fiemapMaxExtents]fiemapExtent
43+
}
44+
45+
func fileExtents(path string) ([]fiemapExtent, error) {
46+
f, err := os.Open(path)
47+
if err != nil {
48+
return nil, err
49+
}
50+
defer f.Close()
51+
52+
var req fiemapRequest
53+
req.Header.Length = ^uint64(0)
54+
req.Header.Flags = fiemapFlagSync
55+
req.Header.ExtentCount = fiemapMaxExtents
56+
57+
if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), uintptr(fsIOCFiemap), uintptr(unsafe.Pointer(&req))); errno != 0 {
58+
return nil, errno
59+
}
60+
return req.Extents[:req.Header.MappedExtents], nil
61+
}
62+
63+
// assertCopyReflinked walks srcDir and verifies that at least one regular
64+
// file shares a physical extent with its counterpart under dstDir. A
65+
// successful FICLONE leaves the destination pointing at the source's
66+
// extents, so FIEMAP will report identical fe_physical offsets. If every
67+
// inspected pair has disjoint extents, the FICLONE fast path silently
68+
// degraded to a full byte copy and we want to fail loudly. Requires a
69+
// reflink-capable filesystem under the test's scratch directory (XFS with
70+
// reflink=1 in CI).
71+
func assertCopyReflinked(t *testing.T, srcDir, dstDir string) {
72+
t.Helper()
73+
74+
type candidate struct {
75+
rel string
76+
size int64
77+
}
78+
var candidates []candidate
79+
require.NoError(t, filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error {
80+
if err != nil {
81+
return err
82+
}
83+
if !d.Type().IsRegular() {
84+
return nil
85+
}
86+
info, err := d.Info()
87+
if err != nil {
88+
return err
89+
}
90+
if info.Size() == 0 {
91+
return nil
92+
}
93+
rel, err := filepath.Rel(srcDir, path)
94+
if err != nil {
95+
return err
96+
}
97+
candidates = append(candidates, candidate{rel: rel, size: info.Size()})
98+
return nil
99+
}))
100+
require.NotEmpty(t, candidates, "no non-empty regular files under %s", srcDir)
101+
102+
var inspected, shared int
103+
for _, c := range candidates {
104+
dstPath := filepath.Join(dstDir, c.rel)
105+
if _, err := os.Stat(dstPath); err != nil {
106+
continue
107+
}
108+
srcExtents, err := fileExtents(filepath.Join(srcDir, c.rel))
109+
if err != nil {
110+
t.Logf("FIEMAP %s: %v", c.rel, err)
111+
continue
112+
}
113+
dstExtents, err := fileExtents(dstPath)
114+
if err != nil {
115+
t.Logf("FIEMAP %s: %v", dstPath, err)
116+
continue
117+
}
118+
inspected++
119+
if extentsShareAny(srcExtents, dstExtents) {
120+
shared++
121+
}
122+
}
123+
require.NotZero(t, inspected, "no files inspected for reflink sharing")
124+
require.NotZero(t, shared,
125+
"no files shared physical extents between %s and %s; FICLONE fast path produced full byte copies",
126+
srcDir, dstDir)
127+
}
128+
129+
func extentsShareAny(a, b []fiemapExtent) bool {
130+
if len(a) == 0 || len(b) == 0 {
131+
return false
132+
}
133+
seen := make(map[uint64]struct{}, len(a))
134+
for _, e := range a {
135+
seen[e.Physical] = struct{}{}
136+
}
137+
for _, e := range b {
138+
if _, ok := seen[e.Physical]; ok {
139+
return true
140+
}
141+
}
142+
return false
143+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !linux
2+
3+
package instances
4+
5+
import "testing"
6+
7+
func assertCopyReflinked(t *testing.T, srcDir, dstDir string) {
8+
t.Helper()
9+
t.Logf("reflink assertion skipped on non-Linux (src=%s dst=%s)", srcDir, dstDir)
10+
}

0 commit comments

Comments
 (0)