//! In-kernel test cases, run at the end of bring-up when the kernel is built with //! `-Dtest-case=`. Each case writes structured markers to the serial port //! that the QEMU harness (test/qemu_test.py) asserts on: //! //! [PASS]/[FAIL] per assertion //! DANOS-TEST-RESULT: PASS|FAIL overall, for non-faulting cases //! //! Faulting cases (fault-ud, fault-pf, fault-df) deliberately don't return a //! result line — they trigger a CPU exception, and the harness asserts on the //! exception report the handler prints (which also reaches serial). const std = @import("std"); const danos = @import("danos"); const arch = @import("arch"); const platform = @import("platform"); const pmm = @import("pmm.zig"); const heap = @import("heap.zig"); const sched = @import("scheduler.zig"); const ipc = @import("ipc.zig"); /// Formatted write straight to serial, independent of the framebuffer console. fn log(comptime fmt: []const u8, args: anytype) void { var buf: [128]u8 = undefined; arch.serialWrite(std.fmt.bufPrint(&buf, fmt, args) catch return); } var passed: u32 = 0; var failed: u32 = 0; fn check(name: []const u8, ok: bool) void { if (ok) { passed += 1; log("[PASS] {s}\n", .{name}); } else { failed += 1; log("[FAIL] {s}\n", .{name}); } } /// Emit the overall result line the harness matches, then the done sentinel. fn result() void { log("DANOS-TEST-RESULT: {s} ({d} passed, {d} failed)\n", .{ if (failed == 0) "PASS" else "FAIL", passed, failed, }); log("DANOS-TEST-DONE\n", .{}); } pub fn run(case: []const u8, boot_info: *const BootInfo) void { if (eql(case, "smoke")) { smoke(boot_info); } else if (eql(case, "timer")) { timer(); } else if (eql(case, "clock")) { clock(); } else if (eql(case, "vmm")) { vmm(); } else if (eql(case, "heap")) { heapTest(); } else if (eql(case, "sched")) { schedTest(); } else if (eql(case, "priority")) { priorityTest(); } else if (eql(case, "sleep")) { sleepTest(); } else if (eql(case, "event")) { eventTest(); } else if (eql(case, "ipc")) { ipcTest(); } else if (eql(case, "fault-ud")) { faultInvalidOpcode(); } else if (eql(case, "fault-pf")) { faultPageFault(); } else if (eql(case, "fault-df")) { faultDoubleFault(); } else if (eql(case, "fault-nx")) { faultNoExecute(); } else if (eql(case, "fault-null")) { faultNull(); } else if (eql(case, "poweroff")) { powerTest(.off); } else if (eql(case, "reboot")) { powerTest(.reboot); } else { log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case}); } } fn platformHal() platform.Hal { return .{ .mapMmio = arch.mapPage, .pioRead = arch.pioRead, .pioWrite = arch.pioWrite, }; } /// Drive an ACPI power transition. On success the machine powers off or resets, /// so QEMU exits — the harness observes the process exit. If control returns, the /// transition failed and we emit a FAIL result. fn powerTest(comptime action: enum { off, reboot }) void { const name = if (action == .off) "poweroff" else "reboot"; log("DANOS-TEST-BEGIN: {s}\n", .{name}); const hal = platformHal(); log("DANOS-POWER: attempting {s}\n", .{name}); switch (action) { .off => platform.shutdown(hal), .reboot => platform.reboot(hal), } check("power transition took effect", false); result(); } const BootInfo = danos.BootInfo; fn eql(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b); } /// Non-destructive checks of the memory map and frame allocator. fn smoke(boot_info: *const BootInfo) void { log("DANOS-TEST-BEGIN: smoke\n", .{}); // The memory map has some usable RAM. const mm = boot_info.memory_map; const regions = @as([*]const danos.MemoryRegion, @ptrFromInt(mm.regions))[0..mm.len]; var usable: u64 = 0; for (regions) |r| { if (r.kind == .usable) usable += r.pages; } check("memory map reports usable RAM", usable > 0); // The frame allocator hands out distinct, page-aligned frames. const a = pmm.alloc(); const b = pmm.alloc(); check("alloc returns a frame", a != null); check("alloc returns distinct frames", a != null and b != null and a.? != b.?); check("frames are page-aligned", (a orelse 1) % danos.page_size == 0); // Freeing restores the count. const before = pmm.stats().free_frames; if (a) |p| pmm.free(p); if (b) |p| pmm.free(p); check("free returns frames to the pool", pmm.stats().free_frames == before + 2); // Paging is active on our own tables (CR3 is non-zero and page-aligned). const cr3 = arch.readCr3(); check("paging active (CR3 set)", cr3 != 0 and cr3 % danos.page_size == 0); result(); } /// Verify device interrupts fire and return: the timer tick counter must advance /// on its own. Interrupts are already enabled by kmain before tests run. fn timer() void { log("DANOS-TEST-BEGIN: timer\n", .{}); const start = arch.ticks(); // Busy-wait for the counter to advance. arch.ticks() is a volatile load, so // the compiler re-reads it each iteration and sees the interrupt's update. // The cap is only a safety net; the harness timeout is the real backstop. var spins: u64 = 0; while (arch.ticks() == start and spins < 5_000_000_000) spins +%= 1; check("timer interrupts advance the tick count", arch.ticks() > start); result(); } /// Verify the on-demand VMM: map a fresh frame at an unused virtual address, and /// check it's writable and reads back. fn vmm() void { log("DANOS-TEST-BEGIN: vmm\n", .{}); const frame = pmm.alloc(); check("frame available to map", frame != null); if (frame) |phys| { var virt: u64 = 0x0000_4000_0000_0000; // canonical, well clear of everything mapped arch.mapPage(virt, phys, true); const p: *volatile u64 = @ptrFromInt(virt); p.* = 0xdead_c0de_cafe_babe; check("mapped page is writable and reads back", p.* == 0xdead_c0de_cafe_babe); arch.unmapPage(virt); pmm.free(phys); virt += 0; } result(); } /// Exercise the kernel heap: basic alloc/write/free, reuse, growth beyond the /// initial region, and a std container backed by it. fn heapTest() void { log("DANOS-TEST-BEGIN: heap\n", .{}); const a = heap.allocator(); // Allocate, write a pattern, read it back, free. const buf = a.alloc(u8, 4096) catch null; check("alloc 4096 bytes", buf != null); if (buf) |b| { @memset(b, 0xAB); check("heap memory is writable and reads back", b[0] == 0xAB and b[4095] == 0xAB); a.free(b); } // Freeing then re-allocating the same size should reuse the block. const p1 = a.alloc(u64, 8) catch null; const addr1 = if (p1) |p| @intFromPtr(p.ptr) else 0; if (p1) |p| a.free(p); const p2 = a.alloc(u64, 8) catch null; const addr2 = if (p2) |p| @intFromPtr(p.ptr) else 0; check("freed block is reused", addr1 != 0 and addr1 == addr2); if (p2) |p| a.free(p); // Force growth past the initial page and check every block is usable. var blocks: [64]?[]u8 = .{null} ** 64; var ok = true; for (&blocks, 0..) |*slot, i| { const b = a.alloc(u8, 4096) catch null; slot.* = b; if (b) |bb| @memset(bb, @intCast(i & 0xff)) else { ok = false; } } for (blocks, 0..) |slot, i| { if (slot) |bb| { if (bb[0] != @as(u8, @intCast(i & 0xff)) or bb[4095] != @as(u8, @intCast(i & 0xff))) ok = false; } } check("many allocations (heap growth) stay valid", ok); for (blocks) |slot| { if (slot) |bb| a.free(bb); } // A std container backed by the kernel heap. var list: std.ArrayList(u32) = .empty; var sum: u64 = 0; var expected: u64 = 0; var i: u32 = 0; var list_ok = true; while (i < 1000) : (i += 1) { list.append(a, i) catch { list_ok = false; }; expected += i; } for (list.items) |v| sum += v; list.deinit(a); check("std.ArrayList on the kernel heap", list_ok and sum == expected); result(); } /// Verify the calibrated clocks: sane measured frequencies, monotonic uptime that /// advances with real ticks, and — the point of the TSC clock — nanosecond /// resolution far finer than the 1 ms tick, with the unit functions consistent. fn clock() void { log("DANOS-TEST-BEGIN: clock\n", .{}); const lapic = arch.lapicHz(); check("LAPIC frequency measured", lapic > 1_000_000 and lapic < 100_000_000_000); const tsc = arch.tscHz(); check("TSC frequency measured", tsc > 100_000_000 and tsc < 100_000_000_000); // Uptime advances over ~5 real ticks (1000 Hz => 1 tick == 1 ms). const start_ticks = arch.ticks(); const start_ms = arch.millis(); var spins: u64 = 0; while (arch.ticks() < start_ticks + 5 and spins < 5_000_000_000) spins +%= 1; const elapsed_ms = arch.millis() - start_ms; check("uptime advances with ticks", elapsed_ms >= 5 and elapsed_ms < 100); // Sub-millisecond resolution: spin until nanos() first advances, then confirm // that first step happened within a millisecond — so nanos() resolves finer // than the 1 ms tick (a tick clock's smallest step *is* 1 ms). Spinning to the // first change is robust to QEMU's coarse TSC update granularity. const n1 = arch.nanos(); var s2: u64 = 0; while (arch.nanos() == n1 and s2 < 10_000_000) s2 +%= 1; const n2 = arch.nanos(); check("nanos() has sub-millisecond resolution", n2 > n1 and (n2 - n1) < 1_000_000); // The unit functions agree (within rounding). const ns = arch.nanos(); check("nanos/micros/millis are consistent", diffWithin(arch.micros(), ns / 1000, 1000) and diffWithin(arch.millis(), ns / 1_000_000, 2)); result(); } fn diffWithin(a: u64, b: u64, tol: u64) bool { return if (a > b) a - b <= tol else b - a <= tol; } // --- scheduler tests ------------------------------------------------------ var counters = [_]u64{0} ** 3; fn spin0() void { const p: *volatile u64 = &counters[0]; while (true) p.* = p.* +% 1; } fn spin1() void { const p: *volatile u64 = &counters[1]; while (true) p.* = p.* +% 1; } fn spin2() void { const p: *volatile u64 = &counters[2]; while (true) p.* = p.* +% 1; } /// Preemption: spawn three tasks that busy-loop *without* yielding. If they all /// make progress, the timer must be preempting between them (and the context /// switch works) — because nothing yields voluntarily. fn schedTest() void { log("DANOS-TEST-BEGIN: sched\n", .{}); counters = .{ 0, 0, 0 }; sched.spawn(spin0, 4); sched.spawn(spin1, 4); sched.spawn(spin2, 4); const c0: *volatile u64 = &counters[0]; const c1: *volatile u64 = &counters[1]; const c2: *volatile u64 = &counters[2]; var spins: u64 = 0; while ((c0.* == 0 or c1.* == 0 or c2.* == 0) and spins < 5_000_000_000) spins +%= 1; check("all three non-yielding tasks made progress (preemption)", c0.* > 0 and c1.* > 0 and c2.* > 0); result(); } var run_order = [_]u8{0} ** 4; var run_n: usize = 0; fn recordExit(priority: u8) void { run_order[run_n] = priority; run_n += 1; sched.exit(); } fn taskHigh() void { recordExit(6); } fn taskMid() void { recordExit(4); } fn taskLow() void { recordExit(2); } /// Fixed priority: with preemption off (deterministic), spawn tasks at three /// priorities and let them run cooperatively. They must run highest-first. fn priorityTest() void { log("DANOS-TEST-BEGIN: priority\n", .{}); sched.setPreemption(false); sched.setPriority(1); // above the idle task (0), below the workers — runs last run_n = 0; sched.spawn(taskLow, 2); sched.spawn(taskMid, 4); sched.spawn(taskHigh, 6); while (run_n < 3) sched.yield(); // regain control only once the workers are done check("tasks ran highest-priority first", run_order[0] == 6 and run_order[1] == 4 and run_order[2] == 2); sched.setPriority(4); sched.setPreemption(true); result(); } var event_wq: sched.WaitQueue = .{}; var event_stage: u32 = 0; fn eventWaiter() void { event_stage = 1; // reached the wait sched.wait(&event_wq); // block until woken event_stage = 3; // woken and resumed sched.exit(); } /// Event-based blocking: a task blocks on a wait queue and is woken. The waiter is /// higher priority, so waking it preempts us and it runs to completion at once. fn eventTest() void { log("DANOS-TEST-BEGIN: event\n", .{}); event_stage = 0; sched.spawn(eventWaiter, 6); // higher priority than this task (4) var spins: u64 = 0; while (event_stage != 1 and spins < 1_000_000_000) : (spins += 1) sched.yield(); check("waiter reached the wait and blocked", event_stage == 1); sched.wake(&event_wq); check("wake resumed the blocked waiter (preempting)", event_stage == 3); result(); } var channel: ipc.Channel(u64, 4) = .{}; var recv_sum: u64 = 0; var recv_count: u64 = 0; fn producer() void { var i: u64 = 1; while (i <= 100) : (i += 1) channel.send(i); sched.exit(); } fn consumer() void { var n: u64 = 0; while (n < 100) : (n += 1) { recv_sum += channel.recv(); recv_count += 1; } sched.exit(); } /// IPC: a producer and consumer pass 100 messages through a 4-slot channel. The /// small buffer forces the channel full and empty repeatedly, exercising both the /// blocking-send and blocking-recv paths. The messages must arrive intact. fn ipcTest() void { log("DANOS-TEST-BEGIN: ipc\n", .{}); channel = .{}; recv_sum = 0; recv_count = 0; sched.spawn(consumer, 5); // above this task (4) so they run and we observe after sched.spawn(producer, 5); var spins: u64 = 0; while (recv_count < 100 and spins < 2_000_000_000) : (spins += 1) sched.yield(); check("all 100 messages received", recv_count == 100); check("messages arrived intact (sum 1..100 == 5050)", recv_sum == 5050); result(); } /// Blocking: sleep(50) should block this task for about 50 ms (measured on the /// calibrated clock) — not busy-wait — while the idle task runs. fn sleepTest() void { log("DANOS-TEST-BEGIN: sleep\n", .{}); const t0 = arch.millis(); sched.sleep(50); const elapsed = arch.millis() - t0; check("sleep(50) blocked for ~50 ms", elapsed >= 50 and elapsed <= 70); result(); } fn faultInvalidOpcode() void { log("DANOS-TEST-BEGIN: fault-ud\n", .{}); asm volatile ("ud2"); } /// Verify NX: fetching an instruction from a data page (mapped no-execute) faults. fn faultNoExecute() void { log("DANOS-TEST-BEGIN: fault-nx\n", .{}); var scratch: u64 = 0xC3; // a lone `ret` — harmless if NX somehow let it run const f: *const fn () void = @ptrFromInt(@intFromPtr(&scratch)); f(); // instruction fetch from an NX page -> #PF before it executes log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{}); } /// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults. fn faultNull() void { log("DANOS-TEST-BEGIN: fault-null\n", .{}); // Launder the address through empty asm so the compiler no longer knows it's // 0 (otherwise it folds a null-pointer safety panic instead of doing the real // access). `allowzero` skips the same null check on the cast. The write then // hits the unmapped page 0 and takes a real hardware #PF. var addr: u64 = 0; addr = asm ("" : [ret] "=r" (-> u64) : [in] "0" (addr)); const p: *allowzero volatile u64 = @ptrFromInt(addr); p.* = 1; } fn faultPageFault() void { log("DANOS-TEST-BEGIN: fault-pf\n", .{}); // Runtime address so the backend emits a register store (not a `mov moffs`, // which the self-hosted x86_64 backend can't encode). var addr: u64 = 0xdeadbeef000; // well above all mapped RAM const p: *volatile u64 = @ptrFromInt(addr); p.* = 1; addr += 0; } fn faultDoubleFault() void { log("DANOS-TEST-BEGIN: fault-df\n", .{}); arch.disableInterrupts(); // so only the ud2 delivery (not a timer tick) triggers the #DF // Point RSP at unmapped memory, then fault: the CPU can't push the fault // frame, which escalates to #DF — survivable only because #DF runs on IST1. var bad_sp: u64 = 0x5000000000; asm volatile ( \\mov %[sp], %%rsp \\ud2 : : [sp] "r" (bad_sp), : .{ .memory = true } ); bad_sp += 0; }