#!/usr/bin/env python3 """Reproducible QEMU integration tests for danos. For each test case it builds the kernel with `-Dtest-case=`, boots it headless in QEMU with the serial port captured to a file, and asserts that the expected marker appears in that output before a timeout. The kernel's serial log is machine-readable (see src/kernel/tests.zig and src/kernel/arch/*/serial.zig), so no screen-scraping is involved. Structured per-architecture so a second CPU (e.g. an AArch64 Raspberry Pi) is a matter of adding an ARCHES entry and its serial/boot support — the test cases and the runner stay the same. Usage: python3 test/qemu_test.py # run all cases on the default arch python3 test/qemu_test.py --arch x86_64 # pick an architecture python3 test/qemu_test.py smoke fault-pf # run only named cases """ import argparse import os import re import shutil import subprocess import sys import time REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) WORK = os.path.join(REPO, "zig-out", "qemu-test") # --- Architecture configurations ------------------------------------------- # Each describes how to build and boot that architecture. Only the fields that # actually differ between architectures live here. ARCHES = { "x86_64": { "zig_flags": [], # build.zig currently pins the kernel to x86_64 "qemu": "qemu-system-x86_64", # Firmware lives in different places per OS/distro; resolve_firmware() # picks the first that exists so the harness runs unconfigured on Arch, # Debian/Ubuntu, Fedora, and macOS (Homebrew). Override by reordering or # dropping an absolute path at the front. "ovmf_code": [ "/usr/share/edk2/x64/OVMF_CODE.4m.fd", # Arch "/usr/share/OVMF/OVMF_CODE_4M.fd", # Debian/Ubuntu "/usr/share/OVMF/OVMF_CODE.fd", # older Debian/Ubuntu "/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", # Fedora "/opt/homebrew/share/qemu/edk2-x86_64-code.fd", # macOS Homebrew (Apple Silicon) "/usr/local/share/qemu/edk2-x86_64-code.fd", # macOS Homebrew (Intel) ], "ovmf_vars": [ "/usr/share/edk2/x64/OVMF_VARS.4m.fd", # Arch "/usr/share/OVMF/OVMF_VARS_4M.fd", # Debian/Ubuntu "/usr/share/OVMF/OVMF_VARS.fd", # older Debian/Ubuntu "/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", # Fedora "/opt/homebrew/share/qemu/edk2-i386-vars.fd", # macOS Homebrew (Apple Silicon) "/usr/local/share/qemu/edk2-i386-vars.fd", # macOS Homebrew (Intel) ], # zig-out is a FHS-shaped image and the boot volume; the harness copies the # boot-critical files from their FHS paths into a fresh ESP with the same # layout. (dest in ESP, source path under zig-out) — identical here. "efi_app": ("EFI/BOOT/BOOTX64.efi", "EFI/BOOT/BOOTX64.efi"), "kernel": ("system/kernel", "system/kernel"), # The init user program and the initial-ramdisk (VFS server + drivers). "extra": [("system/services/init", "system/services/init"), ("boot/initial-ramdisk.img", "boot/initial-ramdisk.img")], # Built as a function so we can splice in per-run paths. "qemu_args": lambda a, esp, vars_fd, serial: [ "-machine", "q35", "-m", "128M", "-drive", f"if=pflash,format=raw,readonly=on,file={a['ovmf_code']}", "-drive", f"if=pflash,format=raw,file={vars_fd}", "-drive", f"format=raw,file=fat:rw:{esp}", "-net", "none", "-vga", "none", "-device", "VGA,edid=on,xres=1280,yres=720", "-display", "none", "-serial", f"file:{serial}", "-no-reboot", # exit instead of rebooting on a triple fault ], }, # To add an architecture, e.g. "aarch64": provide its qemu binary, firmware, # boot method, and the AArch64 kernel/serial support in src/kernel/arch/aarch64/. } # --- Test cases ------------------------------------------------------------ # `expect`: a regex that must appear in serial output => pass. # `fail`: optional regex whose appearance => immediate fail. CASES = [ {"name": "smoke", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "discovery", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "wx", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "timer", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "clock", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "vmm", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "heap", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "sched", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "priority", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "sleep", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "event", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "ipc", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Synchronous IPC: a client and server ping-pong 100 calls through an # endpoint (rendezvous, reply routing, cross-address-space copy). {"name": "ipc-call", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # IPC capability passing (M13): a client hands the server an endpoint in a call, # the server hands one back in its reply; each is verified same-object + shared. {"name": "ipc-cap", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # DMA memory (M14): contiguous frame allocation, below-4G cap, coherent mapping, # and reclaim on teardown. {"name": "dma", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # MSI (M15): allocate a per-device vector and deliver it as a notification (a # self-IPI stands in for the device's MSI write, since the HPET has no MSI). {"name": "msi", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # IOMMU (M16): boot with an emulated VT-d unit and confirm danos parses the DMAR # table and reads the unit's registers. Detection only — enforcement is future. {"name": "iommu", "qemu_extra": ["-device", "intel-iommu,intremap=off"], "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Port I/O grants: a claimed device's io_port resource lets a driver read/write its # ports (PS/2 status 0x64), gated by the claim; out-of-range/unclaimed is refused. {"name": "ioport", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Monotonic clock (clock() syscall source): calibrated, advancing, never backwards. {"name": "clock", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Parallelism: needs more than one core, so this case boots with -smp 4. {"name": "smp", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Affinity: a pinned task must never migrate off its core. {"name": "affinity", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Stress the big kernel lock across cores; heavier, so a longer timeout. {"name": "smp-stress", "smp": 4, "timeout": 150, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Retry: a forced first-wake failure must still bring every core online. {"name": "smp-retry", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"}, {"name": "fault-pf", "expect": r"page fault \(vector 14\)"}, {"name": "fault-df", "expect": r"double fault \(vector 8\)"}, # Fault on an application processor: a #DF pinned to core 1 must be caught by that # core's own IST (N >= 1), proving per-core TSS works; a broken one triple-faults. {"name": "fault-ap-df", "smp": 4, "expect": r"core [1-9]\d*: double fault \(vector 8\)", "fail": r"could not pin"}, {"name": "fault-nx", "expect": r"page fault \(vector 14\)", "fail": r"NX not enforced"}, {"name": "fault-null", "expect": r"page fault \(vector 14\)"}, # Memory grants: the mmap/munmap path hands out user pages into a process's # arena, translate resolves them, munmap frees them, and no frames leak. {"name": "usermem", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Isolation: a ring-3 read of a kernel-only page must #PF with error code # 0x5 (present|user) at the user IP. ([\s\S] spans lines; `.` doesn't.) {"name": "user-pf", "expect": r"page fault \(vector 14\)[\s\S]*error code : 0x5[\s\S]*IP\s*: 0x00007000000000", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Fault recovery: a scheduled ring-3 process that faults is killed — resources # reclaimed, core kept — while init's heartbeat proves the OS survived. {"name": "fault-recovery", "timeout": 60, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Process arguments: argv arrives on the SysV entry stack (argv[0] = the spawned # name, argv[1..] = the system_spawn argument blob) and echoes back intact. {"name": "args", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The real user binary: the bootloader ships /system/services/init off the ESP, the # kernel loads the ELF and runs it in ring 3, and it writes + exits cleanly. {"name": "init", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Real processes: /system/services/init loaded as a scheduled ring-3 process with its # own address space, run twice (create/exit/teardown/recreate), coexisting # with a kernel task under preemption. {"name": "process", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # process_enumerate: a task-table snapshot lists spawned processes by name and # id alongside the kernel tasks, and a too-small buffer still reports the total. {"name": "process-list", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # process_kill + the exit notification: only the supervisor may kill; a blocked # victim is reaped in place and a spinning one dies by the deferred (tick) path; # each death posts one exit badge to the endpoint given at spawn. {"name": "process-kill", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The user-side whole: process-test supervises two children from ring 3 — # spawn with an exit endpoint, enumerate, kill, notification, gone. {"name": "supervision", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M17.1: a dead process's device claims are released by the reap — kill a child # holding a claim, the device must be claimable again (process-lifecycle.md). {"name": "claim-release", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M17.3: published exit events — the VFS subscribes, a client dies holding an # open handle, and the VFS releases it (process-lifecycle.md "Who learns of a death"). {"name": "vfs-client-death", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M17.4: signals over IPC — ping, reload, terminate (clean exit), the one-shot # timer, and the stop sequence's two endings, all driven from ring 3. {"name": "signals", "smp": 4, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M18.2: bus tree reports — the xHCI driver scans its root-hub ports and # reports both QEMU devices; the manager mirrors, prunes on the reporter's # death, and the respawned driver re-reports (docs/device-manager.md). {"name": "usb-report", "smp": 4, "timeout": 150, "qemu_extra": ["-device", "qemu-xhci,id=xhci", "-device", "usb-kbd,bus=xhci.0", "-device", "usb-mouse,bus=xhci.0"], "expect": r"device-manager: child added[\s\S]*" r"device-manager: child added[\s\S]*" r"device-manager: test mode: killing the reporter[\s\S]*" r"device-manager: child removed[\s\S]*" r"device-manager: restarting usb-xhci-bus[\s\S]*" r"device-manager: child added", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M19.1: the ring-3 PCI scan (pci-bus walks the ECAM through its mmio_map # grant) finds exactly the functions the kernel's own walk recorded. {"name": "pci-scan", "smp": 4, "timeout": 60, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M18.3: the application surface — device-list enumerates the tree over IPC, # subscribes (endpoint as capability), and observes the removed/added events # the reporter's test-kill produces (docs/device-manager.md). {"name": "device-list", "smp": 4, "timeout": 150, "qemu_extra": ["-device", "qemu-xhci,id=xhci", "-device", "usb-kbd,bus=xhci.0", "-device", "usb-mouse,bus=xhci.0"], "expect": r"device-list: \d+ devices[\s\S]*" r"device-list: subscribed[\s\S]*" r"device-manager: test mode: killing the reporter[\s\S]*" r"device-list: removed \(device[\s\S]*" r"device-list: added \(device", "fail": r"DANOS-TEST-RESULT: FAIL"}, # M18.1: the device manager's hello + restart policy — xHCI hellos clean and # stays; crash-test faults, is restarted with backoff (re-claiming its device # each time), and hits the crash-loop cap (docs/device-manager.md). {"name": "driver-restart", "smp": 4, "timeout": 150, "qemu_extra": ["-device", "qemu-xhci,id=xhci", "-device", "usb-kbd,bus=xhci.0", "-device", "usb-mouse,bus=xhci.0"], "expect": r"usb-xhci-bus: hello acknowledged[\s\S]*" r"device-manager: restarting crash-test[\s\S]*" r"device-manager: crash-test is failing repeatedly", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The initial_ramdisk: the loader ferries a bundle of user binaries; the kernel parses # it and spawns each as a ring-3 process (here the VFS-server stub heartbeats). {"name": "initial-ramdisk", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The user-space VFS: a client opens/writes/reads a file through the rt file # API, which IPCs the VFS server process; the round trip must match. {"name": "vfs", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The input service: a synthetic keyboard source publishes events, the service # broadcasts them over the async ipc_send primitive, and a subscriber (which joined by # passing its endpoint as a capability) receives them — source -> service -> subscriber. {"name": "input", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # IO passthrough + IRQ-as-IPC: a user-space HPET driver maps device MMIO into # its own address space, binds the device's interrupt to an IPC endpoint, and # is woken by the hardware five times while blocked (never polling). {"name": "hpet", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Device manager: a ring-3 service enumerates /system/devices and matches each # device to a driver (discovery + policy in user space). This increment logs the # decision; spawning follows. {"name": "device-manager", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # Bus driver: a user process claims a device, enumerates its children from the # hardware, and publishes each with dev_register — and the kernel refuses a child # whose window escapes the parent's (else dev_register maps arbitrary memory). {"name": "bus", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # IRQ teardown: an exiting driver's line is masked and its slot cleared (so no # ISR notifies a freed endpoint), and a sibling owner sharing that endpoint # keeps its own binding. The path hpet never takes, since it runs forever. {"name": "irqfree", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The MMIO-grant teardown fix: a device-granted frame must not be reclaimed # as RAM when its address space is destroyed. {"name": "iopass", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, # The ACPI power path succeeds by QEMU *exiting* (S5 off / reset), so match the # pre-transition marker; the FAIL line only appears if the transition didn't take. {"name": "poweroff", "expect": r"DANOS-POWER: attempting poweroff", "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "reboot", "expect": r"DANOS-POWER: attempting reboot", "fail": r"DANOS-TEST-RESULT: FAIL"}, ] TIMEOUT = 30 # seconds per case def build(arch, case): cmd = ["zig", "build", f"-Dtest-case={case}"] + arch["zig_flags"] r = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True) if r.returncode != 0: return r.stderr.strip() or r.stdout.strip() return None def make_esp(arch): """Assemble a fresh EFI System Partition from the freshly built binaries.""" esp = os.path.join(WORK, "esp") if os.path.exists(esp): shutil.rmtree(esp) efi_dest, efi_src = arch["efi_app"] kern_dest, kern_src = arch["kernel"] fhs = os.path.join(REPO, "zig-out") # zig-out is the FHS image os.makedirs(os.path.join(esp, os.path.dirname(efi_dest)), exist_ok=True) os.makedirs(os.path.join(esp, os.path.dirname(kern_dest)), exist_ok=True) shutil.copy(os.path.join(fhs, efi_src), os.path.join(esp, efi_dest)) shutil.copy(os.path.join(fhs, kern_src), os.path.join(esp, kern_dest)) for dest, src in arch.get("extra", []): os.makedirs(os.path.join(esp, os.path.dirname(dest)), exist_ok=True) shutil.copy(os.path.join(fhs, src), os.path.join(esp, dest)) return esp def resolve_firmware(arch): """Collapse the ovmf_code/ovmf_vars candidate lists to the first path that exists on this machine. Mutates `arch` in place; idempotent (a resolved string is left untouched). Exits with a clear message if none is found.""" for key, label in (("ovmf_code", "OVMF_CODE"), ("ovmf_vars", "OVMF_VARS")): val = arch[key] if isinstance(val, str): continue # already resolved on a previous call for cand in val: if os.path.exists(cand): arch[key] = cand break else: sys.exit(f"error: no {label} firmware image found; looked in:\n " + "\n ".join(val) + "\nInstall OVMF (edk2-ovmf / ovmf) or add its path above.") def run_case(arch, case): err = build(arch, case["name"]) if err: return False, "build failed:\n" + err esp = make_esp(arch) vars_fd = os.path.join(WORK, "vars.fd") shutil.copy(arch["ovmf_vars"], vars_fd) serial = os.path.join(WORK, "serial.log") if os.path.exists(serial): os.remove(serial) expect = re.compile(case["expect"]) fail = re.compile(case["fail"]) if case.get("fail") else None cmd = [arch["qemu"]] + arch["qemu_args"](arch, esp, vars_fd, serial) if case.get("smp"): # some cases need more than one core (e.g. parallelism) cmd += ["-smp", str(case["smp"])] if case.get("qemu_extra"): # extra qemu args, e.g. -device intel-iommu for the IOMMU case cmd += case["qemu_extra"] qemu = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: timeout = case.get("timeout", TIMEOUT) deadline = time.monotonic() + timeout while time.monotonic() < deadline: time.sleep(0.2) text = "" if os.path.exists(serial): with open(serial, "r", errors="replace") as f: text = f.read() if fail and fail.search(text): return False, "hit failure marker" if expect.search(text): return True, "matched " + repr(case["expect"]) if qemu.poll() is not None: # QEMU exited on its own if expect.search(text): return True, "matched " + repr(case["expect"]) return False, "QEMU exited before matching (triple fault?)" return False, f"timed out after {timeout}s without matching {case['expect']!r}" finally: if qemu.poll() is None: qemu.terminate() try: qemu.wait(timeout=5) except subprocess.TimeoutExpired: qemu.kill() def main(): ap = argparse.ArgumentParser() ap.add_argument("--arch", default="x86_64", choices=sorted(ARCHES)) ap.add_argument("cases", nargs="*", help="case names to run (default: all)") args = ap.parse_args() arch = ARCHES[args.arch] if not shutil.which(arch["qemu"]): print(f"error: {arch['qemu']} not found on PATH", file=sys.stderr) return 2 resolve_firmware(arch) selected = [c for c in CASES if not args.cases or c["name"] in args.cases] os.makedirs(WORK, exist_ok=True) print(f"danos qemu tests arch={args.arch} cases={len(selected)}\n") failures = 0 for case in selected: print(f" {case['name']:<12} ... ", end="", flush=True) ok, detail = run_case(arch, case) if not ok: # Keep the evidence: serial.log is otherwise overwritten by the # next case, and an intermittent failure's log is unrecoverable. source = os.path.join(WORK, "serial.log") if os.path.exists(source): shutil.copy(source, os.path.join(WORK, f"{case['name']}-failed-serial.log")) print(("PASS" if ok else "FAIL") + f" ({detail})") if not ok: failures += 1 print() total = len(selected) print(f"{total - failures}/{total} passed") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())