diff --git a/e2e/platforms/qemu_windows/guest.py b/e2e/platforms/qemu_windows/guest.py index 5676666e..9889ffbe 100644 --- a/e2e/platforms/qemu_windows/guest.py +++ b/e2e/platforms/qemu_windows/guest.py @@ -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`.""" diff --git a/e2e/platforms/qemu_windows/run.py b/e2e/platforms/qemu_windows/run.py index d02ce3d2..4a061c7b 100644 --- a/e2e/platforms/qemu_windows/run.py +++ b/e2e/platforms/qemu_windows/run.py @@ -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 diff --git a/e2e/platforms/qemu_windows/tests/test_runner.py b/e2e/platforms/qemu_windows/tests/test_runner.py index dadb322b..9c98b79d 100644 --- a/e2e/platforms/qemu_windows/tests/test_runner.py +++ b/e2e/platforms/qemu_windows/tests/test_runner.py @@ -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()