On a headless or real board nothing captures serial, so the boot log — the whole diagnostic stream — is lost at power-off. This retains it in the kernel and copies it to the boot USB volume as /mnt/usb/DANOS.LOG, the on-disk equivalent of QEMU's `-serial file:`. Pull the stick, read DANOS.LOG on another machine. How it fits together: - Kernel RAM sink (log.zig): a fixed 256 KiB in-image buffer registered as a log sink in kmain, right after serial. Because userspace debug_write funnels through log.write, it captures the entire stream — kernel lines and every service's output — from the first line. Fills linearly and stops when full (earliest boot output, the most valuable, is kept); no allocation, so it is panic-safe. - klog_read syscall (32): copies that buffer out to a user buffer, the mirror of debug_write — same overflow-safe user-half bounds check, kernel -> user copy, under the kernel lock so the snapshot can't grow mid-copy. Wrapped by runtime.system.klogRead. - log-flush (new one-shot, in the initial-ramdisk): waits for the fat server to mount /mnt/usb, then copies the whole log to /mnt/usb/DANOS.LOG. init spawns it once the boot services are up (fire-and-forget; it polls the mount itself). If no volume is mounted — no stick, or the no-VFS ramdisk sweep — it exits silently. - init shutdown flush: init repeats the copy inline at the top of shutDown(), BEFORE it tears down the storage services (the fat server is stopped first), so a clean poweroff captures the fullest log while /mnt/usb is still writable. - unistd.writeAll: loops write() past the 224-byte VFS payload cap; both flush paths use it. The filename is 8.3 (DANOS.LOG) at the mount root — the FAT short-name rule, and there is no mkdir on the FAT path yet. Extend-only writes mean the two same-session flushes never leave stale bytes (the shutdown log is a superset of the boot log); a shorter log on a later boot of the same stick can leave a stale tail — a noted, cosmetic limitation, not worth pulling O_TRUNC into the FAT write path for now. Verified end to end under QEMU: a new `log-flush` case (reusing the orderly-shutdown build) asserts both markers then S5, and DANOS.LOG is read back out of the image afterwards (11804 bytes, containing the kernel init line, the FAT mount line, and the boot flush marker). Regression stays green: zig build, zig build test, zig build check-fat-image, and a sequential QEMU sweep — smoke, init, initial-ramdisk (log-flush silent in the bare sweep), orderly-shutdown, fat-mount, usb-storage, usb-hid, vfs, input, device-manager, process, signals, dma, fault-pf.
633 lines
30 KiB
Python
633 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Reproducible QEMU integration tests for danos.
|
|
|
|
For each test case it builds the kernel with `-Dtest-case=<name>`, 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 json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
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 itself the FHS-shaped boot volume (docs/efi.md): the build
|
|
# installs BOOTX64.efi, the kernel, init, and the initial-ramdisk at their
|
|
# boot paths. The harness presents zig-out to the guest directly — exactly
|
|
# as `zig build run-x86-64` does — so there is no separate ESP to assemble.
|
|
# Built as a function so we can splice in per-run paths.
|
|
"qemu_args": lambda a, boot_volume, 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}",
|
|
# Boot off a FAT USB device: the boot volume is a mass-storage device on
|
|
# the xHCI bus (usb-kbd/usb-mouse ride the same controller). `boot_volume`
|
|
# is the FAT image the build produces. bootindex=0 steers OVMF to it.
|
|
"-device", "qemu-xhci,id=xhci",
|
|
"-device", "usb-kbd,bus=xhci.0",
|
|
"-device", "usb-mouse,bus=xhci.0",
|
|
"-drive", f"if=none,id=bootusb,format=raw,file={boot_volume}",
|
|
"-device", "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
|
|
"-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 = [
|
|
# smoke also proves the QMP channel: the harmless query must be delivered
|
|
# (handshake + command) before the case may pass — see run_case.
|
|
{"name": "smoke",
|
|
"qmp_after": {"delay": 2, "command": "query-status"},
|
|
"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"},
|
|
# TSC clocksource + cross-core warp check (real Intel/AMD / KVM path). TCG won't
|
|
# advertise an invariant TSC, so the kernel forces the TSC clocksource on for this
|
|
# case (gated in kernel.zig) and runs the per-AP warp check across the 4 cores;
|
|
# their TSCs are synchronized, so it stays on the TSC (no HPET fallback). The rest
|
|
# of the suite exercises the HPET fallback instead. See docs/timers.md.
|
|
{"name": "tsc-sync",
|
|
"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,
|
|
# The xHCI bus + usb-kbd/usb-mouse come from the default boot config now
|
|
# (every case boots off a usb-storage device on that bus).
|
|
"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"},
|
|
# USB HID end to end: boot the full tree, enumerate the xHCI, and let the
|
|
# manager spawn the USB keyboard driver, which opens its device over the
|
|
# transfer protocol, asks for boot protocol, subscribes to its interrupt
|
|
# endpoint, and comes up — proof the class-driver <-> controller path works.
|
|
{"name": "usb-hid",
|
|
"smp": 4,
|
|
"timeout": 150,
|
|
# usb-kbd/usb-mouse ride the default boot xHCI bus (see qemu_args).
|
|
"expect": r"(?=[\s\S]*usb-hid/keyboard: ok)(?=[\s\S]*usb-hid/mouse: ok)",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# USB mass storage end to end: the boot usb-storage device (the FAT32 image,
|
|
# which has a real 0x55AA boot sector) is enough — the manager spawns
|
|
# usb-storage, which opens the device, runs the Bulk-Only / SCSI bring-up,
|
|
# reads its capacity, and reads block 0 (the 0x55AA boot sig). Proof of the
|
|
# bulk transfer path + BOT + SCSI end to end.
|
|
{"name": "usb-storage",
|
|
"smp": 4,
|
|
"timeout": 150,
|
|
"expect": r"usb-storage: ready[\s\S]*usb-storage: block 0 signature 0x55aa",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# FAT mount end to end: the fat server mounts the boot usb-storage device (the
|
|
# FAT32 image) into the VFS at /mnt/usb. A fat-test client then lists and reads
|
|
# through the mount — proof of the whole stack: block device -> FAT parse ->
|
|
# VFS routing -> file read.
|
|
{"name": "fat-mount",
|
|
"smp": 4,
|
|
"timeout": 150,
|
|
"expect": r"fat: mounted /mnt/usb[\s\S]*fat-test: ok",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# Boot-from-USB smoke: the whole system now boots off the FAT32 image on a
|
|
# usb-storage device (OVMF -> \EFI\BOOT\BOOTX64.efi -> kernel), so the kernel
|
|
# reaching its PASS marker at all proves the USB boot path end to end. Reuses
|
|
# the smoke kernel build; the value is the explicit, named regression guard.
|
|
{"name": "usb-boot",
|
|
"build_case": "smoke",
|
|
"qmp_after": {"delay": 2, "command": "query-status"},
|
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# M20.1: the ring-3 AML parse (the acpi service maps the blobs and parses
|
|
# them) finds exactly the Device count the kernel's own parse produced.
|
|
{"name": "acpi-parse",
|
|
"smp": 4,
|
|
"timeout": 60,
|
|
"expect": r"acpi-parse: ok",
|
|
"fail": r"acpi-parse: mismatch|DANOS-TEST-RESULT: FAIL"},
|
|
# M20.3: the flip — ps2-bus now comes up from the acpi service's report, not
|
|
# a kernel-built node. Ordered: report -> spawn -> the driver attaches its
|
|
# keyboard, proving discovery runs entirely in ring 3 (docs/discovery.md).
|
|
{"name": "acpi-ps2",
|
|
"smp": 4,
|
|
"timeout": 150,
|
|
"expect": r"acpi: reported PNP0303[\s\S]*"
|
|
r"device-manager: spawned ps2-bus[\s\S]*"
|
|
r"ps2-bus: keyboard driver attached",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# M21.1: the SCI + power button. Boot the manager (which spawns the acpi
|
|
# service); ~4s in, QMP system_powerdown raises the ACPI power-button fixed
|
|
# event; the service's SCI handler must log the press (docs/acpi.md).
|
|
{"name": "power-button",
|
|
"smp": 4,
|
|
"timeout": 60,
|
|
"qmp_after": {"delay": 4, "command": "system_powerdown"},
|
|
"expect": r"power: button pressed",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# M21.3 capstone: orderly shutdown. Boot init (the full tree comes up);
|
|
# ~5s in, QMP system_powerdown raises the power button; the acpi service
|
|
# publishes it, init stops its children then requests S5, and QEMU exits.
|
|
# The ordered regex proves button -> shutting-down -> entering-S5; the case
|
|
# passes on QEMU's self-exit through S5 (docs/power.md).
|
|
{"name": "orderly-shutdown",
|
|
"smp": 4,
|
|
"timeout": 90,
|
|
"qmp_after": {"delay": 5, "command": "system_powerdown"},
|
|
"expect": r"power: button pressed[\s\S]*"
|
|
r"init: shutting down[\s\S]*"
|
|
r"power: entering S5",
|
|
"fail": r"power: S5 write did not take|DANOS-TEST-RESULT: FAIL"},
|
|
# M8: the boot log is persisted to the USB FAT volume. Reuses the orderly-
|
|
# shutdown build (full tree + power button): init spawns log-flush at boot,
|
|
# which copies the kernel log to /mnt/usb/DANOS.LOG once /mnt/usb is mounted
|
|
# (first marker); then the power button drives init's own pre-teardown flush
|
|
# (second marker), proving both triggers write the file while storage is up.
|
|
{"name": "log-flush",
|
|
"build_case": "orderly-shutdown",
|
|
"smp": 4,
|
|
"timeout": 150,
|
|
"qmp_after": {"delay": 8, "command": "system_powerdown"},
|
|
"expect": r"log-flush: wrote \d+ bytes to /mnt/usb/DANOS\.LOG[\s\S]*"
|
|
r"init: flushed log to /mnt/usb/DANOS\.LOG[\s\S]*"
|
|
r"power: entering S5",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# M20.2: the acpi service evaluates _CRS/_STA in ring 3 and registers +
|
|
# reports its _HID devices — the two PS/2 nodes must appear with resources
|
|
# (keyboard: io 0x60/0x64 + IRQ = 3; mouse: IRQ = 1) (docs/discovery.md).
|
|
{"name": "acpi-report",
|
|
"smp": 4,
|
|
"timeout": 150,
|
|
"expect": r"acpi: reported PNP0303 \(device \d+, 3 resources\)[\s\S]*"
|
|
r"acpi: reported PNP0F13 \(device \d+, 1 resources\)",
|
|
"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,
|
|
"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,
|
|
"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",
|
|
"timeout": 60, # the acpi service's boot-time SCI setup can push the marker past 30s under load
|
|
"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"},
|
|
# Device manager: a ring-3 service enumerates /system/devices, matches the PCI host
|
|
# bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn
|
|
# -> driver-up (the spawned pci-bus logs "<N> functions found").
|
|
{"name": "device-manager",
|
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# device_register containment (in-kernel): registering a child whose MMIO window
|
|
# escapes its parent's grant is refused (NotContained) — else dev_register would map
|
|
# arbitrary physical memory — while an identical re-register stays idempotent.
|
|
{"name": "containment",
|
|
"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. A long-running driver never reaches this teardown path.
|
|
{"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 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 qmp_send(path, command):
|
|
"""One QMP command: connect, capabilities handshake, execute. Raises on any
|
|
failure — the caller retries until the guest's socket is ready. This is how
|
|
a case injects a host-side event (system_powerdown = the ACPI power button)
|
|
into the running guest (docs/power.md)."""
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.settimeout(5)
|
|
try:
|
|
sock.connect(path)
|
|
stream = sock.makefile("rw")
|
|
stream.readline() # the QMP greeting
|
|
stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n")
|
|
stream.flush()
|
|
stream.readline() # {"return": {}}
|
|
stream.write(json.dumps({"execute": command}) + "\n")
|
|
stream.flush()
|
|
stream.readline()
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def run_case(arch, case):
|
|
# A case's kernel build defaults to its name; `build_case` decouples the two
|
|
# so a case can reuse another's kernel (e.g. usb-boot reuses smoke's).
|
|
err = build(arch, case.get("build_case", case["name"]))
|
|
if err:
|
|
return False, "build failed:\n" + err
|
|
|
|
# The bootable FAT32 USB image the build produced (tools/make-fat-image.py),
|
|
# presented to the guest as a usb-storage device (see qemu_args).
|
|
boot_volume = os.path.join(REPO, "zig-out", "danos-usb.img")
|
|
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, boot_volume, 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"]
|
|
# A QMP control socket, always present (additive): how a case's `qmp_after`
|
|
# hook injects host-side events into the guest mid-run. Kept under a short temp
|
|
# dir, not WORK: a unix socket path is capped at ~104 bytes (sun_path), and a
|
|
# deep worktree path (e.g. .claude/worktrees/<name>/zig-out/qemu-test/qmp.sock)
|
|
# blows that limit on macOS, so QEMU fails to bind and exits before booting.
|
|
qmp_path = os.path.join(tempfile.gettempdir(), f"danos-qmp-{os.getpid()}.sock")
|
|
if os.path.exists(qmp_path):
|
|
os.remove(qmp_path)
|
|
cmd += ["-qmp", f"unix:{qmp_path},server,nowait"]
|
|
qmp_after = case.get("qmp_after") # {"delay": seconds, "command": "..."}
|
|
qmp_sent = False
|
|
started = time.monotonic()
|
|
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)
|
|
if qmp_after and not qmp_sent and time.monotonic() - started >= qmp_after["delay"]:
|
|
try:
|
|
qmp_send(qmp_path, qmp_after["command"])
|
|
qmp_sent = True
|
|
except OSError:
|
|
pass # socket not up yet; retry next tick
|
|
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):
|
|
if qmp_after and not qmp_sent:
|
|
continue # the hook must deliver before the case may pass
|
|
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())
|