Skip to content

Commit e65b87a

Browse files
rgarciaclaude
andauthored
Add APFS clonefile fast path for macOS VM disk forks (#276)
* Add design proposal: APFS clonefile fast path for macOS VM forks Documents wiring clonefile(2) into the forkvm reflink slot on darwin so VM disk forks become copy-on-write-instant on Apple Silicon, with a sparse-copy fallback for cross-volume cases. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * forkvm: add APFS clonefile(2) reflink fast path on darwin Wire Apple's clonefile(2) into forkvm's reflink->sparse fallback ladder so forking a guest directory on APFS becomes a near-instant, copy-on-write, space-shared operation instead of a dense SEEK_DATA/SEEK_HOLE byte copy. clonefile(2) requires the destination to not exist, so the darwin path removes any stale destination first, clones with CLONE_NOFOLLOW|CLONE_NOOWNERCOPY, then chmods to honor the perms contract. Unsupported/cross-volume failures (ENOTSUP/EOPNOTSUPP/EXDEV/EEXIST/EINVAL/ENOTDIR) map to ErrReflinkUnsupported to fall back to the sparse copy; EIO/ENOSPC/EACCES propagate. The non-Linux stub is retagged to !linux && !darwin so darwin uses the new file. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * TEMP(ci): narrow to forkvm reflink feature — revert before merge Disable the linux test and e2e-install jobs on this branch and run only the forkvm tests on the darwin runner (make test TEST='Reflink|CopyGuestDirectory| CopyRegularFile|Sparse|Clone') so this PR gets fast, focused darwin signal. Revert this commit before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * TEMP(ci): quote forkvm test regex so the | survives the make recipe The make recipe substitutes -run=$(TEST) unquoted, so the recipe shell split the alternation on |. Wrap the regex in embedded single quotes so go test receives it intact. Part of the temporary forkvm-only narrowing; revert before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * forkvm: fix darwin clone tests for the macOS runner Use a short /tmp base in the clone-correctness test so the unix socket fits the sockaddr length limit (macOS t.TempDir() under /var/folders is too long), and assert CoW block sharing via volume free space (statfs) rather than per-file st_blocks, which still reports the full logical size for an APFS clone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * forkvm: harden darwin clone tests and tighten errno mapping Address review findings on the APFS clonefile fast path: - SharesBlocks now compares the clone's free-space consumption against a dense control file written in the same window instead of an absolute statfs delta, so concurrent cross-package activity on the shared runner perturbs both samples rather than flaking the assertion. - Drop EINVAL and ENOTDIR from isReflinkUnsupportedError: on clonefile(2) these are programming/path errors, not capability signals, so silently falling back would mask a broken flag set. They now propagate. - Add skipUnlessAPFS so clone tests fail hard (not skip) when HYPEMAN_REQUIRE_APFS_CLONE=1, preventing a false green on a non-APFS runner; clone-correctness probes APFS before asserting. - Add a darwin integration test that injects a clonefile rejection via a test seam and verifies the sparse fallback still yields identical bytes. - Trim the CLONE_NOFOLLOW comment to note it is defensive only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * TEMP(ci): verbose darwin run + require APFS clone — revert before merge Set VERBOSE=1 so the narrowed darwin run logs per-test PASS/SKIP (proving the clone tests executed), and HYPEMAN_REQUIRE_APFS_CLONE=1 so an unexpected non-APFS skip fails the step instead of reporting a false green. Revert alongside the other TEMP(ci) commits before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci: restore full test workflow (drop temporary Rosetta-narrowing) Reverts the temporary CI narrowing used during development; runs the full linux + darwin + e2e-install suite again. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * forkvm: add macOS fork-speed benchmark (clonefile vs sparse) BenchmarkForkGuestDisk copies a dense guest-disk-shaped file via the APFS clonefile fast path and the sparse-copy fallback (the pre-clonefile behavior) for an apples-to-apples comparison. Includes a temporary CI step to capture the numbers on the macOS runner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci: drop temporary fork-speed benchmark step Numbers captured; the BenchmarkForkGuestDisk benchmark remains runnable via `go test -bench=BenchmarkForkGuestDisk ./lib/forkvm/`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * instances: add end-to-end VM fork-speed measurement TestVZForkSpeed (gated behind HYPEMAN_FORK_BENCH) times mgr.ForkInstance end to end — copy guest disk + rewrite manifest + materialize a stopped instance — with the APFS clonefile fast path vs the sparse fallback, on a source given a dense overlay. Includes a temporary CI step to capture the numbers on the macOS runner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci: drop temporary e2e fork-speed step Numbers captured; TestVZForkSpeed remains runnable via HYPEMAN_FORK_BENCH=1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Address review: align clonefile RFC with shipped errno behavior - Drop EEXIST from isReflinkUnsupportedError: the pre-clone os.Remove owns the "destination exists" case, so a per-file EEXIST is unreachable and should propagate rather than flip the whole walk to sparse. - Remove the flake-prone CoW free-space test and its statfs helpers; the fork-speed benchmark already evidences the space/latency win. - Reconcile the RFC's errno block, prose, and testing plan with the shipped mapping (EINVAL/ENOTDIR/EEXIST propagate, not fall back), soften the perm-parity claim (umask divergence), and trim volatile x/sys generated-file line numbers to stable public symbols. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * forkvm: address review — CoW write-isolation test + test hygiene + doc fixes From hiro's review of #276 (all non-blocking): - Add TestCopyRegularFileReflink_WriteIsolation: writing to a clone must not modify the source (the headline CoW correctness property; previously only verified manually). - fork_speed_darwin_test: reset the global reflink flag via t.Cleanup so a require failure mid-loop can't leak disabled-state into other tests. - copy_bench_darwin_test: note the shared source means the sparse sub-benchmark reads cache-warm (ratio valid; absolute sparse number is warm-cache). - copy_reflink_darwin_test: listener close via t.Cleanup (consistency). - proposal: correct the unix.Clonefile availability note (generated per-arch, links on both darwin/arm64 and darwin/amd64; verified via go doc). The reflinkDisabled flag is left in place: it predates this PR (lib/forkvm/copy.go on main) and is used by existing tests to exercise the sparse fallback path deterministically; removing it is out of scope here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: rgarcia <72655+rgarcia@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8331138 commit e65b87a

6 files changed

Lines changed: 821 additions & 4 deletions

File tree

docs/proposals/apfs-clonefile-forks.md

Lines changed: 325 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//go:build darwin
2+
3+
package forkvm
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strconv"
10+
"testing"
11+
)
12+
13+
// benchDiskSizeBytes returns the size of the synthetic guest disk copied by the
14+
// fork benchmark. Override with HYPEMAN_BENCH_DISK_BYTES (bytes); defaults to 2 GiB.
15+
func benchDiskSizeBytes(b *testing.B) int64 {
16+
if v := os.Getenv("HYPEMAN_BENCH_DISK_BYTES"); v != "" {
17+
n, err := strconv.ParseInt(v, 10, 64)
18+
if err != nil {
19+
b.Fatalf("invalid HYPEMAN_BENCH_DISK_BYTES %q: %v", v, err)
20+
}
21+
return n
22+
}
23+
return 2 << 30
24+
}
25+
26+
// writeDenseFile writes sizeBytes of non-zero data so the sparse-copy fallback
27+
// has real work to do (a hole-free extent it must read and rewrite in full).
28+
func writeDenseFile(path string, sizeBytes int64) error {
29+
f, err := os.Create(path)
30+
if err != nil {
31+
return err
32+
}
33+
defer f.Close()
34+
35+
buf := make([]byte, 1<<20)
36+
for i := range buf {
37+
buf[i] = 0xAB
38+
}
39+
for written := int64(0); written < sizeBytes; {
40+
n := int64(len(buf))
41+
if rem := sizeBytes - written; rem < n {
42+
n = rem
43+
}
44+
if _, err := f.Write(buf[:n]); err != nil {
45+
return err
46+
}
47+
written += n
48+
}
49+
return f.Sync()
50+
}
51+
52+
// BenchmarkForkGuestDisk measures the dominant cost of forking a VM on macOS:
53+
// copying the guest disk. It compares the APFS clonefile(2) fast path against
54+
// the sparse-copy fallback, which is the pre-clonefile behavior (reflink
55+
// disabled).
56+
//
57+
// Both sub-benchmarks read the same source file, so by the time the sparse run
58+
// executes the source is cache-warm: the clonefile-vs-sparse ratio is valid, but
59+
// read the absolute sparse number as a warm-cache best case, not a cold read.
60+
//
61+
// Run with, e.g.:
62+
//
63+
// go test -run='^$' -bench=BenchmarkForkGuestDisk -benchmem -benchtime=5x ./lib/forkvm/
64+
func BenchmarkForkGuestDisk(b *testing.B) {
65+
size := benchDiskSizeBytes(b)
66+
dir := b.TempDir()
67+
src := filepath.Join(dir, "disk.raw")
68+
if err := writeDenseFile(src, size); err != nil {
69+
b.Fatalf("write source disk: %v", err)
70+
}
71+
72+
for _, tc := range []struct {
73+
name string
74+
disabled bool
75+
}{
76+
{"clonefile", false}, // with the change
77+
{"sparse", true}, // without the change (reflink fallback)
78+
} {
79+
b.Run(tc.name, func(b *testing.B) {
80+
SetReflinkDisabled(tc.disabled)
81+
b.Cleanup(func() { SetReflinkDisabled(false) })
82+
b.SetBytes(size)
83+
b.ResetTimer()
84+
for i := 0; i < b.N; i++ {
85+
dst := filepath.Join(dir, fmt.Sprintf("copy-%s-%d.raw", tc.name, i))
86+
if err := CopyRegularFile(src, dst); err != nil {
87+
b.Fatalf("copy: %v", err)
88+
}
89+
b.StopTimer()
90+
_ = os.Remove(dst)
91+
b.StartTimer()
92+
}
93+
})
94+
}
95+
}

