.. /blog
2026-03-20 // 8 min

The Swap Storm: VMEXIT_INTR Infinite Loop

vmcbguest-vmdebugging

Stage 9 of my hypervisor runs a guest VM inside the blue-pill by swapping VMCBs. The host has its own VMCB. The guest has its own VMCB. On a signal, I save the host VMCB and load the guest VMCB. The guest runs until it exits, then I swap back. Simple concept. What actually happened was an infinite ping-pong that froze the entire machine in under a second.

The system didn't BSOD. It didn't black-screen. It just stopped responding. Mouse frozen, keyboard dead, no crash dump. That's the worst kind of failure because there's nothing to analyze after the fact. I had to reconstruct what happened from exit traces captured before the freeze.

The VMCB swap mechanism

The idea behind VMCB swap is straightforward. The host OS is already running as a guest under my blue-pill. It has a VMCB. When I want to run a second guest (the actual VM), I need to switch the CPU from executing under the host's VMCB to executing under the guest's VMCB.

// Simplified swap logic in the VMEXIT handler:
if per_cpu.switch_to_guest {
    // save host context
    vmsave(per_cpu.host_vmcb_pa);
    // load guest context
    vmload(per_cpu.guest_vmcb_pa);
    // point the vmrun loop at the guest VMCB
    per_cpu.active_vmcb = per_cpu.guest_vmcb_pa;
    per_cpu.is_guest_vm = true;
    per_cpu.switch_to_guest = false;
}

// On guest VMEXIT, check if we need to swap back:
if per_cpu.is_guest_vm && exit_code == VMEXIT_INTR {
    // guest yielded due to interrupt — swap back to host
    vmsave(per_cpu.guest_vmcb_pa);
    vmload(per_cpu.host_vmcb_pa);
    per_cpu.active_vmcb = per_cpu.host_vmcb_pa;
    per_cpu.is_guest_vm = false;
    per_cpu.switch_to_guest = true;
}

The storm

Here's what happens with that last line. The guest exits on VMEXIT_INTR because a clock interrupt fired. I swap to the host VMCB and set switch_to_guest = true. The host resumes, executes maybe one or two instructions, and then its next VMEXIT fires -- often also VMEXIT_INTR because there are more pending interrupts. My handler sees switch_to_guest = true and immediately swaps back to the guest.

The guest runs briefly, gets another interrupt, exits, swaps to host. Host gets an interrupt, swaps to guest. Guest gets an interrupt, swaps to host. Infinite loop. The core spends 100% of its time swapping VMCBs and never actually processes any host interrupts.

// The swap storm timeline:
//
// t0:  guest VMRUN
// t1:  guest VMEXIT_INTR → swap to host, re-arm
// t2:  host VMRUN
// t3:  host VMEXIT (any) → switch_to_guest=true → swap to guest
// t4:  guest VMRUN
// t5:  guest VMEXIT_INTR → swap to host, re-arm
// t6:  host VMRUN
// t7:  host VMEXIT → swap to guest
//      ... forever ...
//
// Host OS on this core STARVES.
// If this is core 0 (BSP), the ENTIRE system freezes.
host_exits_before_swap: 1-2
guest_exits_before_yield: 1
The BSP (Bootstrap Processor) is core 0. If the swap storm happens on core 0, the entire Windows OS freezes because the BSP handles critical system management -- DPC delivery, APC processing, timer management. When core 0 can't process its own interrupts, the whole system stops.

The fix: exit countdown

The solution is to give the host time to breathe. After the guest yields, instead of immediately re-arming switch_to_guest, I set a countdown. The host has to process 50 exits before I'll consider swapping back to the guest.

// Fixed swap-back logic:
if per_cpu.is_guest_vm && exit_code == VMEXIT_INTR {
    vmsave(per_cpu.guest_vmcb_pa);
    vmload(per_cpu.host_vmcb_pa);
    per_cpu.active_vmcb = per_cpu.host_vmcb_pa;
    per_cpu.is_guest_vm = false;
    // DON'T re-arm immediately. Give host 50 exits first.
    per_cpu.host_exit_countdown = 50;
}

// In the main exit handler, decrement:
if per_cpu.host_exit_countdown > 0 {
    per_cpu.host_exit_countdown -= 1;
    if per_cpu.host_exit_countdown == 0 {
        per_cpu.switch_to_guest = true;
    }
}

50 is a tuned value. Too low and the storm still happens under heavy interrupt load. Too high and the guest gets terrible latency. At 50, the host processes its clock interrupts, DPCs, and any pending I/O before the guest gets another timeslice. The system went from "instant freeze" to "2.7 million exits with OVMF booting to the DXE phase."

Why it was hidden behind the register bug

This is the cruelest part. The swap storm was there from the very first VMCB swap implementation. But I didn't see it because the GuestRegs/GuestGprs layout bug was also present. With scrambled registers, the guest would triple-fault within a handful of exits. The guest died so fast that the swap storm never had time to develop.

When I fixed the register layout, the guest suddenly lived long enough to hit clock interrupts and start the ping-pong cycle. I thought I'd broken something by fixing the registers. In reality I'd just revealed the second bug that was always there, hiding behind the first one.

// The debugging timeline:
//
// Day 1: GuestRegs cast bug present
//   → guest triple-faults in <10 exits
//   → guest dies → no swap storm
//   → system appears "stable" (guest just doesn't work)
//
// Day 2: Fixed register layout
//   → guest lives → hits VMEXIT_INTR → swap storm
//   → system FREEZES
//   → "I broke it by fixing the registers??"
//
// Day 2 evening: Found the re-arm bug
//   → exit countdown = 50
//   → 2.7M exits, OVMF booting, system responsive

Layered bugs

The VMCB swap had five distinct bugs that I found in sequence: the GuestRegs layout mismatch, host stealth post-processing running on guest exits, rdmsr faulting at GIF=0 for unknown MSRs, NPT allocation deadlocking at GIF=0 because the kernel allocator needs interrupts, and finally the swap storm itself.

Each bug masked the next. Fixing one revealed the next. The system went from "guest doesn't boot" to "guest boots but system freezes" to "everything works." The swap storm was the last one -- the one that was actually causing the freeze the whole time, hidden behind four layers of earlier failures.

This was the ONLY change needed to go from total system freeze to a working guest VM. Not the register fix, not the GIF=0 safety fixes, not the stealth bypass. Those were all real bugs, but the countdown was what made the difference between a frozen machine and a responsive one with OVMF booting in the guest.