Stage 3 — Error Messages and Diagnostics
What class of issue this is
Stage 3 is the first stage that touches behaviour, but in the safest possible way: you improve the error types and the messages they produce without changing the control flow that succeeds. When Firecracker rejects a config, fails a KVM ioctl, or can't open a device file, the operator (often an orchestrator parsing the JSON error body off the API socket) gets back exactly one thing: an error. A Stage 3 fix makes that error actionable — it names what failed, why, and what value was wrong — without altering when the error fires.
Concretely, a Stage 3 PR is one of:
- Adding context to a Rust error enum variant (the device id, the bad value, the ioctl name).
- Replacing a
{:?}debug-print or a generic string with aDisplaymessage a human can act on. - Splitting an over-broad error variant into specific ones so callers (and operators) can tell two failures apart.
- Fixing the mapping from an internal error to the API status/
fault_messagethe caller sees.
Why it's at this difficulty
You are editing error paths, which means you must understand where an error originates, how it
propagates up through ?, and how it is finally rendered to the API caller — without breaking any of
the success paths or the existing tests that assert on error text. It is more than Stage 2 (you are
changing what the program outputs on failure) but far less than Stage 4 (you are not changing when
it fails). Maps to Levels 2–3; read the
API server & action channel deep dive for how an
error gets back to the socket.
What you must already understand
- Firecracker error enums are mostly built with
thiserror(#[derive(Debug, thiserror::Error)]#[error("...")]). Find the conventions on your branch:
rg -n "thiserror::Error|#\[error\(" src/vmm/src/ | head -30
rg -n "enum .*Error" src/vmm/src/ | head -40
- How an error becomes an API response. The control-plane action enum and its error type live in the RPC interface; the API server maps them to HTTP. Trace it:
rg -n "VmmActionError|enum VmmAction\b" src/vmm/src/rpc_interface.rs
rg -n "fault_message|ErrorKind|to_response|FaultMessage|BadRequest" src/firecracker/src/api_server/ | head
- The threading split, because the error you improve may be produced on the VMM thread and rendered on the API thread (threading model deep dive).
Representative tasks
| Task | Where it lives | Find it with | Test |
|---|---|---|---|
| Add the bad value to a config error | vmm_config/*.rs error enums | rg -n "#\[error" src/vmm/src/vmm_config/ | unit test asserting the message |
| Name the ioctl in a KVM error | vstate/vm.rs, vstate/vcpu/ | `rg -n "errno | ioctl |
| Name the device/file in a device error | devices/ | rg -n "#\[error" src/vmm/src/devices/ | unit test |
| Split a catch-all error variant | any *Error enum | `rg -n "GenericError | Other(String) |
| Fix the internal→API error mapping | rpc_interface.rs, api_server/ | rg -n "VmmActionError" src/ | integration test on the HTTP body |
How to approach one — worked example: a config error with no context
Illustrative of the pattern. The
rgfinds the real enum on your branch; the refactor moved these around, so do not trust a path.
Symptom: PUT /machine-config with an out-of-range vcpu_count returns an error that says only
"Invalid vCPU count" — it does not say what was passed or what the bound is, so the operator cannot
fix it without reading the source.
Locate the error variant and where it is produced
rg -n "vcpu_count|VcpuCount|InvalidVcpuCount|#\[error" src/vmm/src/vmm_config/machine_config.rs
git log --oneline -n 5 -- src/vmm/src/vmm_config/machine_config.rs
The variant looks roughly like:
#![allow(unused)] fn main() { #[derive(Debug, thiserror::Error)] pub enum MachineConfigError { #[error("Invalid vCPU count")] InvalidVcpuCount, // ... } }
Diff — carry the offending value and the bound in the variant
--- a/src/vmm/src/vmm_config/machine_config.rs
+++ b/src/vmm/src/vmm_config/machine_config.rs
@@
#[derive(Debug, thiserror::Error)]
pub enum MachineConfigError {
- #[error("Invalid vCPU count")]
- InvalidVcpuCount,
+ #[error("The vCPU number is invalid! The vCPU count must be 1 or a multiple of 2 up to {max}, got {got}.")]
+ InvalidVcpuCount { got: u8, max: u8 },
@@ // at the construction site:
- return Err(MachineConfigError::InvalidVcpuCount);
+ return Err(MachineConfigError::InvalidVcpuCount { got: vcpu_count, max: MAX_SUPPORTED_VCPUS });
Three rules for error-message diffs:
- State the constraint and the actual value. "Invalid" alone forces the operator into the
source.
got=3, max=32lets them fix it immediately. - Do not change when the error fires. The condition that triggers it is unchanged; only the message and the variant's data change. Tightening the condition is Stage 4.
- Mind what the message reveals. This error goes back over the API socket. It is fine to echo a user-supplied config value; it is not fine to leak host paths or internal addresses a guest could use.
Verify the rendered text — including the API body
Add or update a unit test that asserts the exact Display text (reviewers want the message
pinned so it cannot silently regress):
#![allow(unused)] fn main() { #[test] fn test_invalid_vcpu_count_message() { let err = MachineConfigError::InvalidVcpuCount { got: 3, max: 32 }; assert_eq!( err.to_string(), "The vCPU number is invalid! The vCPU count must be 1 or a multiple of 2 up to 32, got 3." ); } }
cargo test -p vmm machine_config
If the error also crosses the API boundary, pin the HTTP body in a pytest integration test so the contract is guarded end to end:
rg -n "def test_.*machine_config|MachineConfig" tests/integration_tests/ | head
tools/devtool test -- -k machine_config
How to approach a KVM / ioctl error
Illustrative. Run the grep to find a real candidate.
Symptom: a failed KVM ioctl surfaces as a bare errno with no indication of which ioctl
failed — useless when three different ioctls can fail in the same function.
rg -n "Errno|errno|kvm_ioctls|map_err" src/vmm/src/vstate/vm.rs | head
Wrap each fallible ioctl with the call name so the message identifies the operation:
- let vm_fd = kvm.create_vm().map_err(VmError::CreateVm)?;
+ let vm_fd = kvm
+ .create_vm()
+ .map_err(|e| VmError::CreateVm(e))?; // variant's #[error] says "KVM_CREATE_VM failed: {0}"
#![allow(unused)] fn main() { #[error("Failed to create the VM (KVM_CREATE_VM): {0}")] CreateVm(#[source] kvm_ioctls::Error), }
Using #[source] preserves the underlying error chain so {e:#} / a logging layer can print the
full cause, while the top-level #[error] names the ioctl. See the
KVM fundamentals deep dive for the ioctl vocabulary.
What a good PR looks like
- Behaviour-preserving. Success paths untouched; only failure messages/variants change. If you find yourself changing a condition, you have crossed into Stage 4 — split the PR.
- Every changed message is pinned by a test asserting the exact
Displaytext (and the API body if it crosses the socket). Reviewers treat error text as a contract. - Variants carry structured data (
{ got, max }, the device id, the ioctl) rather than a pre-formattedString, so callers can match on them and the message stays consistent. #[source]preserves the cause chain for wrapped lower-level errors; no information is dropped.- No secrets, host paths, or guest-driven spam leaked in the new text.
- Gates green:
tools/devtool fmt,checkstyle,checkbuild --all; clippy clean.
Graduation criteria — ready for Stage 4 when
- You have one merged error-message PR with a unit test asserting the exact text, plus (ideally) an integration test pinning the API body when the error crosses the socket.
- You can trace an error from where it is produced, up through
?, to theVmmActionErrorand the HTTP response the caller receives — and name the file at each hop with anrg. - You can articulate the Stage 3 / Stage 4 line: Stage 3 improves what the error says; Stage 4 changes when it fires (adding or tightening validation). You will reach for Stage 4 the next time you notice a bad value gets accepted rather than merely badly reported.