Skip to content

Add QEMU microvm hypervisor backend - #376

Open
rgarcia wants to merge 4 commits into
mainfrom
raf/qemu-microvm
Open

Add QEMU microvm hypervisor backend#376
rgarcia wants to merge 4 commits into
mainfrom
raf/qemu-microvm

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add qemu-microvm as a first-class hypervisor backend on Linux amd64
  • use QEMU's microvm board with virtio-mmio disks, networking, vsock, and balloon devices
  • keep standard qemu behavior unchanged (q35 on amd64, virt on arm64)
  • validate microvm constraints before side effects: no PCI/vGPU, no hotplug memory, and at most eight virtio-mmio devices
  • preserve the backend across stop/start, standby/restore, snapshots, and warm forks
  • add an opt-in q35-vs-microvm boot/RSS benchmark and user/developer documentation

The public API exposes only hypervisor: qemu-microvm; QEMU machine types remain an internal restore detail. This avoids adding a QEMU-specific machine_type field to the generic instance API.

Background

QEMU's microvm machine type is a minimalist x86 virtual platform inspired by Firecracker. Compared with the general-purpose q35 PC model, it deliberately omits PCI and ACPI, exposes paravirtualized devices over virtio-mmio, and supports at most eight of those devices. QEMU designed it for short-lived guests and as a baseline for optimizing boot time and footprint.

That model fits Hypeman's direct-kernel Linux guests: we already control the kernel/initrd and primarily need virtio disks, networking, vsock, and ballooning. Selecting qemu-microvm avoids initializing hardware that these workloads do not use while retaining Hypeman's existing QEMU/QMP lifecycle, snapshot, and fork machinery.

It is not a transparent replacement for standard QEMU. Upstream documents no PCI-only devices, no hotplug, and no live migration across QEMU versions. This PR therefore exposes it as a distinct Hypeman hypervisor capability contract instead of a generic QEMU board toggle, and validates those constraints before creating a VM.

Further reading:

Validation

  • go build ./...
  • go vet ./lib/hypervisor/... ./lib/instances/... ./cmd/api/api/...
  • go test ./lib/hypervisor/qemu ./cmd/api/api -count=1
  • focused instance unit tests for backend resolution and snapshot normalization
  • make test TEST='TestQEMUMicroVM.*' TEST_TIMEOUT=12m
  • standard QEMU regressions:
    • TestQEMUBasicEndToEnd
    • TestQEMUStandbyAndRestore
    • TestQEMUForkFromRunningNetwork
  • real API/CLI smoke: qemu-microvm booted Alpine, reached Running, and returned x86_64; standard qemu continued to use q35
  • OpenAPI regeneration is deterministic and no public machine_type remains

Performance sample

Three sequential nginx samples on this shared Linux amd64/KVM host:

backend boot-to-program p50 p95 RSS p50 RSS p95
qemu 946ms 1.00s 324MB 360MB
qemu-microvm 381ms 428ms 333MB 335MB

These numbers are non-gating; the included benchmark is opt-in.

Closes #275


Note

Medium Risk
Touches VM lifecycle, snapshot restore, and QMP pooling; microvm’s exact-QEMU-version standby contract can surprise operators after upgrades, though constraints are validated early and covered by integration tests.

Overview
Adds qemu-microvm as a selectable hypervisor alongside standard qemu, wired through the API/CLI/OpenAPI and a dedicated QEMU starter that pins QEMU’s microvm board with virtio-mmio devices (no PCI transport).

QEMU layer: VMConfig.MachineType drives board selection; microvm builds use -no-user-config, reject hotplug/PCI, and enforce the eight virtio-mmio device budget. qemu-config.json now stores QEMUVersion for microvm; standby/warm restore refuses mismatched QEMU versions (RequiresExactSnapshotVersion). The QMP client pool is backend-aware and avoids evicting a replacement client on stale errors.

Instances layer: Create and stopped-snapshot restore/fork validate microvm limits (volumes, GPU/PCI, hotplug) before mutations; version-locked backends resolve/detect QEMU version on create and cold start. Stopped snapshots may target_hypervisor: qemu-microvm (and switch from standard qemu).

