Lab 4.4: Fix a vCPU/KVM State Edge Case
This is a fix-it lab in src/vmm/src/vstate/vcpu/. You will diagnose a representative class of
vCPU bug — a KVM ioctl error that surfaces with no context — reproduce it, find the code with rg,
fix it with proper error handling, add a unit test in vstate, and trace how a vCPU error propagates
from the vCPU thread, through the VcpuResponse channels, to the VMM thread and the user. The class of
bug is the most common one at this layer: a Result from a KVM ioctl that loses its meaning on the
way up.
The skill is precise and unglamorous: when KVM_RUN or KVM_SET_CPUID2 returns an errno, the user
must learn which ioctl failed, on which vCPU, with what value, and the maintainer must be able to
find the call site from the message alone. A bare Error: errno 22 is useless. Getting the error
context right — and pinning it with a test — is exactly the kind of small, mergeable PR a Level 4
graduate opens.
Background
Firecracker's vCPU code is a stack of Results. A KVM ioctl can fail (the kernel rejects an MSR, a
CPUID leaf is malformed, KVM_RUN returns an unexpected exit). The kvm-ioctls crate returns
Result<_, kvm_ioctls::Error> (an errno wrapper). Firecracker wraps those in its own error enums and
propagates them up:
KVM ioctl (errno)
│ kvm-ioctls returns Err(errno)
▼
KvmVcpu method ──► wraps in a typed VcpuError (with context?)
│
▼
Vcpu run loop ──► turns it into a VcpuResponse / a faulted state
│ over the VcpuResponse channel
▼
VMM thread ──► logs it, increments a metric, tears the microVM down
│
▼
the user ──► sees a log line / an API error / a non-zero exit
The bug class: somewhere in that chain, the error is flattened — a map_err(|_| SomethingError)
that throws away the errno, an unhandled VcpuExit variant logged as a generic "unexpected exit," or a
? that propagates a type so broad the user can't tell SET_CPUID2 from SET_MSRS. The fix is to add
context: name the ioctl, the vCPU index, and the offending value, and keep the underlying errno.
This lab teaches the motion: reproduce → locate → add context → test → confirm propagation. We use one concrete, realistic scenario, but the technique transfers to every vCPU/KVM error in the file.
Companion reading: vCPU run loop & VM exits, KVM fundamentals, kvm-ioctls & kvm-bindings, CPU templates & CPUID.
Why This Lab Matters for Contributors
"Firecracker died with errno 22 and I have no idea why" is a real, frequent issue shape. The
maintainers' first move is to read vstate/vcpu/ and ask "which ioctl, which value?" — and if the
error swallowed that, the bug is twice as hard. Improving error context is one of the highest-value,
most-mergeable kinds of PR at this layer (it's a whole stage in the
issue roadmap and a recurring good first issue
shape). This lab is also the bridge from reading the run loop (Lab 4.1)
to changing it under test.
Prerequisites
- Firecracker builds and unit tests run:
tools/devtool test -- src/vmm(orcargo test -p vmm). - You completed Lab 4.1 — you can find the run loop, the
VcpuExitmatch, and theVcpuResponsechannel. - You read Lab 4.3 — you know where CPUID/MSRs are set (a common failure site).
mkdir -p ~/fc-notes ; : > ~/fc-notes/reading-log-4.4.md
Note: This lab is written around a teaching regression you plant yourself, so you can verify the fix against the real, correct code. The technique (add ioctl + value context, test it) is exactly what you'd do on a genuine issue. Names and exact error enums are version-sensitive — every step gives you the
rgto find the real ones on your branch.
Step 1 (15 min) — Read the error chain end to end
Find the vCPU error types and how a KVM ioctl failure becomes one:
# The error enum(s) for the vCPU.
rg -n "enum .*Error" src/vmm/src/vstate/vcpu/
# Where KVM ioctls are called and their Results wrapped.
rg -n "set_cpuid2|set_msrs|set_regs|set_sregs|\.run\(\)|map_err|\.map_err\(" \
src/vmm/src/vstate/vcpu/x86_64.rs
# How an error leaves the run loop toward the VMM thread.
rg -n "VcpuResponse|VcpuEmulation|Error|fault|exit" src/vmm/src/vstate/vcpu/mod.rs
In your reading log, write the chain for one concrete ioctl — say set_cpuid2:
- The
KvmVcpumethod that callsvcpu_fd.set_cpuid2(...)and what it does with theErr. - The
VcpuErrorvariant it produces (does it keep the errno? the leaf? the vCPU index?). - How that error reaches
Vcpu's run loop and becomes aVcpuResponse(or a faulted state). - What the VMM thread does with that
VcpuResponse(logs, metric, teardown).
This is the artifact: a four-hop chain from errno to the user, with the function name at each hop.
Step 2 (10 min) — The bug: a flattened error
Here is the planted regression — a realistic "simplification" that throws away the context. Pick the
set_cpuid2 (or set_msrs) call site you found and degrade it like this:
--- a/src/vmm/src/vstate/vcpu/x86_64.rs
+++ b/src/vmm/src/vstate/vcpu/x86_64.rs
@@ impl KvmVcpu {
fn configure_cpuid(&self, cpuid: &CpuId) -> Result<(), VcpuError> {
- self.fd
- .set_cpuid2(cpuid)
- .map_err(|err| VcpuError::SetCpuid2 { source: err, vcpu_index: self.index })?;
+ // "Simplified" — drops the ioctl identity and the vCPU index.
+ self.fd.set_cpuid2(cpuid).map_err(|_| VcpuError::Generic)?;
Ok(())
}
Two defects, both realistic and both common in real PRs:
- Lost errno + ioctl identity.
map_err(|_| VcpuError::Generic)discards which ioctl failed and the underlying errno. A user sees a generic error; a maintainer can't tellSET_CPUID2fromSET_MSRS. - Lost vCPU index. With multiple vCPUs, "which one?" is the first question. Dropping
self.indexmakes a multi-vCPU failure ambiguous.
Note: This is a teaching regression. The real code keeps the source error and context. You're practicing the fix-it motion on a class whose correct behavior you can verify against the actual source on your branch — read what the real
map_errdoes before you plant the bug, so your fix restores it faithfully.
Step 3 (15 min) — Reproduce: make the ioctl fail
You need the ioctl to actually fail so you can see the bad message. The cleanest reproduction is a unit test that feeds an invalid CPUID/MSR set (more reliable than provoking it at runtime), but first confirm the runtime symptom so you understand the user-facing impact.
A wrong-vendor CPU template (from Lab 4.3) makes KVM_SET_CPUID2
or downstream configuration reject the set on some hosts. Boot with a mismatched template and watch the
log:
# On an AMD host, deliberately apply an Intel template (or vice versa):
curl -X PUT --unix-socket /tmp/fc.sock \
--data '{"vcpu_count":2,"mem_size_mib":256,"cpu_template":"T2"}' http://localhost/machine-config
# ... boot-source, drives ...
curl -X PUT --unix-socket /tmp/fc.sock --data '{"action_type":"InstanceStart"}' http://localhost/actions
grep -iE "cpuid|errno|vcpu|set_cpuid|Generic|Error" /tmp/fc.log | tail
With the buggy code, the log says something like VcpuError::Generic / a bare error — no errno, no
ioctl name, no vCPU index. With the fixed code, it names the ioctl and the vCPU. Record both
messages in your log; the difference is the entire value of the fix.
Tip: If you can't easily provoke the runtime failure on your host (vendor/template alignment), don't force it — the unit test in Step 5 is the real proof. Reproduce conceptually, then pin it in a test.
Step 4 (10 min) — The fix: restore the context
@@ impl KvmVcpu {
fn configure_cpuid(&self, cpuid: &CpuId) -> Result<(), VcpuError> {
- self.fd.set_cpuid2(cpuid).map_err(|_| VcpuError::Generic)?;
+ self.fd
+ .set_cpuid2(cpuid)
+ .map_err(|err| VcpuError::SetCpuid2 { source: err, vcpu_index: self.index })?;
Ok(())
}
The fix restores three things:
- The ioctl identity — a dedicated
SetCpuid2variant (so the message namesKVM_SET_CPUID2). - The underlying errno —
source: errkeeps thekvm_ioctls::Error, soEINVALvsENOMEMis visible. - The vCPU index —
vcpu_index: self.index, so a multi-vCPU failure is unambiguous.
If the SetCpuid2 variant doesn't already exist on your branch, add it to the VcpuError enum with a
#[error(...)] (Firecracker uses thiserror) message that includes the index and source:
#![allow(unused)] fn main() { #[error("Failed to set CPUID (KVM_SET_CPUID2) on vCPU {vcpu_index}: {source}")] SetCpuid2 { source: kvm_ioctls::Error, vcpu_index: u8 }, }
Pitfall — error messages are a contract. Log lines and API error strings are consumed by humans, log scrapers, and runbooks. Name the ioctl the way the kernel does (
KVM_SET_CPUID2), keep the errno, and don't churn the wording casually. When you improve one, note it inCHANGELOG.mdunderChanged/Fixed.
Step 5 (20 min) — The unit test that pins the context
This is the heart of the lab. Find the existing vCPU tests to mirror their setup (they construct a
KvmVcpu against a real /dev/kvm in CI):
rg -n "#\[cfg\(test\)\]|mod tests|fn test_|KvmVcpu::new|setup_vcpu|Kvm::new" src/vmm/src/vstate/vcpu/
Write a test that forces the ioctl to fail and asserts the error carries context. The reliable way
to force KVM_SET_CPUID2 to fail is to hand it a malformed CPUID set (e.g. too many entries, or an
empty/invalid leaf set) — read how the existing tests build a CpuId and invert it:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; // Helper from the existing test module; adapt to your branch. fn test_vcpu() -> KvmVcpu { // ... existing helper builds a Kvm + Vm + a vCPU index 0 ... setup_vcpu(0) } #[test] fn test_set_cpuid2_error_has_context() { let vcpu = test_vcpu(); // Build a deliberately-invalid CPUID set so KVM_SET_CPUID2 returns an errno. // (Too many entries is a common, reliable rejection. Verify the cap on your branch.) let bad_cpuid = make_oversized_cpuid(); let err = vcpu .configure_cpuid(&bad_cpuid) .expect_err("KVM_SET_CPUID2 should reject an oversized CPUID set"); // The fix's contract: the error names the ioctl AND the vCPU index AND keeps the source. match err { VcpuError::SetCpuid2 { vcpu_index, .. } => { assert_eq!(vcpu_index, 0, "error must carry the vCPU index"); } other => panic!("expected SetCpuid2 with context, got {other:?}"), } // And the rendered message must be actionable. let msg = err.to_string(); assert!(msg.contains("CPUID"), "message names the ioctl: {msg}"); assert!(msg.contains("vCPU 0"), "message names the vCPU: {msg}"); } } }
The assertions are the whole point:
- The buggy code returns
VcpuError::Generic→ thematchpanics (the test fails). - The fixed code returns
VcpuError::SetCpuid2 { vcpu_index: 0, source }→ the test passes, and the message assertions pin that the ioctl name and vCPU index can't silently regress.
Run it:
tools/devtool test -- src/vmm/src/vstate/vcpu/ -- --nocapture
# or directly:
cargo test -p vmm --lib vstate::vcpu -- test_set_cpuid2_error_has_context --nocapture
A failing run on the buggy code, then a passing run after the fix, is your proof.
Note: Some
vstatetests require/dev/kvmand are gated (they only run on a KVM-capable host / in the dev container). Run them intools/devtool(inside the container, on a KVM host), not on a machine without/dev/kvm. Ifmake_oversized_cpuid/setup_vcpudon't exist verbatim, read the real test module — it has equivalents; reuse them rather than constructing aKvmVcpuby hand.
Step 6 (10 min) — Confirm propagation to the VMM thread and user
The test pins the type; now confirm the journey. Trace, from the test or by reading, how a
VcpuError from configure_cpuid reaches the user:
# Where the run loop / builder consumes the vCPU error.
rg -n "VcpuResponse|VcpuConfigureError|configure|StartMicrovm|build_microvm" src/vmm/src/ | grep -i vcpu
# Where the VMM thread logs/metrics a vCPU failure.
rg -n "METRICS\.vcpu|error!|fatal|exit_code|FcExitCode" src/vmm/src/
In your log, complete the propagation: configure_cpuid → the builder/Vcpu that called it → the
VmmAction result / a VcpuResponse / a process exit code → what the user sees (an API error body on
a pre-boot failure, or a log line + non-zero exit on a runtime one). The fix's payoff is that every
hop now carries the ioctl name and vCPU index instead of Generic.
Implementation Requirements / Deliverables
-
A reading-log error chain: the four hops from
errnoto the user forset_cpuid2(or your chosen ioctl), with the function name at each hop. -
The fix restores the ioctl-specific
VcpuErrorvariant, keeps thesourceerrno, and carries thevcpu_index. -
A unit test in
vstate/vcpu/that forces the ioctl to fail and asserts the error is the context-bearing variant and that its message names the ioctl and vCPU. Fails on the bug, passes on the fix. - The before/after log messages recorded, showing the difference (generic vs ioctl + vCPU + errno).
-
A note on the full propagation path to the user, and a
CHANGELOG.mdentry underFixedif this were a real PR.
Troubleshooting
The test panics with "no such device" / can't open /dev/kvm
You're running outside a KVM-capable environment. Run it in tools/devtool on a host with /dev/kvm
(the dev container passes it through). vstate tests that touch real ioctls are gated for exactly this
reason.
KVM_SET_CPUID2 accepts my "invalid" CPUID set
KVM is more permissive than you assumed. Make it clearly invalid: exceed the entry cap
(rg -n "KVM_MAX_CPUID_ENTRIES|MAX_CPUID" src/vmm/ ~/.cargo to find the limit), or read how the
existing tests provoke a rejection and copy that. The goal is a reliable Err, not a specific errno.
My VcpuError::SetCpuid2 variant doesn't compile
You likely need the #[error(...)] attribute (Firecracker uses thiserror) and the right field
types. rg -n "thiserror|#\[error" src/vmm/src/vstate/vcpu/ shows the existing variants' exact shape —
mirror one.
The error reaches the user but still looks generic
You fixed one call site; another on the path re-wraps it lossily. Grep the whole chain for map_err(|_|
and From impls that flatten — rg -n "map_err\(\|_\|" src/vmm/src/vstate/. A good error survives
every hop.
Expected Output
> Task test (vstate::vcpu)
test vstate::vcpu::x86_64::tests::test_set_cpuid2_error_has_context ... ok
# Runtime log, AFTER the fix (context restored):
ERROR Failed to set CPUID (KVM_SET_CPUID2) on vCPU 0: Invalid argument (os error 22)
# Runtime log, BEFORE the fix (the regression you removed):
ERROR VcpuError::Generic
Stretch Goals
- Do it for
set_msrs. Apply the same motion toKVM_SET_MSRS: a rejected MSR should name the MSR index, the vCPU, and the errno. MSR failures are a classic snapshot-restore bug (Level 9) — restoring an MSR the target host rejects. - Harden an unhandled exit. Find the catch-all arm of the
VcpuExitmatch (Lab 4.1). If an unexpected variant is logged as a generic "unexpected exit," turn it into a typed error that names the variant. Add a test (you can construct aVcpuExitvalue in a unit test) asserting the message includes the variant. - Metric the failure. Find the
METRICScounter for vCPU errors (rg -n "vcpu.*fail|VcpuMetrics" src/vmm/) and confirm your error path increments the right one. A good fix improves both the message and the metric. - Trace a real issue. Find a closed Firecracker issue about an opaque vCPU/KVM error
(
gh issue list --repo firecracker-microvm/firecracker --search "errno vcpu KVM_RUN" --state closed) and read the PR that fixed it. Compare its approach to yours.
Validation / Self-check
Answer without notes. These gate completion.
- Trace the four hops a KVM ioctl
errnotakes fromkvm-ioctlsto the user. Name what each hop adds (or should add) to the error. - What three pieces of context must a good vCPU-ioctl error carry, and why is each one the first thing a maintainer asks for?
- Why is
map_err(|_| VcpuError::Generic)a bug even though the program still "works"? Who pays the cost, and when? - How did you force
KVM_SET_CPUID2to fail in a unit test reliably? Why is a unit test a better proof than a runtime reproduction here? - Your test asserts two things: the error variant and the error message. Why pin both? What regression does each assertion catch?
- After your fix, what exactly does the user see for a pre-boot CPUID failure vs a runtime one, and why do they differ?
- If this were a real PR, what goes in
CHANGELOG.md, under which heading, and why is the wording of the error message itself something you must not churn casually?
When your test fails on the bug and passes on the fix, your before/after log messages show the restored context, and you can recite the propagation chain, you've completed Lab 4.4 — and Level 4. Continue to Level 5: Testing and Debugging.