Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions lib/forkvm/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,37 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
)

var ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
var (
ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
ErrReflinkUnsupported = errors.New("reflink unsupported")
)

// reflinkDisabled, when nonzero, forces CopyGuestDirectory to skip the FICLONE
// fast path entirely. Tests set this; production code leaves it untouched.
var reflinkDisabled atomic.Bool

// SetReflinkDisabled toggles the FICLONE fast path. Intended for tests that
// need to exercise the sparse-copy fallback explicitly.
func SetReflinkDisabled(disabled bool) {
reflinkDisabled.Store(disabled)
}

// reflinkUnsupportedSticky tracks whether reflink has already been observed to
// fail with an "unsupported" signal for this destination filesystem. Once set,
// we skip subsequent FICLONE attempts within the same CopyGuestDirectory call
// to avoid re-paying the rejection on every file.
type copyState struct {
reflinkDead bool
}

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

state := &copyState{}
if reflinkDisabled.Load() {
state.reflinkDead = true
}

return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
Expand Down Expand Up @@ -61,7 +90,7 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
return nil

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

// copyRegularFile clones path to dstPath, preferring FICLONE reflink and
// falling back to sparse extent copy. The state object lets us short-circuit
// future reflink attempts once we observe an "unsupported" signal from the
// destination filesystem in the current copy.
func copyRegularFile(state *copyState, srcPath, dstPath string, perms fs.FileMode) error {
if state == nil || !state.reflinkDead {
err := copyRegularFileReflink(srcPath, dstPath, perms)
if err == nil {
return nil
}
if !errors.Is(err, ErrReflinkUnsupported) {
return err
}
if state != nil {
state.reflinkDead = true
}
}
return copyRegularFileSparse(srcPath, dstPath, perms)
}

func shouldSkipDirectory(relPath string) bool {
return relPath == "logs"
}
Expand Down
65 changes: 65 additions & 0 deletions lib/forkvm/copy_reflink_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build linux

package forkvm

import (
"errors"
"fmt"
"io/fs"
"os"

"golang.org/x/sys/unix"
)

// copyRegularFileReflink attempts to clone srcPath to dstPath via FICLONE
// (reflink). On filesystems that support copy-on-write at the block layer
// (btrfs, xfs with reflink=1, zfs, bcachefs), this is effectively
// instantaneous and consumes no additional space until pages diverge.
//
// Returns ErrReflinkUnsupported when the filesystem or kernel rejects the
// operation; callers should fall back to a full-copy path.
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) (retErr error) {
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()

dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perms)
if err != nil {
return err
}
defer func() {
if cerr := dst.Close(); retErr == nil && cerr != nil {
retErr = cerr
}
if retErr != nil {
_ = os.Remove(dstPath)
}
}()

if err := unix.IoctlFileClone(int(dst.Fd()), int(src.Fd())); err != nil {
if isReflinkUnsupportedError(err) {
return fmt.Errorf("%w: FICLONE rejected for %s: %v", ErrReflinkUnsupported, srcPath, err)
}
return fmt.Errorf("FICLONE %s -> %s: %w", srcPath, dstPath, err)
}
return nil
}

// isReflinkUnsupportedError returns true when an FICLONE failure indicates the
// operation cannot be served by the filesystem and the caller should fall
// back. Real errors (EIO, ENOSPC) propagate as-is.
func isReflinkUnsupportedError(err error) bool {
switch {
case errors.Is(err, unix.EINVAL),
errors.Is(err, unix.ENOTSUP),
errors.Is(err, unix.EOPNOTSUPP),
errors.Is(err, unix.EXDEV),
errors.Is(err, unix.ETXTBSY),
errors.Is(err, unix.EISDIR),
errors.Is(err, unix.ENOTTY):
return true
}
return false
}
17 changes: 17 additions & 0 deletions lib/forkvm/copy_reflink_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !linux

package forkvm

import (
"fmt"
"io/fs"
)

// copyRegularFileReflink is unavailable on non-Linux platforms. On macOS APFS
// supports clonefile(2) and could be wired up here, but we currently only
// rely on the sparse-copy fallback off-Linux.
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) error {
_ = dstPath
_ = perms
return fmt.Errorf("%w: reflink unsupported on this platform: %s", ErrReflinkUnsupported, srcPath)
}
56 changes: 56 additions & 0 deletions lib/forkvm/copy_reflink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package forkvm

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCopyGuestDirectory_ReflinkFallback exercises the sparse-copy fallback
// path. The reflink fast path is fs-dependent and not portable across CI
// runners; this test forces it off and verifies copy correctness.
func TestCopyGuestDirectory_ReflinkFallback(t *testing.T) {
SetReflinkDisabled(true)
t.Cleanup(func() { SetReflinkDisabled(false) })

src := filepath.Join(t.TempDir(), "src")
dst := filepath.Join(t.TempDir(), "dst")

require.NoError(t, os.MkdirAll(src, 0755))
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(src, "config.json"), []byte(`{"x":1}`), 0644))

require.NoError(t, CopyGuestDirectory(src, dst))

got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
require.NoError(t, err)
assert.Equal(t, "rootfs-bytes", string(got))

got, err = os.ReadFile(filepath.Join(dst, "config.json"))
require.NoError(t, err)
assert.Equal(t, `{"x":1}`, string(got))
}

// TestCopyGuestDirectory_ReflinkAttempted verifies that with reflink enabled
// (the default), the copy still produces a correct destination on filesystems
// where FICLONE either succeeds or falls back transparently. This is the
// happy-path smoke test for the new fast path; on filesystems that don't
// support FICLONE the fallback handles correctness.
func TestCopyGuestDirectory_ReflinkAttempted(t *testing.T) {
SetReflinkDisabled(false)

src := filepath.Join(t.TempDir(), "src")
dst := filepath.Join(t.TempDir(), "dst")

require.NoError(t, os.MkdirAll(src, 0755))
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))

require.NoError(t, CopyGuestDirectory(src, dst))

got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
require.NoError(t, err)
assert.Equal(t, "rootfs-bytes", string(got))
}
3 changes: 3 additions & 0 deletions lib/forkvm/copy_sparse_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ func TestCopyGuestDirectory_PreservesSparseFiles(t *testing.T) {
}

func TestCopyGuestDirectory_FailsWhenSparseSeekingUnsupported(t *testing.T) {
SetReflinkDisabled(true)
t.Cleanup(func() { SetReflinkDisabled(false) })

src := filepath.Join(t.TempDir(), "src")
dst := filepath.Join(t.TempDir(), "dst")
require.NoError(t, os.MkdirAll(src, 0755))
Expand Down
Loading