Docs add QEMU dev deps and usage; an opt-in boot/RSS benchmark compares q35 vs microvm.

Reviewed by Cursor Bugbot for commit fc1fb3e. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
-->

✱ stlc build

go code · compare

Your SDK build was successful.

generate ✅bootstrap ✅format ✅

112 files generated at ac8dcb2 (pushed)

go get github.com/kernel/hypeman-go-staging@ac8dcb2677d2b6e2650a6ebf6de7e9a5a33ac98e
typescript code · compare

Your SDK build was successful.

generate ✅bootstrap ✅format ✅

134 files generated at 98cab13 (pushed)

Diagnostics: 💡 0 new / 5 total note
LevelCodeMessageTargets
Build metadata
Buildbd_766XRTxQ-small-crab
Timestamp2026-08-09T19:55:52.769Z
stlc8413509
Spec hash8fca70cc4269
Config hash9125eb22cb4c

This comment is auto-generated by stlc and is kept up to date as you push.
If you push new commits, re-run this workflow to update this comment.
Last updated: 2026-08-09 19:56:10 UTC

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Microvm switch skips constraint checks
    • Restore and fork now validate stopped-snapshot metadata against create-time qemu-microvm constraints before any payload or metadata mutation, so incompatible switches fail early with ErrInvalidRequest.

Create PR

Or push these changes by commenting:

@cursor push 7810766d9a
Preview (7810766d9a)
diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go
--- a/lib/instances/snapshot.go
+++ b/lib/instances/snapshot.go
@@ -273,6 +273,10 @@
 	if err != nil {
 		return nil, err
 	}