lib/forkvm/copy_reflink_darwin.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//go:build darwin
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+
// cloneFileFn indirects clonefile(2) so tests can inject a rejection and
15+
// exercise the sparse-copy fallback. Production leaves it untouched.
16+
var cloneFileFn = unix.Clonefile
17+
18+
// copyRegularFileReflink clones srcPath to dstPath via clonefile(2) (APFS
19+
// copy-on-write). On APFS this is effectively instantaneous and consumes no
20+
// additional space until the cloned file's pages diverge.
21+
//
22+
// Unlike the Linux FICLONE path, clonefile(2) requires the destination to NOT
23+
// exist: it creates the destination as part of the clone. We remove any stale
24+
// destination first and must not pre-create or O_TRUNC it.
25+
//
26+
// Returns ErrReflinkUnsupported when the volume cannot serve the clone (e.g.
27+
// cross-volume EXDEV, non-APFS ENOTSUP); callers fall back to a sparse copy.
28+
// Real errors (EIO, ENOSPC, EACCES) propagate as-is.
29+
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) error {
30+
// clonefile fails with EEXIST if the destination is present. The walk may be
31+
// re-running over a partially populated destination, so clear it first.
32+
if err := os.Remove(dstPath); err != nil && !errors.Is(err, os.ErrNotExist) {
33+
return fmt.Errorf("remove stale clone destination %s: %w", dstPath, err)
34+
}
35+
36+
// CLONE_NOOWNERCOPY drops owner/SUID/SGID/ACL metadata we have no business
37+
// propagating into a fork; the explicit Chmod below re-establishes the mode.
38+
// CLONE_NOFOLLOW is defensive only: callers route regular files here and
39+
// symlinks through os.Symlink, so the source is never a symlink in practice.
40+
flags := unix.CLONE_NOFOLLOW | unix.CLONE_NOOWNERCOPY
41+
if err := cloneFileFn(srcPath, dstPath, flags); err != nil {
42+
if isReflinkUnsupportedError(err) {
43+
return fmt.Errorf("%w: clonefile rejected for %s: %v", ErrReflinkUnsupported, srcPath, err)
44+
}
45+
return fmt.Errorf("clonefile %s -> %s: %w", srcPath, dstPath, err)
46+
}
47+
48+
if err := os.Chmod(dstPath, perms); err != nil {
49+
_ = os.Remove(dstPath)
50+
return fmt.Errorf("chmod cloned file %s: %w", dstPath, err)
51+
}
52+
return nil
53+
}
54+
55+
// isReflinkUnsupportedError returns true when a clonefile failure indicates the
56+
// clone cannot be served and the caller should fall back to a sparse copy. Only
57+
// volume/filesystem-capability signals belong here; everything else propagates.
58+
//
59+
// EINVAL and ENOTDIR are programming/path errors on clonefile(2) against
60+
// fixed-constant flags, not capability signals — mapping them to the fallback
61+
// would silently disable the fast path for every file if a future edit broke the
62+
// flag set. EEXIST is owned by the os.Remove above (the destination is cleared
63+
// before the clone); mapping a per-file EEXIST here would wrongly flip the whole
64+
// directory walk to sparse via the reflinkDead short-circuit. Real errors (EIO,
65+
// ENOSPC, EACCES) likewise propagate.
66+
func isReflinkUnsupportedError(err error) bool {
67+
switch {
68+
case errors.Is(err, unix.ENOTSUP),
69+
errors.Is(err, unix.EOPNOTSUPP),
70+
errors.Is(err, unix.EXDEV):
71+
return true
72+
}
73+
return false
74+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//go:build darwin
2+
3+
package forkvm
4+
5+
import (
6+
"bytes"
7+
"errors"
8+
"net"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"golang.org/x/sys/unix"
16+
)
17+
18+
// skipUnlessAPFS skips a clone test when the temp volume cannot serve a
19+
// clonefile (i.e. not APFS). On CI we set HYPEMAN_REQUIRE_APFS_CLONE=1 so the
20+
// clone path can't silently skip and report a false green on a non-APFS runner;
21+
// there the unsupported signal becomes a hard failure instead.
22+
func skipUnlessAPFS(t *testing.T, err error) {
23+
t.Helper()
24+
if !errors.Is(err, ErrReflinkUnsupported) {
25+
return
26+
}
27+
if os.Getenv("HYPEMAN_REQUIRE_APFS_CLONE") == "1" {
28+
t.Fatalf("clonefile reported unsupported but HYPEMAN_REQUIRE_APFS_CLONE=1: %v", err)
29+
}
30+
t.Skip("clonefile unsupported on this volume; not APFS")
31+
}
32+
33+
// TestCopyGuestDirectory_CloneCorrectness exercises the real clonefile(2) path
34+
// over a guest-shaped tree: regular files must be byte-identical, perms must be
35+
// preserved (the Chmod contract), symlinks copied, and runtime sockets skipped.
36+
func TestCopyGuestDirectory_CloneCorrectness(t *testing.T) {
37+
SetReflinkDisabled(false)
38+
39+
// Use a short base path so the unix socket below fits within the sockaddr
40+
// length limit; macOS t.TempDir() paths under /var/folders are too long.
41+
base, err := os.MkdirTemp("/tmp", "forkvm-clone-*")
42+
require.NoError(t, err)
43+
t.Cleanup(func() { _ = os.RemoveAll(base) })
44+
45+
src := filepath.Join(base, "src")
46+
dst := filepath.Join(base, "dst")
47+
require.NoError(t, os.MkdirAll(src, 0755))
48+
49+
rootfs := bytes.Repeat([]byte("rootfs-"), 1<<20) // ~7 MiB dense payload
50+
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), rootfs, 0644))
51+
require.NoError(t, os.WriteFile(filepath.Join(src, "config.ext4"), []byte("config-bytes"), 0600))
52+
require.NoError(t, writeSparseFile(filepath.Join(src, "overlay.raw"), 64*1024*1024))
53+
require.NoError(t, os.Symlink("rootfs.ext4", filepath.Join(src, "rootfs-link")))
54+
55+
socketPath := filepath.Join(src, "vsock.sock")
56+
listener, err := net.Listen("unix", socketPath)
57+
require.NoError(t, err)
58+
t.Cleanup(func() { _ = listener.Close() })
59+
60+
// Probe the volume so a non-APFS runner skips (or fails under CI) before we
61+
// assert on clone output rather than silently exercising the sparse path.
62+
skipUnlessAPFS(t, copyRegularFileReflink(filepath.Join(src, "config.ext4"), filepath.Join(base, "probe"), 0600))
63+
64+
require.NoError(t, CopyGuestDirectory(src, dst))
65+
66+
for _, name := range []string{"rootfs.ext4", "config.ext4", "overlay.raw"} {
67+
want, err := os.ReadFile(filepath.Join(src, name))
68+
require.NoError(t, err)
69+
got, err := os.ReadFile(filepath.Join(dst, name))
70+
require.NoError(t, err)
71+
assert.True(t, bytes.Equal(want, got), "contents of %s must match", name)
72+
73+
srcInfo, err := os.Stat(filepath.Join(src, name))
74+
require.NoError(t, err)
75+
dstInfo, err := os.Stat(filepath.Join(dst, name))
76+
require.NoError(t, err)
77+
assert.Equal(t, srcInfo.Mode().Perm(), dstInfo.Mode().Perm(), "perms of %s must be preserved", name)
78+
}
79+
80+
target, err := os.Readlink(filepath.Join(dst, "rootfs-link"))
81+
require.NoError(t, err)
82+
assert.Equal(t, "rootfs.ext4", target)
83+
84+
assert.NoFileExists(t, filepath.Join(dst, "vsock.sock"))
85+
}
86+
87+
// TestCopyRegularFileReflink_StaleDestination guards the "destination must not
88+
// exist" divergence from the Linux path: a pre-existing destination is removed
89+
// before the clone, so the clone still succeeds and matches the source.
90+
func TestCopyRegularFileReflink_StaleDestination(t *testing.T) {
91+
dir := t.TempDir()
92+
src := filepath.Join(dir, "rootfs.ext4")
93+
dst := filepath.Join(dir, "dst.ext4")
94+
95+
want := bytes.Repeat([]byte("clone-me"), 4096)
96+
require.NoError(t, os.WriteFile(src, want, 0644))
97+
require.NoError(t, os.WriteFile(dst, []byte("stale-contents"), 0600))
98+
99+
err := copyRegularFileReflink(src, dst, 0644)
100+
skipUnlessAPFS(t, err)
101+
require.NoError(t, err)
102+
103+
got, err := os.ReadFile(dst)
104+
require.NoError(t, err)
105+
assert.True(t, bytes.Equal(want, got))
106+
107+
info, err := os.Stat(dst)
108+
require.NoError(t, err)
109+
assert.Equal(t, os.FileMode(0644), info.Mode().Perm())
110+
}
111+
112+
// TestCopyRegularFileReflink_WriteIsolation verifies the clone is copy-on-write:
113+
// mutating the clone must not change the source. This is the headline correctness
114+
// property of the fast path — a fork must never corrupt the instance it forked from.
115+
func TestCopyRegularFileReflink_WriteIsolation(t *testing.T) {
116+
dir := t.TempDir()
117+
src := filepath.Join(dir, "rootfs.ext4")
118+
dst := filepath.Join(dir, "fork.ext4")
119+
120+
orig := bytes.Repeat([]byte("source-data"), 8192)
121+
require.NoError(t, os.WriteFile(src, orig, 0644))
122+
123+
err := copyRegularFileReflink(src, dst, 0644)
124+
skipUnlessAPFS(t, err)
125+
require.NoError(t, err)
126+
127+
// Diverge the clone by overwriting it entirely.
128+
require.NoError(t, os.WriteFile(dst, bytes.Repeat([]byte("forked-data!"), 8192), 0644))
129+
130+
// The source must be byte-for-byte unchanged (copy-on-write).
131+
got, err := os.ReadFile(src)
132+
require.NoError(t, err)
133+
assert.True(t, bytes.Equal(orig, got), "writing to the clone must not modify the source")
134+
}
135+
136+
// TestCopyGuestDirectory_DarwinReflinkFallback drives the darwin-specific
137+
// rejection path: when clonefile reports an unsupported error, copyRegularFile
138+
// must route through isReflinkUnsupportedError to the sparse copy and still
139+
// produce byte-identical output. CI volumes are always APFS, so this is the
140+
// only coverage of the clonefile-rejected -> sparse transition on darwin.
141+
func TestCopyGuestDirectory_DarwinReflinkFallback(t *testing.T) {
142+
orig := cloneFileFn
143+
cloneFileFn = func(src, dst string, flags int) error { return unix.EXDEV }
144+
t.Cleanup(func() { cloneFileFn = orig })
145+
SetReflinkDisabled(false)
146+
147+
src := filepath.Join(t.TempDir(), "src")
148+
dst := filepath.Join(t.TempDir(), "dst")
149+
require.NoError(t, os.MkdirAll(src, 0755))
150+
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))
151+
require.NoError(t, writeSparseFile(filepath.Join(src, "overlay.raw"), 16*1024*1024))
152+
153+
require.NoError(t, CopyGuestDirectory(src, dst))
154+
155+
got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
156+
require.NoError(t, err)
157+
assert.Equal(t, "rootfs-bytes", string(got))
158+
159+
want, err := os.ReadFile(filepath.Join(src, "overlay.raw"))
160+
require.NoError(t, err)
161+
got, err = os.ReadFile(filepath.Join(dst, "overlay.raw"))
162+
require.NoError(t, err)
163+
assert.True(t, bytes.Equal(want, got), "sparse fallback output must match source")
164+
}
165+
166+
func TestIsReflinkUnsupportedError(t *testing.T) {
167+
tests := []struct {
168+
name string
169+
err error
170+
want bool
171+
}{
172+
{"ENOTSUP", unix.ENOTSUP, true},
173+
{"EOPNOTSUPP", unix.EOPNOTSUPP, true},
174+
{"EXDEV", unix.EXDEV, true},
175+
// EEXIST is owned by the pre-clone os.Remove; EINVAL/ENOTDIR are
176+
// programming/path errors on clonefile(2). None are capability signals,
177+
// so they propagate rather than trigger fallback.
178+
{"EEXIST", unix.EEXIST, false},
179+
{"EINVAL", unix.EINVAL, false},
180+
{"ENOTDIR", unix.ENOTDIR, false},
181+
{"EIO", unix.EIO, false},
182+
{"ENOSPC", unix.ENOSPC, false},
183+
{"EACCES", unix.EACCES, false},
184+
{"nil", nil, false},
185+
}
186+
for _, tc := range tests {
187+
t.Run(tc.name, func(t *testing.T) {
188+
assert.Equal(t, tc.want, isReflinkUnsupportedError(tc.err))
189+
})
190+
}
191+
}

lib/forkvm/copy_reflink_other.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//go:build !linux
1+
//go:build !linux && !darwin
22

33
package forkvm
44

@@ -7,9 +7,8 @@ import (
77
"io/fs"
88
)
99

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.
10+
// copyRegularFileReflink is unavailable on platforms without a copy-on-write
11+
// clone primitive; callers fall back to the sparse copy.
1312
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) error {
1413
_ = dstPath
1514
_ = perms

0 commit comments

Comments
 (0)