Skip to content

fix: four harness bugs (profile mutation, launch expectations, result folding, boot wait) - #289

Merged
markg-github merged 5 commits into
AMDEPYC:mainfrom
markg-github:fix/harness-bugs
Aug 19, 2026
Merged

fix: four harness bugs (profile mutation, launch expectations, result folding, boot wait)#289
markg-github merged 5 commits into
AMDEPYC:mainfrom
markg-github:fix/harness-bugs

Conversation

@markg-github

Copy link
Copy Markdown
Contributor

Four pre-existing harness bugs, none of which need any knowledge of ID blocks to review. Split out so they can merge ahead of, and independently of, the ID block work in #247.

+59/-6 across 4 files, one commit per fix.


1. cli.py — re-read the VM profile from context between steps

Why a test would need to change the profile mid-run. The vm_profile a 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 than ctx.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_data value 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 in ctx.profile.

The bug. execute_test assigned the loop-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 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. profile is the only StepContext field with step → harness dataflow. launch has the identical overwrite shape (ctx.launch = launch each iteration) but flows the other way — the harness produces it and steps consume it — so overwriting is correct there. step_results is 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 — honour expected_result when a launch raises

vm_launch has two distinct failure modes, and only one of them raises:

Failure mode Signal Old handling
QEMU exits immediately raises VMLaunchError error, unconditionally
QEMU runs, guest never boots returns ok=False exit_code=1, expected_result honoured

The ok=False path was already correct. So a step declaring expected_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_START before guest memory is loaded, so QEMU dies instantly and raises → error. A launch rejected later, after QEMU is already running, returns ok=Falsepass. 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:0 and still report error, so existing behavior is unchanged.

3. cli.py — stop a later test result masking a worse earlier one

if tr.result != "pass":
    overall = tr.result

Every non-passing test overwrites overall, so the certification reports the result of the last non-passing test rather than the worst one:

test1 error  ->  overall = "error"
test2 fail   ->  overall = "fail"     <- the error is now lost
test3 pass   ->  unchanged
                 final: "fail"

Why it matters. fail and error prescribe different responses. fail means the platform failed a check — investigate the platform. error means the harness could not run the check at all (module import failure, timeout, crash) — investigate the harness. Reporting fail when an error occurred 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_level is 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 overwrite fail with skip, 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 < error ranks 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 exited

QEMU liveness is checked exactly once, at wait_ready_seconds. wait_for_guest then polls vsock until vsock_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:

+14.6s ..  +17.2s   QEMU alive
+17.2s ..           gone
step ran +13.8s .. +195.9s

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 of Vsock agent on CID ... not ready after 180.0s. A step asserting exit_code:1 is 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.

process defaults to None, 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-test against four guest images, since the point of a fixes-only PR is that existing behavior still works:

Image Result Secs
centos-10 pass 16
fedora-41 pass 19
ubuntu-25.10 pass 19
ubuntu-26.04 pass 19

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 issues SNP_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.log is 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 into ctx.artifact_dir would make this diagnosable from artifacts alone.

Copilot AI lite review requested due to automatic review settings August 14, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes four pre-existing issues in the SEV verification harness to make multi-step VM runs more reliable: it preserves step-driven profile updates, correctly applies expected_result to launch exceptions, folds certification status using a severity ordering, and aborts guest-boot waits early when QEMU exits.

Changes:

  • cli.py: Preserve step-updated ctx.profile across steps; fold certification outcome via severity ordering.
  • runner.py: Treat VMLaunchError as a checkable launch outcome by honoring expected_result.
  • vm_profile.py / guest_vsock.py: Pass the QEMU process handle to vsock wait logic to stop waiting when QEMU has already exited, and surface QEMU stderr context.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
sev_verify/cli.py Re-reads ctx.profile each step; adds severity-based folding for overall certification result.
sev_verify/runner.py Honors expected_result even when vm_launch raises VMLaunchError.
sev_verify/vm_profile.py Forwards the QEMU process handle into boot readiness checks and appends QEMU stderr tail on exit.
sev_verify/guest_vsock.py Adds optional process-aware early abort for guest readiness waits.
Suppressed comments (1)

