This document describes how to write and run tests for the Panda kernel.
# Run all tests (kernel and userspace)
make test
# Run only kernel tests
make kernel-test
# Run only userspace tests
make userspace-testKernel tests run inside QEMU and test kernel functionality directly. They are located in panda-kernel/tests/.
Create a new file in panda-kernel/tests/, for example panda-kernel/tests/my_feature.rs:
#![no_std]
#![no_main]
panda_kernel::test_harness!(test_one, test_two);
fn test_one() {
assert_eq!(1 + 1, 2);
}
fn test_two() {
// Test can use kernel APIs
let boxed = alloc::boxed::Box::new(42);
assert_eq!(*boxed, 42);
}Key points:
- Use
#![no_std]and#![no_main]attributes - Use the
test_harness!macro with a list of test function names - Each test function takes no arguments and returns nothing
- Use
assert!,assert_eq!, etc. for assertions - Tests have access to kernel internals including the allocator
Add your test name to KERNEL_TESTS in Makefile:
KERNEL_TESTS := basic heap pci memory scheduler process nx_bit raii apic my_feature#![no_std]
#![no_main]
extern crate alloc;
use alloc::{boxed::Box, vec::Vec};
panda_kernel::test_harness!(box_allocation, vec_allocation);
fn box_allocation() {
let boxed = Box::new(42);
assert_eq!(*boxed, 42);
}
fn vec_allocation() {
let mut vec = Vec::new();
for i in 0..100 {
vec.push(i);
}
assert_eq!(vec.len(), 100);
}Userspace tests are standalone programs that run as processes on top of the kernel. They are located in userspace/tests/.
- Create a new crate in
userspace/tests/:
mkdir -p userspace/tests/my_test/src- Create
userspace/tests/my_test/Cargo.toml:
[package]
name = "my_test"
version = "0.1.0"
edition = "2024"
[dependencies]
libpanda = { path = "../../libpanda" }- Create
userspace/tests/my_test/src/main.rs:
#![no_std]
#![no_main]
use libpanda::environment;
libpanda::main! {
environment::log("My test starting");
// Test logic here...
if some_condition_failed {
environment::log("FAIL: something went wrong");
return 1; // Non-zero exit code fails the test
}
environment::log("My test passed");
0 // Zero exit code means success
}- Create
userspace/tests/my_test/expected.txtwith expected log output:
# Comments start with #
My test starting
My test passed
Add your test name to USERSPACE_TESTS in Makefile:
USERSPACE_TESTS := vfs_test preempt_test spawn_test yield_test my_testSome tests require additional helper programs (e.g., spawn_test needs spawn_child). Define extras in the Makefile:
my_test_EXTRAS := my_helper
export my_test_EXTRASTests use the libpanda API organised by resource type:
use libpanda::environment; // System operations
use libpanda::file; // File operations
use libpanda::process; // Process operations
// Environment operations (via HANDLE_ENVIRONMENT)
environment::log("message"); // Log to console
environment::open("/path", flags); // Open file, returns handle
environment::spawn("/path"); // Spawn process
environment::time(); // Get system time
// File operations (on file handles)
file::read(handle, &mut buf); // Read from file
file::write(handle, &buf); // Write to file
file::seek(handle, offset, whence); // Seek in file
file::stat(handle, &mut stat); // Get file stats
file::close(handle); // Close file
// Process operations (via HANDLE_SELF or child handles)
process::yield_now(); // Yield CPU
process::exit(code); // Exit process
process::getpid(); // Get process ID
process::wait(child_handle); // Wait for childThe test framework extracts log messages and verifies they appear in the expected order.
In the default ordered mode:
- Lines starting with
#are comments - Each non-comment line must appear in the log output
- Lines must appear in the specified order
- Additional log messages between expected lines are allowed
Example expected.txt:
# VFS test checks file operations
VFS test starting
VFS test passed
For tests with non-deterministic output (e.g., concurrent processes), use # @unordered mode with # @barrier markers:
# @unordered
# Patterns within a section can match in any order.
# Use # @barrier to enforce ordering between sections.
First thing that happens
Second thing (order with first doesn't matter)
# @barrier
# Everything above must complete before anything below
Third thing
Fourth thing (order with third doesn't matter)
# @barrier
Final thing that must come last
Rules:
# @unorderedat the start enables unordered mode- Patterns within a section can match log lines in any order
# @barrierenforces that all patterns before it match log lines that appear before any patterns after it- Each pattern still must appear exactly once in the log
When you need this: for every pair of adjacent lines in expected.txt,
ask whether the second line is actually caused by the first — is there a
real synchronization primitive (a channel recv, process::wait, an event
wait) between them, or could the scheduler just as validly run them in the
other order? If a child process's line isn't gated by receiving something
from the parent first (e.g. it's logged before the child's first blocking
call), its order relative to the parent's most recent log line is not
guaranteed — group them with @unordered.
This bit a real test: buffer_owner_test's child deliberately does some
work (allocating its own buffer) before touching its parent channel at
all, specifically so its log line has no causal relationship to anything
the parent has logged. expected.txt assumed a fixed order between the
parent's post-send line and the child's first line anyway. The ordered
matcher discards every log line up through a match once found, so on the
timing where the child's unordered line came out first in the raw log,
the matcher's search for the parent's (later) line silently swallowed the
child's line as collateral — surfacing as "expected log not found" for a
line that genuinely was there, just in the "wrong" position. This passed
locally under every KVM-accelerated run and failed on every CI run (which
has no KVM, so uses the much-slower TCG backend with different relative
process-scheduling timing) — a hallmark of this specific bug: one
specific line missing, every other line from the same process present and
correct, and it never reproduces locally. See that failure investigation
for the full story if you want the cautionary tale.
Example from preempt_test/expected.txt:
# @unordered
Preempt test: spawning 3 CPU-bound children
Preempt test: parent doing CPU-bound work
# @barrier
preempt_child: completed
preempt_child: completed
preempt_child: completed
Preempt test: parent work done, waiting for children
# @barrier
Preempt test: all children completed successfully
This verifies:
- Spawning and parent work messages appear first (either order)
- Then all 3 children complete and parent finishes work (any interleaving)
- Finally the success message appears last
For GUI tests, you can verify the visual output using screenshot comparison instead of (or in addition to) log matching.
-
Create
userspace/tests/my_test/expected.pngwith the expected screenshot. -
In your test, call
environment::screenshot_ready()when the display is in the expected state:
#![no_std]
#![no_main]
use libpanda::environment;
libpanda::main! {
// Set up GUI, draw to surface, etc.
// Signal that the test is ready for screenshot capture
environment::screenshot_ready();
// The test harness will capture the screenshot and terminate QEMU
loop {
core::hint::spin_loop();
}
}The test harness:
- Watches for the
<<<SCREENSHOT_READY>>>marker in the log - Captures a screenshot via the QEMU monitor
- Compares against
expected.png(with 1% fuzz tolerance for anti-aliasing) - Fails if the screenshots differ by more than 1000 pixels
If expected.png doesn't exist on the first run, the actual screenshot is saved to build/utest-<name>/<name>_actual.png for review. Copy it to expected.png if correct.
To update a screenshot after intentional changes:
cp build/utest-my_test/my_test_actual.png userspace/tests/my_test/expected.pngFor tests that intentionally trigger a fault (e.g., writing to a read-only page), the test process gets killed by the kernel before it can log any results. To validate that the kernel handled the fault correctly, use expected_fault.txt alongside expected.txt.
The kernel emits a <<<PROCESS_FAULT>>> marker in the error log when it kills a process due to a page fault. This marker only appears when a process is killed for a fault, so it will not appear during normal operation.
- Create
userspace/tests/my_test/expected_fault.txtwith a single pattern to match the kernel's fault log line:
# Verify the kernel detected the fault and killed the process
<<<PROCESS_FAULT>>> Page fault in process
-
The test harness extracts all
ERROR:lines from the QEMU serial output and checks that each pattern inexpected_fault.txtappears in order (same semantics as orderedexpected.txt). -
Use
expected.txtto verify the test started correctly, andexpected_fault.txtto verify the kernel's error handling.
Important: Each pattern in expected_fault.txt matches and consumes a log line. Since the kernel emits the fault details on a single line, use one pattern per fault event rather than splitting across multiple patterns.
This pattern is useful for any test where the expected behaviour is for the kernel to kill the process (protection violations, invalid memory access, etc.).
For tests that modify on-disk filesystem state (e.g., writing to an ext2 partition), you can verify the resulting disk contents after the test completes using expected_debugfs.txt. This runs the Linux debugfs tool against the test disk image and checks that expected patterns appear in its output.
- Create
userspace/tests/my_test/expected_debugfs.txt:
# Lines starting with '>' are debugfs commands.
# All other non-comment lines are expected patterns that must appear
# in order in the debugfs output.
# Verify file was written correctly
>cat hello.txt
Hello from Panda OS!
# Verify nested file was modified
>cat subdir/nested.txt
Modified content
- The test harness:
- Extracts all lines starting with
>as debugfs commands (the>prefix is stripped) - Runs them against the test disk image (
test-disk.img) usingdebugfs - Checks that each non-comment, non-command line appears in the debugfs output in order
- Fails if any expected pattern is missing
- Extracts all lines starting with
File format:
- Lines starting with
#are comments (ignored) - Lines starting with
>are debugfs commands (e.g.,>cat hello.txt,>ls /,>stat file.txt) - All other non-blank lines are expected output patterns, matched in order using substring matching
- Patterns are consumed sequentially: after matching pattern N, pattern N+1 is searched for only in the output that follows
When to use this: Use expected_debugfs.txt when your test writes to a block device and you need to verify the on-disk state is correct. This is particularly useful for filesystem write tests where you want to confirm that data, metadata, and directory structures were persisted correctly. Combine it with expected.txt to verify both runtime behaviour (log output) and persistent state (disk contents).
Requirements: The test must use a disk image. Tests that need a disk image should be added to the appropriate section in scripts/setup-userspace-test.sh and scripts/run-qemu-test.sh (for the virtio-blk drive attachment).
Example from ext2_write_test/expected_debugfs.txt:
# Verify filesystem state after write tests.
# Test 1+2: hello.txt was overwritten and then appended to
>cat hello.txt
Written by Panda OS! Extra data appended.
# Test 4: nested.txt had bytes 7-13 overwritten with "PATCHED"
>cat subdir/nested.txt
Nested PATCHED
- Exit code 0: Test passed
- Exit code non-zero: Test failed (QEMU exits immediately)
- Timeout (60s default): Test failed
CI runs QEMU without KVM (pure TCG software emulation, no /dev/kvm on
GitHub Actions runners), which has meaningfully different scheduling timing
from a local KVM-accelerated run. A failure that only reproduces in CI is
usually not "impossible to debug locally" — it just needs the right theory.
Check causes in roughly this order, cheapest first:
- A missing-line, order-only mismatch in
expected.txt. Symptom: one specific log line is reported missing, while every other line from the same process is present and in the right relative order to each other. This is almost always an unguarded ordering assumption between two lines from different processes — see "When you need this" under Unordered mode above. Check first: does the "missing" line's process reach it before or after any blocking call? If before, and the preceding expected line belongs to a different process, their relative order isn't guaranteed — fixexpected.txt, don't touch kernel code. This is a five-minute check against a multi-hour kernel investigation, so rule it out before assuming a concurrency bug in the kernel. - Force TCG locally to get closer to CI's timing. Temporarily edit the
-accel kvm -accel tcgline inscripts/run-qemu-test.shto-accel tcgonly, then run the failing test (ormake test) several times in a loop. Revert the edit before committing anything — it's a local diagnostic change, not a real fix. Note this doesn't always reproduce CI's exact failure even when the underlying cause is real and CI-reproducible; a clean run under forced TCG is weak evidence of correctness, not proof. - A genuine kernel race, only after 1 and 2 don't explain it. If you land here: reproducing locally (even under forced TCG) is the bar for confidence — "I read the code and this looks plausible" is not the same as "I watched the bug happen and then watched it stop happening." Say explicitly which kind of evidence you actually have when reporting a fix.
- Check whether local actually matches CI's environment, starting with
the QEMU version. CI runs every step through
nix develop(see.github/workflows/ci.yml), the same devShell a localnix developgives you, pinned by the committedflake.lock— so this should be moot today. It wasn't always: CI used to install QEMU viaapt-get install qemu-system-x86(Ubuntu's packaged build, several major versions behind the Nix-pinned one), and that gap alone was enough to produce genuinely different TCG/timer/virtio timing that no amount of matching-accel tcgor parallelism locally could reproduce — see "Resolved CI-only flakes" below for the full story. If CI's workflow ever changes to install tools outside the Nix devShell again, re-check this first before trusting a local non-repro.
Flaky tests are not acceptable long-term — this section is a record of ones that were found, root-caused, and fixed, kept for pattern-matching the next one, not a list of tests it's okay to ignore. If a test here starts failing again, treat it as a regression, not a known quirk.
-
buffer_transfer_test(M2.2 era). Intermittently failed in CI with a distinctive symptom: the child'srecv_with_handlecall succeeded, the message payload was correct, but the attached buffer handle came backNoneeven though the parent'ssend_with_handlecall had succeeded (the attachment genuinely existed at send time). The child then exited without signalling, and the parent's follow-uprecvfailed withPeerClosed— a consequence of the child's earlier failure, not a second independent bug.Code review of
resource/channel.rs'ssend_with_attachment/recv_with_attachmentandsyscall/channel.rs::handle_recv's check → register → recheck loop (the pattern from the lost-wakeup fix,fbf2649) found no bug: message and attachment are pushed/popped together under one lock, with no window where they could be split. 50+ local reproduction attempts (isolated runs, CI-matched parallelism, CPU-constrained viataskset) never reproduced it.Root cause: CI and local were never running the same QEMU. CI installed QEMU via
apt-get install qemu-system-x86(Ubuntu's packaged build); every local attempt used the Nix flake's pinned QEMU 11.0.1 — a gap of several major versions, big enough on its own to produce genuinely different TCG/timer/virtio timing. None of the local non-repro runs were ever evidence the kernel code was correct; they only proved the failure doesn't reproduce under a QEMU build CI doesn't use.Fix:
93614d4switched CI to run every step throughnix develop, resolving to the exact same toolchain (including QEMU) a localnix developgives, pinned by the committedflake.lock. Confirmed clean across 3 consecutive CI runs afterward (previously failing in roughly half of all runs) — strong evidence the version gap was the actual cause. This also closes the whole class of "works locally, fails in CI" problem, not just this one instance: local non-repro is now actually meaningful evidence again.
scripts/run-tests.sh- Runs multiple tests in parallelscripts/run-qemu-test.sh- Runs a single test in QEMUscripts/setup-kernel-test.sh- Prepares kernel test environmentscripts/setup-userspace-test.sh- Prepares userspace test environment
Tests are built to:
- Kernel tests:
build/test-<name>/ - Userspace tests:
build/utest-<name>/
Test logs are written to:
- Kernel tests:
build/test-<name>.log - Userspace tests:
build/utest-<name>.log