danos/docs/testing.md

8.7 KiB

Testing

danos is a freestanding kernel — it can't be unit-tested like a normal library, because most of what it does only means anything on a booted CPU. So the main test strategy is boot it in QEMU and assert on what it does, reproducibly and without a human staring at the screen.

There are two layers:

  • Host unit tests (zig build test) — for pure, platform-independent logic. What began as the three shared contracts (system/boot-handoff.zig, system/abi.zig, system/devices/device-abi.zig) now spans ~26 modules: protocol and on-wire definitions (VFS, USB, virtio-gpu), the FAT engine, the display compositor, PS/2 and HID decoding, the kernel log ring, and the runtime's time/thread — the full list is the test step in build.zig. These compile for the host and run natively.
  • QEMU integration tests (python3 test/qemu_test.py) — boot the real kernel and check its behaviour. This is the interesting part.

The key enabler: serial output

The framebuffer console draws pixels, which a test can't read without screen-scraping. So the kernel also writes everything to a serial port (system/kernel/architecture/x86_64/serial.zig, a 16550 UART on COM1). The diagnostic log fans out to registered sinks, and the serial UART is one of them, so all kernel output — boot log, memory summary, exception reports — appears on serial as plain text.

QEMU captures that with -serial file:serial.log, giving a machine-readable transcript. Serial is per-architecture (x86 uses port I/O; an ARM board uses a memory-mapped UART), so it lives behind the architecture boundary — and adding a new architecture's UART is what makes the same tests run there.

The serial log sink is compiled in only under -Dserial (off by default). A real machine often has no live legacy COM1 — writing to a dead one is slow — and the boot log is kept in a RAM buffer (klog) and flushed to disk instead, so serial is now purely a QEMU/dev aid. The harness (test/qemu_test.py) builds every case with -Dserial=true, and zig build run-x86-64 boots a serial-enabled image variant, so both get the transcript; a flashable zig build image leaves serial out. (Even with -Dserial, a loopback probe disables a dead port at boot, so a serial-enabled image is still safe on real hardware.)

In-kernel test cases

Building with -Dtest-case=<name> makes the kernel, after normal bring-up, run one self-test from system/kernel/tests.zig instead of idling. Each case writes structured markers to serial:

DANOS-TEST-BEGIN: smoke
[PASS] memory map reports usable RAM
[PASS] alloc returns distinct frames
...
DANOS-TEST-RESULT: PASS (6 passed, 0 failed)
DANOS-TEST-DONE

The core kernel cases (the suite has since grown far beyond this table — SMP, threads, processes, display, USB, FAT and more; the full list is the CASES table in test/qemu_test.py):

