Run the gates: test a change the way nos does
Boot self-tests, smoke boots on both architectures, UB-checked builds, per-subsystem gates and host fuzzers: what to run after a change, and how to judge it.
nos has no test runner and no separate test binary. A change is checked by a stack of things, from the most often run to the least: the self-tests every boot runs, the smoke boots CI runs on both architectures, builds with undefined-behaviour checks, a gate per subsystem for what a smoke boot cannot notice, and four fuzzers that run on the host. By the end of this guide you will know which of them to run for a change and how to read what they say.
You need:
- the nos tree, and Docker or the native toolchain (Build nos);
- QEMU for both architectures (
qemu-system-x86_64andqemu-system-aarch64), and Python 3; - for the fuzzers:
cargoon the host forhv-fuzz.py,net-fuzz.pyandfs-fuzz.py, andclangwith its sanitizers plusmakeforcpp-fuzz.py.
The long form of this page, with what every gate checks and the failure it is there for, is Tests and gates.
The one rule: judge by the exit code
Every check here passes or fails by its exit code, never by what it printed. Grepping the output is exactly how a failure gets read as success. A build that stops on a stale dependency file says No rule to make target, not error. A boot log that was never written contains no PANIC: either.
./scripts/smoke-test.sh --skip-build
echo "exit: $?"
If you pipe a check's output, through tail for example, the pipeline's status is the last command's unless you set -o pipefail first.
1. Self-tests at every boot
Each boot runs the kernel's self-tests before it goes on. They live in src/cpp/kernel/test.cpp, where each one is a Stdlib::Error TestXxx() function:
- When they run.
Test::Test()runs early in boot, called fromMain2inmain.cppand frommain_arm64.cpp;Test::TestMultiTasking()runs later. A failing test returns a non-successStdlib::Error. - The Rust side runs from the same place, through
rust_test. Among its tests are the frame test, which writes a page through each CPU's slot of the temporary mapping window, and a test of the lock-free ring. TestModulesloads themodtestmodule embedded in the kernel image, then feeds the module loader damaged copies of it.fstest=onon the kernel command line runs the filesystem self-test on/once it is mounted, and printsfstest: passedorfstest: FAILED.
To add a test, write a TestXxx() and register it in the Test() dispatcher. To run a single test, edit Test() to call only that function, rebuild, boot (./scripts/qemu.sh writes the serial console to nos.log), and watch nos.log.
2. Smoke boots on both architectures
Every change must keep both smoke boots green. CI runs them on every push to master and on every pull request, and a change to common code that only builds on x86 is a broken change.
./scripts/smoke-test.sh
./scripts/smoke-arm64.sh
SMOKE_HVF=1 ./scripts/smoke-arm64.sh
What the scripts do:
- They build first. Each builds in the
nos-builderDocker image, incrementally:make rust && make nocheckfor x86-64,make nocheck ARCH=aarch64for arm64. With--skip-buildthey boot what is already built.make smokeruns the x86-64 one. - They boot headless. x86-64 boots under TCG with virtio-blk (modern), virtio-scsi (legacy), NVMe, virtio-net and two virtio-rng devices attached, so the Rust drivers and the MSI-X path are covered along with the C++ under them. arm64 boots QEMU
virtwith virtio-mmio block, net and rng, under TCG so that it runs anywhere;SMOKE_HVF=1uses HVF on Apple Silicon, about four times as fast. Both boot a 1 KiB-block ext2 root withfstest=on. - They wait for markers. Success is every marker present in the serial log. On x86-64 those are
After test,Preempt is now on,MountRootFs: mounted ext2 on /,fstest: passed,EntropySource registered: rng0andrng1, andboot: complete. On arm64 they areSelf test passed,Cpus started,After test,MountRootFs: mounted ext2 on /,fstest: passed,EntropySource registered: rng0andboot: complete. - They fail fast.
PANIC:,fstest: FAILED, an entropy source that registered and gave nothing, QEMU exiting early, or a timeout ends the run withSMOKE FAIL: <reason>(orSMOKE-ARM64 FAIL:) and the last 30 lines of the log. Success printssmoke: OK (<seconds>s), orsmoke-arm64: OK (<seconds>s).
SMOKE_TIMEOUT sets the seconds to wait (300 by default), and SMOKE_LOG where the serial log goes.
3. Builds with undefined-behaviour checks
The same kernel can be built with both halves checking themselves. The first undefined operation then becomes a panic that names its site, and every gate becomes a UB check over the code it drives:
make nocheck UBSAN=1
make nocheck RUSTUB=1
make nocheck ARCH=aarch64 UBSAN=1 RUSTUB=1
UBSAN=1builds the C++ with clang's undefined-behaviour sanitizer: shifts past the width, signed overflow, out-of-bounds indexes the compiler knows of, null or misaligned accesses, invalidboolandenumvalues, falling off the end of a function. Its objects live apart, inout/$(ARCH)-ubsan/. A report looks likePANIC:Report():ubsan.cpp,267: UBSAN: shift exponent 64 is too large for 64-bit type 'unsigned long' at src/cpp/.... Boot withubsan=warnto collect every report instead of stopping at the first.RUSTUB=1turns oncore's checks of its own unsafe preconditions (ptr::readof an unaligned or null pointer, a badslice::from_raw_parts,get_uncheckedpast the end and others), plus overflow checks, in the kernel's Rust and in every module. A report starts withRUST PANIC: panicked atand names the precondition. It does not analyse your ownunsafe: out-of-bounds raw pointers, aliasing and data races are not checked.- The two are independent and can be set together.
versionthen says+ubsanand+rustub.
Run the gate on the images as they are, with --skip-build where the script builds: the smoke scripts' own build step would make a plain kernel again. A gate that loads modules takes them from the flavour that was built. CI boots a UBSAN=1 RUSTUB=1 build on both architectures, as its last step.
4. The gate for what you touched
What a smoke boot cannot notice has a test of its own, in scripts/. Each is there for a failure that looks like success, and most were written the day after meeting one. They all want the kernel built first, take --keep to leave their scratch directory behind, and exit 0 only if every check passed. Several are arm64 only, because they drive the shell over UDP and the arm64 boot is the one whose command line enables it; the code they test is the same on both architectures.
| After touching | Run | Where |
|---|---|---|
| a mapping path | wx-test.sh [--arch x86_64|aarch64] |
both |
the panic path, panic=N, a reset |
panic-test.sh [--arch x86_64|aarch64] |
both |
| the block layer, the partition tables | parttest.py [--arch aarch64|x86_64] |
both, in CI |
| the block layer's asynchronous path, the C ABI modules reach it through | netblk-test.py [--arch x86_64|aarch64] |
both |
| ext2 | ext2-test.py (lets e2fsck judge the image) |
arm64, in CI |
| nanofs | nanofs-test.py |
arm64, in CI |
| the disk log | disklog-test.py |
arm64 |
| the trace path, a net device, netconsole | netconsole-test.py |
arm64, in CI |
| TCP, the HTTP client | tcp-test.py |
arm64, in CI |
TCP's listening side, the module loader, the ffi declarations, ssh, modules/sshd |
sshd-test.py [--arch aarch64|x86_64] |
both |
the receive path, the frame pool, modules/netload |
netload-test.py [--arch aarch64|x86_64] (about seven minutes on arm64, by design) |
both; arm64 in CI |
drivers/igb, the NIC driver seam |
tcp-test.py --nic igb, netload-test.py --nic igb (QEMU 8.0 or later) |
by hand |
drivers/usb/ |
usb-test.py |
x86-64 |
the hypervisor: hv, hvarch, modules/hv |
hv-test.py [--arch x86_64|aarch64] |
both |
| the hypervisor's MMIO decoder and guest page walker | insn-test.py |
host, in CI |
anything a guest reaches in hv: devices, APICs, MMIO, the loader, ACPI, the CPUID and MSR policy, the run loop, the guests' switch and NAT |
hv-fuzz.py |
host, in CI |
| a Linux guest: the loader, the policy, the devices, more than one CPU, ACPI, the network | hv-linux-test.py --bzimage <bzImage> --initrd <cpio>, with --disk, --net, --cpus N, --acpi, --attach as needed |
x86-64, by hand |
| what a distribution's kernel meets | hv-distro-test.py --iso <alpine-virt.iso> [--debian <raw>] |
x86-64, by hand |
anything the network hands the kernel: the stack, DHCP, DNS, HTTP and TLS, the UDP shell, netconsole, ssh, sshd |
net-fuzz.py |
host, in CI |
anything a disk hands the kernel: partition tables, ext2, nanofs, the VFS, root= |
fs-fuzz.py |
host, in CI |
| what firmware, a bootloader or a disk hands the C++, and the page tables and heap under everything | cpp-fuzz.py |
host, in CI |
the system image, nos-install, root=UUID= |
image-test.py |
x86-64, in CI |
| a wait primitive, the scheduler's choice of the idle task | idle-wait-test.py (needs 12 or more CPUs and KVM) |
x86-64 |
drivers/r8168, drivers/r8125 |
nothing emulates them: the compiler, then the real machine | — |
This table is a summary. The authoritative one, with the exact files each row covers, is in the project's CLAUDE.md, and Tests and gates explains every gate. The hypervisor gates that boot a real Linux need a guest kernel and initramfs, built as in Boot a Linux guest.
5. The host fuzzers
Four programs build the kernel's own sources for the host and drive them with random inputs, each held to a model of what the code should do. A panic, a broken invariant, a broken lock rule, a spin and a hang are findings, each reported with the seed and iteration that reproduce it.
| Script | What it fuzzes | Needs |
|---|---|---|
hv-fuzz.py |
everything a guest reaches in the hypervisor: devices, local APIC and IO-APIC, MMIO, the Linux loader and ACPI tables, the CPUID and MSR policy, the run loop on every CPU of a guest, the guests' switch, DHCP and NAT, and where guests are placed | cargo |
net-fuzz.py |
everything the network hands the kernel: frames of every kind, TCP, HTTP and HTTPS, DNS, DHCP, ICMP and ARP, the UDP shell, netconsole, SSH | cargo |
fs-fuzz.py |
everything a disk hands the kernel: MBR and GPT, the disk log, sound and damaged ext2 and nanofs images, the VFS, the storage commands, root= |
cargo |
cpp-fuzz.py |
the C++ under ASan and UBSan: the device tree, Multiboot2 tags, ACPI tables, the memory map, the command line, the GRUB environment block, the module loader, printf, the page tables and the heap |
clang with sanitizers, make |
With no arguments a fuzzer is a gate: fixed inputs from a fixed seed, the same every run, from well under a minute (hv-fuzz.py) to a couple of minutes (net-fuzz.py, fs-fuzz.py). A campaign gives each target a number of seconds and a seed of its own:
python3 scripts/hv-fuzz.py
python3 scripts/net-fuzz.py --seed 7 --seconds 300
python3 scripts/fs-fuzz.py --target ext2 --target nanofs
python3 scripts/net-fuzz.py --replay tcp out/net-fuzz/findings/X.hex --trace
- Findings are saved under
out/<fuzzer>/findings/, and--replayruns one again.--traceis available onnet-fuzz.py,fs-fuzz.pyandcpp-fuzz.py. - Exit codes: 0 means no finding, 1 a finding, 3 a hang.
- Statistics:
HV_FUZZ_STATS=1,NET_FUZZ_STATS=1,FS_FUZZ_STATS=1andCPP_FUZZ_STATS=1add each fuzzer's statistics: its slowest inputs and how often it reached the states it counts. - New code: when you add a parser of untrusted input, a new device or a new protocol, give its fuzzer a new target.
What CI runs
.github/workflows/ci.yml runs on every push to master and every pull request, as one job per architecture:
- Both legs:
make check(cppcheck, which fails on any finding),make nocheck ARCH=<arch>, the smoke boot,parttest.py --arch <arch> --tcg, and last aUBSAN=1 RUSTUB=1build with its own smoke boot. - The x86-64 leg adds
make imagewithimage-test.py,insn-test.py,hv-fuzz.pyandfs-fuzz.py. - The aarch64 leg adds
net-fuzz.py,cpp-fuzz.py, andext2-test.py,nanofs-test.py,netconsole-test.py,tcp-test.pyandnetload-test.py, each with--tcg.
Every gate in the table that CI does not run, you run yourself when your change reaches it. That includes the hypervisor's Linux and distribution gates (a bzImage is megabytes, and the distribution images are downloads), hv-test.py, idle-wait-test.py and the hardware NIC gates.
Before you call a change done
make check, and builds for both architectures:make nocheckandmake nocheck ARCH=aarch64.- Both smoke boots.
- The gate, or gates, for what you touched.
- The same smoke boots and gate on a
UBSAN=1 RUSTUB=1build, with--skip-build. - The fuzzer for the code, if the code reads anything from outside: a packet, a disk, firmware, a guest.
- For C++, a second read of the diff for undefined behaviour and nothing else: values from outside used as an index, a length or a shift count before they are checked, overflow, uninitialised reads, lifetimes, shared state without a lock. The sanitizer only catches what the gates drive.
- For Rust,
scripts/unsafe-count.pyon the crates you changed. A higher count needs a reason.
If you met a failure that looked like success along the way, write the gate that would have caught it.
Troubleshooting
No rule to make target ... and nothing else. After moving, renaming or deleting a header, the .d files in out/ still name the old path. Run a clean build, or delete those .d files.
A smoke boot times out under TCG on a slow machine. Raise SMOKE_TIMEOUT. On Apple Silicon, SMOKE_HVF=1 makes the arm64 boot about four times as fast.
hv-test.py or hv-linux-test.py --cpus refuses to run under TCG. They need QEMU 9.2 or later, whose TCG puts the accesses of a guest with paging off through the nested page table.
A check of hv-linux-test.py --cpus 2 that is bound to time fails on a loaded machine. The restarting guest's six resets in a minute and the log flood within --vm-secs are such checks. Run the gate again on a quiet machine before you read the failure as your change's.
A build suddenly wants the network. Builds are offline: the Rust dependencies are vendored and the toolchain is pinned in src/rust/rust-toolchain.toml. Either a dependency was never vendored, in which case run scripts/vendor.sh and commit what it produces, or the nos-builder image predates the pin, in which case rebuild it with docker build --platform linux/amd64 -t nos-builder ..
Next steps
- Tests and gates: what every gate does and the failure it is there for.
- Build nos and Run nos in QEMU.
- Write a loadable module, and give it a gate.
- Boot a Linux guest: what the hypervisor gates do, by hand.