fix(e2e): run a scenario again when QEMU aborted under it

QEMU has died with SIGABRT while a second display was plugged in (a
hotplug repeat and monitors.at_cursor), which is not a result about kfw.
Its stderr is kept, a dead guest is named as such, and the scenario is
run once more on a fresh guest.
This commit is contained in:
LGUG2Z committed 2026-10-30 18:22:44 -07:00
1 parent ea449c2d80
commit d4d293fbd8
3 files changed
+60 -6

No files matched your search

+18 -1
View File
@@ -183,7 +183,24 @@ class Guest:
shutil.copytree(self._run / "tpm", dest / "tpm", dirs_exist_ok=True)
def _start(self, argv: list[str]) -> None:
self._procs.append(subprocess.Popen(argv))
# what a program writes to stderr is kept: it says why QEMU aborted
log = self._run / f"{Path(argv[0]).name}.err"
with open(log, "wb") as err:
self._procs.append(subprocess.Popen(argv, stderr=err))
def died(self) -> str | None:
"""Why QEMU has exited, if it has while the guest was in use (a signal
that killed it, and the last of what it wrote), else `None`."""
for proc in self._procs:
code = proc.poll()
if code is None or proc.args[0] != "qemu-system-x86_64":
continue
log = self._run / "qemu-system-x86_64.err"
tail = log.read_text(errors="replace").strip().splitlines()[-5:] \
if log.exists() else []
how = f"signal {-code}" if code < 0 else f"status {code}"
return f"QEMU exited with {how}: " + " | ".join(tail)
return None
def upload(self, local_dir: Path, names: list[str]) -> None:
"""Puts binaries under test in the guest's `C:\\e2e\\bin`."""
+24 -5
View File
@@ -64,6 +64,10 @@ PLACEMENT_INTEGER_BUG = (
)
class GuestDied(RuntimeError):
"""QEMU exited while a scenario was running: not what kfw did."""
class RecordedChecks(Checks):
"""`Checks` that also keeps each check, for the result file."""
@@ -423,13 +427,20 @@ def run_one(
print(f" AGENT LOST: {e}")
checks.bad(str(e))
_fetch_logs(guest, out)
except Exception:
except Exception as e:
died = guest.died()
if died:
# the guest is gone, so there is nothing to fetch from it
raise GuestDied(died) from e
_fetch_logs(guest, out)
raise
finally:
lap("scenario")
print(f" seconds: {timings}")
platform.screenshot(f"final-{name}")
try:
platform.screenshot(f"final-{name}")
except Exception:
pass
if recorder:
_collect_recording(platform, out)
# matches a recorder session directory with the run that made it
@@ -461,9 +472,17 @@ def main(argv: list[str]) -> int:
state = ensure(Path(golden), Path(os.environ["KOMOREBI_E2E_STATE"]))
for name in names:
try:
checks = run_one(
Path(golden), Path(bin_dir), Path(out) / name, name, state
)
try:
checks = run_one(
Path(golden), Path(bin_dir), Path(out) / name, name, state
)
except GuestDied as e:
# QEMU itself aborted (it has, while a display was plugged in):
# not a result about kfw, so the scenario runs once more
print(f"{name}: THE GUEST DIED ({e}); running it again")
checks = run_one(
Path(golden), Path(bin_dir), Path(out) / name, name, state
)
except Exception:
# a scenario that cannot run is a failure of its own, and the
# others still run
@@ -226,3 +226,21 @@ def test_acting_on_a_window_that_is_not_there_raises_a_clear_error() -> None:
platform.fullscreen, platform.user_maximize):
with pytest.raises(RuntimeError, match="no window titled 'gone'"):
act(gone)
def test_a_guest_that_died_says_how_and_a_live_one_says_nothing(tmp_path: Path) -> None:
guest = Guest(Path("golden"), tmp_path)
live = subprocess.Popen(["sleep", "5"])
guest._procs.append(live)
assert guest.died() is None
live.kill()
live.wait()
dead = subprocess.Popen(["sh", "-c", "kill -ABRT $$"])
dead.wait()
dead.args = ["qemu-system-x86_64"]
(tmp_path / "qemu-system-x86_64.err").write_text("assertion failed\nAborted\n")
guest._procs = [dead]
assert "signal 6" in guest.died()
assert "assertion failed" in guest.died()