sev_verify/guest_vsock.py:185

  • Same typing consistency issue as wait_for_guest: annotate process as subprocess.Popen[bytes] | None to match VMLaunchResult.process and the actual subprocess.Popen usage in vm_profile.
def check_guest_ready(
    profile: VMProfile,
    *,
    timeout: float | None = None,
    poll_interval: float = 2.0,
    process: subprocess.Popen | None = None,
) -> tuple[bool, str]:

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread sev_verify/cli.py Outdated
Comment thread sev_verify/guest_vsock.py
Copilot AI review requested due to automatic review settings August 14, 2026 18:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (4)

sev_verify/vm_profile.py:244

  • process is dereferenced unconditionally (process.poll()), but check_guest_ready() accepts process=None and VMLaunchResult.process is typed as optional in context. If process can be None in this code path, this will raise AttributeError and mask the real boot failure. Consider guarding process explicitly (e.g., if process is not None and process.poll() is not None:) before accessing it.
            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}"

sev_verify/guest_vsock.py:146

  • subprocess.Popen[bytes] in annotations can be a runtime TypeError (“type 'Popen' is not subscriptable”) unless this module uses postponed evaluation of annotations (e.g., from __future__ import annotations) or otherwise avoids evaluating the subscript at runtime. To keep this safe across runtime configurations, consider changing the annotation to an unsubscripted subprocess.Popen | None, or using a typing alias that won’t be evaluated at runtime.
    process: subprocess.Popen[bytes] | None = None,

