PR 0: harness bug fixes (independent of ID block work) - #168
Draft
markg-github wants to merge 5 commits into
Draft
PR 0: harness bug fixes (independent of ID block work)#168markg-github wants to merge 5 commits into
markg-github wants to merge 5 commits into
Conversation
markg-github
force-pushed
the
fix/harness-bugs
branch
from
August 17, 2026 20:30
d6192f2 to
91648ac
Compare
execute_test assigned the loop's local `profile` onto `ctx.profile` at the top of every iteration. A callable step that replaces the profile — `dataclasses.replace` on a frozen VMProfile returns a new object, so mutating it in place is not possible — had that replacement silently overwritten before the next step ran. Any handler that reconfigures the guest for subsequent steps was therefore a no-op. Read the profile out of the context instead of writing the stale local back over it.
run_vm_launch_step reported "error" unconditionally when vm_launch raised VMLaunchError, ignoring the step's expected_result. That made it impossible to write a step that expects a launch to be refused: QEMU exiting immediately raises rather than returning ok=False, so a step declaring expected_result="exit_code:1" errored on exactly the outcome it was asserting. Evaluate expected_result against exit code 1 and the exception text, and record exit_code=1 so the result is self-describing. Steps that do not declare an expectation still default to "exit_code:0" and so still report error, leaving existing behavior unchanged.
execute_certification folded per-test results with
if tr.result != "pass":
overall = tr.result
which is last-writer-wins, not worst-wins. A run where the first test
errors and a later one merely fails reported the certification as
"fail", losing the error. The bug is latent today because both outcomes
are non-passing, but it silently downgrades severity in reports, and any
new non-passing state makes it worse.
Fold with an explicit severity ordering instead.
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.
markg-github
force-pushed
the
fix/harness-bugs
branch
from
August 17, 2026 20:45
91648ac to
be72ac2
Compare
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> fix: parameterize the QEMU process type annotation guest_vsock used a bare subprocess.Popen while VMLaunchResult.process is annotated subprocess.Popen[bytes]. Bare Popen reads as Popen[Any] to a type checker, losing the stream element type for no benefit — only .poll() and .returncode are used. Match the existing annotation. refactor: split the severity comparison into named locals The single-expression comparison ran to 107 characters and read poorly, with _RESULT_SEVERITY.get(..., unknown_severity) appearing twice on one line. Name both severities and compare them. Behavior is identical: an unrecognised state still ranks above every known one, so a typo surfaces loudly rather than masking a real failure. That rationale moves from a comment into the docstring, where it is visible to callers.
markg-github
force-pushed
the
fix/harness-bugs
branch
from
August 17, 2026 21:32
be72ac2 to
732ae03
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four pre-existing harness bugs, none of which need any knowledge of ID blocks to review. Extracted so they can merge ahead of, and independently of, the ID block work. Review-preview PR against the fork.
+59/-6 across 4 files, one commit per fix.
1.
cli.py— re-read the VM profile from context between stepsWhy a test would need to change the profile mid-run. The
vm_profilea test module declares is static — it is evaluated before the run begins. Any launch parameter that can only be computed during the run has no other way in thanctx.profile.Examples: an ID block cannot be built until the guest has been measured, and the guest cannot be measured until the run is underway, so it is necessarily compute-then-relaunch. Likewise a policy derived from observed platform state (check whether SMT is active, then set the policy bit to match), a
host_datavalue derived from something measured earlier, or a guest image chosen by a preceding step.The rule this establishes: static launch configuration belongs in
vm_profile; anything a step computes belongs inctx.profile.The bug.
execute_testassigned the loop-localprofileontoctx.profileat the top of every iteration. A callable step that replaces the profile —dataclasses.replaceon a frozenVMProfilereturns a new object, so in-place mutation is impossible — had that replacement overwritten before the next step ran. The second channel silently did not work, and any handler reconfiguring the guest for later steps was a no-op.Does this generalise to other context fields? Not today, and the reason is worth recording.
profileis the onlyStepContextfield with step → harness dataflow.launchhas the identical overwrite shape (ctx.launch = launcheach iteration) but flows the other way — the harness produces it and steps consume it — so overwriting is correct there.step_resultsis a list mutated in place, so assignment never clobbers it. The remaining fields are static per test.The fix is therefore correctly scoped, but the trap is general: any future context field intended to carry a value out of a step will hit exactly this bug.
2.
runner.py— honourexpected_resultwhen a launch raisesvm_launchhas two distinct failure modes, and only one of them raises:VMLaunchErrorerror, unconditionallyok=Falseexit_code=1,expected_resulthonouredThe
ok=Falsepath was already correct. So a step declaringexpected_result="exit_code:1"passed or errored depending on how the launch failed — something a test author can neither control nor predict.Concretely: a policy the platform cannot satisfy is rejected at
SNP_LAUNCH_STARTbefore guest memory is loaded, so QEMU dies instantly and raises → error. A launch rejected later, after QEMU is already running, returnsok=False→ pass. Two tests asserting the same thing, disagreeing purely because of rejection timing.This aligns the raise path with the return path that already worked. Steps that declare no expectation still default to
exit_code:0and still reporterror, so existing behavior is unchanged.3.
cli.py— stop a later test result masking a worse earlier oneEvery non-passing test overwrites
overall, so the certification reports the result of the last non-passing test rather than the worst one:Why it matters.
failanderrorprescribe different responses.failmeans the platform failed a check — investigate the platform.errormeans the harness could not run the check at all (module import failure, timeout, crash) — investigate the harness. Reportingfailwhen anerroroccurred sends the reader down the wrong path.Why it is subtle, and why it has survived. Today it changes only the label, never the verdict: both states are non-passing, so the certification does not pass either way, and
_highest_certified_levelis unaffected because it inspects per-test results rather than this fold.It becomes actively harmful the moment a non-passing state exists that should not dominate. The stacked PR adds
skip: a skipped test running after a failed one would overwritefailwithskip, converting "something failed" into "something was not assessed" — which reads as benign and is not.Fold with an explicit severity ordering instead. One point worth a reviewer's opinion: the ordering
pass < skip < fail < errorranks error above fail, following the usual test-framework convention that could-not-run outranks failed. That is a judgment call, not a derivation.4.
vm_profile.py/guest_vsock.py— stop waiting for a guest whose QEMU has exitedQEMU liveness is checked exactly once, at
wait_ready_seconds.wait_for_guestthen polls vsock untilvsock_boot_timeout(180s) holding no reference to the process. If QEMU exits after that single check, the harness spends the remaining timeout pinging a VM that no longer exists.Measured on an EPYC 9654, sampling the process at 1 Hz during a launch the firmware rejects:
QEMU lived 2.6s; the step took 182.1s. Reproduced identically under two BIOS versions.
The diagnostics matter more than the 179 seconds. Firmware had already reported the cause —
SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement'— and the harness discarded it in favour ofVsock agent on CID ... not ready after 180.0s. A step assertingexit_code:1is satisfied by that timeout just as well as by a real rejection, so such a test cannot fail for the right reason. The message now carries QEMU's exit code and stderr tail.processdefaults toNone, preserving behavior for any caller that does not pass it.Verification
Unit, local: the severity fold across six orderings; the liveness check with a fake process (dies at 2s → aborts at 2.0s; no handle → full timeout and the original message, i.e. backward compatible).
On hardware, this branch alone (EPYC 9654, Genoa) — the pre-existing
c3_0_0_0 attestation-testagainst four guest images, since the point of a fixes-only PR is that existing behavior still works:11/11 steps passing on each — real guest launch, report retrieval, KDS certificate and VCEK fetch, chain verification, signature/TCB verification, measurement comparison.
certified_level: 3.0.0-0.Not tested on hardware: level
3.0.0-1(snphost-config-commit), which issuesSNP_COMMIT. That was skipped deliberately on a machine mid-way through a firmware-stepping exercise rather than because of anything in this PR.Fixes 1, 2 and 4 are exercised end-to-end by the ID block test in the stacked PR: with all four in place a suite that took ~200s takes 22s, and the negative-launch steps assert real firmware rejections instead of timeouts.
Follow-up not included
/tmp/guest-error.logis a single fixed path clobbered by every launch; recovering the evidence in fix 4 required sampling it externally at 1 Hz. Writing it per-launch intoctx.artifact_dirwould make this diagnosable from artifacts alone.