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 a Display message 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_message the 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

TaskWhere it livesFind it withTest
Add the bad value to a config errorvmm_config/*.rs error enumsrg -n "#\[error" src/vmm/src/vmm_config/unit test asserting the message
Name the ioctl in a KVM errorvstate/vm.rs, vstate/vcpu/`rg -n "errnoioctl
Name the device/file in a device errordevices/rg -n "#\[error" src/vmm/src/devices/unit test
Split a catch-all error variantany *Error enum`rg -n "GenericErrorOther(String)
Fix the internal→API error mappingrpc_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 rg finds 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:

  1. State the constraint and the actual value. "Invalid" alone forces the operator into the source. got=3, max=32 lets them fix it immediately.
  2. 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.
  3. 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 Display text (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-formatted String, 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 the VmmActionError and the HTTP response the caller receives — and name the file at each hop with an rg.
  • 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.

Next: Stage 4 — API and Configuration Validation.