From 49632e373d711dae39beb76f040636968bccaf63 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 6 Jul 2026 17:25:40 +0800 Subject: [PATCH] fix(vm): spare a stopped origin when the restore kill step fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KillForRestore marked the VM error on any non-ErrNotRunning failure, including WithRunningVM's fail-closed /proc-scan error. For a hibernated (stopped) VM the VMM is provably gone — hibernate marks error if its terminate fails — so a transient scan hiccup during wake bricked a perfectly restorable VM: ResolveForRestore refuses error state, and nothing on disk had been touched. Route the failure through FailRestore (#89's origin-aware helper): stopped origins stay stopped and the wake remains retryable; a failed kill of a genuinely live VMM on a running origin still quarantines. Closes #91 --- hypervisor/restore.go | 6 ++++-- hypervisor/restore_test.go | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/hypervisor/restore.go b/hypervisor/restore.go index e0566f56..0c2c1d4a 100644 --- a/hypervisor/restore.go +++ b/hypervisor/restore.go @@ -18,14 +18,16 @@ import ( func (b *Backend) KillForRestore(ctx context.Context, vmID string, rec *VMRecord, terminate func(pid int) error, runtimeFiles []string) error { killErr := b.WithRunningVM(ctx, rec, terminate) if killErr != nil && !errors.Is(killErr, ErrNotRunning) { - b.MarkError(ctx, vmID) + // A stopped origin has no live VMM, so a transient liveness-scan error + // here must not brick the wake — nothing was mutated, retry converges. + b.FailRestore(ctx, vmID, rec.State) return fmt.Errorf("stop running VM: %w", killErr) } CleanupRuntimeFiles(ctx, rec.RunDir, runtimeFiles) return nil } -// FailRestore marks the VM error after a post-kill restore failure; a stopped +// FailRestore marks the VM error after a restore-path failure; a stopped // origin is spared so hibernate wake stays retryable. Run-dir-mutating steps // (staged merge, direct populate) quarantine unconditionally at their own // site; origin is the pre-kill state — the DB may read stopped after the kill. diff --git a/hypervisor/restore_test.go b/hypervisor/restore_test.go index 4e949681..17f45324 100644 --- a/hypervisor/restore_test.go +++ b/hypervisor/restore_test.go @@ -201,3 +201,42 @@ func TestResolveForRestoreStates(t *testing.T) { }) } } + +func TestKillForRestoreFailureKeepsOriginContract(t *testing.T) { + tests := []struct { + name string + origin types.VMState + want types.VMState + }{ + {"stopped origin stays restorable (hibernate wake)", types.VMStateStopped, types.VMStateStopped}, + {"running origin quarantines", types.VMStateRunning, types.VMStateError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, id := newHibernateTestVM(t) + if err := b.DB.Update(t.Context(), func(idx *VMIndex) error { + idx.VMs[id].State = tt.origin + return nil + }); err != nil { + t.Fatalf("seed state: %v", err) + } + rec, err := b.LoadRecord(t.Context(), id) + if err != nil { + t.Fatalf("load record: %v", err) + } + + killErr := b.KillForRestore(t.Context(), id, &rec, func(int) error { return errors.New("kill refused") }, nil) + if killErr == nil || !strings.Contains(killErr.Error(), "stop running VM") { + t.Fatalf("KillForRestore: %v, want stop running VM error", killErr) + } + + got, err := b.LoadRecord(t.Context(), id) + if err != nil { + t.Fatalf("reload record: %v", err) + } + if got.State != tt.want { + t.Errorf("state %s, want %s", got.State, tt.want) + } + }) + } +}