sev_verify/runner.py:190

  • _check_expected_values() documents checking exit_code and stdout, but this path passes str(exc) as the “stdout” argument while also writing it to stderr. That makes expected_result=stdout_contains:... effectively match against an error message, which is inconsistent with the documented semantics and with where the output is stored in StepResult. Consider either (a) extending _check_expected_values to check stderr (e.g., a stderr_contains kind or a generalized “output_contains”), or (b) storing the exception message in stdout when using stdout_contains expectations so the behavior is internally consistent.
        # A launch that fails hard is still a launch outcome, so honour the
        # step's expected_result. Reporting "error" unconditionally made it
        # impossible to write a step that expects a launch to be refused —
        # QEMU exiting immediately raises rather than returning ok=False.
        passed = _check_expected_values(step, 1, str(exc))
        return (
            StepResult(
                step=step,
                result="pass" if passed else "error",
                exit_code=1,
                stderr=str(exc),
                duration_ms=duration_ms,
            ),

sev_verify/cli.py:509

  • The comparison on line 509 is hard to read and likely to violate typical line-length linting. Consider splitting into named locals (e.g., current_sev, candidate_sev) to improve readability and ease future edits (such as changing how unknown results are handled).
def _worse_result(current: str, candidate: str) -> str:
    """Return whichever of the two results is more severe."""
    unknown_severity = max(_RESULT_SEVERITY.values()) + 1
    if _RESULT_SEVERITY.get(candidate, unknown_severity) > _RESULT_SEVERITY.get(current, unknown_severity):
        return candidate
    return current

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
markg-github marked this pull request as ready for review August 17, 2026 21:00
Copilot AI review requested due to automatic review settings August 17, 2026 21:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

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.
Copilot AI review requested due to automatic review settings August 17, 2026 21:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

sev_verify/runner.py:182

  • VMLaunchError includes QEMU’s real exit code (see vm_profile.py:218-221), but this handler hard-codes 1 both for the expected_result check and the returned StepResult.exit_code. That can misreport failures and makes expected_result=exit_code:<actual> impossible for hard-fail launches. Consider extracting the real code from the exception message and using it for both _check_expected_values and StepResult.exit_code (falling back to 1 if parsing fails).
        passed = _check_expected_values(step, 1, str(exc))

@amd-aliem amd-aliem left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, you might need to re-run one of the image build CI tests

@markg-github

Copy link
Copy Markdown
Contributor Author

Thank you, @amd-aliem

@markg-github
markg-github merged commit d4d8f80 into AMDEPYC:main Aug 19, 2026
20 of 21 checks passed
markg-github added a commit to markg-github/sev-certify that referenced this pull request Sep 1, 2026
Addresses the review comment asking whether the negative launches could
check the reason for failure rather than accepting any launch error.

expected_result="exit_code:1" is satisfied by anything that fails,
including a boot timeout — so these steps could not distinguish a real
firmware rejection from a hung guest, and would have passed even if the
ID block were ignored entirely.

The harness fix merged as AMDEPYC#289 makes the firmware's own text reachable
from both launch-failure paths (the raise path passes str(exc) through
_check_expected_values; the ok=False path now carries QEMU's stderr
tail), so it can be asserted directly. Observed on an EPYC 9654 at BIOS
1.10.6:

  bad measurement  SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement'
  SMT policy       SNP_LAUNCH_START  ret=-22 fw_error=0  ''
  ABI version      SNP_LAUNCH_START  ret=-5 fw_error=7  'Policy is not allowed'

The SMT case is refused by KVM before the firmware sees it, so it has no
firmware string to match; it asserts SNP_LAUNCH_START instead, which
still rules out a boot timeout. Worth noting that this test therefore
exercises KVM's policy validation rather than the firmware's.
markg-github added a commit to markg-github/sev-certify that referenced this pull request Sep 3, 2026
Addresses the FMC/Turin review comments on AMDEPYC#247. The gap was worse than
"unsupported": TCB_VERSION is laid out differently on Turin, and the two
layouts are indistinguishable from the eight bytes alone.

    byte   Milan/Genoa    Turin/Venice
    0      BOOT_LOADER    FMC
    1      TEE            BOOT_LOADER
    2      --             TEE
    3      --             SNP
    6      SNP            --
    7      MICROCODE      MICROCODE

Decoding a Turin report with the legacy layout yielded BOOT_LOADER=FMC,
TEE=BOOT_LOADER, SNP=0 — plausible values, no error. The sev crate
handles this by dispatching on processor Generation
(from_legacy_bytes/from_turin_bytes); we hardcoded one layout.

Resolve a generation from the host's CPUID family/model, which is
authoritative for a report produced by a guest on this machine and,
unlike the report's own CPUID copy, present regardless of report
version. parse() takes it as an argument so it stays a pure function of
its input, unit-testable without hardware. When the report is v3+ it
carries CPUID too, and the two are cross-checked: a mismatch means the
report did not come from this machine.

SUPPORTED_GENERATIONS records what has been validated on hardware, not
what we believe we could decode — currently Genoa alone. An unrecognised
processor raises rather than assuming a transcribed layout is right: a
certification harness reporting a pass on silicon it has never run on is
the failure this gate exists to prevent. Layouts for generations not yet
exercised are recorded alongside it, so adding one is an entry plus a
validation note.

Keyed on family/model pairs rather than family/model/stepping triples:
AMD scopes SEV firmware images by family and model only, and snpguest's
get_processor_model partitions the same way.

The module docstring now also records that this is a short-term
stand-in. snpguest already parses reports correctly and
generation-aware via the sev crate but exposes them only as
human-readable text; once it gains machine-readable output this module
should be replaced by consuming that rather than extended to cover
further generations.

test: assert why a negative launch was rejected, not just that it failed

Addresses the review comment asking whether the negative launches could
check the reason for failure rather than accepting any launch error.

expected_result="exit_code:1" is satisfied by anything that fails,
including a boot timeout — so these steps could not distinguish a real
firmware rejection from a hung guest, and would have passed even if the
ID block were ignored entirely.

The harness fix merged as AMDEPYC#289 makes the firmware's own text reachable
from both launch-failure paths (the raise path passes str(exc) through
_check_expected_values; the ok=False path now carries QEMU's stderr
tail), so it can be asserted directly. Observed on an EPYC 9654 at BIOS
1.10.6:

  bad measurement  SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement'
  SMT policy       SNP_LAUNCH_START  ret=-22 fw_error=0  ''
  ABI version      SNP_LAUNCH_START  ret=-5 fw_error=7  'Policy is not allowed'

The SMT case is refused by KVM before the firmware sees it, so it has no
firmware string to match; it asserts SNP_LAUNCH_START instead, which
still rules out a boot timeout. Worth noting that this test therefore
exercises KVM's policy validation rather than the firmware's.

refactor: drop the duplicated calculate_measurement

feat: improve SMT sub-test

fix: satisfy crypto module dependency

This fixes sev-certify automated runs. For sev_verify only runs, it may
or may not. See the updated sev_verify/readme.md.

fix: decode reports whose firmware left the CPUID fields empty

The parser refused any report whose CPUID bytes did not resolve to a validated
generation, reporting it as an unsupported processor. That is wrong when the
processor is fine and the firmware simply did not populate the field: SEV
firmware 1.55 build 38 leaves all three bytes zero in version-3 reports, where
build 39 fills them in. On such a platform every report was rejected, and the
error blamed the CPU.

The host's CPUID is authoritative — it describes the silicon this code runs on,
which is what the TCB_VERSION layout depends on, and it is present regardless of
report version. The report's copy is a cross-check, useful only when it
resolves. So an unresolvable one is now recorded in a new cpuid_note field and
decoding proceeds with the host's generation.

The exception is kept for the case that does indicate a problem: both CPUIDs
resolve to validated generations and disagree, meaning the report came from
another machine. That branch only becomes reachable once a second generation is
added to SUPPORTED_GENERATIONS; with one validated generation every disagreeing
CPUID is unresolvable instead. That ordering is deliberate — a report is called
foreign only when both generations are ones we have actually validated against.

With no host generation supplied and an unresolvable CPUID, TCB_VERSION is now
left undecoded rather than raising, matching what a v2 report already does.

docs: say how to add a report version, not just a processor

SUPPORTED_GENERATIONS explains how to validate and add a processor
generation; KNOWN_VERSIONS only stated which versions were readable and
refused the rest, leaving the reader to work out what adding one involves.

Record the procedure, and that versions 4 and 5 already exist and are
refused. Note the cheap case explicitly: the sev crate's ReportVariant
mapping groups versions sharing a layout, so a version grouped with one
already listed reads identically, which makes v4 straightforward and v5
the one needing scrutiny. Also note that additions are not always new
offsets — v5's GuestPolicy and PlatformInfo changes are new bits in
existing fields.

State that version and generation are independent axes, since the two
gates look redundant until it is clear that one decides which fields
exist and the other how TCB_VERSION is ordered.

feat: validate Turin and report version 5 on hardware

Adds the second processor generation and the second report version, both
confirmed on an EPYC 9575F rather than transcribed.

The Turin TCB_VERSION layout had been taken from the sev crate and never
exercised. A version 5 report from that machine carried REPORTED_TCB bytes
0103020600000062, which decode under the Turin layout to
bootloader=3 tee=2 snp=6 microcode=98 fmc=1 — matching snphost show tcb
exactly. The same bytes under the legacy layout give
bootloader=1 tee=3 snp=0 microcode=98 and no FMC: plausible values, silently
wrong. That is the failure this gate exists to prevent, now demonstrated
rather than asserted.

That report also showed version 5 moves none of the fields read here: TCB at
0x180 and CPUID at 0x188 both decoded correctly, so v5's additions are the
new GuestPolicy and PlatformInfo bits the crate documents rather than
relocations. Version 4 remains refused, never having been seen.

Two consequences worth noting. The generation mismatch branch in parse() was
unreachable while only one generation was validated; with two, a report from
the wrong machine is now genuinely detected. And Turin firmware 1.58
populates the report CPUID fields that Genoa's BIOS-supplied 1.55 build 38
leaves zeroed, so both sides of that behaviour are now covered by tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants