Add a FAT12/16/32 filesystem the VFS mounts at /mnt/usb, reading and writing a USB stick through the block device. Verified end to end under QEMU: the fat server mounts the volume, the VFS routes /mnt/usb to it, and a client lists the root and reads a file (the ELF magic of /mnt/usb/system/kernel). - engine.zig: the FAT engine over a BlockDevice interface — mount (a bare FAT or, as QEMU's VVFAT and most real sticks present it, an MBR-partitioned disk), FAT chain walk (12/16/32), cluster allocation, directory traversal with long-name read, and file read / write / create. Host-tested against a RAM-backed FAT16 image (create, cluster-spanning write, mid-file overwrite, read-back, list). - on-disk.zig: the align(1) boot-sector / directory / long-name / FSInfo structs and the cluster-count FAT-type detection. - fat.zig: the server — wraps the .block device (a DMA bounce buffer) in a BlockDevice, mounts the FAT, serves the vfs-protocol as a backend, and mounts itself into the VFS at /mnt/usb. Spawned by init as a boot service. - runtime.block: the block-device client (geometry / read / write by physical address, so whole sectors never cross IPC). - Raise the kernel service-name registry (maximum_services) 8 -> 16: it is indexed directly by ServiceId, and fat = 8 was being rejected, so the fat server exited before registering. - VFS: an absolute path with no matching mount is now not-found rather than silently created in the flat ramfs — so /mnt/usb fails cleanly until mounted. Tests: fat-mount (the full stack: block -> FAT -> VFS mount -> list + file read) passes; host units cover the engine and on-disk structs; the vfs, shutdown, and USB regression suite stays green (10/10).
618 lines
29 KiB
Python
618 lines
29 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}",
|
|
"-drive", f"format=raw,file=fat:rw:{boot_volume}",
|
|
"-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,
|
|
"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"},
|
|
# 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,
|
|
"qemu_extra": ["-device", "qemu-xhci,id=xhci",
|
|
"-device", "usb-kbd,bus=xhci.0",
|
|
"-device", "usb-mouse,bus=xhci.0"],
|
|
"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: attach a usb-storage device (a FAT volume via
|
|
# QEMU's VVFAT, so it has a real boot sector), boot the full tree, and let the
|
|
# manager spawn 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,
|
|
"qemu_extra": ["-device", "qemu-xhci,id=xhci",
|
|
"-drive", "if=none,id=stick,format=raw,file=fat:rw:" + os.path.join(REPO, "zig-out"),
|
|
"-device", "usb-storage,drive=stick,bus=xhci.0"],
|
|
"expect": r"usb-storage: ready[\s\S]*usb-storage: block 0 signature 0x55aa",
|
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
|
# FAT mount end to end: attach a usb-storage device (a FAT volume via VVFAT),
|
|
# boot the full tree, and let the fat server mount it 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,
|
|
"qemu_extra": ["-device", "qemu-xhci,id=xhci",
|
|
"-drive", "if=none,id=stick,format=raw,file=fat:rw:" + os.path.join(REPO, "zig-out"),
|
|
"-device", "usb-storage,drive=stick,bus=xhci.0"],
|
|
"expect": r"fat: mounted /mnt/usb[\s\S]*fat-test: ok",
|
|
"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"},
|
|
# 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,
|
|
"qemu_extra": ["-device", "qemu-xhci,id=xhci",
|
|
"-device", "usb-kbd,bus=xhci.0",
|
|
"-device", "usb-mouse,bus=xhci.0"],
|
|
"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,
|
|
"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",
|
|
"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):
|
|
err = build(arch, case["name"])
|
|
if err:
|
|
return False, "build failed:\n" + err
|
|
|
|
# zig-out is the FHS boot volume; hand it to the guest as-is (see qemu_args).
|
|
boot_volume = os.path.join(REPO, "zig-out")
|
|
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())
|