From 7c351fa42999f88c5aec5f3322956ae3d93d4df3 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Tue, 12 May 2026 23:42:30 +0000 Subject: [PATCH 1/4] fork: share template mem-file via hardlink for firecracker fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Firecracker fork descends from a Template source, skip copying the snapshot mem-file and hardlink it to the source's instead. Firecracker mmaps the mem-file MAP_PRIVATE on restore, so all forks COW from the same backing inode — no per-fork copy required. Hardlink rather than symlink: firecracker's restore path temporarily aliases the source data dir to the fork data dir while loading the snapshot (withSnapshotSourceDirAlias). A symlink whose target traverses the source dir would resolve back into the fork dir during that window and trip ELOOP; a hardlink resolves by inode so the alias has no effect on it. Hardlinks require both paths on the same filesystem, which holds for our standard data-dir layout. Gated to Firecracker only because other hypervisors (cloud-hypervisor, qemu, vz) don't share MAP_PRIVATE semantics on their snapshot layouts. Restricted to Template sources because they are explicitly promoted as fork-only and can never be restored — sharing the mem-file with a non-Template source would let a later RestoreInstance mutate the file out from under live forks. Stacked on hypeship/template-as-state so the Template state both gates "this snapshot is safe to fan out from" and lets fork counts be derived at read time. --- lib/forkvm/copy.go | 29 ++++ lib/forkvm/copy_test.go | 19 +++ lib/instances/fork.go | 22 ++- lib/instances/templates.go | 46 ++++++ .../templates_shared_memfile_test.go | 143 ++++++++++++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 lib/instances/templates.go create mode 100644 lib/instances/templates_shared_memfile_test.go diff --git a/lib/forkvm/copy.go b/lib/forkvm/copy.go index fda4a48f..8ee9c5ff 100644 --- a/lib/forkvm/copy.go +++ b/lib/forkvm/copy.go @@ -33,12 +33,30 @@ type copyState struct { reflinkDead bool } +// CopyOptions tunes CopyGuestDirectory behavior. The zero value reproduces +// the original full-copy semantics; callers can opt into skipping specific +// paths when the consumer arranges its own substitute (e.g. a symlink to a +// template-shared mem-file). +type CopyOptions struct { + // SkipRelPaths lists relative paths under srcDir that should not be + // materialized in dstDir. Comparison is exact and uses forward-slash + // separators on all platforms. + SkipRelPaths []string +} + // CopyGuestDirectory recursively copies a guest directory to a new destination. // 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 { + return CopyGuestDirectoryWithOptions(srcDir, dstDir, CopyOptions{}) +} + +// CopyGuestDirectoryWithOptions is the option-taking variant of +// CopyGuestDirectory. Use this when forking with template-shared assets, so +// the caller can install a symlink in place of a heavy copied file. +func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error { srcInfo, err := os.Stat(srcDir) if err != nil { return fmt.Errorf("stat source directory: %w", err) @@ -56,6 +74,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error { state.reflinkDead = true } + skipSet := make(map[string]struct{}, len(opts.SkipRelPaths)) + for _, p := range opts.SkipRelPaths { + skipSet[filepath.ToSlash(p)] = struct{}{} + } + return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -68,6 +91,12 @@ func CopyGuestDirectory(srcDir, dstDir string) error { if relPath == "." { return nil } + if _, skip := skipSet[filepath.ToSlash(relPath)]; skip { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } if d.IsDir() && shouldSkipDirectory(relPath) { return filepath.SkipDir } diff --git a/lib/forkvm/copy_test.go b/lib/forkvm/copy_test.go index c71f6c4e..56fb6caf 100644 --- a/lib/forkvm/copy_test.go +++ b/lib/forkvm/copy_test.go @@ -44,6 +44,25 @@ func TestCopyGuestDirectory(t *testing.T) { assert.Equal(t, "metadata.json", linkTarget) } +func TestCopyGuestDirectory_SkipRelPaths(t *testing.T) { + src := filepath.Join(t.TempDir(), "src") + dst := filepath.Join(t.TempDir(), "dst") + + require.NoError(t, os.MkdirAll(filepath.Join(src, "snapshots", "snapshot-latest"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "config.json"), []byte(`{}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "memory"), []byte("the heavy mem-file"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "state"), []byte("device state"), 0644)) + + err := CopyGuestDirectoryWithOptions(src, dst, CopyOptions{ + SkipRelPaths: []string{"snapshots/snapshot-latest/memory"}, + }) + require.NoError(t, err) + + assert.NoFileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory")) + assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "config.json")) + assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "state")) +} + func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) { src := filepath.Join(t.TempDir(), "src") dst := filepath.Join(t.TempDir(), "dst") diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 1bb4102f..e358048f 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -255,19 +255,39 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin fromSnapshot := source.State == StateStandby || source.State == StateTemplate + // shareMemFile gates mem-file fan-out from the source's standby snapshot. + // Firecracker only: it mmaps the snapshot mem-file MAP_PRIVATE on restore, + // so all forks safely COW from the same backing file. Cloud-hypervisor and + // other hypervisors take a copy-mode path and don't benefit. Restricted to + // Template sources because they are explicitly promoted as fork-only and + // can never be restored — sharing the mem-file with a non-Template source + // would let a later RestoreInstance mutate the file out from under live + // forks. + shareMemFile := source.State == StateTemplate && stored.HypervisorType == hypervisor.TypeFirecracker + if fromSnapshot { if err := m.ensureSnapshotMemoryReady(ctx, m.paths.InstanceSnapshotLatest(id), m.snapshotJobKeyForInstance(id), stored.HypervisorType); err != nil { return nil, fmt.Errorf("prepare standby snapshot for fork: %w", err) } } - if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil { + copyOpts := forkvm.CopyOptions{} + if shareMemFile { + copyOpts.SkipRelPaths = []string{templateSharedMemFileRelPath} + } + if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return nil, fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } return nil, fmt.Errorf("clone guest directory: %w", err) } + if shareMemFile { + if err := m.installForkSharedMemFile(dstDir, id); err != nil { + return nil, fmt.Errorf("install shared mem-file: %w", err) + } + } + starter, err := m.getVMStarter(stored.HypervisorType) if err != nil { return nil, fmt.Errorf("get vm starter: %w", err) diff --git a/lib/instances/templates.go b/lib/instances/templates.go new file mode 100644 index 00000000..b40e5859 --- /dev/null +++ b/lib/instances/templates.go @@ -0,0 +1,46 @@ +package instances + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + templateSharedMemFileName = "memory" + templateSharedMemFileRelPath = "snapshots/snapshot-latest/memory" +) + +// installForkSharedMemFile arranges the fork's snapshot directory so the +// guest mem-file is a hardlink to the source template instance's snapshot +// mem-file instead of a per-fork copy. firecracker mmaps the mem-file +// MAP_PRIVATE during restore, so all forks COW from the same backing inode. +// +// Layout: forkDataDir is the fork's data dir. The snapshot dir is at +// /snapshots/snapshot-latest, and the mem-file lives at +// /memory. The hardlink shares the inode with the source +// instance's standby snapshot mem-file. +// +// We use a hardlink rather than a symlink because firecracker's restore +// path temporarily aliases the source data dir to the fork data dir while +// it loads the snapshot (see withSnapshotSourceDirAlias). A symlink whose +// target traverses the source dir would resolve back into the fork dir +// during that window and trip ELOOP; a hardlink resolves by inode so the +// alias has no effect on it. Hardlinks require both paths on the same +// filesystem, which holds for our standard data-dir layout. +func (m *manager) installForkSharedMemFile(forkDataDir, sourceInstanceID string) error { + srcMem := filepath.Join(m.paths.InstanceSnapshotLatest(sourceInstanceID), templateSharedMemFileName) + if _, err := os.Stat(srcMem); err != nil { + return fmt.Errorf("stat template mem-file: %w", err) + } + dstSnapshotDir := filepath.Join(forkDataDir, "snapshots", "snapshot-latest") + if err := os.MkdirAll(dstSnapshotDir, 0o755); err != nil { + return fmt.Errorf("ensure fork snapshot dir: %w", err) + } + dstMem := filepath.Join(dstSnapshotDir, templateSharedMemFileName) + _ = os.Remove(dstMem) + if err := os.Link(srcMem, dstMem); err != nil { + return fmt.Errorf("hardlink shared mem-file: %w", err) + } + return nil +} diff --git a/lib/instances/templates_shared_memfile_test.go b/lib/instances/templates_shared_memfile_test.go new file mode 100644 index 00000000..eba26a38 --- /dev/null +++ b/lib/instances/templates_shared_memfile_test.go @@ -0,0 +1,143 @@ +package instances + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sameInode(t *testing.T, a, b string) bool { + t.Helper() + ai, err := os.Stat(a) + require.NoError(t, err) + bi, err := os.Stat(b) + require.NoError(t, err) + as := ai.Sys().(*syscall.Stat_t) + bs := bi.Sys().(*syscall.Stat_t) + return as.Ino == bs.Ino && as.Dev == bs.Dev +} + +// TestInstallForkSharedMemFile_HardlinksSourceMemFile verifies that the helper +// creates a hardlink at the fork's snapshot mem-file path that shares the +// source instance's mem-file inode. +func TestInstallForkSharedMemFile_HardlinksSourceMemFile(t *testing.T) { + t.Parallel() + + mgr, _ := newStorageOnlyManager(t) + sourceID := "shared-memfile-source" + + srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) + require.NoError(t, os.MkdirAll(srcSnapshotDir, 0o755)) + srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) + require.NoError(t, os.WriteFile(srcMem, []byte("guest memory bytes"), 0o644)) + + forkDir := filepath.Join(t.TempDir(), "fork-data") + + require.NoError(t, mgr.installForkSharedMemFile(forkDir, sourceID)) + + forkMem := filepath.Join(forkDir, "snapshots", "snapshot-latest", templateSharedMemFileName) + info, err := os.Lstat(forkMem) + require.NoError(t, err) + assert.True(t, info.Mode().IsRegular(), "fork mem-file must be a regular file (hardlink), not a symlink") + assert.True(t, sameInode(t, srcMem, forkMem), "fork mem-file must share the source's inode") +} + +// TestInstallForkSharedMemFile_ErrorsWhenSourceMissing makes sure the helper +// refuses to silently create a dangling link when the source mem-file does not +// exist. +func TestInstallForkSharedMemFile_ErrorsWhenSourceMissing(t *testing.T) { + t.Parallel() + + mgr, _ := newStorageOnlyManager(t) + forkDir := filepath.Join(t.TempDir(), "fork-data") + + err := mgr.installForkSharedMemFile(forkDir, "no-such-source") + require.Error(t, err) +} + +// TestForkFirecrackerSharesMemFile_FromTemplate verifies the end-to-end fork +// path: when the source is a Firecracker Template instance, the fork's +// mem-file is a hardlink to the source's mem-file instead of a copy. This +// preserves the firecracker MAP_PRIVATE COW semantics that let multiple forks +// share the heavy backing file. +func TestForkFirecrackerSharesMemFile_FromTemplate(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "shared-memfile-fc-src" + createStandbySnapshotSourceFixture(t, mgr, sourceID, "shared-memfile-fc-src", hypervisor.TypeFirecracker) + promoteFixtureToTemplate(t, mgr, sourceID) + + srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) + srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) + require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644)) + snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID) + require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) + require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) + + forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "shared-memfile-fc-fork", + TargetState: StateStopped, + }, true) + require.NoError(t, err) + require.NotNil(t, forked) + + forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName) + info, err := os.Lstat(forkMem) + require.NoError(t, err) + assert.True(t, info.Mode().IsRegular(), "fork mem-file must be a regular file (hardlink) for firecracker fan-out") + assert.True(t, sameInode(t, srcMem, forkMem), "fork mem-file must share the source's inode") +} + +// TestForkFirecrackerStandbySourceDoesNotShareMemFile guards the +// non-Template carve-out: forking a plain Standby source must copy the +// mem-file outright. Sharing would let a later RestoreInstance on the source +// mutate the file out from under live forks. +func TestForkFirecrackerStandbySourceDoesNotShareMemFile(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "standby-fork-fc-src" + createStandbySnapshotSourceFixture(t, mgr, sourceID, "standby-fork-fc-src", hypervisor.TypeFirecracker) + + srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) + srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) + require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644)) + snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID) + require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) + require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) + + forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "standby-fork-fc-fork", + TargetState: StateStopped, + }, true) + require.NoError(t, err) + require.NotNil(t, forked) + + forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName) + info, err := os.Lstat(forkMem) + require.NoError(t, err) + require.True(t, info.Mode().IsRegular(), "standby-source fork mem-file must be a regular file copy") + assert.False(t, sameInode(t, srcMem, forkMem), "standby-source fork mem-file must be a copy, not a hardlink to source") +} + +// promoteFixtureToTemplate marks the source's stored metadata as a Template +// without invoking the full PromoteToTemplate lifecycle (which would require +// a live VM). Test-only shortcut. +func promoteFixtureToTemplate(t *testing.T, mgr *manager, id string) { + t.Helper() + meta, err := mgr.loadMetadata(id) + require.NoError(t, err) + meta.IsTemplate = true + require.NoError(t, mgr.saveMetadata(meta)) +} From 101b5bd9712ed83c9b6b2365be1819f0b86356c8 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Wed, 13 May 2026 04:52:39 +0000 Subject: [PATCH 2/4] test: gate firecracker fork tests behind linux build tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two TestForkFirecracker* tests invoke forkInstanceFromStoppedOrStandby which needs a hypervisor VM starter — firecracker is linux-only, so these fail on darwin with 'no VM starter for hypervisor type: firecracker'. Split into _linux_test.go; leave the pure-helper TestInstallForkSharedMemFile_* tests cross-platform. --- .../templates_shared_memfile_linux_test.go | 84 +++++++++++++++++++ .../templates_shared_memfile_test.go | 72 ---------------- 2 files changed, 84 insertions(+), 72 deletions(-) create mode 100644 lib/instances/templates_shared_memfile_linux_test.go diff --git a/lib/instances/templates_shared_memfile_linux_test.go b/lib/instances/templates_shared_memfile_linux_test.go new file mode 100644 index 00000000..49ccf98e --- /dev/null +++ b/lib/instances/templates_shared_memfile_linux_test.go @@ -0,0 +1,84 @@ +//go:build linux + +package instances + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestForkFirecrackerSharesMemFile_FromTemplate verifies the end-to-end fork +// path: when the source is a Firecracker Template instance, the fork's +// mem-file is a hardlink to the source's mem-file instead of a copy. This +// preserves the firecracker MAP_PRIVATE COW semantics that let multiple forks +// share the heavy backing file. +func TestForkFirecrackerSharesMemFile_FromTemplate(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "shared-memfile-fc-src" + createStandbySnapshotSourceFixture(t, mgr, sourceID, "shared-memfile-fc-src", hypervisor.TypeFirecracker) + promoteFixtureToTemplate(t, mgr, sourceID) + + srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) + srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) + require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644)) + snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID) + require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) + require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) + + forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "shared-memfile-fc-fork", + TargetState: StateStopped, + }, true) + require.NoError(t, err) + require.NotNil(t, forked) + + forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName) + info, err := os.Lstat(forkMem) + require.NoError(t, err) + assert.True(t, info.Mode().IsRegular(), "fork mem-file must be a regular file (hardlink) for firecracker fan-out") + assert.True(t, sameInode(t, srcMem, forkMem), "fork mem-file must share the source's inode") +} + +// TestForkFirecrackerStandbySourceDoesNotShareMemFile guards the +// non-Template carve-out: forking a plain Standby source must copy the +// mem-file outright. Sharing would let a later RestoreInstance on the source +// mutate the file out from under live forks. +func TestForkFirecrackerStandbySourceDoesNotShareMemFile(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "standby-fork-fc-src" + createStandbySnapshotSourceFixture(t, mgr, sourceID, "standby-fork-fc-src", hypervisor.TypeFirecracker) + + srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) + srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) + require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644)) + snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID) + require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) + require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) + + forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "standby-fork-fc-fork", + TargetState: StateStopped, + }, true) + require.NoError(t, err) + require.NotNil(t, forked) + + forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName) + info, err := os.Lstat(forkMem) + require.NoError(t, err) + require.True(t, info.Mode().IsRegular(), "standby-source fork mem-file must be a regular file copy") + assert.False(t, sameInode(t, srcMem, forkMem), "standby-source fork mem-file must be a copy, not a hardlink to source") +} diff --git a/lib/instances/templates_shared_memfile_test.go b/lib/instances/templates_shared_memfile_test.go index eba26a38..f221a0d3 100644 --- a/lib/instances/templates_shared_memfile_test.go +++ b/lib/instances/templates_shared_memfile_test.go @@ -1,13 +1,11 @@ package instances import ( - "context" "os" "path/filepath" "syscall" "testing" - "github.com/kernel/hypeman/lib/hypervisor" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -61,76 +59,6 @@ func TestInstallForkSharedMemFile_ErrorsWhenSourceMissing(t *testing.T) { require.Error(t, err) } -// TestForkFirecrackerSharesMemFile_FromTemplate verifies the end-to-end fork -// path: when the source is a Firecracker Template instance, the fork's -// mem-file is a hardlink to the source's mem-file instead of a copy. This -// preserves the firecracker MAP_PRIVATE COW semantics that let multiple forks -// share the heavy backing file. -func TestForkFirecrackerSharesMemFile_FromTemplate(t *testing.T) { - t.Parallel() - - mgr, _ := setupTestManager(t) - ctx := context.Background() - - sourceID := "shared-memfile-fc-src" - createStandbySnapshotSourceFixture(t, mgr, sourceID, "shared-memfile-fc-src", hypervisor.TypeFirecracker) - promoteFixtureToTemplate(t, mgr, sourceID) - - srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) - srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) - require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644)) - snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID) - require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) - require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) - - forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ - Name: "shared-memfile-fc-fork", - TargetState: StateStopped, - }, true) - require.NoError(t, err) - require.NotNil(t, forked) - - forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName) - info, err := os.Lstat(forkMem) - require.NoError(t, err) - assert.True(t, info.Mode().IsRegular(), "fork mem-file must be a regular file (hardlink) for firecracker fan-out") - assert.True(t, sameInode(t, srcMem, forkMem), "fork mem-file must share the source's inode") -} - -// TestForkFirecrackerStandbySourceDoesNotShareMemFile guards the -// non-Template carve-out: forking a plain Standby source must copy the -// mem-file outright. Sharing would let a later RestoreInstance on the source -// mutate the file out from under live forks. -func TestForkFirecrackerStandbySourceDoesNotShareMemFile(t *testing.T) { - t.Parallel() - - mgr, _ := setupTestManager(t) - ctx := context.Background() - - sourceID := "standby-fork-fc-src" - createStandbySnapshotSourceFixture(t, mgr, sourceID, "standby-fork-fc-src", hypervisor.TypeFirecracker) - - srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID) - srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName) - require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644)) - snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID) - require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) - require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) - - forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ - Name: "standby-fork-fc-fork", - TargetState: StateStopped, - }, true) - require.NoError(t, err) - require.NotNil(t, forked) - - forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName) - info, err := os.Lstat(forkMem) - require.NoError(t, err) - require.True(t, info.Mode().IsRegular(), "standby-source fork mem-file must be a regular file copy") - assert.False(t, sameInode(t, srcMem, forkMem), "standby-source fork mem-file must be a copy, not a hardlink to source") -} - // promoteFixtureToTemplate marks the source's stored metadata as a Template // without invoking the full PromoteToTemplate lifecycle (which would require // a live VM). Test-only shortcut. From 1bfb7a6910e3260aa37940be1966d9cbc560b117 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 17:34:04 +0000 Subject: [PATCH 3/4] fork: hardlink snapshot mem-file into snapshot forks Snapshot forks copy the source guest dir into the fork instance dir; the dominant cost is the multi-GB mem-file. Hardlink it instead and skip the file from the directory walk via CopyOptions.SkipRelPaths (introduced for template forks). This is safe because: - snapshot mem-files are immutable - the hypervisor mmaps them MAP_PRIVATE on restore, so fork writes never reach the underlying file - hardlinks survive snapshot deletion via inode refcount, so a deleted snapshot never strands a running fork Falls back to the regular copy walk when no raw mem-file is present. --- lib/instances/snapshot.go | 51 +++++++++++++++++++++++++++++-- lib/instances/snapshot_test.go | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 908583cd..09749500 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "time" "github.com/kernel/hypeman/lib/forkvm" @@ -414,16 +415,27 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if target != nil && target.State == compressionJobStateRunning { m.recordSnapshotCompressionPreemption(ctx, snapshotCompressionPreemptionForkSnapshot, target.Target) } - if err := m.ensureSnapshotMemoryReady(ctx, m.paths.SnapshotGuestDir(snapshotID), "", rec.StoredMetadata.HypervisorType); err != nil { + srcDir := m.paths.SnapshotGuestDir(snapshotID) + if err := m.ensureSnapshotMemoryReady(ctx, srcDir, "", rec.StoredMetadata.HypervisorType); err != nil { return nil, fmt.Errorf("prepare snapshot memory for fork: %w", err) } - if err := forkvm.CopyGuestDirectory(m.paths.SnapshotGuestDir(snapshotID), dstDir); err != nil { + copyOpts := forkvm.CopyOptions{} + srcMemPath, srcMemRel, hasSharedMem := snapshotMemHardlinkSource(srcDir) + if hasSharedMem { + copyOpts.SkipRelPaths = []string{srcMemRel} + } + if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return nil, fmt.Errorf("fork from snapshot requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } return nil, fmt.Errorf("clone snapshot payload: %w", err) } + if hasSharedMem { + if err := installForkSnapshotMemHardlink(srcMemPath, dstDir, srcMemRel); err != nil { + return nil, fmt.Errorf("hardlink snapshot mem-file into fork: %w", err) + } + } starter, err := m.getVMStarter(targetHypervisor) if err != nil { @@ -645,3 +657,38 @@ func (m *manager) listSnapshotRecords() ([]snapshotRecord, error) { } return records, nil } + +// snapshotMemHardlinkSource resolves the raw mem-file under a snapshot guest +// dir. Returns its absolute path and forward-slash-relative path for use as a +// CopyOptions skip key. Returns ok=false if no raw mem-file is present (e.g. +// only-compressed snapshot whose decompression failed, or a snapshot kind that +// doesn't carry guest memory). Callers fall back to the regular copy walk. +func snapshotMemHardlinkSource(srcDir string) (absPath, relSlash string, ok bool) { + abs, found := findRawSnapshotMemoryFile(srcDir) + if !found { + return "", "", false + } + rel, err := filepath.Rel(srcDir, abs) + if err != nil { + return "", "", false + } + return abs, filepath.ToSlash(rel), true +} + +// installForkSnapshotMemHardlink hardlinks the source snapshot mem-file into +// the fork's data dir at the matching relative path. Snapshot mem-files are +// immutable and the hypervisor mmaps them MAP_PRIVATE on restore, so all +// forks of a snapshot can safely share the same inode — fork writes never +// reach the underlying file. Hardlinks are FS-local and survive snapshot +// deletion via inode refcount, so a deleted snapshot never strands a fork. +func installForkSnapshotMemHardlink(srcMemPath, dstDir, relSlash string) error { + dstMem := filepath.Join(dstDir, filepath.FromSlash(relSlash)) + if err := os.MkdirAll(filepath.Dir(dstMem), 0o755); err != nil { + return fmt.Errorf("ensure fork mem-file parent dir: %w", err) + } + _ = os.Remove(dstMem) + if err := os.Link(srcMemPath, dstMem); err != nil { + return fmt.Errorf("link snapshot mem-file: %w", err) + } + return nil +} diff --git a/lib/instances/snapshot_test.go b/lib/instances/snapshot_test.go index cc634f55..e2238a02 100644 --- a/lib/instances/snapshot_test.go +++ b/lib/instances/snapshot_test.go @@ -280,6 +280,62 @@ func TestForkSnapshotFromCompressedSourceCopiesRawMemory(t *testing.T) { assert.False(t, ok, "forked snapshot payload should not retain compressed memory artifacts from the source snapshot") } +func TestForkSnapshotHardlinksRawMemoryFile(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + ctx := context.Background() + + hvType := mgr.defaultHypervisor + sourceID := "snapshot-fork-hardlink-src" + createStandbySnapshotSourceFixture(t, mgr, sourceID, "snapshot-fork-hardlink-src", hvType) + + snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStandby, + Name: "standby-for-fork-hardlink", + }) + require.NoError(t, err) + + // Plant the raw mem-file at the top of the snapshot guest dir so it + // survives applyForkTargetState's snapshot-latest wipe and we can stat + // the fork's hardlinked copy after the call returns. Production layout + // nests it under snapshots/snapshot-latest/memory; the helpers under + // test treat both paths identically via findRawSnapshotMemoryFile. + memContents := []byte("guest memory bytes for hardlink test") + snapshotDir := mgr.paths.SnapshotGuestDir(snap.Id) + snapshotMem := filepath.Join(snapshotDir, "memory-ranges") + require.NoError(t, os.WriteFile(snapshotMem, memContents, 0o644)) + snapshotConfigPath := filepath.Join(snapshotDir, "snapshots", "snapshot-latest", "config.json") + require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755)) + require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644)) + + srcInfo, err := os.Stat(snapshotMem) + require.NoError(t, err) + + forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{ + Name: "snapshot-fork-hardlink", + TargetState: StateStopped, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = mgr.DeleteInstance(context.Background(), forked.Id) }) + + forkMem := filepath.Join(mgr.paths.InstanceDir(forked.Id), "memory-ranges") + forkInfo, err := os.Stat(forkMem) + require.NoError(t, err, "fork should have a hardlinked memory file alongside its instance dir") + assert.True(t, os.SameFile(srcInfo, forkInfo), "fork mem-file should share an inode with the snapshot mem-file (hardlink)") + + got, err := os.ReadFile(forkMem) + require.NoError(t, err) + assert.Equal(t, memContents, got, "fork mem-file should expose the same bytes as the snapshot mem-file") + + // Hardlinks survive deletion of the source path: removing the snapshot + // drops one reference but leaves the inode alive for the fork. + require.NoError(t, mgr.DeleteSnapshot(ctx, snap.Id)) + stillThere, err := os.ReadFile(forkMem) + require.NoError(t, err, "fork mem-file should remain readable after snapshot deletion") + assert.Equal(t, memContents, stillThere) +} + func createStoppedSnapshotSourceFixture(t *testing.T, mgr *manager, id, name string, hvType hypervisor.Type) { t.Helper() require.NoError(t, mgr.ensureDirectories(id)) From 85fe864728c9b90ad66a9dfd6784a0a086254ce8 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 15 May 2026 14:47:13 +0000 Subject: [PATCH 4/4] Assert snapshot fork memory hardlinks --- .../snapshot_integration_scenario_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/instances/snapshot_integration_scenario_test.go b/lib/instances/snapshot_integration_scenario_test.go index fa351c00..630de600 100644 --- a/lib/instances/snapshot_integration_scenario_test.go +++ b/lib/instances/snapshot_integration_scenario_test.go @@ -2,6 +2,7 @@ package instances import ( "context" + "os" "testing" "time" @@ -108,4 +109,23 @@ func runStandbySnapshotScenario(t *testing.T, mgr *manager, tmpDir string, cfg s require.Equal(t, StateStandby, currentFork.State) assertCopyReflinked(t, p.SnapshotGuestDir(snapshot.Id), p.InstanceDir(forkID)) + assertSnapshotForkMemoryHardlinked(t, p.SnapshotGuestDir(snapshot.Id), p.InstanceDir(forkID)) +} + +func assertSnapshotForkMemoryHardlinked(t *testing.T, srcDir, forkDir string) { + t.Helper() + + srcMem, ok := findRawSnapshotMemoryFile(srcDir) + if !ok { + t.Logf("raw snapshot memory file not recognized under %s; hardlink assertion skipped", srcDir) + return + } + forkMem, ok := findRawSnapshotMemoryFile(forkDir) + require.True(t, ok, "fork should contain the raw snapshot memory file") + + srcInfo, err := os.Stat(srcMem) + require.NoError(t, err) + forkInfo, err := os.Stat(forkMem) + require.NoError(t, err) + require.True(t, os.SameFile(srcInfo, forkInfo), "fork snapshot memory file should hardlink the source snapshot memory file") }