Case What it checks How the harness confirms it
smoke memory map has usable RAM; frame alloc/free; paging active DANOS-TEST-RESULT: PASS
discovery ACPI discovery populated the platform facts: MADT (LAPIC base, CPU count) and FADT (PM/reset registers) DANOS-TEST-RESULT: PASS
wx W^X audit: kernel code is executable; rodata, data, heap, and stack are NX DANOS-TEST-RESULT: PASS
timer device interrupts fire and return (tick count advances) DANOS-TEST-RESULT: PASS
clock LAPIC + TSC calibrated; monotonic uptime advances; nanos() has sub-ms resolution DANOS-TEST-RESULT: PASS
wall-clock the CMOS RTC read at boot yields a plausible current epoch DANOS-TEST-RESULT: PASS
vmm on-demand map works: a mapped page is writable and reads back DANOS-TEST-RESULT: PASS
heap kernel heap: alloc/free, block reuse, growth, and a std container on it DANOS-TEST-RESULT: PASS
sched preemption: three non-yielding tasks all make progress DANOS-TEST-RESULT: PASS
priority fixed-priority tasks run highest-first DANOS-TEST-RESULT: PASS
sleep a task blocks for ~50 ms (real block, not a busy-wait) DANOS-TEST-RESULT: PASS
event a task blocks on a wait queue and is woken (preempting) DANOS-TEST-RESULT: PASS
ipc producer/consumer pass 100 messages through a 4-slot channel intact DANOS-TEST-RESULT: PASS
ipc-call synchronous IPC: client and server ping-pong 100 calls through one endpoint (rendezvous, reply routing, cross-copy) DANOS-TEST-RESULT: PASS
ipc-cap capability passing: endpoints handed over in a call and its reply arrive as the same object, shared not moved DANOS-TEST-RESULT: PASS
dma DMA memory: contiguous frame allocation, below-4G cap, coherent mapping, reclaim on teardown DANOS-TEST-RESULT: PASS
msi an MSI vector is allocated and delivered as an endpoint notification (a self-IPI stands in for the device write) DANOS-TEST-RESULT: PASS
iommu the VT-d unit is found in the DMAR table and its registers read back (detection only; boots with an emulated IOMMU) DANOS-TEST-RESULT: PASS
ioport port I/O grants: an io_port resource admits in-range reads, refuses out-of-range/unclaimed DANOS-TEST-RESULT: PASS
fault-ud invalid-opcode exception is caught serial shows invalid opcode (vector 6)
fault-pf page fault caught with CR2 page fault (vector 14)
fault-df double fault caught on IST1 (not a triple-fault reset) double fault (vector 8)
fault-ap-df a double fault pinned to an application processor is caught by that core's own TSS/IST (boots with -smp 4) core N: double fault (vector 8), N ≥ 1
fault-nx executing a data page (NX) faults page fault (vector 14)
fault-null dereferencing the unmapped page 0 faults page fault (vector 14)
fault-recovery a ring-3 process that faults is killed and reaped while init keeps heartbeating — the OS survives DANOS-TEST-RESULT: PASS

The faulting cases don't print a result line — they deliberately raise a CPU exception, and the harness asserts on the exception report the handler prints (which also reaches serial). This reuses the real fault path as the test oracle: if the IDT/TSS weren't wired up, fault-df would triple-fault and the marker would never appear.

The harness

test/qemu_test.py ties it together. For each case it:

  1. builds the kernel with -Dtest-case=<name> (the build produces the bootable FAT32 USB image, zig-out/danos-usb.img),
  2. copies that image to a fresh per-run boot volume, so the guest's mutations don't dirty the build artifact,
  3. boots it headless in QEMU with serial captured to a file and -no-reboot (so a triple fault exits rather than looping),
  4. polls the serial log until the case's expected regex appears (pass), a failure marker appears, or a timeout elapses (fail),
  5. kills QEMU and moves on.
$ python3 test/qemu_test.py
danos qemu tests  arch=x86_64  cases=4

  smoke        ... PASS  (matched 'DANOS-TEST-RESULT: PASS')
  fault-ud     ... PASS  (matched 'invalid opcode \(vector 6\)')
  fault-pf     ... PASS  (matched 'page fault \(vector 14\)')
  fault-df     ... PASS  (matched 'double fault \(vector 8\)')

4/4 passed

It exits non-zero if any case fails, so it drops straight into CI. Run a subset with python3 test/qemu_test.py smoke fault-pf.

(It's a standalone script rather than a zig build step on purpose: a build step that shells out to a harness which itself runs zig build would contend on the build cache lock.)

Built for multiple architectures

The runner separates what is tested (the cases and their expected markers) from how a given CPU is built and booted (the ARCHES table: the QEMU binary, firmware, boot method, serial device). The cases are architecture-neutral — "a page fault is reported", not "this x86 encoding faults".

So bringing up a second architecture — an AArch64 Raspberry Pi is the motivating one — means:

  1. implement system/kernel/architecture/aarch64/ (CPU ops, its UART, exception vectors, page tables) behind the same architecture interface,
  2. add an aarch64 entry to ARCHES with its qemu-system-aarch64 invocation,

and the same smoke / fault-* cases run against it: python3 test/qemu_test.py --arch aarch64. A green suite on both is the definition of "it works across architectures".

Writing a new case

  1. Add a function to system/kernel/tests.zig and dispatch it in run on its name.
  2. Emit [PASS]/[FAIL] lines and a DANOS-TEST-RESULT: line (non-faulting cases), or trigger the condition and rely on the handler's output (faulting cases).
  3. Add an entry to CASES in test/qemu_test.py with the regex that proves it.