The source layout now mirrors the runtime filesystem hierarchy
(docs/danos-file-system-hierarchy-FSH.md): what lives under system/ in the
source is what a running danos represents under /system. Each service and
driver is a sub-project directory that is its own Zig module — cross-project
references go by module name, never by a path into another project's files.
Moves (all git mv, history preserved):
- src/ -> system/ (danos internals; the self-representation)
root.zig -> danos.zig (the kernel<->user contract module)
kernel/arch/ -> kernel/architecture/ (arch -> architecture)
device/ -> devices/ (what /system/devices reflects)
boot/ -> /boot (the loaders, top level)
- sbin/ -> split by role:
init, vfs -> system/services/<name>/<name>.zig
hpetd, busd -> system/drivers/<name>/<name>.zig
vfs-test -> system/services/vfs/vfs-test.zig (inside the vfs project)
- lib/ -> library/runtime/ (room for other libraries beside runtime)
The VFS wire protocol becomes its own module, system/services/vfs/protocol.zig
("vfs-protocol"): the vfs sub-project exposes its interface, and the runtime's
file layer imports it by name. First instance of the "protocol module" pattern
(docs/driver-model.md); usb/block will expose theirs the same way.
Also: fix a naming-standard violation in the protocol — Op -> Operation (and
req -> request, _pad -> _padding). Docs updated: /system/services added to the
FHS doc, a repository-layout section added to the docs index, and stale source
paths swept across comments and docs.
Runtime boot paths are unchanged (the bootloader still loads /sbin/init);
aligning the runtime filesystem to the FHS is a separate follow-up. Suite 35/35
plus host tests green.
5.9 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 in the shareddanosmodule (the handoff layout insystem/danos.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). Console.write mirrors every
byte to it, 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 arch boundary — and adding
a new architecture's UART is what makes the same tests run there.
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
Current cases:
| Case | What it checks | How the harness confirms it |
|---|---|---|
smoke |
memory map has usable RAM; frame alloc/free; paging active | 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 |
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 |
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-nx |
executing a data page (NX) faults | page fault (vector 14) |
fault-null |
dereferencing the unmapped page 0 faults | page fault (vector 14) |
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:
- builds the kernel with
-Dtest-case=<name>, - assembles a fresh EFI System Partition from the built binaries,
- boots it headless in QEMU with serial captured to a file and
-no-reboot(so a triple fault exits rather than looping), - polls the serial log until the case's expected regex appears (pass), a failure marker appears, or a timeout elapses (fail),
- 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:
- implement
system/kernel/arch/aarch64/(CPU ops, its UART, exception vectors, page tables) behind the samearchinterface, - add an
aarch64entry toARCHESwith itsqemu-system-aarch64invocation,
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
- Add a function to
system/kernel/tests.zigand dispatch it inrunon its name. - Emit
[PASS]/[FAIL]lines and aDANOS-TEST-RESULT:line (non-faulting cases), or trigger the condition and rely on the handler's output (faulting cases). - Add an entry to
CASESintest/qemu_test.pywith the regex that proves it.