From fcde75aae0fc901db6daa85c6e0b30f98b6a7dca Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 3 Jun 2026 13:52:13 -0400 Subject: [PATCH 01/17] Use UFFD only for initial snapshot fork restore --- lib/instances/firecracker_uffd.go | 6 +- lib/instances/firecracker_uffd_test.go | 303 +++++++++++++++++++++++++ lib/instances/fork.go | 9 + lib/instances/restore.go | 1 + lib/instances/snapshot.go | 7 + lib/instances/standby.go | 1 + lib/instances/stop.go | 1 + lib/instances/types.go | 7 +- 8 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 lib/instances/firecracker_uffd_test.go diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index 4a4dc67a..05a25041 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -22,12 +22,16 @@ func (m *manager) useFirecrackerUFFD(stored *StoredMetadata) bool { m.firecrackerSnapshotMemoryBackend == uffdpager.BackendUFFD } +func useFirecrackerUFFDOnNextRestore(hvType hypervisor.Type, sourceIsStandby bool, targetState State) bool { + return hvType == hypervisor.TypeFirecracker && sourceIsStandby && targetState != StateStopped +} + func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snapshotDir string) (hypervisor.RestoreOptions, error) { opts := hypervisor.RestoreOptions{SnapshotMemoryBackend: hypervisor.SnapshotMemoryBackendFile} if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { return opts, nil } - if !m.useFirecrackerUFFD(stored) { + if !m.useFirecrackerUFFD(stored) || !stored.FirecrackerUseUFFDOnNextRestore { stored.FirecrackerUFFDSessionID = "" stored.FirecrackerUFFDPagerVersion = "" return opts, nil diff --git a/lib/instances/firecracker_uffd_test.go b/lib/instances/firecracker_uffd_test.go new file mode 100644 index 00000000..605814c5 --- /dev/null +++ b/lib/instances/firecracker_uffd_test.go @@ -0,0 +1,303 @@ +package instances + +import ( + "context" + "testing" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/uffdpager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFirecrackerSnapshotRestoreOptionsOneShotUFFDGate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + backend string + useNext bool + wantBackend hypervisor.SnapshotMemoryBackend + wantErr bool + }{ + { + name: "config file ignores armed one-shot flag", + backend: uffdpager.BackendFile, + useNext: true, + wantBackend: hypervisor.SnapshotMemoryBackendFile, + }, + { + name: "config uffd with unarmed flag uses file backend", + backend: uffdpager.BackendUFFD, + useNext: false, + wantBackend: hypervisor.SnapshotMemoryBackendFile, + }, + { + name: "config uffd with armed flag requires pager", + backend: uffdpager.BackendUFFD, + useNext: true, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mgr := &manager{firecrackerSnapshotMemoryBackend: tc.backend} + stored := &StoredMetadata{ + Id: "fc-restore-options", + HypervisorType: hypervisor.TypeFirecracker, + FirecrackerSnapshotCacheKey: "snapshot-cache-key", + FirecrackerUseUFFDOnNextRestore: tc.useNext, + FirecrackerUFFDSessionID: "stale-session", + FirecrackerUFFDPagerVersion: "stale-version", + } + + opts, err := mgr.firecrackerSnapshotRestoreOptions(stored, t.TempDir()) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "pager is not configured") + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantBackend, opts.SnapshotMemoryBackend) + assert.Empty(t, opts.SnapshotMemoryCacheKey) + assert.Empty(t, opts.SnapshotMemorySessionID) + assert.Empty(t, stored.FirecrackerUFFDSessionID) + assert.Empty(t, stored.FirecrackerUFFDPagerVersion) + }) + } +} + +func TestForkInstanceFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-instance-source" + createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + + forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "one-shot-uffd-instance-fork", + TargetState: StateStandby, + }, true) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.True(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Equal(t, "shared-template-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDSessionID) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDPagerVersion) +} + +func TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTarget(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-instance-stopped-source" + createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + + forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "one-shot-uffd-instance-stopped-fork", + TargetState: StateStopped, + }, true) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.False(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Equal(t, "shared-template-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) +} + +func TestForkSnapshotFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-snapshot-source" + createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-snapshot-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + + snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStandby, + Name: "one-shot-uffd-snapshot", + }) + require.NoError(t, err) + + forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{ + Name: "one-shot-uffd-snapshot-fork", + TargetState: StateStandby, + }) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.True(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Equal(t, "shared-snapshot-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDSessionID) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDPagerVersion) +} + +func TestForkSnapshotFromStoppedDoesNotArmOneShotUFFD(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-stopped-snapshot-source" + createStoppedSnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "stopped-snapshot-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + + snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStopped, + Name: "one-shot-uffd-stopped-snapshot", + }) + require.NoError(t, err) + + forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{ + Name: "one-shot-uffd-stopped-snapshot-fork", + TargetState: StateStopped, + }) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.False(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Empty(t, meta.StoredMetadata.FirecrackerSnapshotCacheKey) +} + +func TestRestoreInstanceClearsOneShotUFFDFlagAfterSuccessfulRestore(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendFile + ctx := context.Background() + + id := "one-shot-uffd-restore-clear" + createStandbySnapshotSourceFixture(t, mgr, id, id, hypervisor.TypeFirecracker) + meta, err := mgr.loadMetadata(id) + require.NoError(t, err) + meta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" + meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore = true + require.NoError(t, mgr.saveMetadata(meta)) + + inst, err := mgr.restoreInstance(ctx, id) + require.NoError(t, err) + require.NotNil(t, inst) + + updated, err := mgr.loadMetadata(id) + require.NoError(t, err) + assert.False(t, updated.StoredMetadata.FirecrackerUseUFFDOnNextRestore) +} + +func installOneShotFirecrackerStarter(t *testing.T, mgr *manager) { + t.Helper() + previous, hadPrevious := mgr.vmStarters[hypervisor.TypeFirecracker] + mgr.vmStarters[hypervisor.TypeFirecracker] = oneShotFirecrackerTestStarter{} + t.Cleanup(func() { + if hadPrevious { + mgr.vmStarters[hypervisor.TypeFirecracker] = previous + return + } + delete(mgr.vmStarters, hypervisor.TypeFirecracker) + }) +} + +type oneShotFirecrackerTestStarter struct{} + +func (oneShotFirecrackerTestStarter) SocketName() string { + return "firecracker.sock" +} + +func (oneShotFirecrackerTestStarter) GetBinaryPath(*paths.Paths, string) (string, error) { + return "/bin/true", nil +} + +func (oneShotFirecrackerTestStarter) GetVersion(*paths.Paths) (string, error) { + return "test", nil +} + +func (oneShotFirecrackerTestStarter) StartVM(context.Context, *paths.Paths, string, string, hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { + return 1234, oneShotFirecrackerTestHypervisor{}, nil +} + +func (oneShotFirecrackerTestStarter) RestoreVM(context.Context, *paths.Paths, string, string, string, hypervisor.RestoreOptions) (int, hypervisor.Hypervisor, error) { + return 1234, oneShotFirecrackerTestHypervisor{}, nil +} + +func (oneShotFirecrackerTestStarter) PrepareFork(context.Context, hypervisor.ForkPrepareRequest) (hypervisor.ForkPrepareResult, error) { + return hypervisor.ForkPrepareResult{}, nil +} + +type oneShotFirecrackerTestHypervisor struct{} + +func (oneShotFirecrackerTestHypervisor) DeleteVM(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) Shutdown(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) GetVMInfo(context.Context) (*hypervisor.VMInfo, error) { + return &hypervisor.VMInfo{State: hypervisor.StateRunning}, nil +} + +func (oneShotFirecrackerTestHypervisor) Pause(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) Resume(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) Snapshot(context.Context, string) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) ResizeMemory(context.Context, int64) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) ResizeMemoryAndWait(context.Context, int64, time.Duration) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) SetTargetGuestMemoryBytes(context.Context, int64) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) GetTargetGuestMemoryBytes(context.Context) (int64, error) { + return 0, nil +} + +func (oneShotFirecrackerTestHypervisor) Capabilities() hypervisor.Capabilities { + return hypervisor.Capabilities{SupportsSnapshot: true, SupportsPause: true} +} diff --git a/lib/instances/fork.go b/lib/instances/fork.go index bbafeabf..b1e1f9ba 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -224,6 +224,10 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin return nil, false, err } } + targetState, err := resolveForkTargetState(req.TargetState, source.State) + if err != nil { + return nil, err + } if err := validateForkVolumeSafety(stored.Volumes); err != nil { return nil, false, err } @@ -283,6 +287,7 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin forkMeta.RestartStatus = restartpolicy.Status{} forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" + forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(forkMeta.HypervisorType, source.State == StateStandby, targetState) if source.State != StateStandby { forkMeta.FirecrackerSnapshotCacheKey = "" } @@ -490,6 +495,10 @@ func (m *manager) applyForkTargetState(ctx context.Context, forkID string, targe return nil, fmt.Errorf("load stopped fork metadata: %w", err) } meta.StoredMetadata.VsockCID = generateVsockCID(forkID) + meta.StoredMetadata.FirecrackerSnapshotCacheKey = "" + meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore = false + meta.StoredMetadata.FirecrackerUFFDSessionID = "" + meta.StoredMetadata.FirecrackerUFFDPagerVersion = "" if err := m.saveMetadata(meta); err != nil { return nil, fmt.Errorf("save stopped fork metadata: %w", err) } diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 13863d30..2a4cc6bd 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -363,6 +363,7 @@ func (m *manager) restoreInstance( // before markers ever hydrated we resume in Initializing. resumePhase, _ := runningPhaseFromMarkers(stored) stored.Phases.Record(resumePhase, time.Now().UTC()) + stored.FirecrackerUseUFFDOnNextRestore = false meta = &metadata{StoredMetadata: *stored} if err := m.saveMetadata(meta); err != nil { // VM is running but metadata failed diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 17f19b98..e1742e75 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -318,6 +318,12 @@ func (m *manager) restoreSnapshot(ctx context.Context, id string, snapshotID str } restored.SocketPath = m.paths.InstanceSocket(id, starter.SocketName()) restored.VsockSocket = m.paths.InstanceSocket(id, hypervisor.VsockSocketNameForType(targetHypervisor)) + restored.FirecrackerUseUFFDOnNextRestore = false + restored.FirecrackerUFFDSessionID = "" + restored.FirecrackerUFFDPagerVersion = "" + if targetState == StateStopped { + restored.FirecrackerSnapshotCacheKey = "" + } if rec.Snapshot.Kind == SnapshotKindStopped { restored.VsockCID = generateVsockCID(id) } @@ -446,6 +452,7 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS forkMeta.RestartStatus = restartpolicy.Status{} forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" + forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(targetHypervisor, rec.Snapshot.Kind == SnapshotKindStandby, targetState) if rec.Snapshot.Kind != SnapshotKindStandby { forkMeta.FirecrackerSnapshotCacheKey = "" } diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 33328600..a25a9636 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -217,6 +217,7 @@ func (m *manager) standbyInstance( stored.StoppedAt = &now stored.HypervisorPID = nil stored.PendingStandbyCompression = nil + stored.FirecrackerUseUFFDOnNextRestore = false if err := m.refreshFirecrackerSnapshotCacheKey(stored, snapshotDir); err != nil { log.WarnContext(ctx, "failed to refresh firecracker snapshot cache key", "instance_id", id, "error", err) } diff --git a/lib/instances/stop.go b/lib/instances/stop.go index 8c128351..873be8fa 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -307,6 +307,7 @@ func (m *manager) stopInstance( stored.ProgramStartedAt = nil stored.GuestAgentReadyAt = nil stored.FirecrackerSnapshotCacheKey = "" + stored.FirecrackerUseUFFDOnNextRestore = false stored.Phases.Record(phasetracking.PhaseStopped, now) meta = &metadata{StoredMetadata: *stored} diff --git a/lib/instances/types.go b/lib/instances/types.go index 0603beba..488b0918 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -118,9 +118,10 @@ type StoredMetadata struct { HypervisorPID *int // Hypervisor process ID (may be stale after host restart) // Firecracker UFFD snapshot restore metadata. - FirecrackerSnapshotCacheKey string - FirecrackerUFFDSessionID string - FirecrackerUFFDPagerVersion string + FirecrackerSnapshotCacheKey string + FirecrackerUseUFFDOnNextRestore bool + FirecrackerUFFDSessionID string + FirecrackerUFFDPagerVersion string // Paths SocketPath string // Path to API socket From a000931d0e3b528f50ff73de6b5f26bf85c1f7e3 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 10:18:21 -0400 Subject: [PATCH 02/17] Defer UFFD fork memory clone until standby --- lib/forkvm/copy.go | 33 ++++++++++++++++++ lib/forkvm/copy_test.go | 37 ++++++++++++++++++++ lib/hypervisor/firecracker/process.go | 6 +++- lib/hypervisor/hypervisor.go | 7 ++-- lib/instances/firecracker_uffd.go | 48 ++++++++++++++++++++++++-- lib/instances/firecracker_uffd_test.go | 18 ++++++++++ lib/instances/fork.go | 8 ++++- lib/instances/snapshot.go | 11 +++++- lib/instances/snapshot_alias_lock.go | 16 ++++++--- lib/instances/standby.go | 20 +++++++++++ lib/instances/standby_test.go | 22 ++++++++++++ lib/instances/types.go | 9 ++--- lib/uffdpager/server_sessions_linux.go | 19 +++++++--- 13 files changed, 232 insertions(+), 22 deletions(-) diff --git a/lib/forkvm/copy.go b/lib/forkvm/copy.go index fda4a48f..9ffccf64 100644 --- a/lib/forkvm/copy.go +++ b/lib/forkvm/copy.go @@ -33,12 +33,22 @@ type copyState struct { reflinkDead bool } +// CopyOptions controls which guest-directory files are copied. +type CopyOptions struct { + SkipRelativePaths map[string]struct{} +} + // 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 CopyGuestDirectory with optional path skips. +func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error { srcInfo, err := os.Stat(srcDir) if err != nil { return fmt.Errorf("stat source directory: %w", err) @@ -68,6 +78,9 @@ func CopyGuestDirectory(srcDir, dstDir string) error { if relPath == "." { return nil } + if _, ok := opts.SkipRelativePaths[filepath.Clean(relPath)]; ok { + return nil + } if d.IsDir() && shouldSkipDirectory(relPath) { return filepath.SkipDir } @@ -115,6 +128,26 @@ func CopyGuestDirectory(srcDir, dstDir string) error { }) } +// CopyRegularFile copies one regular file using the same reflink-first behavior +// as CopyGuestDirectory. +func CopyRegularFile(srcPath, dstPath string) error { + info, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("stat source file: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("source path is not a regular file: %s", srcPath) + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { + return fmt.Errorf("create destination parent: %w", err) + } + state := ©State{} + if reflinkDisabled.Load() { + state.reflinkDead = true + } + return copyRegularFile(state, srcPath, dstPath, info.Mode().Perm()) +} + // 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 diff --git a/lib/forkvm/copy_test.go b/lib/forkvm/copy_test.go index c71f6c4e..00634b26 100644 --- a/lib/forkvm/copy_test.go +++ b/lib/forkvm/copy_test.go @@ -44,6 +44,43 @@ func TestCopyGuestDirectory(t *testing.T) { assert.Equal(t, "metadata.json", linkTarget) } +func TestCopyGuestDirectoryWithOptionsSkipsRelativePaths(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", "memory"), []byte("memory"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "state"), []byte("state"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "overlay.raw"), []byte("overlay"), 0644)) + + require.NoError(t, CopyGuestDirectoryWithOptions(src, dst, CopyOptions{ + SkipRelativePaths: map[string]struct{}{ + filepath.Join("snapshots", "snapshot-latest", "memory"): {}, + }, + })) + + assert.NoFileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory")) + assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "state")) + assert.FileExists(t, filepath.Join(dst, "overlay.raw")) +} + +func TestCopyRegularFile(t *testing.T) { + src := filepath.Join(t.TempDir(), "src", "memory") + dst := filepath.Join(t.TempDir(), "dst", "snapshots", "snapshot-latest", "memory") + + require.NoError(t, os.MkdirAll(filepath.Dir(src), 0755)) + require.NoError(t, os.WriteFile(src, []byte("memory"), 0640)) + + require.NoError(t, CopyRegularFile(src, dst)) + + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) + info, err := os.Stat(dst) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0640), info.Mode().Perm()) +} + func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) { src := filepath.Join(t.TempDir(), "src") dst := filepath.Join(t.TempDir(), "dst") diff --git a/lib/hypervisor/firecracker/process.go b/lib/hypervisor/firecracker/process.go index 8d6d30c2..b30a4b37 100644 --- a/lib/hypervisor/firecracker/process.go +++ b/lib/hypervisor/firecracker/process.go @@ -142,10 +142,14 @@ func (s *Starter) RestoreVM(ctx context.Context, p *paths.Paths, version string, if sessionID == "" { sessionID = filepath.Base(filepath.Dir(socketPath)) } + backingMemoryPath := strings.TrimSpace(opts.SnapshotMemoryBackingPath) + if backingMemoryPath == "" { + backingMemoryPath = snapshotMemoryPath(snapshotPath) + } resp, err := s.uffd.CreateSession(ctx, uffdpager.CreateSessionRequest{ SessionID: sessionID, InstanceID: sessionID, - BackingMemoryPath: snapshotMemoryPath(snapshotPath), + BackingMemoryPath: backingMemoryPath, CacheKey: opts.SnapshotMemoryCacheKey, }) if err != nil { diff --git a/lib/hypervisor/hypervisor.go b/lib/hypervisor/hypervisor.go index 6d6d9292..24736258 100644 --- a/lib/hypervisor/hypervisor.go +++ b/lib/hypervisor/hypervisor.go @@ -132,9 +132,10 @@ const ( ) type RestoreOptions struct { - SnapshotMemoryBackend SnapshotMemoryBackend - SnapshotMemoryCacheKey string - SnapshotMemorySessionID string + SnapshotMemoryBackend SnapshotMemoryBackend + SnapshotMemoryBackingPath string + SnapshotMemoryCacheKey string + SnapshotMemorySessionID string } // ForkNetworkConfig contains network identity fields for fork preparation. diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index 05a25041..e7b3b7fd 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -11,11 +11,14 @@ import ( "syscall" "time" + "github.com/kernel/hypeman/lib/forkvm" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/uffdpager" ) +const firecrackerSnapshotMemoryRelPath = "snapshots/snapshot-latest/memory" + func (m *manager) useFirecrackerUFFD(stored *StoredMetadata) bool { return stored != nil && stored.HypervisorType == hypervisor.TypeFirecracker && @@ -26,6 +29,14 @@ func useFirecrackerUFFDOnNextRestore(hvType hypervisor.Type, sourceIsStandby boo return hvType == hypervisor.TypeFirecracker && sourceIsStandby && targetState != StateStopped } +func (m *manager) shouldDeferFirecrackerSnapshotMemoryCopy(stored *StoredMetadata, sourceIsStandby bool, targetState State) bool { + return m.useFirecrackerUFFD(stored) && useFirecrackerUFFDOnNextRestore(stored.HypervisorType, sourceIsStandby, targetState) +} + +func firecrackerSnapshotMemoryPathInGuestDir(guestDir string) string { + return filepath.Join(guestDir, firecrackerSnapshotMemoryRelPath) +} + func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snapshotDir string) (hypervisor.RestoreOptions, error) { opts := hypervisor.RestoreOptions{SnapshotMemoryBackend: hypervisor.SnapshotMemoryBackendFile} if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { @@ -40,10 +51,14 @@ func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snap return opts, fmt.Errorf("firecracker uffd snapshot restore is enabled but the pager is not configured") } + backingMemoryPath := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath) + if backingMemoryPath == "" { + backingMemoryPath = filepath.Join(snapshotDir, "memory") + } cacheKey := strings.TrimSpace(stored.FirecrackerSnapshotCacheKey) if cacheKey == "" { var err error - cacheKey, err = firecrackerSnapshotCacheKey(stored, snapshotDir) + cacheKey, err = firecrackerSnapshotCacheKeyForPaths(stored, backingMemoryPath, filepath.Join(snapshotDir, "state")) if err != nil { return opts, err } @@ -52,6 +67,7 @@ func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snap stored.FirecrackerUFFDSessionID = stored.Id stored.FirecrackerUFFDPagerVersion = m.firecrackerUFFDPager.VersionKey() opts.SnapshotMemoryBackend = hypervisor.SnapshotMemoryBackendUFFD + opts.SnapshotMemoryBackingPath = backingMemoryPath opts.SnapshotMemoryCacheKey = cacheKey opts.SnapshotMemorySessionID = stored.FirecrackerUFFDSessionID return opts, nil @@ -69,6 +85,28 @@ func (m *manager) refreshFirecrackerSnapshotCacheKey(stored *StoredMetadata, sna return nil } +func (m *manager) materializeDeferredFirecrackerSnapshotMemory(ctx context.Context, stored *StoredMetadata, snapshotDir string) error { + if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { + return nil + } + sourcePath := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath) + if sourcePath == "" { + return nil + } + targetPath := filepath.Join(snapshotDir, "memory") + if _, err := os.Stat(targetPath); err == nil { + stored.FirecrackerDeferredSnapshotMemoryPath = "" + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat deferred firecracker snapshot memory target: %w", err) + } + if err := forkvm.CopyRegularFile(sourcePath, targetPath); err != nil { + return fmt.Errorf("materialize deferred firecracker snapshot memory: %w", err) + } + stored.FirecrackerDeferredSnapshotMemoryPath = "" + return nil +} + func (m *manager) closeFirecrackerUFFDSession(ctx context.Context, stored *StoredMetadata) { if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker || stored.FirecrackerUFFDSessionID == "" { return @@ -120,11 +158,15 @@ func (m *manager) checkFirecrackerUFFDSessionHealth(ctx context.Context, stored } func firecrackerSnapshotCacheKey(stored *StoredMetadata, snapshotDir string) (string, error) { - memoryInfo, err := os.Stat(filepath.Join(snapshotDir, "memory")) + return firecrackerSnapshotCacheKeyForPaths(stored, filepath.Join(snapshotDir, "memory"), filepath.Join(snapshotDir, "state")) +} + +func firecrackerSnapshotCacheKeyForPaths(stored *StoredMetadata, memoryPath string, statePath string) (string, error) { + memoryInfo, err := os.Stat(memoryPath) if err != nil { return "", fmt.Errorf("stat firecracker snapshot memory for uffd cache key: %w", err) } - stateInfo, err := os.Stat(filepath.Join(snapshotDir, "state")) + stateInfo, err := os.Stat(statePath) if err != nil { return "", fmt.Errorf("stat firecracker snapshot state for uffd cache key: %w", err) } diff --git a/lib/instances/firecracker_uffd_test.go b/lib/instances/firecracker_uffd_test.go index 605814c5..52ea8b00 100644 --- a/lib/instances/firecracker_uffd_test.go +++ b/lib/instances/firecracker_uffd_test.go @@ -2,6 +2,8 @@ package instances import ( "context" + "os" + "path/filepath" "testing" "time" @@ -81,10 +83,13 @@ func TestForkInstanceFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing. sourceID := "one-shot-uffd-instance-source" createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(sourceID)) + require.NoError(t, os.WriteFile(sourceMemoryPath, []byte("source memory"), 0644)) sourceMeta, err := mgr.loadMetadata(sourceID) require.NoError(t, err) sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" require.NoError(t, mgr.saveMetadata(sourceMeta)) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ Name: "one-shot-uffd-instance-fork", @@ -96,8 +101,11 @@ func TestForkInstanceFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing. require.NoError(t, err) assert.True(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) assert.Equal(t, "shared-template-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Equal(t, sourceMemoryPath, meta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDSessionID) assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDPagerVersion) + assert.NoFileExists(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(forked.Id))) + assert.FileExists(t, filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), "state")) } func TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTarget(t *testing.T) { @@ -109,10 +117,13 @@ func TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTarget(t *testing sourceID := "one-shot-uffd-instance-stopped-source" createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(sourceID)) + require.NoError(t, os.WriteFile(sourceMemoryPath, []byte("source memory"), 0644)) sourceMeta, err := mgr.loadMetadata(sourceID) require.NoError(t, err) sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" require.NoError(t, mgr.saveMetadata(sourceMeta)) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ Name: "one-shot-uffd-instance-stopped-fork", @@ -124,6 +135,8 @@ func TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTarget(t *testing require.NoError(t, err) assert.False(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) assert.Equal(t, "shared-template-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Empty(t, meta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + assert.FileExists(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(forked.Id))) } func TestForkSnapshotFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing.T) { @@ -135,10 +148,12 @@ func TestForkSnapshotFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing. sourceID := "one-shot-uffd-snapshot-source" createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + require.NoError(t, os.WriteFile(firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(sourceID)), []byte("source memory"), 0644)) sourceMeta, err := mgr.loadMetadata(sourceID) require.NoError(t, err) sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-snapshot-cache" require.NoError(t, mgr.saveMetadata(sourceMeta)) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ Kind: SnapshotKindStandby, @@ -156,8 +171,11 @@ func TestForkSnapshotFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing. require.NoError(t, err) assert.True(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) assert.Equal(t, "shared-snapshot-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Equal(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.SnapshotGuestDir(snap.Id)), meta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDSessionID) assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDPagerVersion) + assert.NoFileExists(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(forked.Id))) + assert.FileExists(t, filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), "state")) } func TestForkSnapshotFromStoppedDoesNotArmOneShotUFFD(t *testing.T) { diff --git a/lib/instances/fork.go b/lib/instances/fork.go index b1e1f9ba..de1be08f 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -256,13 +256,17 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin srcDir := m.paths.InstanceDir(id) dstDir := m.paths.InstanceDir(forkID) + deferredSnapshotMemoryPath := "" + if m.shouldDeferFirecrackerSnapshotMemoryCopy(stored, source.State == StateStandby, targetState) { + deferredSnapshotMemoryPath = firecrackerSnapshotMemoryPathInGuestDir(srcDir) + } cu := cleanup.Make(func() { _ = os.RemoveAll(dstDir) }) defer cu.Clean() - if err := m.copyForkSourceGuestDirectory(ctx, source.State, id, stored, srcDir, dstDir); err != nil { + if err := m.copyForkSourceGuestDirectory(ctx, source.State, id, stored, srcDir, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, false, err } @@ -288,8 +292,10 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(forkMeta.HypervisorType, source.State == StateStandby, targetState) + forkMeta.FirecrackerDeferredSnapshotMemoryPath = deferredSnapshotMemoryPath if source.State != StateStandby { forkMeta.FirecrackerSnapshotCacheKey = "" + forkMeta.FirecrackerDeferredSnapshotMemoryPath = "" } // Forks are new instances; phase accounting must not inherit the source's // cumulative durations. The first transition into the fork's runtime diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index e1742e75..7a0bd4f4 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -419,7 +419,14 @@ 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.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir); err != nil { + snapshotGuestDir := m.paths.SnapshotGuestDir(snapshotID) + deferredSource := rec.StoredMetadata + deferredSource.HypervisorType = targetHypervisor + deferredSnapshotMemoryPath := "" + if m.shouldDeferFirecrackerSnapshotMemoryCopy(&deferredSource, rec.Snapshot.Kind == SnapshotKindStandby, targetState) { + deferredSnapshotMemoryPath = firecrackerSnapshotMemoryPathInGuestDir(snapshotGuestDir) + } + if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, err } @@ -453,8 +460,10 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(targetHypervisor, rec.Snapshot.Kind == SnapshotKindStandby, targetState) + forkMeta.FirecrackerDeferredSnapshotMemoryPath = deferredSnapshotMemoryPath if rec.Snapshot.Kind != SnapshotKindStandby { forkMeta.FirecrackerSnapshotCacheKey = "" + forkMeta.FirecrackerDeferredSnapshotMemoryPath = "" } if rec.Snapshot.Kind == SnapshotKindStandby { forkMeta.VsockCID = rec.StoredMetadata.VsockCID diff --git a/lib/instances/snapshot_alias_lock.go b/lib/instances/snapshot_alias_lock.go index b6942c95..af7f72bd 100644 --- a/lib/instances/snapshot_alias_lock.go +++ b/lib/instances/snapshot_alias_lock.go @@ -31,14 +31,18 @@ func copyGuestDirectoryWithAliasReadLock(srcDir, dstDir string) error { }) } -func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState State, sourceID string, stored *StoredMetadata, srcDir, dstDir string) error { +func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState State, sourceID string, stored *StoredMetadata, srcDir, dstDir, deferredSnapshotMemoryPath string) error { if sourceState == StateStandby { if err := m.ensureSnapshotMemoryReady(ctx, m.paths.InstanceSnapshotLatest(sourceID), m.snapshotJobKeyForInstance(sourceID), stored.HypervisorType); err != nil { return fmt.Errorf("prepare standby snapshot for fork: %w", err) } } + copyOptions := forkvm.CopyOptions{} + if deferredSnapshotMemoryPath != "" { + copyOptions.SkipRelativePaths = map[string]struct{}{firecrackerSnapshotMemoryRelPath: {}} + } return withSnapshotSourceAliasReadLock(func() error { - if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil { + if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOptions); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } @@ -48,12 +52,16 @@ func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState }) } -func (m *manager) copySnapshotGuestDirectoryForFork(ctx context.Context, snapshotID string, hvType hypervisor.Type, dstDir string) error { +func (m *manager) copySnapshotGuestDirectoryForFork(ctx context.Context, snapshotID string, hvType hypervisor.Type, dstDir, deferredSnapshotMemoryPath string) error { if err := m.ensureSnapshotMemoryReady(ctx, m.paths.SnapshotGuestDir(snapshotID), "", hvType); err != nil { return fmt.Errorf("prepare snapshot memory for fork: %w", err) } + copyOptions := forkvm.CopyOptions{} + if deferredSnapshotMemoryPath != "" { + copyOptions.SkipRelativePaths = map[string]struct{}{firecrackerSnapshotMemoryRelPath: {}} + } return withSnapshotSourceAliasReadLock(func() error { - if err := forkvm.CopyGuestDirectory(m.paths.SnapshotGuestDir(snapshotID), dstDir); err != nil { + if err := forkvm.CopyGuestDirectoryWithOptions(m.paths.SnapshotGuestDir(snapshotID), dstDir, copyOptions); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return fmt.Errorf("fork from snapshot requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } diff --git a/lib/instances/standby.go b/lib/instances/standby.go index a25a9636..37aba6de 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -139,6 +139,25 @@ func (m *manager) standbyInstance( return nil, fmt.Errorf("prepare retained snapshot target: %w", err) } } + materializeCtx, materializeSpanEnd := m.startLifecycleStep(ctx, "materialize_snapshot_memory_base", + attribute.String("instance_id", id), + attribute.String("hypervisor", string(stored.HypervisorType)), + attribute.String("operation", "materialize_snapshot_memory_base"), + ) + if err := m.materializeDeferredFirecrackerSnapshotMemory(materializeCtx, stored, snapshotDir); err != nil { + materializeSpanEnd(err) + if resumeErr := hv.Resume(ctx); resumeErr != nil { + log.ErrorContext(ctx, "failed to resume VM after deferred snapshot memory materialization error", "instance_id", id, "error", resumeErr) + } + if promotedExistingBase { + if rollbackErr := discardPromotedRetainedSnapshotTarget(snapshotDir); rollbackErr != nil { + log.WarnContext(ctx, "failed to discard promoted snapshot target after materialization error", "instance_id", id, "error", rollbackErr) + } + } + return nil, fmt.Errorf("materialize deferred snapshot memory base: %w", err) + } + materializeSpanEnd(nil) + log.DebugContext(ctx, "creating snapshot", "instance_id", id, "snapshot_dir", snapshotDir) snapshotCtx, snapshotSpanEnd := m.startLifecycleStep(ctx, "create_snapshot", attribute.String("instance_id", id), @@ -218,6 +237,7 @@ func (m *manager) standbyInstance( stored.HypervisorPID = nil stored.PendingStandbyCompression = nil stored.FirecrackerUseUFFDOnNextRestore = false + stored.FirecrackerDeferredSnapshotMemoryPath = "" if err := m.refreshFirecrackerSnapshotCacheKey(stored, snapshotDir); err != nil { log.WarnContext(ctx, "failed to refresh firecracker snapshot cache key", "instance_id", id, "error", err) } diff --git a/lib/instances/standby_test.go b/lib/instances/standby_test.go index 159d63c2..8e0d5150 100644 --- a/lib/instances/standby_test.go +++ b/lib/instances/standby_test.go @@ -1,10 +1,12 @@ package instances import ( + "context" "os" "path/filepath" "testing" + "github.com/kernel/hypeman/lib/hypervisor" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -58,3 +60,23 @@ func TestPrepareRetainedSnapshotTargetDiscardsStaleSnapshotDirBeforeRetry(t *tes _, err = os.Stat(retainedBaseDir) assert.True(t, os.IsNotExist(err), "cleanup without a retained base should leave the retained base location empty") } + +func TestMaterializeDeferredFirecrackerSnapshotMemory(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + sourcePath := filepath.Join(t.TempDir(), "source-memory") + snapshotDir := filepath.Join(t.TempDir(), "snapshot-latest") + require.NoError(t, os.WriteFile(sourcePath, []byte("memory"), 0644)) + + stored := &StoredMetadata{ + HypervisorType: hypervisor.TypeFirecracker, + FirecrackerDeferredSnapshotMemoryPath: sourcePath, + } + require.NoError(t, mgr.materializeDeferredFirecrackerSnapshotMemory(context.Background(), stored, snapshotDir)) + + got, err := os.ReadFile(filepath.Join(snapshotDir, "memory")) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) + assert.Empty(t, stored.FirecrackerDeferredSnapshotMemoryPath) +} diff --git a/lib/instances/types.go b/lib/instances/types.go index 488b0918..9a73dcde 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -118,10 +118,11 @@ type StoredMetadata struct { HypervisorPID *int // Hypervisor process ID (may be stale after host restart) // Firecracker UFFD snapshot restore metadata. - FirecrackerSnapshotCacheKey string - FirecrackerUseUFFDOnNextRestore bool - FirecrackerUFFDSessionID string - FirecrackerUFFDPagerVersion string + FirecrackerSnapshotCacheKey string + FirecrackerUseUFFDOnNextRestore bool + FirecrackerUFFDSessionID string + FirecrackerUFFDPagerVersion string + FirecrackerDeferredSnapshotMemoryPath string // Paths SocketPath string // Path to API socket diff --git a/lib/uffdpager/server_sessions_linux.go b/lib/uffdpager/server_sessions_linux.go index 72989766..cfb14273 100644 --- a/lib/uffdpager/server_sessions_linux.go +++ b/lib/uffdpager/server_sessions_linux.go @@ -25,6 +25,12 @@ func (s *server) createSession(req CreateSessionRequest) (*session, error) { if err != nil { return nil, fmt.Errorf("listen for uffd session %s: %w", id, err) } + backingFile, err := os.Open(req.BackingMemoryPath) + if err != nil { + _ = listener.Close() + _ = os.Remove(socketPath) + return nil, fmt.Errorf("open backing memory for uffd session %s: %w", id, err) + } sess := &session{ id: id, @@ -35,6 +41,7 @@ func (s *server) createSession(req CreateSessionRequest) (*session, error) { listener: listener, server: s, done: make(chan struct{}), + backingFile: backingFile, uffdFD: -1, } @@ -109,12 +116,14 @@ func (s *session) run() { } s.conn = conn - file, err := os.Open(s.backingMemoryPath) - if err != nil { - log.Printf("uffd session %s open backing memory: %v", s.id, err) - return + if s.backingFile == nil { + file, err := os.Open(s.backingMemoryPath) + if err != nil { + log.Printf("uffd session %s open backing memory: %v", s.id, err) + return + } + s.backingFile = file } - s.backingFile = file mappings, uffdFD, err := recvMappingsAndFD(conn) if err != nil { From c496ba48b6b7c5bbb2398ac2e3dfc1eef5977f1e Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 10:21:00 -0400 Subject: [PATCH 03/17] Document one-shot UFFD fork lifecycle --- lib/forkvm/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/forkvm/README.md b/lib/forkvm/README.md index bb6567c7..6e956fec 100644 --- a/lib/forkvm/README.md +++ b/lib/forkvm/README.md @@ -59,6 +59,20 @@ instead of reusing the source identity. - Network override fields are supplied at snapshot load to bind the fork to its own TAP device. - Vsock CID remains stable for snapshot-based flows. +- When the Firecracker snapshot memory backend is configured as UFFD, UFFD is + used as a one-shot acceleration for the first restore of a newly forked + standby snapshot. The fork initially reuses the source snapshot memory as the + pager backing file instead of cloning the large memory file during fanout. +- That deferred memory clone is paid when the fork later enters standby. Before + Firecracker writes the fork's diff snapshot, Hypeman materializes the fork's + own `snapshot-latest/memory` file from the original backing memory. After that + point the fork has a normal on-disk snapshot base, independent from the source. +- Subsequent direct restores of that same fork use Firecracker's normal + file-backed memory backend. If that standby fork is itself forked again, the + new child gets its own one-shot UFFD restore. +- This keeps UFFD on the high-fanout path where shared snapshot cache is most + useful, while preserving the normal Firecracker diff-snapshot lifecycle for + per-instance standby/resume cycles. ## VZ (Virtualization.framework) From 0c1cfd1bdc6671540c5f4cb5d57f7a3878187899 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 10:26:34 -0400 Subject: [PATCH 04/17] Add UFFD fork standby lifecycle test --- lib/instances/firecracker_test.go | 121 +++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/lib/instances/firecracker_test.go b/lib/instances/firecracker_test.go index 6bb28572..585b4df7 100644 --- a/lib/instances/firecracker_test.go +++ b/lib/instances/firecracker_test.go @@ -28,6 +28,7 @@ import ( "github.com/kernel/hypeman/lib/resources" snapshottest "github.com/kernel/hypeman/lib/snapshot/testsupport" "github.com/kernel/hypeman/lib/system" + "github.com/kernel/hypeman/lib/uffdpager" "github.com/kernel/hypeman/lib/volumes" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -36,6 +37,10 @@ import ( ) func setupTestManagerForFirecrackerWithNetworkConfig(t *testing.T, networkCfg config.NetworkConfig) (*manager, string) { + return setupTestManagerForFirecrackerWithConfig(t, networkCfg, ManagerConfig{}) +} + +func setupTestManagerForFirecrackerWithConfig(t *testing.T, networkCfg config.NetworkConfig, managerConfig ManagerConfig) (*manager, string) { tmpDir := t.TempDir() prepareIntegrationTestDataDir(t, tmpDir) cfg := &config.Config{ @@ -57,7 +62,9 @@ func setupTestManagerForFirecrackerWithNetworkConfig(t *testing.T, networkCfg co MaxVcpusPerInstance: 0, MaxMemoryPerInstance: 0, } - mgr := NewManager(p, imageManager, systemManager, networkManager, deviceManager, volumeManager, limits, hypervisor.TypeFirecracker, SnapshotPolicy{}, nil, nil).(*manager) + mgrInterface, err := NewManagerWithConfigE(p, imageManager, systemManager, networkManager, deviceManager, volumeManager, limits, hypervisor.TypeFirecracker, SnapshotPolicy{}, managerConfig, nil, nil) + require.NoError(t, err) + mgr := mgrInterface.(*manager) resourceMgr := resources.NewManager(cfg, p) resourceMgr.SetInstanceLister(mgr) @@ -88,6 +95,15 @@ func requireFirecrackerIntegrationPrereqs(t *testing.T) { } } +func requireUserfaultfdIntegrationPrereqs(t *testing.T) { + t.Helper() + file, err := os.OpenFile("/dev/userfaultfd", os.O_RDWR, 0) + if err != nil { + t.Skipf("/dev/userfaultfd is not accessible, skipping UFFD integration test: %v", err) + } + _ = file.Close() +} + func createNginxImageAndWait(t *testing.T, ctx context.Context, imageManager images.Manager) { t.Helper() @@ -648,6 +664,109 @@ func TestFirecrackerWarmForkChain(t *testing.T) { require.NoError(t, mgr.DeleteSnapshot(ctx, snapshot.Id)) } +func TestFCUFFDOneShotLifecycle(t *testing.T) { + t.Parallel() + requireFirecrackerIntegrationPrereqs(t) + requireUserfaultfdIntegrationPrereqs(t) + if pagerBinary := strings.TrimSpace(os.Getenv("HYPEMAN_UFFD_PAGER_BINARY")); pagerBinary == "" { + t.Skip("HYPEMAN_UFFD_PAGER_BINARY must point at hypeman-uffd-pager for UFFD integration tests") + } else if st, err := os.Stat(pagerBinary); err != nil || !st.Mode().IsRegular() { + t.Skipf("HYPEMAN_UFFD_PAGER_BINARY is not a regular file: %s", pagerBinary) + } + + mgr, tmpDir := setupTestManagerForFirecrackerWithConfig(t, legacyParallelTestNetworkConfig(testNetworkSeq.Add(1)), ManagerConfig{ + FirecrackerSnapshotMemoryBackend: uffdpager.BackendUFFD, + FirecrackerUFFDCacheMaxBytes: 512 << 20, + }) + ctx := context.Background() + p := paths.New(tmpDir) + + imageManager, err := images.NewManager(p, 1, nil) + require.NoError(t, err) + imageName := integrationTestImageRef(t, "docker.io/library/alpine:latest") + snapshottest.EnsureImageReady(t, ctx, p, imageManager, imageName) + + systemManager := system.NewManager(p) + require.NoError(t, systemManager.EnsureSystemFiles(ctx)) + + source, err := mgr.CreateInstance(ctx, CreateInstanceRequest{ + Name: "fc-uffd-oneshot-src", + Image: imageName, + Size: 1024 * 1024 * 1024, + OverlaySize: 1024 * 1024 * 1024, + Vcpus: 1, + NetworkEnabled: false, + Hypervisor: hypervisor.TypeFirecracker, + Cmd: []string{"sleep", "infinity"}, + }) + require.NoError(t, err) + sourceID := source.Id + sourceDeleted := false + t.Cleanup(func() { + if !sourceDeleted { + _ = mgr.DeleteInstance(context.Background(), sourceID) + } + }) + + source, err = waitForInstanceState(ctx, mgr, sourceID, StateRunning, integrationTestTimeout(20*time.Second)) + require.NoError(t, err) + + source, err = mgr.StandbyInstance(ctx, sourceID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, source.State) + require.FileExists(t, filepath.Join(p.InstanceSnapshotLatest(sourceID), "memory")) + + fork, err := mgr.ForkInstance(ctx, sourceID, ForkInstanceRequest{ + Name: "fc-uffd-oneshot-fork", + TargetState: StateRunning, + }) + require.NoError(t, err) + forkID := fork.Id + forkDeleted := false + t.Cleanup(func() { + if !forkDeleted { + _ = mgr.DeleteInstance(context.Background(), forkID) + } + }) + + fork, err = waitForInstanceState(ctx, mgr, forkID, StateRunning, integrationTestTimeout(20*time.Second)) + require.NoError(t, err) + + forkSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(forkID), "memory") + require.NoFileExists(t, forkSnapshotMemoryPath, "UFFD fork should defer the memory clone while running") + forkMeta, err := mgr.loadMetadata(forkID) + require.NoError(t, err) + require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore, "one-shot UFFD flag should clear after first restore") + require.NotEmpty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath, "running fork still needs deferred backing path for standby") + require.NotEmpty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID, "running UFFD-restored fork should keep its pager session") + + fork, err = mgr.StandbyInstance(ctx, forkID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, fork.State) + require.FileExists(t, forkSnapshotMemoryPath, "standby should materialize a normal file-backed snapshot base") + forkMeta, err = mgr.loadMetadata(forkID) + require.NoError(t, err) + require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID) + + fork, err = mgr.RestoreInstance(ctx, forkID) + require.NoError(t, err) + fork, err = waitForInstanceState(ctx, mgr, forkID, StateRunning, integrationTestTimeout(20*time.Second)) + require.NoError(t, err) + require.Equal(t, StateRunning, fork.State) + forkMeta, err = mgr.loadMetadata(forkID) + require.NoError(t, err) + require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore, "direct resume after standby should remain file-backed") + require.Empty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID, "file-backed restore should not create a UFFD session") + + require.NoError(t, mgr.DeleteInstance(ctx, forkID)) + forkDeleted = true + require.NoError(t, mgr.DeleteInstance(ctx, sourceID)) + sourceDeleted = true +} + // TestFirecrackerForkIsolation verifies CoW isolation between a firecracker // source's standby snapshot and a fork derived from it. A fork must end up // with its own mem-file inode (reflink-cloned, not hardlinked) so that From 74eb1f95adb74ee0c264d8f3e8ea82055703807b Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 10:42:39 -0400 Subject: [PATCH 05/17] Clean up UFFD snapshot ownership --- .github/workflows/test.yml | 7 ++++- Makefile | 8 ++++- lib/guestmemory/controller_test.go | 8 +++-- lib/hypervisor/README.md | 2 +- .../cloudhypervisor/cloudhypervisor.go | 2 +- lib/hypervisor/firecracker/config_test.go | 14 +++++++++ lib/hypervisor/firecracker/firecracker.go | 23 ++++++++++++++- lib/hypervisor/hypervisor.go | 6 +++- lib/hypervisor/qemu/qemu.go | 2 +- lib/hypervisor/tracing.go | 4 +-- lib/hypervisor/tracing_test.go | 16 ++++++---- lib/hypervisor/vz/client.go | 2 +- lib/instances/firecracker_uffd.go | 23 --------------- lib/instances/firecracker_uffd_test.go | 2 +- lib/instances/lifecycle_noop_test.go | 8 +++-- lib/instances/standby.go | 29 +++++-------------- lib/instances/standby_test.go | 22 -------------- 17 files changed, 88 insertions(+), 90 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97497329..b40a9f9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -131,13 +131,18 @@ jobs: TLS_ALLOWED_DOMAINS: '*.hypeman-development.com' HYPEMAN_TEST_PREWARM_STRICT: "1" HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001 + HYPEMAN_UFFD_PAGER_BINARY: ${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }} run: | + cp "$PWD/bin/hypeman-uffd-pager" "$HYPEMAN_UFFD_PAGER_BINARY" + chmod +x "$HYPEMAN_UFFD_PAGER_BINARY" export HYPEMAN_TEST_PREWARM_DIR="$HOME/.cache/hypeman-ci/linux-amd64" make test TEST_TIMEOUT=20m - name: Cleanup if: always() - run: sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}" + run: | + sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}" + rm -f "${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }}" test-darwin: runs-on: [self-hosted, macos, arm64] diff --git a/Makefile b/Makefile index a0b71091..82ef76d2 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ SHELL := /bin/bash # Directory where local binaries will be installed BIN_DIR ?= $(CURDIR)/bin GO_TEST_TIMEOUT ?= 300s +UFFD_PAGER_BINARY ?= $(BIN_DIR)/hypeman-uffd-pager $(BIN_DIR): mkdir -p $(BIN_DIR) @@ -256,6 +257,9 @@ build-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binarie go build -tags containers_image_openpgp -o $(BIN_DIR)/hypeman ./cmd/api go build -o $(BIN_DIR)/hypeman-uffd-pager ./cmd/uffd-pager +$(BIN_DIR)/hypeman-uffd-pager: | $(BIN_DIR) + go build -o $@ ./cmd/uffd-pager + # Build all binaries build-all: build @@ -289,19 +293,21 @@ else endif # Linux tests (as root for network capabilities) -test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries build-embedded +test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries build-embedded $(BIN_DIR)/hypeman-uffd-pager @VERBOSE_FLAG=""; \ TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$$PATH"; \ if [ -n "$(VERBOSE)" ]; then VERBOSE_FLAG="-v"; fi; \ if [ -n "$(TEST)" ]; then \ echo "Running specific test: $(TEST)"; \ sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \ + "HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \ "HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \ "HYPEMAN_TEST_PREWARM_STRICT=$${HYPEMAN_TEST_PREWARM_STRICT:-}" \ "HYPEMAN_TEST_REGISTRY=$${HYPEMAN_TEST_REGISTRY:-}" \ go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=$(TEST_TIMEOUT) ./...; \ else \ sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \ + "HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \ "HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \ "HYPEMAN_TEST_PREWARM_STRICT=$${HYPEMAN_TEST_PREWARM_STRICT:-}" \ "HYPEMAN_TEST_REGISTRY=$${HYPEMAN_TEST_REGISTRY:-}" \ diff --git a/lib/guestmemory/controller_test.go b/lib/guestmemory/controller_test.go index 4720ec7a..0b7ea7e9 100644 --- a/lib/guestmemory/controller_test.go +++ b/lib/guestmemory/controller_test.go @@ -47,9 +47,11 @@ func (s *stubHypervisor) Shutdown(ctx context.Context) error { return nil } func (s *stubHypervisor) GetVMInfo(ctx context.Context) (*hypervisor.VMInfo, error) { return &hypervisor.VMInfo{State: hypervisor.StateRunning}, nil } -func (s *stubHypervisor) Pause(ctx context.Context) error { return nil } -func (s *stubHypervisor) Resume(ctx context.Context) error { return nil } -func (s *stubHypervisor) Snapshot(ctx context.Context, destPath string) error { return nil } +func (s *stubHypervisor) Pause(ctx context.Context) error { return nil } +func (s *stubHypervisor) Resume(ctx context.Context) error { return nil } +func (s *stubHypervisor) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { + return nil +} func (s *stubHypervisor) ResizeMemory(ctx context.Context, bytes int64) error { return nil } func (s *stubHypervisor) ResizeMemoryAndWait(ctx context.Context, bytes int64, timeout time.Duration) error { return nil diff --git a/lib/hypervisor/README.md b/lib/hypervisor/README.md index 956bd8a1..6b274b24 100644 --- a/lib/hypervisor/README.md +++ b/lib/hypervisor/README.md @@ -33,7 +33,7 @@ Before using optional features, callers check capabilities: ```go if hv.Capabilities().SupportsSnapshot { - hv.Snapshot(ctx, path) + hv.Snapshot(ctx, path, hypervisor.SnapshotOptions{}) } ``` diff --git a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go index 1d3a7f14..8f6f2484 100644 --- a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go +++ b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go @@ -160,7 +160,7 @@ func (c *CloudHypervisor) Resume(ctx context.Context) error { } // Snapshot creates a VM snapshot. -func (c *CloudHypervisor) Snapshot(ctx context.Context, destPath string) error { +func (c *CloudHypervisor) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { snapshotURL := "file://" + destPath snapshotConfig := vmm.VmSnapshotConfig{DestinationUrl: &snapshotURL} resp, err := c.client.PutVmSnapshotWithResponse(ctx, snapshotConfig) diff --git a/lib/hypervisor/firecracker/config_test.go b/lib/hypervisor/firecracker/config_test.go index c5e8dc6c..edfd33dd 100644 --- a/lib/hypervisor/firecracker/config_test.go +++ b/lib/hypervisor/firecracker/config_test.go @@ -101,6 +101,20 @@ func TestSnapshotLoadParamsSupportsUFFDBackend(t *testing.T) { assert.Equal(t, "/tmp/pager.sock", load.MemBackend.BackendPath) } +func TestMaterializeDeferredSnapshotMemory(t *testing.T) { + t.Parallel() + + sourcePath := filepath.Join(t.TempDir(), "source-memory") + snapshotDir := filepath.Join(t.TempDir(), "snapshot-latest") + require.NoError(t, os.WriteFile(sourcePath, []byte("memory"), 0644)) + + require.NoError(t, materializeDeferredSnapshotMemory(snapshotDir, sourcePath)) + + got, err := os.ReadFile(filepath.Join(snapshotDir, "memory")) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) +} + func TestToBalloonConfig(t *testing.T) { cfg := hypervisor.VMConfig{ GuestMemory: hypervisor.GuestMemoryConfig{ diff --git a/lib/hypervisor/firecracker/firecracker.go b/lib/hypervisor/firecracker/firecracker.go index 3512f653..8411bb28 100644 --- a/lib/hypervisor/firecracker/firecracker.go +++ b/lib/hypervisor/firecracker/firecracker.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/kernel/hypeman/lib/forkvm" "github.com/kernel/hypeman/lib/hypervisor" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -112,10 +113,13 @@ func (f *Firecracker) Resume(ctx context.Context) error { return nil } -func (f *Firecracker) Snapshot(ctx context.Context, destPath string) error { +func (f *Firecracker) Snapshot(ctx context.Context, destPath string, opts hypervisor.SnapshotOptions) error { if err := os.MkdirAll(destPath, 0755); err != nil { return fmt.Errorf("create snapshot directory: %w", err) } + if err := materializeDeferredSnapshotMemory(destPath, opts.DeferredMemoryBackingPath); err != nil { + return err + } params := toSnapshotCreateParams(destPath) if _, err := f.do(ctx, http.MethodPut, "/snapshot/create", params, http.StatusNoContent); err != nil { return fmt.Errorf("create snapshot: %w", err) @@ -123,6 +127,23 @@ func (f *Firecracker) Snapshot(ctx context.Context, destPath string) error { return nil } +func materializeDeferredSnapshotMemory(destPath, sourcePath string) error { + sourcePath = strings.TrimSpace(sourcePath) + if sourcePath == "" { + return nil + } + targetPath := filepath.Join(destPath, "memory") + if _, err := os.Stat(targetPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat deferred snapshot memory target: %w", err) + } + if err := forkvm.CopyRegularFile(sourcePath, targetPath); err != nil { + return fmt.Errorf("materialize deferred snapshot memory: %w", err) + } + return nil +} + func (f *Firecracker) ResizeMemory(ctx context.Context, bytes int64) error { return hypervisor.ErrNotSupported } diff --git a/lib/hypervisor/hypervisor.go b/lib/hypervisor/hypervisor.go index 24736258..ce96fa85 100644 --- a/lib/hypervisor/hypervisor.go +++ b/lib/hypervisor/hypervisor.go @@ -138,6 +138,10 @@ type RestoreOptions struct { SnapshotMemorySessionID string } +type SnapshotOptions struct { + DeferredMemoryBackingPath string +} + // ForkNetworkConfig contains network identity fields for fork preparation. type ForkNetworkConfig struct { TAPDevice string @@ -195,7 +199,7 @@ type Hypervisor interface { // Snapshot creates a VM snapshot at the given path. // Check Capabilities().SupportsSnapshot before calling. - Snapshot(ctx context.Context, destPath string) error + Snapshot(ctx context.Context, destPath string, opts SnapshotOptions) error // ResizeMemory changes the VM's memory allocation. // Check Capabilities().SupportsHotplugMemory before calling. diff --git a/lib/hypervisor/qemu/qemu.go b/lib/hypervisor/qemu/qemu.go index 1da70965..70809f90 100644 --- a/lib/hypervisor/qemu/qemu.go +++ b/lib/hypervisor/qemu/qemu.go @@ -140,7 +140,7 @@ func (q *QEMU) Resume(ctx context.Context) error { // Snapshot creates a VM snapshot using QEMU's migrate-to-file mechanism. // The VM state is saved to destPath/memory file. // The VM config is copied to destPath for restore (QEMU requires exact arg match). -func (q *QEMU) Snapshot(ctx context.Context, destPath string) error { +func (q *QEMU) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { // QEMU uses migrate to file for snapshots // The "file:" protocol is deprecated in QEMU 7.2+, use "exec:cat > path" instead memoryFile := destPath + "/memory" diff --git a/lib/hypervisor/tracing.go b/lib/hypervisor/tracing.go index 0ee93ae0..f15a25fc 100644 --- a/lib/hypervisor/tracing.go +++ b/lib/hypervisor/tracing.go @@ -217,14 +217,14 @@ func (h *tracingHypervisor) Resume(ctx context.Context) (err error) { return h.next.Resume(ctx) } -func (h *tracingHypervisor) Snapshot(ctx context.Context, destPath string) (err error) { +func (h *tracingHypervisor) Snapshot(ctx context.Context, destPath string, opts SnapshotOptions) (err error) { ctx, span := startTraceSpan(ctx, h.tracer, "hypervisor.snapshot", h.spanAttrs( attribute.String("operation", "snapshot"), )..., ) defer func() { finishTraceSpan(span, err) }() - return h.next.Snapshot(ctx, destPath) + return h.next.Snapshot(ctx, destPath, opts) } func (h *tracingHypervisor) ResizeMemory(ctx context.Context, bytes int64) (err error) { diff --git a/lib/hypervisor/tracing_test.go b/lib/hypervisor/tracing_test.go index 409e1bd0..eb8162ed 100644 --- a/lib/hypervisor/tracing_test.go +++ b/lib/hypervisor/tracing_test.go @@ -24,9 +24,11 @@ func (fakeHypervisor) Shutdown(context.Context) error { return nil } func (fakeHypervisor) GetVMInfo(context.Context) (*VMInfo, error) { return &VMInfo{State: StateRunning}, nil } -func (fakeHypervisor) Pause(context.Context) error { return nil } -func (fakeHypervisor) Resume(context.Context) error { return nil } -func (fakeHypervisor) Snapshot(context.Context, string) error { return nil } +func (fakeHypervisor) Pause(context.Context) error { return nil } +func (fakeHypervisor) Resume(context.Context) error { return nil } +func (fakeHypervisor) Snapshot(context.Context, string, SnapshotOptions) error { + return nil +} func (fakeHypervisor) ResizeMemory(context.Context, int64) error { return nil } func (fakeHypervisor) ResizeMemoryAndWait(context.Context, int64, time.Duration) error { return nil @@ -41,9 +43,11 @@ func (fakeHypervisorGetVMInfoError) Shutdown(context.Context) error { return nil func (fakeHypervisorGetVMInfoError) GetVMInfo(context.Context) (*VMInfo, error) { return nil, errors.New("vm info failed") } -func (fakeHypervisorGetVMInfoError) Pause(context.Context) error { return nil } -func (fakeHypervisorGetVMInfoError) Resume(context.Context) error { return nil } -func (fakeHypervisorGetVMInfoError) Snapshot(context.Context, string) error { return nil } +func (fakeHypervisorGetVMInfoError) Pause(context.Context) error { return nil } +func (fakeHypervisorGetVMInfoError) Resume(context.Context) error { return nil } +func (fakeHypervisorGetVMInfoError) Snapshot(context.Context, string, SnapshotOptions) error { + return nil +} func (fakeHypervisorGetVMInfoError) ResizeMemory(context.Context, int64) error { return nil } diff --git a/lib/hypervisor/vz/client.go b/lib/hypervisor/vz/client.go index c3132c1b..f7e31e7f 100644 --- a/lib/hypervisor/vz/client.go +++ b/lib/hypervisor/vz/client.go @@ -249,7 +249,7 @@ func (c *Client) Resume(ctx context.Context) error { return c.doPut(ctx, "/api/v1/vm.resume", nil) } -func (c *Client) Snapshot(ctx context.Context, destPath string) error { +func (c *Client) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { req := snapshotRequest{DestinationPath: destPath} body, err := json.Marshal(req) if err != nil { diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index e7b3b7fd..1f650aae 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -11,7 +11,6 @@ import ( "syscall" "time" - "github.com/kernel/hypeman/lib/forkvm" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/uffdpager" @@ -85,28 +84,6 @@ func (m *manager) refreshFirecrackerSnapshotCacheKey(stored *StoredMetadata, sna return nil } -func (m *manager) materializeDeferredFirecrackerSnapshotMemory(ctx context.Context, stored *StoredMetadata, snapshotDir string) error { - if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { - return nil - } - sourcePath := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath) - if sourcePath == "" { - return nil - } - targetPath := filepath.Join(snapshotDir, "memory") - if _, err := os.Stat(targetPath); err == nil { - stored.FirecrackerDeferredSnapshotMemoryPath = "" - return nil - } else if !os.IsNotExist(err) { - return fmt.Errorf("stat deferred firecracker snapshot memory target: %w", err) - } - if err := forkvm.CopyRegularFile(sourcePath, targetPath); err != nil { - return fmt.Errorf("materialize deferred firecracker snapshot memory: %w", err) - } - stored.FirecrackerDeferredSnapshotMemoryPath = "" - return nil -} - func (m *manager) closeFirecrackerUFFDSession(ctx context.Context, stored *StoredMetadata) { if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker || stored.FirecrackerUFFDSessionID == "" { return diff --git a/lib/instances/firecracker_uffd_test.go b/lib/instances/firecracker_uffd_test.go index 52ea8b00..12b5f7ed 100644 --- a/lib/instances/firecracker_uffd_test.go +++ b/lib/instances/firecracker_uffd_test.go @@ -296,7 +296,7 @@ func (oneShotFirecrackerTestHypervisor) Resume(context.Context) error { return nil } -func (oneShotFirecrackerTestHypervisor) Snapshot(context.Context, string) error { +func (oneShotFirecrackerTestHypervisor) Snapshot(context.Context, string, hypervisor.SnapshotOptions) error { return nil } diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 41cf252a..b430b567 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -38,9 +38,11 @@ func (h lifecycleNoopHypervisor) Shutdown(context.Context) error { return nil } func (h lifecycleNoopHypervisor) GetVMInfo(context.Context) (*hypervisor.VMInfo, error) { return &hypervisor.VMInfo{State: h.state}, nil } -func (h lifecycleNoopHypervisor) Pause(context.Context) error { return nil } -func (h lifecycleNoopHypervisor) Resume(context.Context) error { return nil } -func (h lifecycleNoopHypervisor) Snapshot(context.Context, string) error { return nil } +func (h lifecycleNoopHypervisor) Pause(context.Context) error { return nil } +func (h lifecycleNoopHypervisor) Resume(context.Context) error { return nil } +func (h lifecycleNoopHypervisor) Snapshot(context.Context, string, hypervisor.SnapshotOptions) error { + return nil +} func (h lifecycleNoopHypervisor) ResizeMemory(context.Context, int64) error { return nil } diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 37aba6de..218831ed 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -139,25 +139,6 @@ func (m *manager) standbyInstance( return nil, fmt.Errorf("prepare retained snapshot target: %w", err) } } - materializeCtx, materializeSpanEnd := m.startLifecycleStep(ctx, "materialize_snapshot_memory_base", - attribute.String("instance_id", id), - attribute.String("hypervisor", string(stored.HypervisorType)), - attribute.String("operation", "materialize_snapshot_memory_base"), - ) - if err := m.materializeDeferredFirecrackerSnapshotMemory(materializeCtx, stored, snapshotDir); err != nil { - materializeSpanEnd(err) - if resumeErr := hv.Resume(ctx); resumeErr != nil { - log.ErrorContext(ctx, "failed to resume VM after deferred snapshot memory materialization error", "instance_id", id, "error", resumeErr) - } - if promotedExistingBase { - if rollbackErr := discardPromotedRetainedSnapshotTarget(snapshotDir); rollbackErr != nil { - log.WarnContext(ctx, "failed to discard promoted snapshot target after materialization error", "instance_id", id, "error", rollbackErr) - } - } - return nil, fmt.Errorf("materialize deferred snapshot memory base: %w", err) - } - materializeSpanEnd(nil) - log.DebugContext(ctx, "creating snapshot", "instance_id", id, "snapshot_dir", snapshotDir) snapshotCtx, snapshotSpanEnd := m.startLifecycleStep(ctx, "create_snapshot", attribute.String("instance_id", id), @@ -165,7 +146,11 @@ func (m *manager) standbyInstance( attribute.String("operation", "create_snapshot"), attribute.Bool("reuse_snapshot_base", reuseSnapshotBase), ) - if err := createSnapshot(snapshotCtx, hv, snapshotDir, reuseSnapshotBase); err != nil { + snapshotOptions := hypervisor.SnapshotOptions{} + if stored.HypervisorType == hypervisor.TypeFirecracker { + snapshotOptions.DeferredMemoryBackingPath = stored.FirecrackerDeferredSnapshotMemoryPath + } + if err := createSnapshot(snapshotCtx, hv, snapshotDir, reuseSnapshotBase, snapshotOptions); err != nil { snapshotSpanEnd(err) // Snapshot failed - try to resume VM log.ErrorContext(ctx, "snapshot failed, attempting to resume VM", "instance_id", id, "error", err) @@ -295,7 +280,7 @@ func (m *manager) standbyInstance( } // createSnapshot creates a snapshot using the hypervisor interface -func createSnapshot(ctx context.Context, hv hypervisor.Hypervisor, snapshotDir string, reuseSnapshotBase bool) error { +func createSnapshot(ctx context.Context, hv hypervisor.Hypervisor, snapshotDir string, reuseSnapshotBase bool, opts hypervisor.SnapshotOptions) error { log := logger.FromContext(ctx) // Remove old snapshot if the hypervisor does not support reusing snapshots @@ -311,7 +296,7 @@ func createSnapshot(ctx context.Context, hv hypervisor.Hypervisor, snapshotDir s // Create snapshot via hypervisor API log.DebugContext(ctx, "invoking hypervisor snapshot API", "snapshot_dir", snapshotDir) - if err := hv.Snapshot(ctx, snapshotDir); err != nil { + if err := hv.Snapshot(ctx, snapshotDir, opts); err != nil { return fmt.Errorf("snapshot: %w", err) } diff --git a/lib/instances/standby_test.go b/lib/instances/standby_test.go index 8e0d5150..159d63c2 100644 --- a/lib/instances/standby_test.go +++ b/lib/instances/standby_test.go @@ -1,12 +1,10 @@ package instances import ( - "context" "os" "path/filepath" "testing" - "github.com/kernel/hypeman/lib/hypervisor" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -60,23 +58,3 @@ func TestPrepareRetainedSnapshotTargetDiscardsStaleSnapshotDirBeforeRetry(t *tes _, err = os.Stat(retainedBaseDir) assert.True(t, os.IsNotExist(err), "cleanup without a retained base should leave the retained base location empty") } - -func TestMaterializeDeferredFirecrackerSnapshotMemory(t *testing.T) { - t.Parallel() - - mgr, _ := setupTestManager(t) - sourcePath := filepath.Join(t.TempDir(), "source-memory") - snapshotDir := filepath.Join(t.TempDir(), "snapshot-latest") - require.NoError(t, os.WriteFile(sourcePath, []byte("memory"), 0644)) - - stored := &StoredMetadata{ - HypervisorType: hypervisor.TypeFirecracker, - FirecrackerDeferredSnapshotMemoryPath: sourcePath, - } - require.NoError(t, mgr.materializeDeferredFirecrackerSnapshotMemory(context.Background(), stored, snapshotDir)) - - got, err := os.ReadFile(filepath.Join(snapshotDir, "memory")) - require.NoError(t, err) - assert.Equal(t, []byte("memory"), got) - assert.Empty(t, stored.FirecrackerDeferredSnapshotMemoryPath) -} From d90c5f17da0ad24bab45699bdca2ff067185f187 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 11:04:53 -0400 Subject: [PATCH 06/17] Reproduce chained UFFD fork backing path bug --- lib/instances/firecracker_test.go | 68 +++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/lib/instances/firecracker_test.go b/lib/instances/firecracker_test.go index 585b4df7..78361ad7 100644 --- a/lib/instances/firecracker_test.go +++ b/lib/instances/firecracker_test.go @@ -714,7 +714,59 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { source, err = mgr.StandbyInstance(ctx, sourceID, StandbyInstanceRequest{}) require.NoError(t, err) require.Equal(t, StateStandby, source.State) - require.FileExists(t, filepath.Join(p.InstanceSnapshotLatest(sourceID), "memory")) + sourceSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(sourceID), "memory") + require.FileExists(t, sourceSnapshotMemoryPath) + + intermediate, err := mgr.ForkInstance(ctx, sourceID, ForkInstanceRequest{ + Name: "fc-uffd-oneshot-mid", + TargetState: StateStandby, + }) + require.NoError(t, err) + intermediateID := intermediate.Id + intermediateDeleted := false + t.Cleanup(func() { + if !intermediateDeleted { + _ = mgr.DeleteInstance(context.Background(), intermediateID) + } + }) + intermediateSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(intermediateID), "memory") + require.NoFileExists(t, intermediateSnapshotMemoryPath, "standby UFFD fork should defer the memory clone") + intermediateMeta, err := mgr.loadMetadata(intermediateID) + require.NoError(t, err) + require.True(t, intermediateMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Equal(t, sourceSnapshotMemoryPath, intermediateMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + + chained, err := mgr.ForkInstance(ctx, intermediateID, ForkInstanceRequest{ + Name: "fc-uffd-oneshot-chain", + TargetState: StateStandby, + }) + require.NoError(t, err) + chainedID := chained.Id + chainedDeleted := false + t.Cleanup(func() { + if !chainedDeleted { + _ = mgr.DeleteInstance(context.Background(), chainedID) + } + }) + chainedMeta, err := mgr.loadMetadata(chainedID) + require.NoError(t, err) + require.True(t, chainedMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Equal(t, sourceSnapshotMemoryPath, chainedMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.NoFileExists(t, filepath.Join(p.InstanceSnapshotLatest(chainedID), "memory"), "chained standby UFFD fork should defer the memory clone") + + chained, err = mgr.RestoreInstance(ctx, chainedID) + require.NoError(t, err) + chained, err = waitForInstanceState(ctx, mgr, chainedID, StateRunning, integrationTestTimeout(20*time.Second)) + require.NoError(t, err) + require.Equal(t, StateRunning, chained.State) + chainedMeta, err = mgr.loadMetadata(chainedID) + require.NoError(t, err) + require.False(t, chainedMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Equal(t, sourceSnapshotMemoryPath, chainedMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.NotEmpty(t, chainedMeta.StoredMetadata.FirecrackerUFFDSessionID) + + require.NoError(t, mgr.DeleteInstance(ctx, chainedID)) + chainedDeleted = true fork, err := mgr.ForkInstance(ctx, sourceID, ForkInstanceRequest{ Name: "fc-uffd-oneshot-fork", @@ -738,6 +790,7 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { require.NoError(t, err) require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore, "one-shot UFFD flag should clear after first restore") require.NotEmpty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath, "running fork still needs deferred backing path for standby") + require.Equal(t, sourceSnapshotMemoryPath, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) require.NotEmpty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID, "running UFFD-restored fork should keep its pager session") fork, err = mgr.StandbyInstance(ctx, forkID, StandbyInstanceRequest{}) @@ -750,19 +803,10 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { require.Empty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) require.Empty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID) - fork, err = mgr.RestoreInstance(ctx, forkID) - require.NoError(t, err) - fork, err = waitForInstanceState(ctx, mgr, forkID, StateRunning, integrationTestTimeout(20*time.Second)) - require.NoError(t, err) - require.Equal(t, StateRunning, fork.State) - forkMeta, err = mgr.loadMetadata(forkID) - require.NoError(t, err) - require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore, "direct resume after standby should remain file-backed") - require.Empty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) - require.Empty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID, "file-backed restore should not create a UFFD session") - require.NoError(t, mgr.DeleteInstance(ctx, forkID)) forkDeleted = true + require.NoError(t, mgr.DeleteInstance(ctx, intermediateID)) + intermediateDeleted = true require.NoError(t, mgr.DeleteInstance(ctx, sourceID)) sourceDeleted = true } From bb766f5189ae2ba3ac54fa1c37a0d10ca066d179 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 11:18:18 -0400 Subject: [PATCH 07/17] Preserve deferred UFFD backing path across chained forks --- lib/instances/firecracker_uffd.go | 9 +++++++++ lib/instances/fork.go | 2 +- lib/instances/snapshot.go | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index 1f650aae..1d39b119 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -36,6 +36,15 @@ func firecrackerSnapshotMemoryPathInGuestDir(guestDir string) string { return filepath.Join(guestDir, firecrackerSnapshotMemoryRelPath) } +func firecrackerDeferredSnapshotMemoryPath(stored *StoredMetadata, guestDir string) string { + if stored != nil { + if path := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath); path != "" { + return path + } + } + return firecrackerSnapshotMemoryPathInGuestDir(guestDir) +} + func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snapshotDir string) (hypervisor.RestoreOptions, error) { opts := hypervisor.RestoreOptions{SnapshotMemoryBackend: hypervisor.SnapshotMemoryBackendFile} if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { diff --git a/lib/instances/fork.go b/lib/instances/fork.go index de1be08f..7918f7a2 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -258,7 +258,7 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin dstDir := m.paths.InstanceDir(forkID) deferredSnapshotMemoryPath := "" if m.shouldDeferFirecrackerSnapshotMemoryCopy(stored, source.State == StateStandby, targetState) { - deferredSnapshotMemoryPath = firecrackerSnapshotMemoryPathInGuestDir(srcDir) + deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(stored, srcDir) } cu := cleanup.Make(func() { diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 7a0bd4f4..6660bd4c 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -424,7 +424,7 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS deferredSource.HypervisorType = targetHypervisor deferredSnapshotMemoryPath := "" if m.shouldDeferFirecrackerSnapshotMemoryCopy(&deferredSource, rec.Snapshot.Kind == SnapshotKindStandby, targetState) { - deferredSnapshotMemoryPath = firecrackerSnapshotMemoryPathInGuestDir(snapshotGuestDir) + deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(&rec.StoredMetadata, snapshotGuestDir) } if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, err From e42de52a4ce2dc680304928b5dde4cddb6f9f547 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 11:53:52 -0400 Subject: [PATCH 08/17] Fix UFFD one-shot standby lifecycle --- lib/hypervisor/firecracker/config_test.go | 17 +++ lib/hypervisor/firecracker/firecracker.go | 41 ++++- lib/hypervisor/firecracker/fork_test.go | 4 +- lib/instances/firecracker_test.go | 176 ++++++++++++++-------- lib/instances/fork.go | 32 ++++ 5 files changed, 200 insertions(+), 70 deletions(-) diff --git a/lib/hypervisor/firecracker/config_test.go b/lib/hypervisor/firecracker/config_test.go index edfd33dd..fcbe7c6b 100644 --- a/lib/hypervisor/firecracker/config_test.go +++ b/lib/hypervisor/firecracker/config_test.go @@ -115,6 +115,23 @@ func TestMaterializeDeferredSnapshotMemory(t *testing.T) { assert.Equal(t, []byte("memory"), got) } +func TestMaterializeDeferredSnapshotMemoryUsesRetainedSnapshotAlternate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourcePath := filepath.Join(root, "snapshots", "snapshot-base", "memory") + alternatePath := filepath.Join(root, "snapshots", "snapshot-latest", "memory") + destPath := filepath.Join(t.TempDir(), "snapshot-latest") + require.NoError(t, os.MkdirAll(filepath.Dir(alternatePath), 0755)) + require.NoError(t, os.WriteFile(alternatePath, []byte("memory"), 0644)) + + require.NoError(t, materializeDeferredSnapshotMemory(destPath, sourcePath)) + + got, err := os.ReadFile(filepath.Join(destPath, "memory")) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) +} + func TestToBalloonConfig(t *testing.T) { cfg := hypervisor.VMConfig{ GuestMemory: hypervisor.GuestMemoryConfig{ diff --git a/lib/hypervisor/firecracker/firecracker.go b/lib/hypervisor/firecracker/firecracker.go index 8411bb28..c2bb7db3 100644 --- a/lib/hypervisor/firecracker/firecracker.go +++ b/lib/hypervisor/firecracker/firecracker.go @@ -138,12 +138,51 @@ func materializeDeferredSnapshotMemory(destPath, sourcePath string) error { } else if !os.IsNotExist(err) { return fmt.Errorf("stat deferred snapshot memory target: %w", err) } - if err := forkvm.CopyRegularFile(sourcePath, targetPath); err != nil { + resolvedSourcePath, err := resolveDeferredSnapshotMemorySourcePath(sourcePath) + if err != nil { + return err + } + if err := forkvm.CopyRegularFile(resolvedSourcePath, targetPath); err != nil { return fmt.Errorf("materialize deferred snapshot memory: %w", err) } return nil } +func resolveDeferredSnapshotMemorySourcePath(sourcePath string) (string, error) { + if _, err := os.Stat(sourcePath); err == nil { + return sourcePath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat deferred snapshot memory source: %w", err) + } + + alternatePath := alternateRetainedSnapshotMemoryPath(sourcePath) + if alternatePath == "" { + return sourcePath, nil + } + if _, err := os.Stat(alternatePath); err == nil { + return alternatePath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat alternate deferred snapshot memory source: %w", err) + } + return sourcePath, nil +} + +func alternateRetainedSnapshotMemoryPath(sourcePath string) string { + if filepath.Base(sourcePath) != "memory" { + return "" + } + snapshotDir := filepath.Dir(sourcePath) + snapshotsDir := filepath.Dir(snapshotDir) + switch filepath.Base(snapshotDir) { + case "snapshot-base": + return filepath.Join(snapshotsDir, "snapshot-latest", "memory") + case "snapshot-latest": + return filepath.Join(snapshotsDir, "snapshot-base", "memory") + default: + return "" + } +} + func (f *Firecracker) ResizeMemory(ctx context.Context, bytes int64) error { return hypervisor.ErrNotSupported } diff --git a/lib/hypervisor/firecracker/fork_test.go b/lib/hypervisor/firecracker/fork_test.go index 5ffe5617..b415dedf 100644 --- a/lib/hypervisor/firecracker/fork_test.go +++ b/lib/hypervisor/firecracker/fork_test.go @@ -44,7 +44,7 @@ func TestPrepareFork_SnapshotRewritePersistsRestoreMetadata(t *testing.T) { assert.True(t, meta.RetainSnapshotSourceDataDirAlias) } -func TestPrepareFork_DoesNotRetainExistingSourceAlias(t *testing.T) { +func TestPrepareFork_RetainsExistingSourceAlias(t *testing.T) { starter := NewStarter() tmp := t.TempDir() sourceDir := filepath.Join(tmp, "source") @@ -63,7 +63,7 @@ func TestPrepareFork_DoesNotRetainExistingSourceAlias(t *testing.T) { meta, err := loadRestoreMetadata(targetDir) require.NoError(t, err) assert.Equal(t, sourceDir, meta.SnapshotSourceDataDir) - assert.False(t, meta.RetainSnapshotSourceDataDirAlias) + assert.True(t, meta.RetainSnapshotSourceDataDirAlias) } func TestPrepareFork_RewritesSnapshotStatePathsWithoutAlias(t *testing.T) { diff --git a/lib/instances/firecracker_test.go b/lib/instances/firecracker_test.go index 78361ad7..cca68bac 100644 --- a/lib/instances/firecracker_test.go +++ b/lib/instances/firecracker_test.go @@ -708,107 +708,149 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { } }) - source, err = waitForInstanceState(ctx, mgr, sourceID, StateRunning, integrationTestTimeout(20*time.Second)) - require.NoError(t, err) - - source, err = mgr.StandbyInstance(ctx, sourceID, StandbyInstanceRequest{}) - require.NoError(t, err) - require.Equal(t, StateStandby, source.State) - sourceSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(sourceID), "memory") - require.FileExists(t, sourceSnapshotMemoryPath) + source = requireRunningSleepInstance(t, ctx, mgr, sourceID) - intermediate, err := mgr.ForkInstance(ctx, sourceID, ForkInstanceRequest{ - Name: "fc-uffd-oneshot-mid", - TargetState: StateStandby, + snapshot, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStandby, + Name: "fc-uffd-oneshot-snap", }) require.NoError(t, err) - intermediateID := intermediate.Id - intermediateDeleted := false + require.Equal(t, SnapshotKindStandby, snapshot.Kind) + snapshotDeleted := false t.Cleanup(func() { - if !intermediateDeleted { - _ = mgr.DeleteInstance(context.Background(), intermediateID) + if !snapshotDeleted { + _ = mgr.DeleteSnapshot(context.Background(), snapshot.Id) } }) - intermediateSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(intermediateID), "memory") - require.NoFileExists(t, intermediateSnapshotMemoryPath, "standby UFFD fork should defer the memory clone") - intermediateMeta, err := mgr.loadMetadata(intermediateID) - require.NoError(t, err) - require.True(t, intermediateMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) - require.Equal(t, sourceSnapshotMemoryPath, intermediateMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + snapshotMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(p.SnapshotGuestDir(snapshot.Id)) + require.FileExists(t, snapshotMemoryPath) + + source = requireRunningSleepInstance(t, ctx, mgr, sourceID) - chained, err := mgr.ForkInstance(ctx, intermediateID, ForkInstanceRequest{ - Name: "fc-uffd-oneshot-chain", - TargetState: StateStandby, + parent, err := mgr.ForkSnapshot(ctx, snapshot.Id, ForkSnapshotRequest{ + Name: "fc-uffd-oneshot-parent", + TargetState: StateRunning, }) require.NoError(t, err) - chainedID := chained.Id - chainedDeleted := false + parentID := parent.Id + parentDeleted := false t.Cleanup(func() { - if !chainedDeleted { - _ = mgr.DeleteInstance(context.Background(), chainedID) + if !parentDeleted { + _ = mgr.DeleteInstance(context.Background(), parentID) } }) - chainedMeta, err := mgr.loadMetadata(chainedID) + parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + parentSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(parentID), "memory") + require.NoFileExists(t, parentSnapshotMemoryPath, "UFFD snapshot fanout should defer the memory clone while running") + parentMeta, err := mgr.loadMetadata(parentID) require.NoError(t, err) - require.True(t, chainedMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) - require.Equal(t, sourceSnapshotMemoryPath, chainedMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) - require.NoFileExists(t, filepath.Join(p.InstanceSnapshotLatest(chainedID), "memory"), "chained standby UFFD fork should defer the memory clone") + require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Equal(t, snapshotMemoryPath, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.NotEmpty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) - chained, err = mgr.RestoreInstance(ctx, chainedID) - require.NoError(t, err) - chained, err = waitForInstanceState(ctx, mgr, chainedID, StateRunning, integrationTestTimeout(20*time.Second)) + parent, err = mgr.StandbyInstance(ctx, parentID, StandbyInstanceRequest{}) require.NoError(t, err) - require.Equal(t, StateRunning, chained.State) - chainedMeta, err = mgr.loadMetadata(chainedID) + require.Equal(t, StateStandby, parent.State) + require.FileExists(t, parentSnapshotMemoryPath, "standby should materialize a normal file-backed snapshot from the deferred UFFD backing") + parentMeta, err = mgr.loadMetadata(parentID) require.NoError(t, err) - require.False(t, chainedMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) - require.Equal(t, sourceSnapshotMemoryPath, chainedMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) - require.NotEmpty(t, chainedMeta.StoredMetadata.FirecrackerUFFDSessionID) + require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) - require.NoError(t, mgr.DeleteInstance(ctx, chainedID)) - chainedDeleted = true + parent, err = mgr.RestoreInstance(ctx, parentID) + require.NoError(t, err) + parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + parentMeta, err = mgr.loadMetadata(parentID) + require.NoError(t, err) + require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) + require.FileExists(t, filepath.Join(p.InstanceSnapshotBase(parentID), "memory"), "file-backed resume should retain the standby snapshot as the next diff base") - fork, err := mgr.ForkInstance(ctx, sourceID, ForkInstanceRequest{ - Name: "fc-uffd-oneshot-fork", + child, err := mgr.ForkInstance(ctx, parentID, ForkInstanceRequest{ + Name: "fc-uffd-oneshot-child", + FromRunning: true, TargetState: StateRunning, }) require.NoError(t, err) - forkID := fork.Id - forkDeleted := false + childID := child.Id + childDeleted := false t.Cleanup(func() { - if !forkDeleted { - _ = mgr.DeleteInstance(context.Background(), forkID) + if !childDeleted { + _ = mgr.DeleteInstance(context.Background(), childID) } }) - fork, err = waitForInstanceState(ctx, mgr, forkID, StateRunning, integrationTestTimeout(20*time.Second)) + parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + child = requireRunningSleepInstance(t, ctx, mgr, childID) + + childSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(childID), "memory") + require.NoFileExists(t, childSnapshotMemoryPath, "running-source child should defer the memory clone while running") + childMeta, err := mgr.loadMetadata(childID) require.NoError(t, err) + require.False(t, childMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.NotEmpty(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.FileExists(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.NotEmpty(t, childMeta.StoredMetadata.FirecrackerUFFDSessionID) - forkSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(forkID), "memory") - require.NoFileExists(t, forkSnapshotMemoryPath, "UFFD fork should defer the memory clone while running") - forkMeta, err := mgr.loadMetadata(forkID) + parent, err = mgr.StandbyInstance(ctx, parentID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, parent.State) + parentSnapshotMemoryPath = filepath.Join(p.InstanceSnapshotLatest(parentID), "memory") + require.FileExists(t, parentSnapshotMemoryPath) + parentMeta, err = mgr.loadMetadata(parentID) require.NoError(t, err) - require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore, "one-shot UFFD flag should clear after first restore") - require.NotEmpty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath, "running fork still needs deferred backing path for standby") - require.Equal(t, sourceSnapshotMemoryPath, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) - require.NotEmpty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID, "running UFFD-restored fork should keep its pager session") + require.Empty(t, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) - fork, err = mgr.StandbyInstance(ctx, forkID, StandbyInstanceRequest{}) + child, err = mgr.StandbyInstance(ctx, childID, StandbyInstanceRequest{}) require.NoError(t, err) - require.Equal(t, StateStandby, fork.State) - require.FileExists(t, forkSnapshotMemoryPath, "standby should materialize a normal file-backed snapshot base") - forkMeta, err = mgr.loadMetadata(forkID) + require.Equal(t, StateStandby, child.State) + require.FileExists(t, childSnapshotMemoryPath, "child standby should materialize from its deferred UFFD backing") + childMeta, err = mgr.loadMetadata(childID) require.NoError(t, err) - require.False(t, forkMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) - require.Empty(t, forkMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) - require.Empty(t, forkMeta.StoredMetadata.FirecrackerUFFDSessionID) + require.False(t, childMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, childMeta.StoredMetadata.FirecrackerUFFDSessionID) - require.NoError(t, mgr.DeleteInstance(ctx, forkID)) - forkDeleted = true - require.NoError(t, mgr.DeleteInstance(ctx, intermediateID)) - intermediateDeleted = true + require.NoError(t, mgr.DeleteInstance(ctx, childID)) + childDeleted = true + require.NoError(t, mgr.DeleteInstance(ctx, parentID)) + parentDeleted = true require.NoError(t, mgr.DeleteInstance(ctx, sourceID)) sourceDeleted = true + require.NoError(t, mgr.DeleteSnapshot(ctx, snapshot.Id)) + snapshotDeleted = true +} + +func requireRunningSleepInstance(t *testing.T, ctx context.Context, mgr Manager, instanceID string) *Instance { + t.Helper() + inst, err := waitForInstanceState(ctx, mgr, instanceID, StateRunning, integrationTestTimeout(20*time.Second)) + require.NoError(t, err) + require.Equal(t, StateRunning, inst.State) + + require.Eventually(t, func() bool { + current, err := mgr.GetInstance(ctx, instanceID) + if err != nil { + t.Logf("get instance %s: %v", instanceID, err) + return false + } + output, exitCode, err := execInInstance(ctx, current, "sh", "-c", "ps | grep '[s]leep' | grep -q infinity") + if err != nil { + t.Logf("exec sleep check for %s: %v", instanceID, err) + return false + } + if exitCode != 0 { + t.Logf("sleep check for %s exited %d: %s", instanceID, exitCode, output) + return false + } + return true + }, integrationTestTimeout(30*time.Second), 250*time.Millisecond) + + inst, err = mgr.GetInstance(ctx, instanceID) + require.NoError(t, err) + return inst } // TestFirecrackerForkIsolation verifies CoW isolation between a firecracker diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 7918f7a2..b28b6467 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -7,6 +7,7 @@ import ( "fmt" "hash/crc32" "os" + "path/filepath" "strings" "time" @@ -100,6 +101,14 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR } return nil, "", false, fmt.Errorf("restore source instance after fork: %w", restoreErr) } + if forkErr == nil && targetState != StateStopped { + if err := m.repointForkDeferredSnapshotMemoryToSourceBase(forked.Id, id); err != nil { + forkErr = fmt.Errorf("update fork deferred snapshot memory backing: %w", err) + if cleanupErr := m.cleanupForkInstanceOnError(ctx, forked.Id); cleanupErr != nil { + forkErr = fmt.Errorf("%v; additionally failed to cleanup forked instance %s: %v", forkErr, forked.Id, cleanupErr) + } + } + } if restoredSource != nil && !restoredSource.NetworkEnabled && (restoredSource.State == StateRunning || restoredSource.State == StateInitializing) { if err := ensureGuestAgentReadyForForkPhase(ctx, &restoredSource.StoredMetadata, "after restoring running fork source"); err != nil { if forkErr != nil { @@ -201,6 +210,29 @@ func generateForkSourceVsockCID(sourceID, forkID string, current int64) int64 { return cid } +func (m *manager) repointForkDeferredSnapshotMemoryToSourceBase(forkID, sourceID string) error { + meta, err := m.loadMetadata(forkID) + if err != nil { + return err + } + stored := &meta.StoredMetadata + if stored.HypervisorType != hypervisor.TypeFirecracker || stored.FirecrackerDeferredSnapshotMemoryPath == "" { + return nil + } + + sourceLatestMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(m.paths.InstanceDir(sourceID)) + if stored.FirecrackerDeferredSnapshotMemoryPath != sourceLatestMemoryPath { + return nil + } + + sourceBaseMemoryPath := filepath.Join(m.paths.InstanceSnapshotBase(sourceID), "memory") + if _, err := os.Stat(sourceBaseMemoryPath); err != nil { + return fmt.Errorf("stat source retained base memory %q: %w", sourceBaseMemoryPath, err) + } + stored.FirecrackerDeferredSnapshotMemoryPath = sourceBaseMemoryPath + return m.saveMetadata(meta) +} + func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id string, req ForkInstanceRequest, supportValidated bool) (*Instance, bool, error) { log := logger.FromContext(ctx) From 3999459e7986c570765afe7f8bb709ca269acecb Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 12:19:51 -0400 Subject: [PATCH 09/17] Guard Firecracker snapshot source alias during UFFD forks --- lib/forkvm/copy.go | 7 +++ lib/forkvm/copy_sparse_unix_test.go | 2 + lib/hypervisor/firecracker/fork_test.go | 2 +- lib/instances/firecracker_uffd.go | 81 +++++++++++++++++++++++++ lib/instances/firecracker_uffd_test.go | 26 +++++++- lib/instances/fork.go | 7 ++- lib/instances/manager.go | 1 + lib/instances/restore.go | 2 + lib/instances/snapshot.go | 4 ++ 9 files changed, 128 insertions(+), 4 deletions(-) diff --git a/lib/forkvm/copy.go b/lib/forkvm/copy.go index 9ffccf64..26a35802 100644 --- a/lib/forkvm/copy.go +++ b/lib/forkvm/copy.go @@ -84,6 +84,9 @@ func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) erro if d.IsDir() && shouldSkipDirectory(relPath) { return filepath.SkipDir } + if !d.IsDir() && shouldSkipRuntimeSocket(relPath) { + return nil + } if !d.IsDir() && shouldSkipRegularFile(relPath) { return nil } @@ -175,3 +178,7 @@ func shouldSkipDirectory(relPath string) bool { func shouldSkipRegularFile(relPath string) bool { return strings.HasSuffix(relPath, ".lz4.tmp") || strings.HasSuffix(relPath, ".zst.tmp") } + +func shouldSkipRuntimeSocket(relPath string) bool { + return strings.HasSuffix(filepath.Base(relPath), ".sock") +} diff --git a/lib/forkvm/copy_sparse_unix_test.go b/lib/forkvm/copy_sparse_unix_test.go index 109c3d5a..54e4d696 100644 --- a/lib/forkvm/copy_sparse_unix_test.go +++ b/lib/forkvm/copy_sparse_unix_test.go @@ -81,6 +81,7 @@ func TestCopyGuestDirectory_SkipsSocketRuntimeArtifacts(t *testing.T) { dst := filepath.Join(base, "dst") require.NoError(t, os.MkdirAll(src, 0755)) require.NoError(t, os.WriteFile(filepath.Join(src, "metadata.json"), []byte(`{"id":"abc"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "vsock.sock"), []byte("stale regular artifact"), 0644)) socketPath := filepath.Join(src, fmt.Sprintf("vz-%d.sock", time.Now().UnixNano())) listener, err := net.Listen("unix", socketPath) @@ -90,6 +91,7 @@ func TestCopyGuestDirectory_SkipsSocketRuntimeArtifacts(t *testing.T) { require.NoError(t, CopyGuestDirectory(src, dst)) assert.NoFileExists(t, filepath.Join(dst, filepath.Base(socketPath))) + assert.NoFileExists(t, filepath.Join(dst, "vsock.sock")) assert.FileExists(t, filepath.Join(dst, "metadata.json")) } diff --git a/lib/hypervisor/firecracker/fork_test.go b/lib/hypervisor/firecracker/fork_test.go index b415dedf..1a0022e4 100644 --- a/lib/hypervisor/firecracker/fork_test.go +++ b/lib/hypervisor/firecracker/fork_test.go @@ -63,7 +63,7 @@ func TestPrepareFork_RetainsExistingSourceAlias(t *testing.T) { meta, err := loadRestoreMetadata(targetDir) require.NoError(t, err) assert.Equal(t, sourceDir, meta.SnapshotSourceDataDir) - assert.True(t, meta.RetainSnapshotSourceDataDirAlias) + assert.False(t, meta.RetainSnapshotSourceDataDirAlias) } func TestPrepareFork_RewritesSnapshotStatePathsWithoutAlias(t *testing.T) { diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index 1d39b119..c86ce0cd 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "syscall" "time" @@ -36,6 +37,82 @@ func firecrackerSnapshotMemoryPathInGuestDir(guestDir string) string { return filepath.Join(guestDir, firecrackerSnapshotMemoryRelPath) } +func (m *manager) lockFirecrackerSnapshotSource(path string) func() { + key := firecrackerSnapshotSourceLockKey(path) + if key == "" { + return func() {} + } + value, _ := m.snapshotSourceLocks.LoadOrStore(key, &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func (m *manager) lockFirecrackerSnapshotSourceForRestore(stored *StoredMetadata, opts hypervisor.RestoreOptions) func() { + if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { + return func() {} + } + path := strings.TrimSpace(opts.SnapshotMemoryBackingPath) + if path == "" { + path = strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath) + } + return m.lockFirecrackerSnapshotSource(path) +} + +func firecrackerSnapshotSourceLockKey(path string) string { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || path == "" { + return "" + } + if filepath.Base(path) != "memory" { + return path + } + snapshotDir := filepath.Dir(path) + if base := filepath.Base(snapshotDir); base != "snapshot-latest" && base != "snapshot-base" { + return path + } + snapshotsDir := filepath.Dir(snapshotDir) + if filepath.Base(snapshotsDir) != "snapshots" { + return path + } + return filepath.Dir(snapshotsDir) +} + +func resolveFirecrackerSnapshotMemoryBackingPath(memoryPath string) (string, error) { + if _, err := os.Stat(memoryPath); err == nil { + return memoryPath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat firecracker snapshot memory backing: %w", err) + } + + alternatePath := alternateFirecrackerRetainedSnapshotMemoryPath(memoryPath) + if alternatePath == "" { + return memoryPath, nil + } + if _, err := os.Stat(alternatePath); err == nil { + return alternatePath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat alternate firecracker snapshot memory backing: %w", err) + } + return memoryPath, nil +} + +func alternateFirecrackerRetainedSnapshotMemoryPath(memoryPath string) string { + if filepath.Base(memoryPath) != "memory" { + return "" + } + snapshotDir := filepath.Dir(memoryPath) + snapshotsDir := filepath.Dir(snapshotDir) + switch filepath.Base(snapshotDir) { + case "snapshot-base": + return filepath.Join(snapshotsDir, "snapshot-latest", "memory") + case "snapshot-latest": + return filepath.Join(snapshotsDir, "snapshot-base", "memory") + default: + return "" + } +} + func firecrackerDeferredSnapshotMemoryPath(stored *StoredMetadata, guestDir string) string { if stored != nil { if path := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath); path != "" { @@ -63,6 +140,10 @@ func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snap if backingMemoryPath == "" { backingMemoryPath = filepath.Join(snapshotDir, "memory") } + backingMemoryPath, err := resolveFirecrackerSnapshotMemoryBackingPath(backingMemoryPath) + if err != nil { + return opts, err + } cacheKey := strings.TrimSpace(stored.FirecrackerSnapshotCacheKey) if cacheKey == "" { var err error diff --git a/lib/instances/firecracker_uffd_test.go b/lib/instances/firecracker_uffd_test.go index 12b5f7ed..dc5ed57d 100644 --- a/lib/instances/firecracker_uffd_test.go +++ b/lib/instances/firecracker_uffd_test.go @@ -91,7 +91,7 @@ func TestForkInstanceFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing. require.NoError(t, mgr.saveMetadata(sourceMeta)) mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD - forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + forked, _, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ Name: "one-shot-uffd-instance-fork", TargetState: StateStandby, }, true) @@ -125,7 +125,7 @@ func TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTarget(t *testing require.NoError(t, mgr.saveMetadata(sourceMeta)) mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD - forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + forked, _, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ Name: "one-shot-uffd-instance-stopped-fork", TargetState: StateStopped, }, true) @@ -235,6 +235,28 @@ func TestRestoreInstanceClearsOneShotUFFDFlagAfterSuccessfulRestore(t *testing.T assert.False(t, updated.StoredMetadata.FirecrackerUseUFFDOnNextRestore) } +func TestResolveFirecrackerSnapshotMemoryBackingPathUsesRetainedBaseAlternate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + latestMemoryPath := filepath.Join(root, "snapshots", "snapshot-latest", "memory") + baseMemoryPath := filepath.Join(root, "snapshots", "snapshot-base", "memory") + require.NoError(t, os.MkdirAll(filepath.Dir(baseMemoryPath), 0755)) + require.NoError(t, os.WriteFile(baseMemoryPath, []byte("base memory"), 0644)) + + resolved, err := resolveFirecrackerSnapshotMemoryBackingPath(latestMemoryPath) + require.NoError(t, err) + assert.Equal(t, baseMemoryPath, resolved) +} + +func TestFirecrackerSnapshotSourceLockKeyUsesGuestDirectory(t *testing.T) { + t.Parallel() + + guestDir := filepath.Join(t.TempDir(), "guests", "source") + assert.Equal(t, guestDir, firecrackerSnapshotSourceLockKey(filepath.Join(guestDir, "snapshots", "snapshot-latest", "memory"))) + assert.Equal(t, guestDir, firecrackerSnapshotSourceLockKey(filepath.Join(guestDir, "snapshots", "snapshot-base", "memory"))) +} + func installOneShotFirecrackerStarter(t *testing.T, mgr *manager) { t.Helper() previous, hadPrevious := mgr.vmStarters[hypervisor.TypeFirecracker] diff --git a/lib/instances/fork.go b/lib/instances/fork.go index b28b6467..1bebe0dc 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -258,7 +258,7 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin } targetState, err := resolveForkTargetState(req.TargetState, source.State) if err != nil { - return nil, err + return nil, false, err } if err := validateForkVolumeSafety(stored.Volumes); err != nil { return nil, false, err @@ -292,6 +292,11 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin if m.shouldDeferFirecrackerSnapshotMemoryCopy(stored, source.State == StateStandby, targetState) { deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(stored, srcDir) } + unlockSnapshotSource := func() {} + if deferredSnapshotMemoryPath != "" { + unlockSnapshotSource = m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) + defer unlockSnapshotSource() + } cu := cleanup.Make(func() { _ = os.RemoveAll(dstDir) diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 8e8e25f3..82c70c45 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -171,6 +171,7 @@ type manager struct { resourceValidator ResourceValidator // Optional validator for aggregate resource limits instanceLocks sync.Map // map[string]*sync.RWMutex - per-instance locks forkMetadataMu sync.Mutex + snapshotSourceLocks sync.Map // map[string]*sync.Mutex - Firecracker snapshot source alias locks bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan hypervisorStateCache sync.Map // map[string]hypervisorStateCacheEntry - last observed hypervisor state per instance hostTopology *HostTopology // Cached host CPU topology diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 2a4cc6bd..1896c6d0 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -281,7 +281,9 @@ func (m *manager) restoreInstance( attribute.String("operation", "restore_from_snapshot"), ) log.InfoContext(ctx, "restoring from snapshot", "instance_id", id, "snapshot_dir", snapshotDir, "hypervisor", stored.HypervisorType) + unlockSnapshotSource := m.lockFirecrackerSnapshotSourceForRestore(stored, restoreOptions) pid, hv, err := m.restoreFromSnapshot(restoreCtx, stored, snapshotDir, restoreOptions) + unlockSnapshotSource() restoreSpanEnd(err) if err != nil { log.ErrorContext(ctx, "failed to restore from snapshot", "instance_id", id, "error", err) diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 6660bd4c..f6b21ec7 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -426,6 +426,10 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if m.shouldDeferFirecrackerSnapshotMemoryCopy(&deferredSource, rec.Snapshot.Kind == SnapshotKindStandby, targetState) { deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(&rec.StoredMetadata, snapshotGuestDir) } + if deferredSnapshotMemoryPath != "" { + unlockSnapshotSource := m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) + defer unlockSnapshotSource() + } if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, err } From fb5eff993b675eeea01a2023e78fa9810c7f9225 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 15:09:18 -0400 Subject: [PATCH 10/17] Bump UFFD pager version for one-shot restore --- lib/uffdpager/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/uffdpager/VERSION b/lib/uffdpager/VERSION index 17e51c38..d917d3e2 100644 --- a/lib/uffdpager/VERSION +++ b/lib/uffdpager/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 From 25517813823bb0d3bde1be112caa8b3086df2f20 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 15:25:10 -0400 Subject: [PATCH 11/17] Install isolated UFFD pager units in CI --- .github/workflows/test.yml | 30 ++++++++++ Makefile | 2 + lib/uffdpager/supervisor_linux.go | 92 +++++++++++++++++++++++++------ scripts/install.sh | 8 ++- 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b40a9f9f..e4ea5c0c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,6 +95,29 @@ jobs: - name: Build run: make build + - name: Install UFFD pager systemd template + run: | + sudo mkdir -p /run/hypeman/uffd + sudo tee /etc/systemd/system/hypeman-uffd@.service > /dev/null <<'EOF' + [Unit] + Description=Hypeman UFFD Pager (%i) + Documentation=https://github.com/kernel/hypeman + After=network.target + + [Service] + Type=simple + Environment="HYPEMAN_UFFD_BINARY=/opt/hypeman/bin/hypeman-uffd-pager" + Environment="HYPEMAN_UFFD_DATA_DIR=/var/lib/hypeman" + Environment="HYPEMAN_UFFD_VERSION_KEY=%i" + Environment="HYPEMAN_UFFD_CACHE_MAX_BYTES=4294967296" + EnvironmentFile=-/run/hypeman/uffd/%i.env + ExecStart=/bin/sh -c 'exec "$HYPEMAN_UFFD_BINARY" --data-dir "$HYPEMAN_UFFD_DATA_DIR" --version-key "$HYPEMAN_UFFD_VERSION_KEY" --cache-max-bytes "$HYPEMAN_UFFD_CACHE_MAX_BYTES"' + Restart=on-failure + RestartSec=5 + KillMode=process + EOF + sudo systemctl daemon-reload + - name: Prewarm test cache env: HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001 @@ -132,6 +155,7 @@ jobs: HYPEMAN_TEST_PREWARM_STRICT: "1" HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001 HYPEMAN_UFFD_PAGER_BINARY: ${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }} + HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX: ci-${{ github.run_id }}-${{ github.run_attempt }} run: | cp "$PWD/bin/hypeman-uffd-pager" "$HYPEMAN_UFFD_PAGER_BINARY" chmod +x "$HYPEMAN_UFFD_PAGER_BINARY" @@ -141,6 +165,12 @@ jobs: - name: Cleanup if: always() run: | + units="$(systemctl list-units --all --full --plain 'hypeman-uffd@ci-${{ github.run_id }}-${{ github.run_attempt }}-*.service' --no-legend | awk '{print $1}' || true)" + if [ -n "$units" ]; then + echo "$units" | xargs -r sudo systemctl stop + echo "$units" | xargs -r sudo systemctl reset-failed + fi + sudo rm -f /run/hypeman/uffd/ci-${{ github.run_id }}-${{ github.run_attempt }}-*.env sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}" rm -f "${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }}" diff --git a/Makefile b/Makefile index 82ef76d2..d2d42997 100644 --- a/Makefile +++ b/Makefile @@ -301,6 +301,7 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries echo "Running specific test: $(TEST)"; \ sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \ "HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \ + "HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$${HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX:-}" \ "HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \ "HYPEMAN_TEST_PREWARM_STRICT=$${HYPEMAN_TEST_PREWARM_STRICT:-}" \ "HYPEMAN_TEST_REGISTRY=$${HYPEMAN_TEST_REGISTRY:-}" \ @@ -308,6 +309,7 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries else \ sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \ "HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \ + "HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$${HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX:-}" \ "HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \ "HYPEMAN_TEST_PREWARM_STRICT=$${HYPEMAN_TEST_PREWARM_STRICT:-}" \ "HYPEMAN_TEST_REGISTRY=$${HYPEMAN_TEST_REGISTRY:-}" \ diff --git a/lib/uffdpager/supervisor_linux.go b/lib/uffdpager/supervisor_linux.go index 93a39369..23c23907 100644 --- a/lib/uffdpager/supervisor_linux.go +++ b/lib/uffdpager/supervisor_linux.go @@ -5,6 +5,8 @@ package uffdpager import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -21,17 +23,18 @@ import ( const ( controlSocketFile = "control.sock" pagerPIDFile = "pager.pid" - pagerEnvFile = "pager.env" sessionsDir = "sessions" - systemdUnitTemplate = "hypeman-uffd@.service" + systemdUnitTemplate = "hypeman-uffd@.service" + systemdRuntimeEnvDir = "/run/hypeman/uffd" ) type Supervisor struct { - dataDir string - versionKey string - cacheMaxBytes int64 - controlSocket string + dataDir string + versionKey string + systemdInstanceKey string + cacheMaxBytes int64 + controlSocket string mu sync.Mutex clients map[string]*http.Client @@ -43,11 +46,12 @@ func NewSupervisor(ctx context.Context, dataDir string, cacheMaxBytes int64) (*S return nil, fmt.Errorf("uffd pager version is empty") } s := &Supervisor{ - dataDir: dataDir, - versionKey: versionKey, - cacheMaxBytes: normalizeCacheMaxBytes(cacheMaxBytes), - controlSocket: pagerControlSocket(dataDir, versionKey), - clients: make(map[string]*http.Client), + dataDir: dataDir, + versionKey: versionKey, + systemdInstanceKey: systemdInstanceKey(versionKey, dataDir), + cacheMaxBytes: normalizeCacheMaxBytes(cacheMaxBytes), + controlSocket: pagerControlSocket(dataDir, versionKey), + clients: make(map[string]*http.Client), } if err := s.ensureRunning(ctx); err != nil { return nil, err @@ -132,11 +136,14 @@ func (s *Supervisor) ensureRunningSystemd(ctx context.Context) error { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create uffd pager directory: %w", err) } - env := fmt.Sprintf("HYPEMAN_UFFD_CACHE_MAX_BYTES=%d\n", s.cacheMaxBytes) - if err := os.WriteFile(filepath.Join(dir, pagerEnvFile), []byte(env), 0644); err != nil { + if err := os.MkdirAll(systemdRuntimeEnvDir, 0755); err != nil { + return fmt.Errorf("create uffd pager systemd environment directory: %w", err) + } + env := s.systemdEnvironment() + if err := os.WriteFile(systemdEnvFile(s.systemdInstanceKey), []byte(env), 0644); err != nil { return fmt.Errorf("write uffd pager systemd environment: %w", err) } - unit := systemdUnitName(s.versionKey) + unit := systemdUnitName(s.systemdInstanceKey) cmd := exec.CommandContext(ctx, "systemctl", "start", unit) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("start uffd pager systemd unit %s: %w: %s", unit, err, strings.TrimSpace(string(out))) @@ -171,6 +178,17 @@ func (s *Supervisor) systemdTemplateInstalled(ctx context.Context) bool { return cmd.Run() == nil } +func (s *Supervisor) systemdEnvironment() string { + var b strings.Builder + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_CACHE_MAX_BYTES", fmt.Sprintf("%d", s.cacheMaxBytes))) + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_DATA_DIR", s.dataDir)) + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_VERSION_KEY", s.versionKey)) + if binary := strings.TrimSpace(os.Getenv("HYPEMAN_UFFD_PAGER_BINARY")); binary != "" { + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_BINARY", binary)) + } + return b.String() +} + func (s *Supervisor) isHealthy(ctx context.Context, versionKey string) bool { var health HealthResponse return s.doJSON(ctx, versionKey, http.MethodGet, "/health", nil, &health) == nil @@ -258,6 +276,48 @@ func urlPathEscape(value string) string { return replacer.Replace(value) } -func systemdUnitName(versionKey string) string { - return "hypeman-uffd@" + versionKey + ".service" +func systemdInstanceKey(versionKey, dataDir string) string { + prefix := sanitizeSystemdInstancePart(os.Getenv("HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX")) + if prefix == "" { + return versionKey + } + sum := sha256.Sum256([]byte(filepath.Clean(dataDir))) + return prefix + "-" + versionKey + "-" + hex.EncodeToString(sum[:4]) +} + +func sanitizeSystemdInstancePart(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + var b strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} + +func systemdEnvFile(instanceKey string) string { + return filepath.Join(systemdRuntimeEnvDir, instanceKey+".env") +} + +func systemdEnvLine(key, value string) string { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return key + `="` + escaped + `"` + "\n" +} + +func systemdUnitName(instanceKey string) string { + return "hypeman-uffd@" + instanceKey + ".service" } diff --git a/scripts/install.sh b/scripts/install.sh index 349b9ca9..bc293e55 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -612,8 +612,12 @@ After=network.target [Service] Type=simple Environment="HOME=${DATA_DIR}" -EnvironmentFile=${DATA_DIR}/uffd/%i/pager.env -ExecStart=${INSTALL_DIR}/${UFFD_PAGER_BINARY_NAME} --data-dir ${DATA_DIR} --version-key %i --cache-max-bytes \${HYPEMAN_UFFD_CACHE_MAX_BYTES} +Environment="HYPEMAN_UFFD_BINARY=${INSTALL_DIR}/${UFFD_PAGER_BINARY_NAME}" +Environment="HYPEMAN_UFFD_DATA_DIR=${DATA_DIR}" +Environment="HYPEMAN_UFFD_VERSION_KEY=%i" +Environment="HYPEMAN_UFFD_CACHE_MAX_BYTES=4294967296" +EnvironmentFile=-/run/hypeman/uffd/%i.env +ExecStart=/bin/sh -c 'exec "\${HYPEMAN_UFFD_BINARY}" --data-dir "\${HYPEMAN_UFFD_DATA_DIR}" --version-key "\${HYPEMAN_UFFD_VERSION_KEY}" --cache-max-bytes "\${HYPEMAN_UFFD_CACHE_MAX_BYTES}"' Restart=on-failure RestartSec=5 KillMode=process From 08bc45977ddf449a34e8291ef71c064bb2fad34b Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 15:48:22 -0400 Subject: [PATCH 12/17] Release UFFD source lock before fork restore --- .github/workflows/test.yml | 4 ++-- lib/instances/fork.go | 12 +++++++++++- lib/instances/snapshot.go | 15 +++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4ea5c0c..e333de51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -167,8 +167,8 @@ jobs: run: | units="$(systemctl list-units --all --full --plain 'hypeman-uffd@ci-${{ github.run_id }}-${{ github.run_attempt }}-*.service' --no-legend | awk '{print $1}' || true)" if [ -n "$units" ]; then - echo "$units" | xargs -r sudo systemctl stop - echo "$units" | xargs -r sudo systemctl reset-failed + echo "$units" | xargs -r sudo systemctl stop || true + echo "$units" | xargs -r sudo systemctl reset-failed || true fi sudo rm -f /run/hypeman/uffd/ci-${{ github.run_id }}-${{ github.run_attempt }}-*.env sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}" diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 1bebe0dc..2b42927d 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -293,10 +293,16 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(stored, srcDir) } unlockSnapshotSource := func() {} + snapshotSourceLocked := false if deferredSnapshotMemoryPath != "" { unlockSnapshotSource = m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) - defer unlockSnapshotSource() + snapshotSourceLocked = true } + defer func() { + if snapshotSourceLocked { + unlockSnapshotSource() + } + }() cu := cleanup.Make(func() { _ = os.RemoveAll(dstDir) @@ -395,6 +401,10 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin } restoreNeedsSourceLock = prepareResult.RequiresSnapshotSourceAlias } + if snapshotSourceLocked { + unlockSnapshotSource() + snapshotSourceLocked = false + } newMeta := &metadata{StoredMetadata: forkMeta} if err := m.saveForkMetadata(ctx, newMeta); err != nil { diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index f6b21ec7..5655e81f 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -426,10 +426,17 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if m.shouldDeferFirecrackerSnapshotMemoryCopy(&deferredSource, rec.Snapshot.Kind == SnapshotKindStandby, targetState) { deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(&rec.StoredMetadata, snapshotGuestDir) } + unlockSnapshotSource := func() {} + snapshotSourceLocked := false if deferredSnapshotMemoryPath != "" { - unlockSnapshotSource := m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) - defer unlockSnapshotSource() + unlockSnapshotSource = m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) + snapshotSourceLocked = true } + defer func() { + if snapshotSourceLocked { + unlockSnapshotSource() + } + }() if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, err } @@ -508,6 +515,10 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS "target_data_dir", forkMeta.DataDir) } } + if snapshotSourceLocked { + unlockSnapshotSource() + snapshotSourceLocked = false + } if err := m.saveMetadata(&metadata{StoredMetadata: forkMeta}); err != nil { return nil, fmt.Errorf("save fork metadata: %w", err) From 67c021f9166e6c5d7caced94aaac28d0d7cbee5d Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 16:03:14 -0400 Subject: [PATCH 13/17] Remove UFFD restore source lock --- lib/instances/firecracker_uffd.go | 11 ----------- lib/instances/restore.go | 2 -- 2 files changed, 13 deletions(-) diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index c86ce0cd..a7642ec4 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -48,17 +48,6 @@ func (m *manager) lockFirecrackerSnapshotSource(path string) func() { return mu.Unlock } -func (m *manager) lockFirecrackerSnapshotSourceForRestore(stored *StoredMetadata, opts hypervisor.RestoreOptions) func() { - if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { - return func() {} - } - path := strings.TrimSpace(opts.SnapshotMemoryBackingPath) - if path == "" { - path = strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath) - } - return m.lockFirecrackerSnapshotSource(path) -} - func firecrackerSnapshotSourceLockKey(path string) string { path = filepath.Clean(strings.TrimSpace(path)) if path == "." || path == "" { diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 1896c6d0..2a4cc6bd 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -281,9 +281,7 @@ func (m *manager) restoreInstance( attribute.String("operation", "restore_from_snapshot"), ) log.InfoContext(ctx, "restoring from snapshot", "instance_id", id, "snapshot_dir", snapshotDir, "hypervisor", stored.HypervisorType) - unlockSnapshotSource := m.lockFirecrackerSnapshotSourceForRestore(stored, restoreOptions) pid, hv, err := m.restoreFromSnapshot(restoreCtx, stored, snapshotDir, restoreOptions) - unlockSnapshotSource() restoreSpanEnd(err) if err != nil { log.ErrorContext(ctx, "failed to restore from snapshot", "instance_id", id, "error", err) From 7dede138161f7c0db576fe89f3611b466eaa5e3e Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 16:07:41 -0400 Subject: [PATCH 14/17] Clear deferred Firecracker memory on stop --- lib/instances/stop.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/instances/stop.go b/lib/instances/stop.go index 873be8fa..7a234600 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -308,6 +308,7 @@ func (m *manager) stopInstance( stored.GuestAgentReadyAt = nil stored.FirecrackerSnapshotCacheKey = "" stored.FirecrackerUseUFFDOnNextRestore = false + stored.FirecrackerDeferredSnapshotMemoryPath = "" stored.Phases.Record(phasetracking.PhaseStopped, now) meta = &metadata{StoredMetadata: *stored} From fed29a9ca00c8487612ae6d7192a5dbda825f863 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 16:16:01 -0400 Subject: [PATCH 15/17] Assert UFFD lifecycle guest state persists --- lib/instances/firecracker_test.go | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/lib/instances/firecracker_test.go b/lib/instances/firecracker_test.go index cca68bac..111c11b8 100644 --- a/lib/instances/firecracker_test.go +++ b/lib/instances/firecracker_test.go @@ -709,6 +709,9 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { }) source = requireRunningSleepInstance(t, ctx, mgr, sourceID) + requireGuestTmpfs(t, ctx, source) + writeGuestFile(t, ctx, source, "/root/uffd-lifecycle/source", "source-disk") + writeGuestFile(t, ctx, source, "/dev/shm/uffd-lifecycle/source", "source-memory") snapshot, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ Kind: SnapshotKindStandby, @@ -726,6 +729,8 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { require.FileExists(t, snapshotMemoryPath) source = requireRunningSleepInstance(t, ctx, mgr, sourceID) + assertGuestFile(t, ctx, source, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, source, "/dev/shm/uffd-lifecycle/source", "source-memory") parent, err := mgr.ForkSnapshot(ctx, snapshot.Id, ForkSnapshotRequest{ Name: "fc-uffd-oneshot-parent", @@ -740,6 +745,10 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { } }) parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/source", "source-memory") + writeGuestFile(t, ctx, parent, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + writeGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") parentSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(parentID), "memory") require.NoFileExists(t, parentSnapshotMemoryPath, "UFFD snapshot fanout should defer the memory clone while running") parentMeta, err := mgr.loadMetadata(parentID) @@ -761,6 +770,10 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { parent, err = mgr.RestoreInstance(ctx, parentID) require.NoError(t, err) parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/source", "source-memory") + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") parentMeta, err = mgr.loadMetadata(parentID) require.NoError(t, err) require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) @@ -784,6 +797,16 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { parent = requireRunningSleepInstance(t, ctx, mgr, parentID) child = requireRunningSleepInstance(t, ctx, mgr, childID) + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/source", "source-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + writeGuestFile(t, ctx, child, "/root/uffd-lifecycle/child-only", "child-disk") + writeGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/child-only", "child-memory") + assertGuestFileAbsent(t, ctx, parent, "/root/uffd-lifecycle/child-only") + assertGuestFileAbsent(t, ctx, parent, "/dev/shm/uffd-lifecycle/child-only") childSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(childID), "memory") require.NoFileExists(t, childSnapshotMemoryPath, "running-source child should defer the memory clone while running") @@ -814,6 +837,20 @@ func TestFCUFFDOneShotLifecycle(t *testing.T) { require.Empty(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) require.Empty(t, childMeta.StoredMetadata.FirecrackerUFFDSessionID) + child, err = mgr.RestoreInstance(ctx, childID) + require.NoError(t, err) + child = requireRunningSleepInstance(t, ctx, mgr, childID) + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/source", "source-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/child-only", "child-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/child-only", "child-memory") + + child, err = mgr.StandbyInstance(ctx, childID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, child.State) + require.NoError(t, mgr.DeleteInstance(ctx, childID)) childDeleted = true require.NoError(t, mgr.DeleteInstance(ctx, parentID)) @@ -853,6 +890,35 @@ func requireRunningSleepInstance(t *testing.T, ctx context.Context, mgr Manager, return inst } +func requireGuestTmpfs(t *testing.T, ctx context.Context, inst *Instance) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "sh", "-c", "mkdir -p /dev/shm && if ! grep -q ' /dev/shm ' /proc/mounts; then mount -t tmpfs -o size=16m tmpfs /dev/shm; fi && grep -q ' /dev/shm ' /proc/mounts") + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) +} + +func writeGuestFile(t *testing.T, ctx context.Context, inst *Instance, path, contents string) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "sh", "-c", "mkdir -p \"$1\" && printf '%s' \"$2\" > \"$3\" && sync", "sh", filepath.Dir(path), contents, path) + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) +} + +func assertGuestFile(t *testing.T, ctx context.Context, inst *Instance, path, contents string) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "cat", path) + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) + require.Equal(t, contents, output) +} + +func assertGuestFileAbsent(t *testing.T, ctx context.Context, inst *Instance, path string) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "test", "!", "-e", path) + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) +} + // TestFirecrackerForkIsolation verifies CoW isolation between a firecracker // source's standby snapshot and a fork derived from it. A fork must end up // with its own mem-file inode (reflink-cloned, not hardlinked) so that From 3c82b174bda2bcd4d7aa597ac6471a4eea654e94 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 16:23:21 -0400 Subject: [PATCH 16/17] Use regexp for UFFD systemd instance sanitizer --- lib/uffdpager/supervisor_linux.go | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/lib/uffdpager/supervisor_linux.go b/lib/uffdpager/supervisor_linux.go index 23c23907..c7fb4c47 100644 --- a/lib/uffdpager/supervisor_linux.go +++ b/lib/uffdpager/supervisor_linux.go @@ -15,6 +15,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "time" @@ -29,6 +30,8 @@ const ( systemdRuntimeEnvDir = "/run/hypeman/uffd" ) +var invalidSystemdInstancePartChars = regexp.MustCompile(`[^A-Za-z0-9._-]`) + type Supervisor struct { dataDir string versionKey string @@ -290,22 +293,7 @@ func sanitizeSystemdInstancePart(value string) string { if value == "" { return "" } - var b strings.Builder - for _, r := range value { - switch { - case r >= 'a' && r <= 'z': - b.WriteRune(r) - case r >= 'A' && r <= 'Z': - b.WriteRune(r) - case r >= '0' && r <= '9': - b.WriteRune(r) - case r == '.', r == '_', r == '-': - b.WriteRune(r) - default: - b.WriteByte('-') - } - } - return strings.Trim(b.String(), "-") + return strings.Trim(invalidSystemdInstancePartChars.ReplaceAllString(value, "-"), "-") } func systemdEnvFile(instanceKey string) string { From c74c539c6edce656456360cbfb1cde48b80470d9 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Thu, 4 Jun 2026 17:55:18 -0400 Subject: [PATCH 17/17] Consolidate UFFD restore state cleanup --- lib/instances/firecracker_uffd.go | 13 +++++++++++-- lib/instances/fork.go | 4 +--- lib/instances/manager.go | 2 +- lib/instances/restore.go | 2 ++ lib/instances/snapshot.go | 4 +--- lib/instances/standby.go | 3 +-- lib/instances/stop.go | 3 +-- 7 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index a7642ec4..18ceb137 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -33,6 +33,16 @@ func (m *manager) shouldDeferFirecrackerSnapshotMemoryCopy(stored *StoredMetadat return m.useFirecrackerUFFD(stored) && useFirecrackerUFFDOnNextRestore(stored.HypervisorType, sourceIsStandby, targetState) } +func clearFirecrackerUFFDRestoreState(stored *StoredMetadata) { + if stored == nil { + return + } + stored.FirecrackerUseUFFDOnNextRestore = false + stored.FirecrackerUFFDSessionID = "" + stored.FirecrackerUFFDPagerVersion = "" + stored.FirecrackerDeferredSnapshotMemoryPath = "" +} + func firecrackerSnapshotMemoryPathInGuestDir(guestDir string) string { return filepath.Join(guestDir, firecrackerSnapshotMemoryRelPath) } @@ -117,8 +127,7 @@ func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snap return opts, nil } if !m.useFirecrackerUFFD(stored) || !stored.FirecrackerUseUFFDOnNextRestore { - stored.FirecrackerUFFDSessionID = "" - stored.FirecrackerUFFDPagerVersion = "" + clearFirecrackerUFFDRestoreState(stored) return opts, nil } if m.firecrackerUFFDPager == nil { diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 2b42927d..ff8cfa25 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -549,9 +549,7 @@ func (m *manager) applyForkTargetState(ctx context.Context, forkID string, targe } meta.StoredMetadata.VsockCID = generateVsockCID(forkID) meta.StoredMetadata.FirecrackerSnapshotCacheKey = "" - meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore = false - meta.StoredMetadata.FirecrackerUFFDSessionID = "" - meta.StoredMetadata.FirecrackerUFFDPagerVersion = "" + clearFirecrackerUFFDRestoreState(&meta.StoredMetadata) if err := m.saveMetadata(meta); err != nil { return nil, fmt.Errorf("save stopped fork metadata: %w", err) } diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 82c70c45..b1bf7578 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -171,7 +171,7 @@ type manager struct { resourceValidator ResourceValidator // Optional validator for aggregate resource limits instanceLocks sync.Map // map[string]*sync.RWMutex - per-instance locks forkMetadataMu sync.Mutex - snapshotSourceLocks sync.Map // map[string]*sync.Mutex - Firecracker snapshot source alias locks + snapshotSourceLocks sync.Map // map[string]*sync.Mutex - Firecracker snapshot source alias locks, retained for process lifetime bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan hypervisorStateCache sync.Map // map[string]hypervisorStateCacheEntry - last observed hypervisor state per instance hostTopology *HostTopology // Cached host CPU topology diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 2a4cc6bd..d2afa0fe 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -363,6 +363,8 @@ func (m *manager) restoreInstance( // before markers ever hydrated we resume in Initializing. resumePhase, _ := runningPhaseFromMarkers(stored) stored.Phases.Record(resumePhase, time.Now().UTC()) + // The one-shot restore has been consumed, but a UFFD-backed VM may still + // need its pager session until the next standby materializes snapshot memory. stored.FirecrackerUseUFFDOnNextRestore = false meta = &metadata{StoredMetadata: *stored} if err := m.saveMetadata(meta); err != nil { diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 5655e81f..749c134e 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -318,9 +318,7 @@ func (m *manager) restoreSnapshot(ctx context.Context, id string, snapshotID str } restored.SocketPath = m.paths.InstanceSocket(id, starter.SocketName()) restored.VsockSocket = m.paths.InstanceSocket(id, hypervisor.VsockSocketNameForType(targetHypervisor)) - restored.FirecrackerUseUFFDOnNextRestore = false - restored.FirecrackerUFFDSessionID = "" - restored.FirecrackerUFFDPagerVersion = "" + clearFirecrackerUFFDRestoreState(&restored) if targetState == StateStopped { restored.FirecrackerSnapshotCacheKey = "" } diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 218831ed..ff857ec1 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -221,8 +221,7 @@ func (m *manager) standbyInstance( stored.StoppedAt = &now stored.HypervisorPID = nil stored.PendingStandbyCompression = nil - stored.FirecrackerUseUFFDOnNextRestore = false - stored.FirecrackerDeferredSnapshotMemoryPath = "" + clearFirecrackerUFFDRestoreState(stored) if err := m.refreshFirecrackerSnapshotCacheKey(stored, snapshotDir); err != nil { log.WarnContext(ctx, "failed to refresh firecracker snapshot cache key", "instance_id", id, "error", err) } diff --git a/lib/instances/stop.go b/lib/instances/stop.go index 7a234600..fb60c68c 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -307,8 +307,7 @@ func (m *manager) stopInstance( stored.ProgramStartedAt = nil stored.GuestAgentReadyAt = nil stored.FirecrackerSnapshotCacheKey = "" - stored.FirecrackerUseUFFDOnNextRestore = false - stored.FirecrackerDeferredSnapshotMemoryPath = "" + clearFirecrackerUFFDRestoreState(stored) stored.Phases.Record(phasetracking.PhaseStopped, now) meta = &metadata{StoredMetadata: *stored}