From 53b50fb74315507d87f2d1b887b6fa07f618838c Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 13 Aug 2026 11:15:09 -0500 Subject: [PATCH] fix: stop waiting for a guest whose QEMU has already exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vm_launch checks whether QEMU is still alive exactly once, at wait_ready_seconds, and never again. wait_for_guest then polls vsock until vsock_boot_timeout with no reference to the process at all. If QEMU exits after that single check, the harness spends the rest of the boot timeout pinging a VM that no longer exists. Measured on an EPYC 9654 with a deliberately corrupted ID block measurement, sampling the process at 1 Hz: +14.6s .. +17.2s QEMU alive +17.2s .. gone step ran +13.8s .. +195.9s QEMU lived 2.6s; the step took 182.1s. ~179s was spent waiting on a dead process. Policy violations are rejected at SNP_LAUNCH_START before guest memory is loaded, so QEMU dies inside the initial check and those launches already fail in ~2s — which is why only the measurement case was slow. Pass the process handle into wait_for_guest and abort as soon as it exits. The step drops from 182.1s to ~3s and the id-block suite from ~200s to ~21s. The diagnostics matter more than the speed. Firmware had already reported the exact cause: SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement' and the harness discarded it in favour of "Vsock agent on CID ... not ready after 180.0s". A negative test asserting exit_code:1 is satisfied by that timeout just as well as by a real rejection, so it would pass identically if the guest merely hung or if the ID block were ignored entirely. The failure message now carries QEMU's exit code and its stderr tail, so a rejected launch states the firmware's reason. process defaults to None, preserving the old behavior for any caller that does not supply it. --- sev_verify/guest_vsock.py | 22 +++++++++++++++++++++- sev_verify/vm_profile.py | 12 +++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/sev_verify/guest_vsock.py b/sev_verify/guest_vsock.py index 6604a95d..9fff3b70 100644 --- a/sev_verify/guest_vsock.py +++ b/sev_verify/guest_vsock.py @@ -14,6 +14,7 @@ import re import shlex import socket +import subprocess import time from pathlib import Path from typing import TYPE_CHECKING, Any @@ -142,9 +143,15 @@ def wait_for_guest( *, timeout: float | None = None, poll_interval: float = 2.0, + process: subprocess.Popen | None = None, ) -> None: """ Block until the guest vsock agent responds or timeout is reached. + + Pass ``process`` (the QEMU handle) to stop waiting the moment QEMU exits. + Without it, a guest that never starts — or one the firmware refuses to + launch — costs the full boot timeout and is then reported as an + indistinguishable "agent not ready", discarding the reason QEMU gave. """ boot_timeout = timeout if timeout is not None else profile.vsock_boot_timeout deadline = time.monotonic() + boot_timeout @@ -156,6 +163,11 @@ def wait_for_guest( return except GuestVsockError as exc: last_error = str(exc) + if process is not None and process.poll() is not None: + raise GuestVsockError( + f"QEMU exited with code {process.returncode} before the " + f"guest became ready" + ) from exc time.sleep(poll_interval) raise GuestVsockError( @@ -169,12 +181,20 @@ def check_guest_ready( *, timeout: float | None = None, poll_interval: float = 2.0, + process: subprocess.Popen | None = None, ) -> tuple[bool, str]: """ Return whether the guest vsock agent responds. + + ``process`` is forwarded to :func:`wait_for_guest`; see its docstring. """ try: - wait_for_guest(profile, timeout=timeout, poll_interval=poll_interval) + wait_for_guest( + profile, + timeout=timeout, + poll_interval=poll_interval, + process=process, + ) except GuestVsockError as exc: return False, str(exc) diff --git a/sev_verify/vm_profile.py b/sev_verify/vm_profile.py index ed500ac5..d042efb3 100644 --- a/sev_verify/vm_profile.py +++ b/sev_verify/vm_profile.py @@ -228,10 +228,20 @@ def vm_launch( # Import here to avoid circular import with guest_vsock. from .guest_vsock import check_guest_ready - booted, boot_error = check_guest_ready(self) + # Passing the process lets the wait abort as soon as QEMU dies, + # instead of polling vsock until the boot timeout expires against + # a VM that no longer exists. + booted, boot_error = check_guest_ready(self, process=process) if not booted: ok = False message = f"Guest did not boot: {boot_error}" + if process.poll() is not None: + # QEMU's own diagnosis is far more useful than "agent not + # ready" — for a rejected launch it names the firmware + # error, e.g. "SNP_LAUNCH_FINISH ... 'Bad measurement'". + stderr_tail = _read_guest_errors(self.guest_error_log).strip() + if stderr_tail: + message = f"{message}\nQEMU stderr:\n{stderr_tail}" elif message == "VM launch verified": message = "VM launched and guest booted"