Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
8de01a5
Guard vGPU releases with live-instance claims
yummybomb Aug 6, 2026
756c6bd
Reconcile vendor VFIO vGPUs against a fail-closed instance inventory
yummybomb Aug 6, 2026
47aa8bd
Fail closed on vGPU claim checks
yummybomb Aug 6, 2026
cd055b6
Retain only vGPU assignment after failed create
yummybomb Aug 6, 2026
1ae2681
Clear released vGPU assignment on start rollback
yummybomb Aug 6, 2026
37802ce
Test start rollback vGPU cleanup
yummybomb Aug 6, 2026
2161077
Normalize legacy mdev paths in live-claim check
yummybomb Aug 7, 2026
0a79988
Bind the live-claimant test socket under /tmp for macOS
yummybomb Aug 7, 2026
efb0896
Surface retained vGPU cleanup through a typed create error and manage…
yummybomb Aug 7, 2026
df9a94e
Generalize the create vGPU error text
yummybomb Aug 7, 2026
cc37093
Scope vGPU claim scan to vendor VFIO and close reconcile gaps
yummybomb Aug 8, 2026
0aefc37
Harden the vendor VFIO release path
yummybomb Aug 9, 2026
dcdd6d8
Fail closed on retained vGPU cleanup
yummybomb Aug 9, 2026
1b8ecd6
Report surviving vGPU retention metadata
yummybomb Aug 9, 2026
26b3e7a
Return accurate vGPU cleanup guidance
yummybomb Aug 9, 2026
846640d
Clarify vGPU retention fallback
yummybomb Aug 9, 2026
278724a
Pass hypervisor identity token to vGPU claim check
yummybomb Aug 10, 2026
2a30a0b
Fail safely on ambiguous vGPU claims
yummybomb Aug 10, 2026
a0509d8
Expose retained vGPU instance IDs
yummybomb Aug 10, 2026
32b9284
Harden vGPU startup rollback recovery
yummybomb Aug 10, 2026
5a3e1a3
Preserve the create failure cause in vGPU cleanup errors
yummybomb Aug 10, 2026
e561888
Fix vGPU reconciliation edge cases
yummybomb Aug 10, 2026
aa95048
Use boot-scoped hypervisor identities for vGPUs
yummybomb Aug 10, 2026
9a23f3e
Run vGPU rollback tests with QEMU
yummybomb Aug 10, 2026
2cad3cf
Protect new vGPU assignments from stale PIDs
yummybomb Aug 10, 2026
a9f0929
Persist vGPU assignments after create rollback failure
yummybomb Aug 10, 2026
937ff9e
Preserve vGPU lifecycle compatibility
yummybomb Aug 10, 2026
76950f7
Reconcile vGPU protection from raw metadata and restore GPUAssignedAt
yummybomb Aug 10, 2026
7b20b43
Surface pending vGPU cleanup from start as a typed error
yummybomb Aug 11, 2026
b6cfb61
Cover retained-stub delete recovery and flag reconcile inventory fail…
yummybomb Aug 11, 2026
97a0d15
Reject vendor VFIO vGPUs on Cloud Hypervisor and improve wedge forensics
yummybomb Aug 11, 2026
c226b26
Surface retained rollback assignments from start as vgpu_cleanup_pending
yummybomb Aug 11, 2026
e8730af
Report retention as persisted when the mid-start save survives
yummybomb Aug 11, 2026
8171652
Leave vGPU hypervisor selection to callers
yummybomb Aug 11, 2026
74470eb
Carry identity fields into the create-pending retention stub
yummybomb Aug 11, 2026
f4d2d9b
Grace recent dead-PID claims in the release scan like reconcile does
yummybomb Aug 11, 2026
09730c2
Reject start on vGPU retention records
yummybomb Aug 13, 2026
ae2bd42
Make vGPU retention records fully delete-only
yummybomb Aug 13, 2026
a27cbe4
Adapt vGPU liveness guards to the identity struct resolver
yummybomb Aug 14, 2026
798e4a1
Deep-copy the phase tracker into the start rollback snapshot
yummybomb Aug 17, 2026
a5d5d19
Retry orphaned vGPU releases in the background after delete
yummybomb Aug 18, 2026
b8b25bd
Bound orphan retries in delete tests and tighten comments
yummybomb Aug 18, 2026
dd5332a
SIGTERM vGPU QEMU before SIGKILL during driver init
yummybomb Aug 19, 2026
cda2695
Apply vGPU SIGTERM grace on stop's direct kill paths
yummybomb Aug 19, 2026
2b584a4
Lower vGPU SIGTERM grace to 5s
yummybomb Aug 19, 2026
b92ba06
Apply vGPU SIGTERM grace in every instance state
yummybomb Aug 20, 2026
5465683
Harden the vGPU claim scan and retention-stub stop path
yummybomb Aug 20, 2026
a9700a4
Share one vGPU retention stub in create
yummybomb Aug 21, 2026
623c121
Name the strict metadata listing variant
yummybomb Aug 21, 2026
17cfdb4
Skip instances deleted mid-listing in reconcile protection
yummybomb Aug 21, 2026
5c0292c
Count abandoned orphaned vGPU releases
yummybomb Aug 21, 2026
7b4621c
Define the vGPU retention-stub rejection once
yummybomb Aug 21, 2026
456bb21
Render pending vGPU cleanup responses through one helper
yummybomb Aug 21, 2026
8403c10
Narrow the vGPU reconcile interface
yummybomb Aug 24, 2026
48b7673
Encapsulate vGPU rollback retention
yummybomb Aug 24, 2026
a44d855
Move vGPU reconciliation into instance manager
yummybomb Aug 24, 2026
2bedc05
Derive QEMU VFIO grace from VM config
yummybomb Aug 24, 2026
4652dcd
Tighten vGPU lifecycle comments
yummybomb Aug 24, 2026
a062d9c
Retry vGPU recovery paths that previously waited for restart
yummybomb Aug 24, 2026
4c149ea
Validate live vGPU claim test identity
yummybomb Aug 24, 2026
ee3c296
Clean guest data before retaining vGPU assignment
yummybomb Aug 24, 2026
c9f6e43
Retry failed vGPU reconciliation
yummybomb Aug 25, 2026
9da5001
Trim vGPU lifecycle comments and tests
yummybomb Aug 25, 2026
8e3d5a0
Track vendor VFIO assignment age and owner for periodic sweeps
yummybomb Aug 25, 2026
3545b1f
Replace vGPU release retry machinery with a periodic reconciler
yummybomb Aug 25, 2026
110328c
Update vgpu_cleanup_pending assertions for the periodic reconcile mes…
yummybomb Aug 25, 2026
a35de81
Check socket ownership for vGPU liveness without a persisted PID
yummybomb Aug 25, 2026
be01bd6
Share one vGPU assignment grace period constant
yummybomb Aug 25, 2026
703de47
Preserve delete-only vGPU retention records
yummybomb Aug 26, 2026
8932ba6
Trim redundant vGPU coverage
yummybomb Aug 26, 2026
eddeaf1
Add counters for vGPU cleanup failure paths
yummybomb Aug 26, 2026
409adb7
Simplify vGPU lifecycle cleanup
yummybomb Aug 26, 2026
84ec438
Simplify vGPU retention persistence to a single attempt
yummybomb Aug 27, 2026
0736fee
Report vGPU retention as persisted when the assignment claim survives
yummybomb Aug 27, 2026
727aa4f
Protect claimed mdevs during reconciliation
yummybomb Aug 28, 2026
081d9f7
Unify vGPU retention wrapping and dedupe test fixtures
yummybomb Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,16 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst

inst, err := s.InstanceManager.CreateInstance(ctx, domainReq)
if err != nil {
var vgpuPending *instances.VGPUCleanupPendingError
switch {
case errors.As(err, &vgpuPending):
log.ErrorContext(ctx, "failed to create instance", "error", err, "image", request.Body.Image)
message, inner := vgpuCleanupPendingDetail(vgpuPending, "create", "delete it to retry")
return oapi.CreateInstance500JSONResponse{
Code: "vgpu_cleanup_pending",
Message: message,
InnerError: inner,
}, nil
Comment thread
cursor[bot] marked this conversation as resolved.
case errors.Is(err, instances.ErrImageNotReady):
return oapi.CreateInstance400JSONResponse{
Code: "image_not_ready",
Expand Down Expand Up @@ -424,6 +433,19 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
return oapi.CreateInstance201JSONResponse(instanceToOAPI(*inst)), nil
}

func vgpuCleanupPendingDetail(pending *instances.VGPUCleanupPendingError, action, retainedGuidance string) (string, *oapi.ErrorDetail) {
message := fmt.Sprintf("failed to %s instance: %v", action, pending)
innerCode := "vgpu_unretained_instance"
if pending.Retained {
message += "; " + retainedGuidance
innerCode = "vgpu_retained_instance"
}
return message, &oapi.ErrorDetail{
Code: lo.ToPtr(innerCode),
Message: lo.ToPtr(pending.InstanceID),
}
}

// GetInstance gets instance details
// The id parameter can be an instance ID, name, or ID prefix
// Note: Resolution is handled by ResolveResource middleware
Expand Down Expand Up @@ -829,7 +851,16 @@ func (s *ApiService) StartInstance(ctx context.Context, request oapi.StartInstan

result, err := s.InstanceManager.StartInstance(ctx, inst.Id, startReq)
if err != nil {
var vgpuPending *instances.VGPUCleanupPendingError
switch {
case errors.As(err, &vgpuPending):
log.ErrorContext(ctx, "failed to start instance", "error", err)
message, inner := vgpuCleanupPendingDetail(vgpuPending, "start", "delete it or retry start to release it")
return oapi.StartInstance500JSONResponse{
Code: "vgpu_cleanup_pending",
Message: message,
InnerError: inner,
}, nil
case errors.Is(err, instances.ErrInvalidState):
return oapi.StartInstance409JSONResponse{
Code: "invalid_state",
Expand Down Expand Up @@ -1264,6 +1295,9 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
if inst.GPUMdevUUID != "" {
gpu.MdevUuid = lo.ToPtr(inst.GPUMdevUUID)
}
if inst.GPUDevicePath != "" {
gpu.DevicePath = lo.ToPtr(inst.GPUDevicePath)
}
oapiInst.Gpu = gpu
}

Expand Down
65 changes: 65 additions & 0 deletions cmd/api/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/kernel/hypeman/lib/instances"
"github.com/kernel/hypeman/lib/instances/phasetracking"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/network"
"github.com/kernel/hypeman/lib/oapi"
"github.com/kernel/hypeman/lib/paths"
restartpolicy "github.com/kernel/hypeman/lib/restart-policy"
Expand Down Expand Up @@ -47,6 +48,39 @@ func TestGetInstance_NotFound(t *testing.T) {
require.Error(t, err)
}

func TestVGPUCleanupPendingDetail(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
retained bool
code string
guidance string
exclude string
}{
{name: "retained", retained: true, code: "vgpu_retained_instance", guidance: "delete it to retry"},
{name: "unretained", code: "vgpu_unretained_instance", guidance: "periodic vGPU reconcile", exclude: "delete"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
message, inner := vgpuCleanupPendingDetail(&instances.VGPUCleanupPendingError{
InstanceID: "inst-1",
Retained: tt.retained,
Err: network.ErrNameExists,
}, "create", "delete it to retry")
assert.Contains(t, message, "inst-1")
assert.Contains(t, message, network.ErrNameExists.Error())
assert.Contains(t, message, tt.guidance)
if tt.exclude != "" {
assert.NotContains(t, message, tt.exclude)
}
require.NotNil(t, inner.Code)
assert.Equal(t, tt.code, *inner.Code)
require.NotNil(t, inner.Message)
assert.Equal(t, "inst-1", *inner.Message)
})
}
}

func TestCreateInstance_AutoPullImage(t *testing.T) {
t.Parallel()
if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) {
Expand Down Expand Up @@ -842,6 +876,16 @@ func TestCreateInstance_ErrorStatusMapping(t *testing.T) {
wantCode string
wantMessage string
}{
{
name: "vGPU cleanup pending beats wrapped name conflict -> 500",
err: &instances.VGPUCleanupPendingError{
InstanceID: "inst-1",
Retained: true,
Err: network.ErrNameExists,
},
wantType: oapi.CreateInstance500JSONResponse{},
wantCode: "vgpu_cleanup_pending",
},
{
name: "platform not available -> 404",
err: fmt.Errorf("resolve image: %w", images.ErrPlatformNotAvailable),
Expand Down Expand Up @@ -966,6 +1010,27 @@ func TestRestoreInstance_ErrorMapping(t *testing.T) {
}
}

func TestStartInstance_VGPUCleanupPendingBeatsWrappedErrorMapping(t *testing.T) {
t.Parallel()
svc := newTestService(t)
resolved := &instances.Instance{
StoredMetadata: instances.StoredMetadata{Id: "inst-1", Name: "inst-1"},
State: instances.StateStopped,
}
svc.InstanceManager = &errActionInstanceManager{Manager: svc.InstanceManager, err: &instances.VGPUCleanupPendingError{
InstanceID: resolved.Id,
Retained: true,
Err: fmt.Errorf("create vGPU for profile p: %w", instances.ErrInsufficientResources),
}}

resp, err := svc.StartInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.StartInstanceRequestObject{Id: resolved.Id})
require.NoError(t, err)
pending, ok := resp.(oapi.StartInstance500JSONResponse)
require.True(t, ok, "expected 500 vgpu_cleanup_pending, got %T", resp)
assert.EqualValues(t, "vgpu_cleanup_pending", pending.Code)
assert.Contains(t, pending.Message, "delete it or retry start")
}

func TestInstanceActions_ImageNotFoundMapsTo404(t *testing.T) {
t.Parallel()

Expand Down
8 changes: 3 additions & 5 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,11 +384,9 @@ func run() error {
return fmt.Errorf("reconcile device state: %w", err)
}

// Reconcile mdev devices (clears orphaned vGPUs from previous runs)
logger.Info("Reconciling mdev devices...")
if err := devices.ReconcileMdevs(app.Ctx, nil); err != nil {
// Log but don't fail - mdev cleanup is best-effort
logger.Warn("failed to reconcile mdev devices", "error", err)
logger.Info("Reconciling vGPU devices...")
if r, ok := app.InstanceManager.(interface{ StartVGPUReconciler(context.Context) }); ok {
r.StartVGPUReconciler(ctx)
}

// Wire up resource validator for aggregate limit checking
Expand Down
5 changes: 0 additions & 5 deletions integration/vgpu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,6 @@ func checkVGPUTestPrerequisites() (string, string) {
if framework == devices.VGPUFrameworkNone {
return "vGPU test requires SR-IOV VFs with an mdev or vendor VFIO vGPU framework", ""
}
if framework == devices.VGPUFrameworkVendorVFIO {
// CreateVGPU rejects vendor VFIO until the instance lifecycle
// integration lands.
return "vGPU test requires the vendor VFIO instance lifecycle integration", ""
}

// Check for available profiles
profiles, err := devices.ListGPUProfiles()
Expand Down
17 changes: 12 additions & 5 deletions lib/devices/GPU.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Instance Create → Assign profile to VF → Attach VF to VM → Instance Runnin
Instance Stop/Delete → Release profile → VF available again
```

Hypeman reconciles orphaned assignments on server restart while preserving devices held open by a running VMM.
Hypeman reconciles orphaned assignments with a periodic fail-closed pass: once at startup and every minute afterward (skipped entirely on hosts without GPUs). Each pass releases assignments whose owning instance is no longer live and clears their metadata, then sweeps device-level leftovers with no live metadata claim. Devices held open by a running VMM and assignments younger than five minutes are preserved, so a release that fails during stop or delete (typically because a GPU-busy VMM's kernel-side VFIO teardown outlives the force-kill wait) is simply retried on later passes until the device is free.

### Hypervisor Support

Expand Down Expand Up @@ -288,10 +288,17 @@ every request, so one wedged VF presents as all vGPU instances failing while
`/resources` reports full capacity.

The wedge itself leaves no host-side log: no kernel error, no XID, no plugin
crash. In the observed case it followed a period of heavy attach/teardown
churn on the VF, including QEMU processes that exited within seconds of
opening the VFIO device (failed start attempts that were then retried), so
suspect any workload that repeatedly kills the VMM mid-device-init.
crash. The trigger is a SIGKILL delivered to QEMU while the vGPU plugin is
still initializing the VF (roughly the first seconds after process start):
a single hard kill in that window wedges the VF near-deterministically,
while QEMU processes that exit voluntarily — error exits, QMP quit, SIGTERM —
run their VFIO teardown and never wedge, and hard kills of fully-initialized
vGPU VMs are also safe. Hypeman therefore SIGTERMs a vGPU QEMU first and only
escalates to SIGKILL after a grace period, both in start-failure cleanup and
when force-killing any vGPU instance (the instance reports Running seconds
before driver init completes, so no state reliably marks the window); a hard
kill after an ignored SIGTERM logs `VF may wedge` with the device path.
External SIGKILLs (OOM killer, manual `kill -9`) can still trigger it.

Confirm by assigning the same profile on a different VF: if that guest
initializes, the VF is wedged, not the driver stack. Remediate by cycling
Expand Down
2 changes: 1 addition & 1 deletion lib/devices/mdev_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func DestroyVGPU(ctx context.Context, assignment VGPUAssignment) error {
return nil
}

func ReconcileVGPUs(ctx context.Context, protectedDevicePaths map[string]struct{}) error {
func ReconcileVGPUs(ctx context.Context, protectedDevicePaths map[string]struct{}, sweepDevices bool) error {
return nil
}

Expand Down
35 changes: 25 additions & 10 deletions lib/devices/mdev_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ import (
)

const (
mdevBusPath = "/sys/class/mdev_bus"
mdevDevices = "/sys/bus/mdev/devices"
orphanedMdevGracePeriod = 5 * time.Minute
procPath = "/proc"
mdevBusPath = "/sys/class/mdev_bus"
mdevDevices = "/sys/bus/mdev/devices"
procPath = "/proc"
)

// mdevMu protects mdev creation/destruction to prevent race conditions
Expand Down Expand Up @@ -708,17 +707,27 @@ func mdevPastGracePeriod(mdevUUID string, gracePeriod time.Duration) (bool, time
return age >= gracePeriod, age, nil
}

func protectedMdevUUIDs(instanceInfos []MdevReconcileInfo) map[string]struct{} {
protected := make(map[string]struct{}, len(instanceInfos))
for _, info := range instanceInfos {
if info.MdevUUID != "" && info.IsRunning {
protected[info.MdevUUID] = struct{}{}
}
}
return protected
}

// ReconcileMdevs destroys orphaned mdevs on managed VFs.
// This is called on server startup to clean up stale mdevs from previous runs.
//
// Policy:
// - Consider only mdevs whose parent VF is currently managed by hypeman (discoverable via /sys/class/mdev_bus)
// - Keep mdevs claimed by live instance metadata
// - Keep mdevs whose VFIO group has an open file handle (/dev/vfio/<group>)
// - Keep mdevs younger than a short grace period to avoid racing very recent state transitions
// - Delete all remaining mdevs
func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) error {
log := logger.FromContext(ctx)
_ = instanceInfos
protectedMdevs := protectedMdevUUIDs(instanceInfos)

vfs, err := discoverMdevVFs()
if err != nil {
Expand Down Expand Up @@ -747,17 +756,22 @@ func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) erro
log.InfoContext(ctx, "reconciling mdev devices",
"total_mdevs", len(mdevs),
"managed_vfs", len(managedVFs),
"grace_period", orphanedMdevGracePeriod.String(),
"grace_period", VGPUAssignmentGracePeriod.String(),
)

groupInUseCache := make(map[int]bool)
var destroyed, failedDestroy, skippedUnmanagedVF, skippedInUse, skippedGrace, skippedProbeError int
var destroyed, failedDestroy, skippedUnmanagedVF, skippedClaimed, skippedInUse, skippedGrace, skippedProbeError int
for _, mdev := range mdevs {
if _, ok := managedVFs[mdev.VFAddress]; !ok {
log.DebugContext(ctx, "skipping mdev on unmanaged VF", "uuid", mdev.UUID, "vf", mdev.VFAddress)
skippedUnmanagedVF++
continue
}
if _, ok := protectedMdevs[mdev.UUID]; ok {
log.DebugContext(ctx, "skipping mdev claimed by live instance", "uuid", mdev.UUID)
skippedClaimed++
continue
}

group, err := mdevIOMMUGroup(mdev.UUID)
if err != nil {
Expand All @@ -782,7 +796,7 @@ func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) erro
continue
}

pastGracePeriod, age, err := mdevPastGracePeriod(mdev.UUID, orphanedMdevGracePeriod)
pastGracePeriod, age, err := mdevPastGracePeriod(mdev.UUID, VGPUAssignmentGracePeriod)
if err != nil {
log.WarnContext(ctx, "failed to determine mdev age, skipping cleanup", "uuid", mdev.UUID, "error", err)
skippedProbeError++
Expand All @@ -792,7 +806,7 @@ func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) erro
log.DebugContext(ctx, "skipping recently created mdev during grace period",
"uuid", mdev.UUID,
"age", age.String(),
"grace_period", orphanedMdevGracePeriod.String(),
"grace_period", VGPUAssignmentGracePeriod.String(),
)
skippedGrace++
continue
Expand All @@ -818,6 +832,7 @@ func ReconcileMdevs(ctx context.Context, instanceInfos []MdevReconcileInfo) erro
"destroyed", destroyed,
"failed_destroy", failedDestroy,
"skipped_unmanaged_vf", skippedUnmanagedVF,
"skipped_claimed", skippedClaimed,
"skipped_in_use", skippedInUse,
"skipped_grace", skippedGrace,
"skipped_probe_error", skippedProbeError,
Expand Down
5 changes: 5 additions & 0 deletions lib/devices/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func ValidateDeviceName(name string) bool {
// GPUMode represents the host's GPU configuration mode
type GPUMode string

// VGPUAssignmentGracePeriod protects a fresh vGPU assignment from cleanup
// until its VM has had time to boot and become identifiable — by a persisted
// hypervisor PID, a control-socket owner, or an open VFIO handle.
const VGPUAssignmentGracePeriod = 5 * time.Minute

type VGPUFramework string

const (
Expand Down
Loading
Loading