+	targetMachineType, err := m.resolveSnapshotTargetMachineType(rec.StoredMetadata, targetHypervisor)
+	if err != nil {
+		return nil, err
+	}
 
 	target, err := m.cancelAndWaitCompressionJob(ctx, m.snapshotJobKeyForSnapshot(snapshotID))
 	if err != nil {
@@ -303,10 +307,7 @@
 	restored.ExitCode = nil
 	restored.ExitMessage = ""
 	restored.HypervisorType = targetHypervisor
-	restored.MachineType, err = normalizeSnapshotMachineType(restored.MachineType, rec.StoredMetadata.HypervisorType, targetHypervisor)
-	if err != nil {
-		return nil, err
-	}
+	restored.MachineType = targetMachineType
 
 	starter, err := m.getVMStarter(targetHypervisor)
 	if err != nil {
@@ -400,6 +401,10 @@
 	if err != nil {
 		return nil, err
 	}
+	targetMachineType, err := m.resolveSnapshotTargetMachineType(rec.StoredMetadata, targetHypervisor)
+	if err != nil {
+		return nil, err
+	}
 
 	forkID := cuid2.Generate()
 	if _, err := m.loadMetadata(forkID); err == nil {
@@ -441,10 +446,7 @@
 	forkMeta.HypervisorPID = nil
 	forkMeta.DataDir = dstDir
 	forkMeta.HypervisorType = targetHypervisor
-	forkMeta.MachineType, err = normalizeSnapshotMachineType(forkMeta.MachineType, rec.StoredMetadata.HypervisorType, targetHypervisor)
-	if err != nil {
-		return nil, err
-	}
+	forkMeta.MachineType = targetMachineType
 	if targetHypervisor != rec.StoredMetadata.HypervisorType {
 		hvVersion, err := starter.GetVersion(m.paths)
 		if err != nil {
@@ -556,6 +558,30 @@
 	return requested, nil
 }
 
+func (m *manager) resolveSnapshotTargetMachineType(stored StoredMetadata, target hypervisor.Type) (hypervisor.MachineType, error) {
+	if target == hypervisor.TypeQEMUMicroVM {
+		var gpu *GPUConfig
+		if stored.GPUProfile != "" {
+			gpu = &GPUConfig{Profile: stored.GPUProfile}
+		}
+		if _, err := m.resolveCreateMachineType(CreateInstanceRequest{
+			HotplugSize:    stored.HotplugSize,
+			Devices:        stored.Devices,
+			Volumes:        stored.Volumes,
+			NetworkEnabled: stored.NetworkEnabled,
+			GPU:            gpu,
+		}, target); err != nil {
+			return "", err
+		}
+	}
+
+	machineType, err := normalizeSnapshotMachineType(stored.MachineType, stored.HypervisorType, target)
+	if err != nil {
+		return "", err
+	}
+	return machineType, nil
+}
+
 func resolveSnapshotTargetState(kind SnapshotKind, requested State) (State, error) {
 	resolved, err := snapshotstore.ResolveTargetState(kind, string(requested))
 	if err != nil {

diff --git a/lib/instances/snapshot_test.go b/lib/instances/snapshot_test.go
--- a/lib/instances/snapshot_test.go
+++ b/lib/instances/snapshot_test.go
@@ -84,6 +84,74 @@
 	assert.ErrorIs(t, err, ErrInvalidRequest)
 }
 
+func TestStoppedSnapshotRestoreRejectsIncompatibleMicroVMSwitch(t *testing.T) {
+	t.Parallel()
+	mgr, _ := setupTestManager(t)
+	if _, err := mgr.getVMStarter(hypervisor.TypeQEMUMicroVM); err != nil {
+		t.Skipf("qemu-microvm starter unavailable: %v", err)
+	}
+	ctx := context.Background()
+
+	sourceID := "snapshot-stopped-restore-microvm-src"
+	createStoppedSnapshotSourceFixture(t, mgr, sourceID, "snapshot-stopped-restore-microvm-src", mgr.defaultHypervisor)
+
+	sourceMeta, err := mgr.loadMetadata(sourceID)
+	require.NoError(t, err)
+	sourceMeta.HotplugSize = 1
+	require.NoError(t, mgr.saveMetadata(sourceMeta))
+
+	snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{
+		Kind: SnapshotKindStopped,
+		Name: "stopped-microvm-restore",
+	})
+	require.NoError(t, err)
+
+	_, err = mgr.RestoreSnapshot(ctx, sourceID, snap.Id, RestoreSnapshotRequest{
+		TargetState:      StateStopped,
+		TargetHypervisor: hypervisor.TypeQEMUMicroVM,
+	})
+	require.Error(t, err)
+	assert.ErrorIs(t, err, ErrInvalidRequest)
+	assert.Contains(t, err.Error(), "does not support hotplug memory")
+
+	after, err := mgr.loadMetadata(sourceID)
+	require.NoError(t, err)
+	assert.Equal(t, mgr.defaultHypervisor, after.HypervisorType)
+	assert.Equal(t, int64(1), after.HotplugSize)
+}
+
+func TestStoppedSnapshotForkRejectsIncompatibleMicroVMSwitch(t *testing.T) {
+	t.Parallel()
+	mgr, _ := setupTestManager(t)
+	if _, err := mgr.getVMStarter(hypervisor.TypeQEMUMicroVM); err != nil {
+		t.Skipf("qemu-microvm starter unavailable: %v", err)
+	}
+	ctx := context.Background()
+
+	sourceID := "snapshot-stopped-fork-microvm-src"
+	createStoppedSnapshotSourceFixture(t, mgr, sourceID, "snapshot-stopped-fork-microvm-src", mgr.defaultHypervisor)
+
+	sourceMeta, err := mgr.loadMetadata(sourceID)
+	require.NoError(t, err)
+	sourceMeta.HotplugSize = 1
+	require.NoError(t, mgr.saveMetadata(sourceMeta))
+
+	snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{
+		Kind: SnapshotKindStopped,
+		Name: "stopped-microvm-fork",
+	})
+	require.NoError(t, err)
+
+	_, err = mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{
+		Name:             "snapshot-fork-microvm-invalid",
+		TargetState:      StateStopped,
+		TargetHypervisor: hypervisor.TypeQEMUMicroVM,
+	})
+	require.Error(t, err)
+	assert.ErrorIs(t, err, ErrInvalidRequest)
+	assert.Contains(t, err.Error(), "does not support hotplug memory")
+}
+
 func TestRestoreSnapshotCancelsSourceInstanceCompressionJob(t *testing.T) {
 	t.Parallel()

You can send follow-ups to the cloud agent here.

Comment thread lib/instances/snapshot_machine_type.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Pool rejects backend type switch
    • GetOrCreateForType now evicts stale pooled clients on hypervisor-type mismatch and reconnects instead of returning a permanent mismatch error.
  • ✅ Fixed: Hardcoded microvm type switches
    • I replaced direct qemu-microvm conditionals in shared lifecycle paths with a typed hypervisor version-policy map and helper functions.

Create PR

Or push these changes by commenting:

@cursor push fa369c1f56
Preview (fa369c1f56)
diff --git a/lib/hypervisor/qemu/pool.go b/lib/hypervisor/qemu/pool.go
--- a/lib/hypervisor/qemu/pool.go
+++ b/lib/hypervisor/qemu/pool.go
@@ -1,7 +1,6 @@
 package qemu
 
 import (
-	"fmt"
 	"sync"
 
 	"github.com/kernel/hypeman/lib/hypervisor"
@@ -28,11 +27,11 @@
 	// Try read lock first for existing connection
 	clientPool.RLock()
 	if client, ok := clientPool.clients[socketPath]; ok {
-		clientPool.RUnlock()
-		if client.hypervisorType != hypervisorType {
-			return nil, poolTypeMismatchError(socketPath, client.hypervisorType, hypervisorType)
+		if client.hypervisorType == hypervisorType {
+			clientPool.RUnlock()
+			return client, nil
 		}
-		return client, nil
+		// Backend identity changed for this socket path. Recreate under write lock.
 	}
 	clientPool.RUnlock()
 
@@ -42,10 +41,11 @@
 
 	// Double-check after acquiring write lock
 	if client, ok := clientPool.clients[socketPath]; ok {
-		if client.hypervisorType != hypervisorType {
-			return nil, poolTypeMismatchError(socketPath, client.hypervisorType, hypervisorType)
+		if client.hypervisorType == hypervisorType {
+			return client, nil
 		}
-		return client, nil
+		// Stale pooled backend type for this socket path. Drop and reconnect.
+		removeLocked(socketPath)
 	}
 
 	// Create new client
@@ -58,20 +58,22 @@
 	return client, nil
 }
 
-func poolTypeMismatchError(socketPath string, cached, requested hypervisor.Type) error {
-	return fmt.Errorf("QEMU client for %s is pooled as hypervisor %s, not %s", socketPath, cached, requested)
-}
-
 // Remove closes and removes a client from the pool.
 // Called automatically on errors to allow fresh reconnection.
 // Close is done asynchronously to avoid blocking if the connection is in a bad state.
 func Remove(socketPath string) {
 	clientPool.Lock()
 	defer clientPool.Unlock()
+	removeLocked(socketPath)
+}
 
+// removeLocked removes an entry while clientPool lock is held.
+func removeLocked(socketPath string) {
 	if client, ok := clientPool.clients[socketPath]; ok {
 		delete(clientPool.clients, socketPath)
 		// Close asynchronously to avoid blocking on stuck connections
-		go client.client.Close()
+		if client.client != nil {
+			go client.client.Close()
+		}
 	}
 }

diff --git a/lib/hypervisor/qemu/pool_test.go b/lib/hypervisor/qemu/pool_test.go
--- a/lib/hypervisor/qemu/pool_test.go
+++ b/lib/hypervisor/qemu/pool_test.go
@@ -7,7 +7,7 @@
 	"github.com/stretchr/testify/require"
 )
 
-func TestGetOrCreateForTypeRejectsCachedBackendMismatch(t *testing.T) {
+func TestGetOrCreateForTypeEvictsCachedBackendMismatch(t *testing.T) {
 	socketPath := t.TempDir() + "/qemu.sock"
 	clientPool.Lock()
 	clientPool.clients[socketPath] = &QEMU{socketPath: socketPath, hypervisorType: hypervisor.TypeQEMU}
@@ -19,5 +19,9 @@
 	})
 
 	_, err := GetOrCreateForType(socketPath, hypervisor.TypeQEMUMicroVM)
-	require.ErrorContains(t, err, "pooled as hypervisor qemu, not qemu-microvm")
+	require.Error(t, err)
+	clientPool.RLock()
+	_, stillPooled := clientPool.clients[socketPath]
+	clientPool.RUnlock()
+	require.False(t, stillPooled, "stale cached backend must be evicted after mismatch")
 }

diff --git a/lib/instances/create.go b/lib/instances/create.go
--- a/lib/instances/create.go
+++ b/lib/instances/create.go
@@ -748,13 +748,13 @@
 		return fmt.Errorf("get vm starter: %w", err)
 	}
 
-	// qemu-microvm snapshots are tied to the binary that boots the VM. Refresh
-	// metadata on every cold start so host upgrades do not leave the instance's
-	// reported version pinned to its original creation time.
-	if stored.HypervisorType == hypervisor.TypeQEMUMicroVM {
+	// Some hypervisors tie snapshot compatibility to the exact runtime binary.
+	// Refresh metadata on cold start so host upgrades do not leave the stored
+	// version pinned to original creation time.
+	if refreshHypervisorVersionOnColdStart(stored.HypervisorType) {
 		detectedVersion, err := starter.GetVersion(m.paths)
 		if err != nil {
-			return fmt.Errorf("get QEMU version for qemu-microvm start: %w", err)
+			return fmt.Errorf("get hypervisor version for %s start: %w", stored.HypervisorType, err)
 		}
 		stored.HypervisorVersion = detectedVersion
 	}

diff --git a/lib/instances/hypervisor_version.go b/lib/instances/hypervisor_version.go
--- a/lib/instances/hypervisor_version.go
+++ b/lib/instances/hypervisor_version.go
@@ -8,19 +8,46 @@
 	"github.com/kernel/hypeman/lib/logger"
 )
 
+type hypervisorVersionPolicy struct {
+	enforceExactInstalledVersion bool
+	refreshOnColdStart           bool
+}
+
+var hypervisorVersionPoliciesByType = map[hypervisor.Type]hypervisorVersionPolicy{
+	hypervisor.TypeQEMUMicroVM: {
+		enforceExactInstalledVersion: true,
+		refreshOnColdStart:           true,
+	},
+}
+
+func resolveHypervisorVersionPolicy(hvType hypervisor.Type) hypervisorVersionPolicy {
+	if policy, ok := hypervisorVersionPoliciesByType[hvType]; ok {
+		return policy
+	}
+	return hypervisorVersionPolicy{}
+}
+
+func enforceExactInstalledHypervisorVersion(hvType hypervisor.Type) bool {
+	return resolveHypervisorVersionPolicy(hvType).enforceExactInstalledVersion
+}
+
+func refreshHypervisorVersionOnColdStart(hvType hypervisor.Type) bool {
+	return resolveHypervisorVersionPolicy(hvType).refreshOnColdStart
+}
+
 func (m *manager) resolveCreateHypervisorVersion(
 	ctx context.Context,
 	starter hypervisor.VMStarter,
 	hvType hypervisor.Type,
 	requested string,
 ) (string, error) {
-	if hvType == hypervisor.TypeQEMUMicroVM {
+	if enforceExactInstalledHypervisorVersion(hvType) {
 		detected, err := starter.GetVersion(m.paths)
 		if err != nil {
-			return "", fmt.Errorf("get QEMU version for qemu-microvm: %w", err)
+			return "", fmt.Errorf("get installed hypervisor version for %s: %w", hvType, err)
 		}
 		if requested != "" && requested != detected {
-			return "", fmt.Errorf("%w: requested qemu-microvm version %q does not match installed QEMU %q", ErrInvalidRequest, requested, detected)
+			return "", fmt.Errorf("%w: requested %s hypervisor version %q does not match installed version %q", ErrInvalidRequest, hvType, requested, detected)
 		}
 		return detected, nil
 	}

diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go
--- a/lib/instances/snapshot.go
+++ b/lib/instances/snapshot.go
@@ -532,8 +532,8 @@
 	if target != source.HypervisorType {
 		version, err = starter.GetVersion(m.paths)
 		if err != nil {
-			if target == hypervisor.TypeQEMUMicroVM {
-				return nil, "", fmt.Errorf("get QEMU version for qemu-microvm snapshot target: %w", err)
+			if enforceExactInstalledHypervisorVersion(target) {
+				return nil, "", fmt.Errorf("get installed hypervisor version for %s snapshot target: %w", target, err)
 			}
 			logger.FromContext(ctx).WarnContext(ctx, "failed to get hypervisor version", "hypervisor", target, "error", err)
 			version = "unknown"

You can send follow-ups to the cloud agent here.

Comment thread lib/hypervisor/qemu/pool.go
Comment thread lib/instances/create.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Pool close blocks under lock
    • Backend-mismatch eviction and resetClient now remove entries and close QMP clients asynchronously so pool lock holders cannot block on stuck Close calls.
  • ✅ Fixed: Stale Remove evicts new client
    • Pool removal now supports pointer-guarded eviction and all QEMU instance error paths remove only if the pooled entry is the same client instance.

Create PR

Or push these changes by commenting:

@cursor push a200381e50
Preview (a200381e50)
diff --git a/lib/hypervisor/qemu/pool.go b/lib/hypervisor/qemu/pool.go
--- a/lib/hypervisor/qemu/pool.go
+++ b/lib/hypervisor/qemu/pool.go
@@ -43,7 +43,8 @@
 		}
 		delete(clientPool.clients, socketPath)
 		if client.client != nil {
-			_ = client.client.Close()
+			// Match Remove's behavior so a stuck QMP close doesn't block the pool lock.
+			go client.client.Close()
 		}
 	}
 
@@ -57,29 +58,33 @@
 	return client, nil
 }
 
-// resetClient synchronously drops a pooled connection before a new QEMU
+// resetClient drops a pooled connection before a new QEMU
 // process reuses the same socket path.
 func resetClient(socketPath string) {
-	clientPool.Lock()
-	defer clientPool.Unlock()
-	if client, ok := clientPool.clients[socketPath]; ok {
-		delete(clientPool.clients, socketPath)
-		if client.client != nil {
-			_ = client.client.Close()
-		}
-	}
+	removeIfCurrent(socketPath, nil)
 }
 
 // Remove closes and removes a client from the pool.
 // Called automatically on errors to allow fresh reconnection.
 // Close is done asynchronously to avoid blocking if the connection is in a bad state.
 func Remove(socketPath string) {
+	removeIfCurrent(socketPath, nil)
+}
+
+// removeIfCurrent removes and closes the pooled client if the current entry
+// matches expected. Passing nil expected removes whatever is currently pooled.
+func removeIfCurrent(socketPath string, expected *QEMU) {
 	clientPool.Lock()
 	defer clientPool.Unlock()
 
 	if client, ok := clientPool.clients[socketPath]; ok {
+		if expected != nil && client != expected {
+			return
+		}
 		delete(clientPool.clients, socketPath)
-		// Close asynchronously to avoid blocking on stuck connections
-		go client.client.Close()
+		// Close asynchronously to avoid blocking on stuck connections.
+		if client.client != nil {
+			go client.client.Close()
+		}
 	}
 }

diff --git a/lib/hypervisor/qemu/pool_test.go b/lib/hypervisor/qemu/pool_test.go
--- a/lib/hypervisor/qemu/pool_test.go
+++ b/lib/hypervisor/qemu/pool_test.go
@@ -26,3 +26,42 @@
 	clientPool.RUnlock()
 	require.False(t, stillCached, "stale backend client must be removed before reconnect")
 }
+
+func TestRemoveIfCurrentSkipsReplacementClient(t *testing.T) {
+	socketPath := t.TempDir() + "/qemu.sock"
+	stale := &QEMU{socketPath: socketPath, hypervisorType: hypervisor.TypeQEMU}
+	replacement := &QEMU{socketPath: socketPath, hypervisorType: hypervisor.TypeQEMUMicroVM}
+
+	clientPool.Lock()
+	clientPool.clients[socketPath] = replacement
+	clientPool.Unlock()
+	t.Cleanup(func() {
+		clientPool.Lock()
+		delete(clientPool.clients, socketPath)
+		clientPool.Unlock()
+	})
+
+	removeIfCurrent(socketPath, stale)
+
+	clientPool.RLock()
+	cached, ok := clientPool.clients[socketPath]
+	clientPool.RUnlock()
+	require.True(t, ok, "replacement client should remain pooled")
+	require.Same(t, replacement, cached, "stale remove must not evict replacement client")
+}
+
+func TestRemoveIfCurrentRemovesMatchingClient(t *testing.T) {
+	socketPath := t.TempDir() + "/qemu.sock"
+	client := &QEMU{socketPath: socketPath, hypervisorType: hypervisor.TypeQEMU}
+
+	clientPool.Lock()
+	clientPool.clients[socketPath] = client
+	clientPool.Unlock()
+
+	removeIfCurrent(socketPath, client)
+
+	clientPool.RLock()
+	_, ok := clientPool.clients[socketPath]
+	clientPool.RUnlock()
+	require.False(t, ok, "matching pooled client should be removed")
+}

diff --git a/lib/hypervisor/qemu/qemu.go b/lib/hypervisor/qemu/qemu.go
--- a/lib/hypervisor/qemu/qemu.go
+++ b/lib/hypervisor/qemu/qemu.go
@@ -71,7 +71,7 @@
 // This sends a graceful shutdown signal to the guest.
 func (q *QEMU) DeleteVM(ctx context.Context) error {
 	if err := q.client.SystemPowerdown(); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return err
 	}
 	clearBalloonTargetCache(q.socketPath)
@@ -81,11 +81,11 @@
 // Shutdown stops the QEMU process.
 func (q *QEMU) Shutdown(ctx context.Context) error {
 	if err := q.client.Quit(); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return err
 	}
 	// Connection is gone after quit, remove from pool
-	Remove(q.socketPath)
+	removeIfCurrent(q.socketPath, q)
 	clearBalloonTargetCache(q.socketPath)
 	return nil
 }
@@ -94,7 +94,7 @@
 func (q *QEMU) GetVMInfo(ctx context.Context) (*hypervisor.VMInfo, error) {
 	status, err := q.client.Status()
 	if err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return nil, fmt.Errorf("query status: %w", err)
 	}
 
@@ -129,7 +129,7 @@
 // Pause suspends VM execution.
 func (q *QEMU) Pause(ctx context.Context) error {
 	if err := q.client.Stop(); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return err
 	}
 	return nil
@@ -138,7 +138,7 @@
 // Resume continues VM execution.
 func (q *QEMU) Resume(ctx context.Context) error {
 	if err := q.client.Continue(); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return err
 	}
 	return nil
@@ -153,13 +153,13 @@
 	memoryFile := destPath + "/memory"
 	uri := "exec:cat > " + memoryFile
 	if err := q.client.Migrate(uri); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return fmt.Errorf("migrate: %w", err)
 	}
 
 	// Wait for migration to complete
 	if err := q.client.WaitMigration(ctx, migrationTimeout); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return fmt.Errorf("wait migration: %w", err)
 	}
 
@@ -197,7 +197,7 @@
 		return fmt.Errorf("target guest memory %d must be non-negative", bytes)
 	}
 	if err := q.client.Balloon(bytes); err != nil {
-		Remove(q.socketPath)
+		removeIfCurrent(q.socketPath, q)
 		return fmt.Errorf("set balloon target: %w", err)
 	}
 	balloonTargetCache.Store(q.socketPath, bytes)

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 33cffb1. Configure here.

Comment thread lib/hypervisor/qemu/pool.go
Comment thread lib/hypervisor/qemu/pool.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

QEMU microvm support

1 participant