From 3d7831ffca89c7f897ba9dd0e9979403f8bcf646 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:48:12 +0000 Subject: [PATCH 01/13] Release stale vGPU assignments on start and retain assignments on failed release Start now releases any stored assignment before acquiring a new one and fails the start if that release fails. Stop and delete retain assignment metadata when release fails instead of clearing it, so a failed release can be retried later instead of leaking the device. --- lib/instances/delete.go | 4 ++-- lib/instances/lifecycle_noop_test.go | 19 +++++++++++++++++++ lib/instances/start.go | 5 +++++ lib/instances/stop.go | 6 ++---- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/instances/delete.go b/lib/instances/delete.go index c5e2151f..d89f981e 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -172,8 +172,8 @@ func (m *manager) deleteInstanceWithOptions( // 7c. Release the vGPU assignment if present. if err := releaseStoredVGPU(ctx, stored); err != nil { - // Log error but continue with cleanup - log.WarnContext(ctx, "failed to destroy vGPU, continuing with cleanup", "instance_id", id, "error", err) + log.ErrorContext(ctx, "failed to destroy vGPU; retaining instance metadata", "instance_id", id, "error", err) + return fmt.Errorf("destroy vGPU: %w", err) } // 8. Delete all instance data diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 5ca7515f..08205a54 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/kernel/hypeman/lib/devices" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/assert" @@ -147,6 +148,24 @@ func TestLifecycleNoopStandbyWithOptionsStillRejectsStandbyInstance(t *testing.T assertNoLifecycleEvent(t, events) } +func TestDeleteRetainsMetadataWhenVGPUReleaseFails(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + require.NoError(t, m.saveMetadata(meta)) + + err = m.DeleteInstance(context.Background(), id) + require.Error(t, err) + + stored, err := m.loadMetadata(id) + require.NoError(t, err) + assert.Equal(t, devices.VGPUFramework("future-framework"), stored.GPUFramework) + assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath) +} + func newLifecycleNoopManagerWithInstance(t *testing.T, state State, now time.Time) (*manager, string) { t.Helper() diff --git a/lib/instances/start.go b/lib/instances/start.go index 36cc311f..3387af92 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -48,6 +48,11 @@ func (m *manager) startInstance( log.ErrorContext(ctx, "invalid state for start", "instance_id", id, "state", inst.State) return nil, fmt.Errorf("%w: cannot start from state %s, must be Stopped", ErrInvalidState, inst.State) } + if err := releaseStoredVGPU(ctx, stored); err != nil { + log.ErrorContext(ctx, "failed to release stale vGPU before start", "instance_id", id, "error", err) + return nil, fmt.Errorf("release stale vGPU before start: %w", err) + } + // 2a. Clear stale exit info from previous run and apply command overrides stored.ExitCode = nil stored.ExitMessage = "" diff --git a/lib/instances/stop.go b/lib/instances/stop.go index a6691126..de7d6cff 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -262,11 +262,9 @@ func (m *manager) stopInstance( } } - // 7. Release the vGPU assignment if present (frees the vGPU slot for other VMs). + // 7. Release the vGPU assignment if present. if err := releaseStoredVGPU(ctx, stored); err != nil { - // Log error but continue - vGPU cleanup is best-effort - log.WarnContext(ctx, "failed to destroy vGPU on stop", "instance_id", id, "error", err) - clearStoredVGPUDevice(stored) + log.WarnContext(ctx, "failed to destroy vGPU on stop; retaining assignment metadata", "instance_id", id, "error", err) } // 8. Always remove stale runtime sockets after process exit. From e562eed47638915c8b79799f91def7c810fa71e6 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:35:31 +0000 Subject: [PATCH 02/13] Release the vGPU before other teardown on delete and let stopped instances retry a failed release --- lib/instances/delete.go | 15 +++--- lib/instances/lifecycle_noop_test.go | 79 ++++++++++++++++++++++++++++ lib/instances/manager.go | 6 +++ lib/instances/vgpu.go | 21 ++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/lib/instances/delete.go b/lib/instances/delete.go index d89f981e..d80fba7e 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -125,6 +125,15 @@ func (m *manager) deleteInstanceWithOptions( } m.closeFirecrackerUFFDSession(ctx, stored) + // 5b. Release the vGPU assignment if present, before any network, device, + // or volume teardown. A failed release retains the instance metadata, and + // nothing destructive has happened to its attachments yet, so a retried + // delete is safe. + if err := releaseStoredVGPU(ctx, stored); err != nil { + log.ErrorContext(ctx, "failed to destroy vGPU; retaining instance metadata", "instance_id", id, "error", err) + return fmt.Errorf("destroy vGPU: %w", err) + } + // 6. Release network allocation if inst.NetworkEnabled { m.unregisterEgressProxyInstance(ctx, id) @@ -170,12 +179,6 @@ func (m *manager) deleteInstanceWithOptions( } } - // 7c. Release the vGPU assignment if present. - if err := releaseStoredVGPU(ctx, stored); err != nil { - log.ErrorContext(ctx, "failed to destroy vGPU; retaining instance metadata", "instance_id", id, "error", err) - return fmt.Errorf("destroy vGPU: %w", err) - } - // 8. Delete all instance data log.DebugContext(ctx, "deleting instance data", "instance_id", id) _, dataSpanEnd := m.startLifecycleStep(ctx, "delete_instance_data", diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 08205a54..289f3c66 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -166,6 +166,85 @@ func TestDeleteRetainsMetadataWhenVGPUReleaseFails(t *testing.T) { assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath) } +func TestDeleteReleasesVGPUBeforeTeardown(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + deviceManager := &recordingDeviceManager{} + m.deviceManager = deviceManager + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + meta.Devices = []string{"dev-1"} + require.NoError(t, m.saveMetadata(meta)) + + err = m.DeleteInstance(context.Background(), id) + require.Error(t, err) + assert.ErrorContains(t, err, "destroy vGPU") + assert.Empty(t, deviceManager.detached) + assert.Empty(t, deviceManager.unbound) + + stored, err := m.loadMetadata(id) + require.NoError(t, err) + assert.Equal(t, devices.VGPUFramework("future-framework"), stored.GPUFramework) +} + +func TestStopStoppedInstanceReleasesRetainedVGPU(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFrameworkNone + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + require.NoError(t, m.saveMetadata(meta)) + + inst, err := m.StopInstance(context.Background(), id) + require.NoError(t, err) + require.NotNil(t, inst) + assert.Equal(t, StateStopped, inst.State) + + stored, err := m.loadMetadata(id) + require.NoError(t, err) + assert.Empty(t, stored.GPUDevicePath) +} + +func TestStopStoppedInstanceVGPUReleaseFailureReturnsError(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + require.NoError(t, m.saveMetadata(meta)) + + _, err = m.StopInstance(context.Background(), id) + require.Error(t, err) + assert.ErrorContains(t, err, "destroy vGPU") + + stored, err := m.loadMetadata(id) + require.NoError(t, err) + assert.Equal(t, devices.VGPUFramework("future-framework"), stored.GPUFramework) + assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath) +} + +// recordingDeviceManager is a devices.Manager stub that records passthrough +// teardown calls. Only the methods delete exercises are implemented. +type recordingDeviceManager struct { + devices.Manager + detached []string + unbound []string +} + +func (m *recordingDeviceManager) MarkDetached(ctx context.Context, deviceID string) error { + m.detached = append(m.detached, deviceID) + return nil +} + +func (m *recordingDeviceManager) UnbindFromVFIO(ctx context.Context, id string) error { + m.unbound = append(m.unbound, id) + return nil +} + func newLifecycleNoopManagerWithInstance(t *testing.T, state State, now time.Time) (*manager, string) { t.Helper() diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 8e8e25f3..c76c858c 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -621,6 +621,12 @@ func (m *manager) StopInstance(ctx context.Context, id string) (*Instance, error if err := m.markRestartManualStopLocked(ctx, id); err != nil { return nil, err } + // A stopped instance can retain a vGPU assignment when the release + // failed during the original stop. Retry it here so the vGPU slot is + // not held until the next start, delete, or hypeman restart. + if err := m.releaseRetainedVGPULocked(ctx, id); err != nil { + return nil, err + } updated, err := m.currentInstanceWithoutHydration(ctx, id) if err != nil { return nil, err diff --git a/lib/instances/vgpu.go b/lib/instances/vgpu.go index c2294ac5..0c3c4130 100644 --- a/lib/instances/vgpu.go +++ b/lib/instances/vgpu.go @@ -2,9 +2,11 @@ package instances import ( "context" + "fmt" "path/filepath" "github.com/kernel/hypeman/lib/devices" + "github.com/kernel/hypeman/lib/logger" ) func setStoredVGPUDevice(stored *StoredMetadata, device *devices.VGPUDevice) { @@ -30,6 +32,25 @@ func releaseStoredVGPU(ctx context.Context, stored *StoredMetadata) error { return nil } +// releaseRetainedVGPULocked releases a vGPU assignment retained on a stopped +// instance after a failed release during the original stop. It is a no-op +// when no assignment is retained. The caller must hold the instance lock. +func (m *manager) releaseRetainedVGPULocked(ctx context.Context, id string) error { + meta, err := m.loadMetadata(id) + if err != nil { + return err + } + stored := &meta.StoredMetadata + if storedVGPUDevicePath(stored) == "" { + return nil + } + if err := releaseStoredVGPU(ctx, stored); err != nil { + logger.FromContext(ctx).ErrorContext(ctx, "failed to destroy retained vGPU; retaining assignment metadata", "instance_id", id, "error", err) + return fmt.Errorf("destroy vGPU: %w", err) + } + return m.saveMetadata(meta) +} + func storedVGPUDevicePath(stored *StoredMetadata) string { if stored.GPUDevicePath != "" { return stored.GPUDevicePath From 54d3f56e7c5b5ee285f1178a8142b6d268cc1372 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:54:39 +0000 Subject: [PATCH 03/13] Keep stop's no-op contract on failed retained vGPU release and pass assignments to DestroyVGPU as a struct --- lib/devices/mdev_darwin.go | 6 +++--- lib/devices/types.go | 7 +++++++ lib/devices/vgpu_linux.go | 11 ++++++----- lib/instances/create.go | 7 ++++++- lib/instances/lifecycle_noop_test.go | 9 +++++---- lib/instances/manager.go | 8 ++++---- lib/instances/start.go | 7 ++++++- lib/instances/vgpu.go | 27 ++++++++++++++++++--------- 8 files changed, 55 insertions(+), 27 deletions(-) diff --git a/lib/devices/mdev_darwin.go b/lib/devices/mdev_darwin.go index 8ec67db4..1427a509 100644 --- a/lib/devices/mdev_darwin.go +++ b/lib/devices/mdev_darwin.go @@ -52,9 +52,9 @@ func IsMdevInUse(mdevUUID string) bool { return false } -func DestroyVGPU(ctx context.Context, framework VGPUFramework, devicePath, mdevUUID string) error { - if framework != VGPUFrameworkNone && framework != VGPUFrameworkMdev { - return fmt.Errorf("unknown vGPU framework %q", framework) +func DestroyVGPU(ctx context.Context, assignment VGPUAssignment) error { + if assignment.Framework != VGPUFrameworkNone && assignment.Framework != VGPUFrameworkMdev { + return fmt.Errorf("unknown vGPU framework %q", assignment.Framework) } return nil } diff --git a/lib/devices/types.go b/lib/devices/types.go index fd717d83..809d669f 100644 --- a/lib/devices/types.go +++ b/lib/devices/types.go @@ -81,6 +81,13 @@ type VirtualFunction struct { Allocated bool `json:"allocated"` // true if a vGPU is assigned to this VF } +// VGPUAssignment identifies an existing vGPU assignment to release. +type VGPUAssignment struct { + Framework VGPUFramework + DevicePath string + MdevUUID string +} + type VGPUDevice struct { Framework VGPUFramework VFAddress string diff --git a/lib/devices/vgpu_linux.go b/lib/devices/vgpu_linux.go index 429e9988..eaf210b4 100644 --- a/lib/devices/vgpu_linux.go +++ b/lib/devices/vgpu_linux.go @@ -23,15 +23,16 @@ func CreateVGPU(ctx context.Context, profileName, instanceID string) (*VGPUDevic }, nil } -func DestroyVGPU(ctx context.Context, framework VGPUFramework, devicePath, mdevUUID string) error { - if framework != VGPUFrameworkNone && framework != VGPUFrameworkMdev { - return fmt.Errorf("unknown vGPU framework %q", framework) +func DestroyVGPU(ctx context.Context, assignment VGPUAssignment) error { + if assignment.Framework != VGPUFrameworkNone && assignment.Framework != VGPUFrameworkMdev { + return fmt.Errorf("unknown vGPU framework %q", assignment.Framework) } + mdevUUID := assignment.MdevUUID if mdevUUID == "" { - if devicePath == "" { + if assignment.DevicePath == "" { return nil } - mdevUUID = filepath.Base(devicePath) + mdevUUID = filepath.Base(assignment.DevicePath) } return DestroyMdev(ctx, mdevUUID) } diff --git a/lib/instances/create.go b/lib/instances/create.go index d7ea02c9..aeee91a8 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -297,7 +297,12 @@ func (m *manager) createInstance( // Add vGPU cleanup to stack cu.Add(func() { - if err := devices.DestroyVGPU(ctx, gpuDevice.Framework, gpuDevice.SysfsPath, gpuDevice.MdevUUID); err != nil { + assignment := devices.VGPUAssignment{ + Framework: gpuDevice.Framework, + DevicePath: gpuDevice.SysfsPath, + MdevUUID: gpuDevice.MdevUUID, + } + if err := devices.DestroyVGPU(ctx, assignment); err != nil { log.WarnContext(ctx, "failed to destroy vGPU on cleanup", "instance_id", id, "error", err) } }) diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 289f3c66..ea791bb4 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -208,7 +208,7 @@ func TestStopStoppedInstanceReleasesRetainedVGPU(t *testing.T) { assert.Empty(t, stored.GPUDevicePath) } -func TestStopStoppedInstanceVGPUReleaseFailureReturnsError(t *testing.T) { +func TestStopStoppedInstanceVGPUReleaseFailureRemainsNoop(t *testing.T) { m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) meta, err := m.loadMetadata(id) require.NoError(t, err) @@ -217,9 +217,10 @@ func TestStopStoppedInstanceVGPUReleaseFailureReturnsError(t *testing.T) { meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" require.NoError(t, m.saveMetadata(meta)) - _, err = m.StopInstance(context.Background(), id) - require.Error(t, err) - assert.ErrorContains(t, err, "destroy vGPU") + inst, err := m.StopInstance(context.Background(), id) + require.NoError(t, err) + require.NotNil(t, inst) + assert.Equal(t, StateStopped, inst.State) stored, err := m.loadMetadata(id) require.NoError(t, err) diff --git a/lib/instances/manager.go b/lib/instances/manager.go index c76c858c..85f75975 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -623,10 +623,10 @@ func (m *manager) StopInstance(ctx context.Context, id string) (*Instance, error } // A stopped instance can retain a vGPU assignment when the release // failed during the original stop. Retry it here so the vGPU slot is - // not held until the next start, delete, or hypeman restart. - if err := m.releaseRetainedVGPULocked(ctx, id); err != nil { - return nil, err - } + // not held until the next start, delete, or hypeman restart. A failed + // retry only logs, keeping stop's no-op contract for already-stopped + // instances. + m.releaseRetainedVGPULocked(ctx, id) updated, err := m.currentInstanceWithoutHydration(ctx, id) if err != nil { return nil, err diff --git a/lib/instances/start.go b/lib/instances/start.go index 3387af92..d417fdf4 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -160,7 +160,12 @@ func (m *manager) startInstance( setStoredVGPUDevice(stored, device) // Add vGPU cleanup to stack cu.Add(func() { - if err := devices.DestroyVGPU(ctx, device.Framework, device.SysfsPath, device.MdevUUID); err != nil { + assignment := devices.VGPUAssignment{ + Framework: device.Framework, + DevicePath: device.SysfsPath, + MdevUUID: device.MdevUUID, + } + if err := devices.DestroyVGPU(ctx, assignment); err != nil { log.WarnContext(ctx, "failed to destroy vGPU on cleanup", "instance_id", id, "error", err) } }) diff --git a/lib/instances/vgpu.go b/lib/instances/vgpu.go index 0c3c4130..cffe2ac1 100644 --- a/lib/instances/vgpu.go +++ b/lib/instances/vgpu.go @@ -2,7 +2,6 @@ package instances import ( "context" - "fmt" "path/filepath" "github.com/kernel/hypeman/lib/devices" @@ -24,7 +23,12 @@ func clearStoredVGPUDevice(stored *StoredMetadata) { func releaseStoredVGPU(ctx context.Context, stored *StoredMetadata) error { path := storedVGPUDevicePath(stored) if path != "" { - if err := devices.DestroyVGPU(ctx, stored.GPUFramework, path, stored.GPUMdevUUID); err != nil { + assignment := devices.VGPUAssignment{ + Framework: stored.GPUFramework, + DevicePath: path, + MdevUUID: stored.GPUMdevUUID, + } + if err := devices.DestroyVGPU(ctx, assignment); err != nil { return err } } @@ -34,21 +38,26 @@ func releaseStoredVGPU(ctx context.Context, stored *StoredMetadata) error { // releaseRetainedVGPULocked releases a vGPU assignment retained on a stopped // instance after a failed release during the original stop. It is a no-op -// when no assignment is retained. The caller must hold the instance lock. -func (m *manager) releaseRetainedVGPULocked(ctx context.Context, id string) error { +// when no assignment is retained, and a failed retry only logs so the +// metadata stays for the next retry. The caller must hold the instance lock. +func (m *manager) releaseRetainedVGPULocked(ctx context.Context, id string) { + log := logger.FromContext(ctx) meta, err := m.loadMetadata(id) if err != nil { - return err + log.WarnContext(ctx, "failed to load metadata for retained vGPU release", "instance_id", id, "error", err) + return } stored := &meta.StoredMetadata if storedVGPUDevicePath(stored) == "" { - return nil + return } if err := releaseStoredVGPU(ctx, stored); err != nil { - logger.FromContext(ctx).ErrorContext(ctx, "failed to destroy retained vGPU; retaining assignment metadata", "instance_id", id, "error", err) - return fmt.Errorf("destroy vGPU: %w", err) + log.WarnContext(ctx, "failed to destroy retained vGPU; retaining assignment metadata", "instance_id", id, "error", err) + return + } + if err := m.saveMetadata(meta); err != nil { + log.WarnContext(ctx, "failed to save metadata after retained vGPU release", "instance_id", id, "error", err) } - return m.saveMetadata(meta) } func storedVGPUDevicePath(stored *StoredMetadata) string { From ec6a4d9467de2bd4b7553a20752ad141a01c01e1 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:17:33 +0000 Subject: [PATCH 04/13] Keep retained vGPU assignments out of instance forks Fork cloned the source's StoredMetadata wholesale, so an assignment retained by a failed release during stop was shared with the fork and either instance's later release could invalidate the other's. Clear the assignment fields on the fork while keeping GPUProfile; the fork acquires its own vGPU on start. --- lib/instances/fork.go | 5 +++++ lib/instances/fork_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/lib/instances/fork.go b/lib/instances/fork.go index ea6d3a4c..7354b3ed 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -298,6 +298,11 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin // phase (Standby for snapshot forks, Stopped for stopped forks) will be // recorded by the appropriate operation when the fork is acted on. forkMeta.Phases.Reset() + // A vGPU assignment is never shared with a fork: normally stop already + // released it, and an assignment retained by a failed release must stay + // with the source so only one instance retries it. The fork acquires its + // own vGPU on start from GPUProfile. + clearStoredVGPUDevice(&forkMeta) switch source.State { case StateStandby: forkMeta.Phases.Record(phasetracking.PhaseStandby, now) diff --git a/lib/instances/fork_test.go b/lib/instances/fork_test.go index 2dc63266..e88bff9f 100644 --- a/lib/instances/fork_test.go +++ b/lib/instances/fork_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/kernel/hypeman/lib/autostandby" + "github.com/kernel/hypeman/lib/devices" "github.com/kernel/hypeman/lib/guest" "github.com/kernel/hypeman/lib/healthcheck" "github.com/kernel/hypeman/lib/hypervisor" @@ -29,6 +30,39 @@ import ( "github.com/stretchr/testify/require" ) +func TestForkInstanceClearsVGPUAssignment(t *testing.T) { + manager, _ := setupTestManager(t) + ctx := context.Background() + hvType := hypervisor.Type("fork-vgpu-test") + hypervisor.RegisterCapabilities(hvType, hypervisor.Capabilities{SupportsConcurrentForkPrepare: true}) + manager.vmStarters[hvType] = concurrentForkPrepareTestStarter{} + + sourceID := "fork-vgpu-source" + createStoppedSnapshotSourceFixture(t, manager, sourceID, sourceID, hvType) + + // A retained assignment (release failed during stop) must stay with the + // source; the fork keeps only the profile and acquires its own vGPU on + // start. + meta, err := manager.loadMetadata(sourceID) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + meta.GPUMdevUUID = "retained-uuid" + require.NoError(t, manager.saveMetadata(meta)) + + forked, err := manager.ForkInstance(ctx, sourceID, ForkInstanceRequest{Name: "fork-vgpu-copy"}) + require.NoError(t, err) + assert.Equal(t, "NVIDIA L40S-2Q", forked.GPUProfile) + assert.Equal(t, devices.VGPUFrameworkNone, forked.GPUFramework) + assert.Empty(t, forked.GPUDevicePath) + assert.Empty(t, forked.GPUMdevUUID) + + source, err := manager.loadMetadata(sourceID) + require.NoError(t, err) + assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", source.GPUDevicePath) +} + func TestForkInstance_VZStoppedSourceSupported(t *testing.T) { t.Parallel() manager, _ := setupTestManager(t) From d91d8ab7d38e1c80453f455488b9e838b663d714 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:37:57 +0000 Subject: [PATCH 05/13] Persist a stale vGPU release during start immediately Start released a retained assignment but only saved metadata on the success path, so a failure later in start left on-disk metadata pointing at a device that was already released. Save right after the release, matching the retained-release retry on stop. --- lib/instances/lifecycle_noop_test.go | 22 ++++++++++++++++++++++ lib/instances/start.go | 16 +++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index ea791bb4..300f9fb7 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -189,6 +189,28 @@ func TestDeleteReleasesVGPUBeforeTeardown(t *testing.T) { assert.Equal(t, devices.VGPUFramework("future-framework"), stored.GPUFramework) } +// A stale release during start must be persisted immediately: if start fails +// later (here at vGPU recreation on a host without VFs), the on-disk metadata +// must no longer point at the already-released device. +func TestStartPersistsStaleVGPUReleaseImmediately(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + m.imageManager = readyFixtureImageManager{name: "test-image"} + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFrameworkNone + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + require.NoError(t, m.saveMetadata(meta)) + + _, err = m.StartInstance(context.Background(), id, StartInstanceRequest{}) + require.Error(t, err) + + stored, err := m.loadMetadata(id) + require.NoError(t, err) + assert.Empty(t, stored.GPUDevicePath, "released assignment should be persisted despite the failed start") + assert.Equal(t, "NVIDIA L40S-2Q", stored.GPUProfile, "profile is kept for the next start") +} + func TestStopStoppedInstanceReleasesRetainedVGPU(t *testing.T) { m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) meta, err := m.loadMetadata(id) diff --git a/lib/instances/start.go b/lib/instances/start.go index d417fdf4..b2911037 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -48,9 +48,19 @@ func (m *manager) startInstance( log.ErrorContext(ctx, "invalid state for start", "instance_id", id, "state", inst.State) return nil, fmt.Errorf("%w: cannot start from state %s, must be Stopped", ErrInvalidState, inst.State) } - if err := releaseStoredVGPU(ctx, stored); err != nil { - log.ErrorContext(ctx, "failed to release stale vGPU before start", "instance_id", id, "error", err) - return nil, fmt.Errorf("release stale vGPU before start: %w", err) + // Release any assignment retained by an earlier failed release and + // persist the cleared fields immediately, so a failure later in start + // cannot leave on-disk metadata pointing at a device that is already + // gone (matching releaseRetainedVGPULocked). + if storedVGPUDevicePath(stored) != "" { + if err := releaseStoredVGPU(ctx, stored); err != nil { + log.ErrorContext(ctx, "failed to release stale vGPU before start", "instance_id", id, "error", err) + return nil, fmt.Errorf("release stale vGPU before start: %w", err) + } + if err := m.saveMetadata(meta); err != nil { + log.ErrorContext(ctx, "failed to save metadata after stale vGPU release", "instance_id", id, "error", err) + return nil, fmt.Errorf("save metadata after stale vGPU release: %w", err) + } } // 2a. Clear stale exit info from previous run and apply command overrides From 30a1229c65ba451e093a22ef787134656f411644 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:21:23 +0000 Subject: [PATCH 06/13] Clear vGPU assignments from snapshot forks --- lib/instances/snapshot.go | 1 + lib/instances/snapshot_test.go | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 97d2ca88..376445ab 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -449,6 +449,7 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS forkMeta.ExitCode = nil forkMeta.ExitMessage = "" forkMeta.RestartStatus = restartpolicy.Status{} + clearStoredVGPUDevice(&forkMeta) forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(targetHypervisor, rec.Snapshot.Kind == SnapshotKindStandby, targetState) diff --git a/lib/instances/snapshot_test.go b/lib/instances/snapshot_test.go index 0fdd5666..f1e5351a 100644 --- a/lib/instances/snapshot_test.go +++ b/lib/instances/snapshot_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/kernel/hypeman/lib/devices" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/images" snapshotstore "github.com/kernel/hypeman/lib/snapshot" @@ -15,6 +16,42 @@ import ( "github.com/stretchr/testify/require" ) +func TestForkSnapshotClearsVGPUAssignment(t *testing.T) { + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "snapshot-vgpu-source" + createStoppedSnapshotSourceFixture(t, mgr, sourceID, sourceID, mgr.defaultHypervisor) + + meta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + meta.GPUMdevUUID = "retained-uuid" + require.NoError(t, mgr.saveMetadata(meta)) + + snapshot, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStopped, + Name: "snapshot-vgpu", + }) + require.NoError(t, err) + + forked, err := mgr.ForkSnapshot(ctx, snapshot.Id, ForkSnapshotRequest{ + Name: "snapshot-vgpu-fork", + TargetState: StateStopped, + }) + require.NoError(t, err) + assert.Equal(t, "NVIDIA L40S-2Q", forked.GPUProfile) + assert.Equal(t, devices.VGPUFrameworkNone, forked.GPUFramework) + assert.Empty(t, forked.GPUDevicePath) + assert.Empty(t, forked.GPUMdevUUID) + + source, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", source.GPUDevicePath) +} + func TestStoppedSnapshotLifecycleAndForkAfterSourceDeletion(t *testing.T) { t.Parallel() mgr, _ := setupTestManager(t) From ae162dbc5cbcf59cf4af1a69f2cf7b940dee4fa1 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:02:16 +0000 Subject: [PATCH 07/13] Preserve current vGPU assignment across snapshot restore Restoring a snapshot rehydrated the vGPU assignment fields embedded in the snapshot metadata. A snapshot taken while an assignment was retained after a failed release could resurrect that claim after the release later succeeded, pointing the instance at a device that is gone or reused. Keep the instance's current assignment instead: device assignments are host state, not snapshot payload, and a claim retained at restore time must survive for the next release retry. --- lib/instances/snapshot.go | 6 +++ lib/instances/snapshot_test.go | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 376445ab..0276e821 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -302,6 +302,12 @@ func (m *manager) restoreSnapshot(ctx context.Context, id string, snapshotID str restored.StoppedAt = nil restored.ExitCode = nil restored.ExitMessage = "" + // vGPU assignments are live host state, not snapshot payload: keep the + // instance's current assignment (possibly retained from a failed release) + // instead of resurrecting the one embedded in the snapshot. + restored.GPUFramework = sourceMeta.GPUFramework + restored.GPUDevicePath = sourceMeta.GPUDevicePath + restored.GPUMdevUUID = sourceMeta.GPUMdevUUID restored.HypervisorType = targetHypervisor starter, err := m.getVMStarter(targetHypervisor) diff --git a/lib/instances/snapshot_test.go b/lib/instances/snapshot_test.go index f1e5351a..11420202 100644 --- a/lib/instances/snapshot_test.go +++ b/lib/instances/snapshot_test.go @@ -52,6 +52,82 @@ func TestForkSnapshotClearsVGPUAssignment(t *testing.T) { assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", source.GPUDevicePath) } +func TestRestoreSnapshotDoesNotResurrectStaleVGPUAssignment(t *testing.T) { + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "snapshot-vgpu-restore-stale" + createStoppedSnapshotSourceFixture(t, mgr, sourceID, sourceID, mgr.defaultHypervisor) + + meta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + meta.GPUMdevUUID = "retained-uuid" + require.NoError(t, mgr.saveMetadata(meta)) + + snapshot, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStopped, + Name: "snapshot-vgpu-restore-stale", + }) + require.NoError(t, err) + + // The retained assignment is released successfully after the snapshot + // was taken; a restore must not resurrect the snapshot's embedded copy. + meta, err = mgr.loadMetadata(sourceID) + require.NoError(t, err) + clearStoredVGPUDevice(&meta.StoredMetadata) + require.NoError(t, mgr.saveMetadata(meta)) + + _, err = mgr.RestoreSnapshot(ctx, sourceID, snapshot.Id, RestoreSnapshotRequest{ + TargetState: StateStopped, + TargetHypervisor: mgr.defaultHypervisor, + }) + require.NoError(t, err) + + restored, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + assert.Equal(t, devices.VGPUFrameworkNone, restored.GPUFramework) + assert.Empty(t, restored.GPUDevicePath) + assert.Empty(t, restored.GPUMdevUUID) +} + +func TestRestoreSnapshotKeepsCurrentVGPUAssignment(t *testing.T) { + mgr, _ := setupTestManager(t) + ctx := context.Background() + + sourceID := "snapshot-vgpu-restore-retained" + createStoppedSnapshotSourceFixture(t, mgr, sourceID, sourceID, mgr.defaultHypervisor) + + snapshot, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStopped, + Name: "snapshot-vgpu-restore-retained", + }) + require.NoError(t, err) + + // An assignment retained after the snapshot was taken (e.g. from a + // failed release on stop) must survive the restore for the next retry. + meta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + meta.GPUMdevUUID = "retained-uuid" + require.NoError(t, mgr.saveMetadata(meta)) + + _, err = mgr.RestoreSnapshot(ctx, sourceID, snapshot.Id, RestoreSnapshotRequest{ + TargetState: StateStopped, + TargetHypervisor: mgr.defaultHypervisor, + }) + require.NoError(t, err) + + restored, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + assert.Equal(t, devices.VGPUFramework("future-framework"), restored.GPUFramework) + assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", restored.GPUDevicePath) + assert.Equal(t, "retained-uuid", restored.GPUMdevUUID) +} + func TestStoppedSnapshotLifecycleAndForkAfterSourceDeletion(t *testing.T) { t.Parallel() mgr, _ := setupTestManager(t) From 264a2bb3d32306f7ac4c9051876fdc233155ed7a Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:36:07 +0000 Subject: [PATCH 08/13] Document vGPU rollback alongside the retention behavior --- lib/devices/GPU.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/devices/GPU.md b/lib/devices/GPU.md index 54a19c47..1b605110 100644 --- a/lib/devices/GPU.md +++ b/lib/devices/GPU.md @@ -235,6 +235,20 @@ To upgrade the NVIDIA driver version: - Run GPU passthrough E2E tests - Verify with real CUDA workloads (e.g., ollama inference) +## Rolling Back vGPU Changes + +Before downgrading Hypeman or the host to a version that does not support the active vGPU framework: + +1. Stop or delete all vGPU instances while the current Hypeman version can release their assignments. +2. Confirm `/resources` reports `used_slots: 0`. +3. Confirm no mdev assignments remain: + ```bash + test -z "$(find /sys/bus/mdev/devices -mindepth 1 -maxdepth 1 2>/dev/null)" + ``` +4. Downgrade only after both checks are clean. + +If assignment cleanup fails, Hypeman retains the instance metadata so a compatible version can retry it. Do not remove that metadata manually while the assignment remains active. + ## Troubleshooting ### No GPU shown in /resources From 811ebca2b8117f8140e859ef7ae439852a53aeec Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:43:48 +0000 Subject: [PATCH 09/13] Block restart policy before delete teardown --- lib/instances/delete.go | 14 +++++++++++--- lib/instances/lifecycle_noop_test.go | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/instances/delete.go b/lib/instances/delete.go index d80fba7e..5d8ede27 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -85,6 +85,14 @@ func (m *manager) deleteInstanceWithOptions( guest.CloseConn(dialer.Key()) } + // 3b. Block the restart policy before any teardown. If the delete fails + // partway (e.g. a failed vGPU release) the metadata is retained with the + // VMM already stopped, and without this marker the restart policy + // controller would start the instance again. + if err := m.markRestartManualStopLocked(ctx, id); err != nil { + return fmt.Errorf("block restart policy before delete: %w", err) + } + // 4. If active, try graceful guest shutdown before force kill. gracefulShutdown := false if !options.skipGracefulShutdown && (inst.State == StateRunning || inst.State == StateInitializing) { @@ -126,9 +134,9 @@ func (m *manager) deleteInstanceWithOptions( m.closeFirecrackerUFFDSession(ctx, stored) // 5b. Release the vGPU assignment if present, before any network, device, - // or volume teardown. A failed release retains the instance metadata, and - // nothing destructive has happened to its attachments yet, so a retried - // delete is safe. + // or volume teardown. A failed release retains the instance metadata; the + // VMM has already been stopped, but its attachments are intact and the + // restart policy is blocked, so a retried delete is safe. if err := releaseStoredVGPU(ctx, stored); err != nil { log.ErrorContext(ctx, "failed to destroy vGPU; retaining instance metadata", "instance_id", id, "error", err) return fmt.Errorf("destroy vGPU: %w", err) diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 300f9fb7..3e85e0cc 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -12,6 +12,7 @@ import ( "github.com/kernel/hypeman/lib/devices" "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/paths" + restartpolicy "github.com/kernel/hypeman/lib/restart-policy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -166,6 +167,25 @@ func TestDeleteRetainsMetadataWhenVGPUReleaseFails(t *testing.T) { assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath) } +func TestDeleteBlocksRestartPolicyWhenVGPUReleaseFails(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.RestartPolicy = &restartpolicy.Policy{Policy: restartpolicy.PolicyAlways} + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUFramework = devices.VGPUFramework("future-framework") + meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" + require.NoError(t, m.saveMetadata(meta)) + + err = m.DeleteInstance(context.Background(), id) + require.Error(t, err) + + stored, err := m.loadMetadata(id) + require.NoError(t, err) + assert.Equal(t, restartpolicy.BlockedReasonManualStop, stored.RestartStatus.BlockedReason, + "a failed delete must not leave the instance restartable") +} + func TestDeleteReleasesVGPUBeforeTeardown(t *testing.T) { m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) deviceManager := &recordingDeviceManager{} From d63652a7fbde143496e1befc87a260fa85b3dd88 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:23:31 +0000 Subject: [PATCH 10/13] Restore vGPU validation import --- lib/instances/vgpu.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/instances/vgpu.go b/lib/instances/vgpu.go index cffe2ac1..b9dbe61a 100644 --- a/lib/instances/vgpu.go +++ b/lib/instances/vgpu.go @@ -2,6 +2,7 @@ package instances import ( "context" + "fmt" "path/filepath" "github.com/kernel/hypeman/lib/devices" From ba2044e738b51642530bc20428a3092b922ac720 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:34:12 +0000 Subject: [PATCH 11/13] Run vGPU lifecycle test with QEMU --- lib/instances/lifecycle_noop_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 3e85e0cc..86128ef1 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -218,6 +218,7 @@ func TestStartPersistsStaleVGPUReleaseImmediately(t *testing.T) { meta, err := m.loadMetadata(id) require.NoError(t, err) meta.GPUProfile = "NVIDIA L40S-2Q" + meta.HypervisorType = hypervisor.TypeQEMU meta.GPUFramework = devices.VGPUFrameworkNone meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4" require.NoError(t, m.saveMetadata(meta)) From a4e00be96da71627e1a8e0fa7a1ba39dfdcc2927 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:03:47 +0000 Subject: [PATCH 12/13] Persist vGPU release before delete teardown --- lib/instances/delete.go | 7 ++++++ lib/instances/lifecycle_noop_test.go | 34 ++++++++++++++++++++++++++-- lib/instances/vgpu.go | 1 - 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/instances/delete.go b/lib/instances/delete.go index 5d8ede27..50ab87a4 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -137,10 +137,17 @@ func (m *manager) deleteInstanceWithOptions( // or volume teardown. A failed release retains the instance metadata; the // VMM has already been stopped, but its attachments are intact and the // restart policy is blocked, so a retried delete is safe. + hadVGPUAssignment := storedVGPUDevicePath(stored) != "" if err := releaseStoredVGPU(ctx, stored); err != nil { log.ErrorContext(ctx, "failed to destroy vGPU; retaining instance metadata", "instance_id", id, "error", err) return fmt.Errorf("destroy vGPU: %w", err) } + if hadVGPUAssignment { + if err := m.saveMetadata(meta); err != nil { + log.ErrorContext(ctx, "failed to save metadata after vGPU release", "instance_id", id, "error", err) + return fmt.Errorf("save metadata after vGPU release: %w", err) + } + } // 6. Release network allocation if inst.NetworkEnabled { diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 86128ef1..e6f1c7e5 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -186,6 +186,32 @@ func TestDeleteBlocksRestartPolicyWhenVGPUReleaseFails(t *testing.T) { "a failed delete must not leave the instance restartable") } +func TestDeletePersistsVGPUReleaseBeforeTeardown(t *testing.T) { + m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) + var persisted *metadata + deviceManager := &recordingDeviceManager{ + onMarkDetached: func() { + var err error + persisted, err = m.loadMetadata(id) + require.NoError(t, err) + }, + } + m.deviceManager = deviceManager + meta, err := m.loadMetadata(id) + require.NoError(t, err) + meta.GPUProfile = "NVIDIA L40S-2Q" + meta.GPUDevicePath = "/sys/bus/mdev/devices/test-mdev" + meta.GPUMdevUUID = "test-mdev" + meta.Devices = []string{"dev-1"} + require.NoError(t, m.saveMetadata(meta)) + + require.NoError(t, m.DeleteInstance(context.Background(), id)) + require.NotNil(t, persisted) + assert.Empty(t, persisted.GPUDevicePath) + assert.Empty(t, persisted.GPUMdevUUID) + assert.Equal(t, "NVIDIA L40S-2Q", persisted.GPUProfile) +} + func TestDeleteReleasesVGPUBeforeTeardown(t *testing.T) { m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC()) deviceManager := &recordingDeviceManager{} @@ -275,12 +301,16 @@ func TestStopStoppedInstanceVGPUReleaseFailureRemainsNoop(t *testing.T) { // teardown calls. Only the methods delete exercises are implemented. type recordingDeviceManager struct { devices.Manager - detached []string - unbound []string + detached []string + unbound []string + onMarkDetached func() } func (m *recordingDeviceManager) MarkDetached(ctx context.Context, deviceID string) error { m.detached = append(m.detached, deviceID) + if m.onMarkDetached != nil { + m.onMarkDetached() + } return nil } diff --git a/lib/instances/vgpu.go b/lib/instances/vgpu.go index b9dbe61a..cffe2ac1 100644 --- a/lib/instances/vgpu.go +++ b/lib/instances/vgpu.go @@ -2,7 +2,6 @@ package instances import ( "context" - "fmt" "path/filepath" "github.com/kernel/hypeman/lib/devices" From 3fa4d40c1ad96e777f736673c79dccbc7b6eea7a Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:21:40 +0000 Subject: [PATCH 13/13] Preserve restart block during vGPU delete --- lib/instances/delete.go | 7 +++++++ lib/instances/lifecycle_noop_test.go | 2 ++ 2 files changed, 9 insertions(+) diff --git a/lib/instances/delete.go b/lib/instances/delete.go index 50ab87a4..adb0dea1 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -92,6 +92,13 @@ func (m *manager) deleteInstanceWithOptions( if err := m.markRestartManualStopLocked(ctx, id); err != nil { return fmt.Errorf("block restart policy before delete: %w", err) } + // markRestartManualStopLocked persists through a separate metadata load. + // Reload it so later saves in this delete do not overwrite the block. + meta, err = m.loadMetadata(id) + if err != nil { + return fmt.Errorf("reload metadata after blocking restart policy: %w", err) + } + stored = &meta.StoredMetadata // 4. If active, try graceful guest shutdown before force kill. gracefulShutdown := false diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index e6f1c7e5..f65694a1 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -199,6 +199,7 @@ func TestDeletePersistsVGPUReleaseBeforeTeardown(t *testing.T) { m.deviceManager = deviceManager meta, err := m.loadMetadata(id) require.NoError(t, err) + meta.RestartPolicy = &restartpolicy.Policy{Policy: restartpolicy.PolicyAlways} meta.GPUProfile = "NVIDIA L40S-2Q" meta.GPUDevicePath = "/sys/bus/mdev/devices/test-mdev" meta.GPUMdevUUID = "test-mdev" @@ -210,6 +211,7 @@ func TestDeletePersistsVGPUReleaseBeforeTeardown(t *testing.T) { assert.Empty(t, persisted.GPUDevicePath) assert.Empty(t, persisted.GPUMdevUUID) assert.Equal(t, "NVIDIA L40S-2Q", persisted.GPUProfile) + assert.Equal(t, restartpolicy.BlockedReasonManualStop, persisted.RestartStatus.BlockedReason) } func TestDeleteReleasesVGPUBeforeTeardown(t *testing.T) {