Files
danos/system/kernel/tests.zig
T
Daniel Samson c37402891a test: block-range — the discrimination fixture for range confinement (V2a)
A process acquires a block channel the way a filesystem does (consumer-hello
the device manager), confines ITSELF to blocks [1,3), then proves the clamp
and the gate: volume-relative LBA 0 maps inside the range and reads; a read
reaching past the range is refused; geometry reports the confined size; and
a confined caller can no longer call define_range (no widening, no escape).
It gates on argv so the ramdisk sweep leaves it silent in other boots, and
coexists with fat (ranges are per-badge).

Discrimination (verified by reverting usb-storage to pre-clamp f1bdce2~1):
the unconfined read still succeeds but define_range returns ENOSYS, so the
fixture cannot arm confinement and the case fails — exactly the property
the clamp adds. With the clamp: block-range 1/1.
2026-08-09 17:25:43 +01:00

4972 lines
233 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! In-kernel test cases, run at the end of bring-up when the kernel is built with
//! `-Dtest-case=<name>`. Each case writes structured markers to the serial port
//! that the QEMU harness (test/qemu_test.py) asserts on:
//!
//! [PASS]/[FAIL] <check> 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 boot_handoff = @import("boot-handoff");
const abi = @import("abi");
const device_abi = @import("device-abi");
const architecture = @import("architecture");
const wall_clock = @import("wall-clock.zig");
const devices_broker = @import("devices-broker.zig");
const platform = @import("platform");
const pmm = @import("pmm.zig");
const iommu = @import("iommu.zig");
const heap = @import("heap.zig");
const scheduler = @import("scheduler.zig");
const ipc = @import("ipc.zig");
const ipcsync = @import("ipc-synchronous.zig");
const irq = @import("irq.zig");
const sync = @import("sync.zig");
const process = @import("process.zig");
const initial_ramdisk = @import("initial-ramdisk");
const kernel_log = @import("log.zig");
const kernel_vfs = @import("vfs.zig");
const user_memory = @import("user-memory.zig");
/// Formatted test-marker write. Goes through the kernel log (not straight to
/// serial): the log lock is what keeps marker lines from interleaving with
/// concurrent user-process records on other cores.
fn log(comptime fmt: []const u8, args: anytype) void {
kernel_log.print(fmt, args);
}
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});
}
}
/// `devices_broker.claim` reduced to a bool, for the `check` assertions below. The
/// broker returns `ClaimError` so ring 3 can tell "stale id" from "someone already
/// owns it" (the errno space in system/abi.zig); a test that only asserts the claim
/// succeeded does not care which, and the ones that do match the error directly.
fn claimOk(id: u64, owner: u32) bool {
devices_broker.claim(id, owner) catch return false;
return true;
}
/// 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_information: *const BootInformation) void {
if (eql(case, "smoke")) {
smoke(boot_information);
} else if (eql(case, "discovery")) {
discoveryTest();
} else if (eql(case, "wx")) {
wxTest();
} else if (eql(case, "timer")) {
timer();
} else if (eql(case, "clock")) {
clock();
} else if (eql(case, "wall-clock")) {
wallClock();
} else if (eql(case, "vmm")) {
vmm();
} else if (eql(case, "heap")) {
heapTest();
} else if (eql(case, "sched")) {
schedulerTest();
} 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, "ipc-call")) {
ipcCallTest();
} else if (eql(case, "ipc-cap")) {
capabilityTest();
} else if (eql(case, "dma")) {
dmaTest();
} else if (eql(case, "msi")) {
msiTest();
} else if (eql(case, "iommu")) {
iommuTest();
} else if (eql(case, "ioport")) {
ioPortTest();
} else if (eql(case, "display")) {
displayTest(boot_information);
} else if (eql(case, "display-service")) {
displayServiceTest(boot_information);
} else if (eql(case, "display-demo")) {
displayDemoTest(boot_information);
} else if (eql(case, "display-cursor")) {
displayCursorTest(boot_information);
} else if (eql(case, "shared-memory")) {
sharedMemoryTest(boot_information);
} else if (eql(case, "virtio-gpu")) {
virtioGpuTest(boot_information);
} else if (eql(case, "display-native")) {
displayNativeTest(boot_information);
} else if (eql(case, "display-reattach")) {
displayReattachTest(boot_information);
} else if (eql(case, "clock")) {
clockTest();
} else if (eql(case, "smp")) {
smpTest();
} else if (eql(case, "affinity")) {
affinityTest();
} else if (eql(case, "smp-stress")) {
stressTest();
} else if (eql(case, "smp-retry")) {
smpRetryTest();
} else if (eql(case, "tsc-sync")) {
tscSyncTest();
} 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-ap-df")) {
faultApTest();
} else if (eql(case, "fault-nx")) {
faultNoExecute();
} else if (eql(case, "fault-null")) {
faultNull();
} else if (eql(case, "fault-smep")) {
faultSmep();
} else if (eql(case, "fault-smap")) {
faultSmap();
} else if (eql(case, "sysret-canonical")) {
sysretCanonicalTest();
} else if (eql(case, "usermem")) {
userMemTest();
} else if (eql(case, "user-memory")) {
userMemoryTest(boot_information);
} else if (eql(case, "user-pf")) {
userPfTest();
} else if (eql(case, "fault-recovery")) {
faultRecoveryTest(boot_information);
} else if (eql(case, "address-space-refcount")) {
addressSpaceRefcountTest(boot_information);
} else if (eql(case, "thread-fault-group")) {
threadFaultGroupTest(boot_information);
} else if (eql(case, "kill-threaded-group")) {
killThreadedGroupTest(boot_information);
} else if (eql(case, "kill-via-worker-tid")) {
killViaWorkerTidTest(boot_information);
} else if (eql(case, "racing-triggers")) {
racingTriggersTest(boot_information);
} else if (eql(case, "exit-group")) {
exitGroupTest(boot_information);
} else if (eql(case, "leader-thread-exit")) {
threadTestMarkerCase(boot_information, "leader-thread-exit", "leader-exit");
} else if (eql(case, "thread-exit-solo")) {
threadTestMarkerCase(boot_information, "thread-exit-solo", "solo");
} else if (eql(case, "shm-mapping-ref")) {
threadTestMarkerCase(boot_information, "shm-mapping-ref", "shm-worker");
} else if (eql(case, "thread-spawn")) {
threadSpawnTest(boot_information);
} else if (eql(case, "thread-join")) {
threadJoinTest(boot_information);
} else if (eql(case, "thread-futex")) {
threadFutexTest(boot_information);
} else if (eql(case, "thread-mutex")) {
threadMutexTest(boot_information);
} else if (eql(case, "thread-id")) {
threadIdTest(boot_information);
} else if (eql(case, "thread-alloc")) {
threadAllocTest(boot_information);
} else if (eql(case, "task-reap")) {
taskReapTest(boot_information);
} else if (eql(case, "thread-tls")) {
threadTlsTest(boot_information);
} else if (eql(case, "thread-rwlock")) {
threadRwlockTest(boot_information);
} else if (eql(case, "args")) {
argsTest(boot_information);
} else if (eql(case, "init")) {
initTest(boot_information);
} else if (eql(case, "process")) {
processTest(boot_information);
} else if (eql(case, "process-list")) {
processListTest(boot_information);
} else if (eql(case, "process-kill")) {
processKillTest(boot_information);
} else if (eql(case, "supervision")) {
supervisionTest(boot_information);
} else if (eql(case, "claim-release")) {
claimReleaseTest(boot_information);
} else if (eql(case, "vfs-client-death")) {
vfsClientDeathTest(boot_information);
} else if (eql(case, "signals")) {
signalsTest(boot_information);
} else if (eql(case, "driver-restart")) {
driverRestartTest(boot_information);
} else if (eql(case, "usb-report")) {
usbReportTest(boot_information);
} else if (eql(case, "usb-hid")) {
usbHidTest(boot_information);
} else if (eql(case, "usb-storage")) {
usbStorageTest(boot_information);
} else if (eql(case, "fat-mount")) {
fatMountTest(boot_information);
} else if (eql(case, "device-list")) {
deviceListTest(boot_information);
} else if (eql(case, "pci-scan")) {
pciScanTest(boot_information);
} else if (eql(case, "pci-caps")) {
pciCapsTest(boot_information);
} else if (eql(case, "iommu-fault")) {
iommuFaultTest(boot_information);
} else if (eql(case, "acpi-parse")) {
acpiParseTest(boot_information);
} else if (eql(case, "acpi-report")) {
acpiReportTest(boot_information);
} else if (eql(case, "acpi-ps2")) {
acpiReportTest(boot_information); // same spawn; the harness regex differs
} else if (eql(case, "power-button")) {
acpiReportTest(boot_information); // boot the manager (spawns the acpi service); harness injects the button
} else if (eql(case, "orderly-shutdown")) {
orderlyShutdownTest(boot_information);
} else if (eql(case, "initial-ramdisk")) {
initialRamdiskTest(boot_information);
} else if (eql(case, "vfs")) {
vfsTest(boot_information);
} else if (eql(case, "kvfs")) {
kernelVfsTest(boot_information);
} else if (eql(case, "input")) {
inputTest(boot_information);
} else if (eql(case, "iopass")) {
ioPassTest();
} else if (eql(case, "irqfree")) {
irqFreeTest();
} else if (eql(case, "containment")) {
containmentTest();
} else if (eql(case, "apertures")) {
apertureTest();
} else if (eql(case, "device-transfer")) {
deviceTransferTest(boot_information);
} else if (eql(case, "device-authority")) {
deviceAuthorityTest(boot_information);
} else if (eql(case, "block-range")) {
blockRangeTest(boot_information);
} else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information);
} else if (eql(case, "protocol-registry")) {
protocolRegistryTest(boot_information);
} else if (eql(case, "protocol-denied")) {
protocolDeniedTest(boot_information);
} else if (eql(case, "protocol-conformance")) {
protocolConformanceTest(boot_information);
} else if (eql(case, "reboot")) {
rebootTest();
} else {
log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case});
}
}
fn platformHal() platform.Hal {
return .{
.mapMmio = architecture.mapMmio,
.pioRead = architecture.pioRead,
.pioWrite = architecture.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.
// Soft-off (S5) is no longer a kernel operation — the ring-3 acpi service owns it
// (exercised end-to-end by `orderly-shutdown`). Reboot stays in the kernel (FADT
// reset register, no AML), so it keeps its own case.
fn rebootTest() void {
log("DANOS-TEST-BEGIN: reboot\n", .{});
const hal = platformHal();
log("DANOS-POWER: attempting reboot\n", .{});
platform.reboot(hal);
check("power transition took effect", false);
result();
}
const BootInformation = boot_handoff.BootInformation;
fn eql(a: []const u8, b: []const u8) bool {
return std.mem.eql(u8, a, b);
}
/// Whether the captured last-write buffer *contains* `needle`. Markers are
/// matched as substrings, not prefixes, so a service's source-path debug prefix
/// (`system/drivers/pci-bus: ...`) still satisfies a marker like `pci-bus: `.
fn bufferHas(needle: []const u8) bool {
return std.mem.indexOf(u8, process.write_buffer[0..process.write_len], needle) != null;
}
/// Substring search across the whole retained log ring (record payloads are
/// contiguous in the stream, so a one-line needle always matches if present).
/// Unlike `bufferHas` (the single LAST write), this survives busy-tree chatter.
fn ringHas(needle: []const u8) bool {
var chunk: [1024]u8 = undefined;
var overlap: [128]u8 = undefined;
var overlap_len: usize = 0;
var offset = kernel_log.status().tail;
while (true) {
const n = kernel_log.readAt(offset, &chunk) orelse return false;
if (n == 0) return false;
offset += n;
// Search the previous tail glued to this chunk, then the chunk itself.
if (overlap_len != 0) {
var glued: [1152]u8 = undefined;
@memcpy(glued[0..overlap_len], overlap[0..overlap_len]);
const m = @min(n, glued.len - overlap_len);
@memcpy(glued[overlap_len..][0..m], chunk[0..m]);
if (std.mem.indexOf(u8, glued[0 .. overlap_len + m], needle) != null) return true;
} else if (std.mem.indexOf(u8, chunk[0..n], needle) != null) return true;
overlap_len = @min(n, @min(overlap.len, needle.len));
@memcpy(overlap[0..overlap_len], chunk[n - overlap_len ..][0..overlap_len]);
}
}
/// How many `pci_device` functions the devices broker currently holds. A durable
/// snapshot, unlike a `bufferHas` poll of the single-latest write_buffer line, so a
/// test can wait on it without racing transient log output. `scratch` is
/// caller-owned to keep the (large) descriptor array off this helper's own frame.
fn brokerPciCount(scratch: []device_abi.DeviceDescriptor) u32 {
const k = @min(devices_broker.enumerate(scratch), scratch.len);
var count: u32 = 0;
for (scratch[0..k]) |d| {
if (d.class == @intFromEnum(device_abi.DeviceClass.pci_device)) count += 1;
}
return count;
}
/// Non-destructive checks of the memory map and frame allocator.
fn smoke(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: smoke\n", .{});
// The memory map has some usable RAM.
const mm = boot_information.memory_map;
const regions = @as([*]const boot_handoff.MemoryRegion, @ptrFromInt(boot_handoff.physicalToVirtual(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) % abi.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 (the root is non-zero and page-aligned).
const root = architecture.activePageTable();
check("paging active (page-table root set)", root != 0 and root % abi.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 = architecture.ticks();
// Busy-wait for the counter to advance. architecture.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 (architecture.ticks() == start and spins < 5_000_000_000) spins +%= 1;
check("timer interrupts advance the tick count", architecture.ticks() > start);
result();
}
/// Verify device discovery populated the platform facts the rest of the kernel
/// depends on — the results the static ACPI tables stashed in globals at boot.
/// These are stable for the QEMU q35 + OVMF machine the harness runs, and span the
/// tables: MADT (LAPIC base, CPU count) and FADT (PM/reset registers). The kernel
/// no longer interprets AML — sleep types are the ring-3 acpi service's concern.
fn discoveryTest() void {
log("DANOS-TEST-BEGIN: discovery\n", .{});
const pinfo = platform.platformInformation();
const pw = platform.powerInformation();
check("LAPIC base discovered (MADT)", pinfo.lapic_base == 0xFEE00000);
check("ACPI PM timer found (FADT)", pinfo.pm_timer.present());
check("PM1a control register found (FADT)", pw.pm1a_cnt.present());
check("reset register supported (FADT)", pw.reset_supported);
check("at least one CPU enumerated (MADT)", platform.cpus().len >= 1);
// M15: every PCI function now carries its own 4 KiB ECAM configuration space as
// resource 0 — the window a driver mmio_maps to walk its capability list (MSI etc).
// M19.3: the kernel seeds only the bridge; functions arrive by the ring-3
// scan (proven equivalent in pci-scan before the walk retired).
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
var bridges: u32 = 0;
var bridge_shape_ok = false;
for (buffer[0..n]) |d| {
if (d.class != @intFromEnum(device_abi.DeviceClass.pci_host_bridge)) continue;
bridges += 1;
var has_bus_range = false;
var has_io = false;
var memory_windows: u32 = 0;
for (d.resources[0..@intCast(d.resource_count)]) |resource| {
if (resource.kind == @intFromEnum(device_abi.ResourceKind.bus_range)) has_bus_range = true;
if (resource.kind == @intFromEnum(device_abi.ResourceKind.io_port)) has_io = true;
if (resource.kind == @intFromEnum(device_abi.ResourceKind.memory)) memory_windows += 1;
}
// ECAM plus at least one MMIO aperture, the bus range, the I/O window.
if (has_bus_range and has_io and memory_windows >= 2) bridge_shape_ok = true;
}
check("a PCI host bridge was seeded (MCFG)", bridges >= 1);
check("the bridge carries ECAM, apertures, bus range, and the I/O window", bridge_shape_ok);
// M19.0: every PCI memory resource (config slice and BARs alike) must be
// contained in one of its parent bridge's windows — the aperture derivation
// from the memory map is what makes a future user-space device_register of
// these functions pass containment. This is the assert that catches a
// too-coarse hole computation before M19.2 would.
var bars_contained = true;
for (buffer[0..n]) |d| {
if (d.class != @intFromEnum(device_abi.DeviceClass.pci_device)) continue;
if (d.parent >= n) {
bars_contained = false;
continue;
}
const bridge = buffer[@intCast(d.parent)];
for (d.resources[0..@intCast(d.resource_count)]) |r| {
if (r.kind != @intFromEnum(device_abi.ResourceKind.memory)) continue;
var inside = false;
for (bridge.resources[0..@intCast(bridge.resource_count)]) |w| {
if (w.kind != @intFromEnum(device_abi.ResourceKind.memory)) continue;
if (r.start >= w.start and r.start + r.len <= w.start + w.len) inside = true;
}
if (!inside) {
bars_contained = false;
log(" escaping BAR: 0x{x}+0x{x} on device {d}\n", .{ r.start, r.len, d.id });
}
}
}
check("every PCI BAR lies inside a bridge aperture (M19.0)", bars_contained);
result();
}
/// Audit the W^X invariant across the memory classes: kernel code must be
/// executable, everything else must not be. `architecture.pageExecutable` reads the leaf
/// page-table entry's NX bit, so this guards the permission overlay in paging.zig —
/// a broader check than `fault-nx`, which only exercises one data page.
fn wxTest() void {
log("DANOS-TEST-BEGIN: wx\n", .{});
check("kernel code is executable (R+X)", architecture.pageExecutable(@intFromPtr(&wxTest)));
const ro = "danos-wx-probe"; // string literal -> .rodata
check("rodata is non-executable (NX)", !architecture.pageExecutable(@intFromPtr(ro.ptr)));
check("kernel data is non-executable (NX)", !architecture.pageExecutable(@intFromPtr(&passed)));
if (heap.allocator().alloc(u8, 64) catch null) |h| {
check("heap is non-executable (NX)", !architecture.pageExecutable(@intFromPtr(h.ptr)));
heap.allocator().free(h);
}
var local: u64 = 0;
_ = &local;
check("stack is non-executable (NX)", !architecture.pageExecutable(@intFromPtr(&local)));
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) |physical| {
var virtual: u64 = 0x0000_4000_0000_0000; // canonical, well clear of everything mapped
architecture.mapPage(virtual, physical, true);
const p: *volatile u64 = @ptrFromInt(virtual);
p.* = 0xdead_c0de_cafe_babe;
check("mapped page is writable and reads back", p.* == 0xdead_c0de_cafe_babe);
architecture.unmapPage(virtual);
pmm.free(physical);
virtual += 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 buffer = a.alloc(u8, 4096) catch null;
check("alloc 4096 bytes", buffer != null);
if (buffer) |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 address1 = if (p1) |p| @intFromPtr(p.ptr) else 0;
if (p1) |p| a.free(p);
const p2 = a.alloc(u64, 8) catch null;
const address2 = if (p2) |p| @intFromPtr(p.ptr) else 0;
check("freed block is reused", address1 != 0 and address1 == address2);
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 wallClock() void {
log("DANOS-TEST-BEGIN: wall-clock\n", .{});
// The RTC was read and anchored at boot (kmain -> wall_clock.init()).
const seconds = wall_clock.nowSeconds();
log(" epoch: {d}\n", .{seconds});
// A plausible current wall-clock: after 2020-01-01 (1577836800) and before 2050
// (2524608000) — catches a broken CMOS read or a wrong epoch conversion.
check("wall clock reads a plausible current epoch", seconds > 1_577_836_800 and seconds < 2_524_608_000);
result();
}
fn clock() void {
log("DANOS-TEST-BEGIN: clock\n", .{});
const timer_clock = architecture.timerClockHz();
check("timer clock frequency measured", timer_clock > 1_000_000 and timer_clock < 100_000_000_000);
const clock_hz = architecture.clockHz();
check("monotonic clock frequency measured", clock_hz > 100_000_000 and clock_hz < 100_000_000_000);
// Uptime advances over ~5 real ticks (1000 Hz => 1 tick == 1 ms).
const start_ticks = architecture.ticks();
const start_ms = architecture.millis();
var spins: u64 = 0;
while (architecture.ticks() < start_ticks + 5 and spins < 5_000_000_000) spins +%= 1;
const elapsed_ms = architecture.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 = architecture.nanos();
var s2: u64 = 0;
while (architecture.nanos() == n1 and s2 < 10_000_000) s2 +%= 1;
const n2 = architecture.nanos();
check("nanos() has sub-millisecond resolution", n2 > n1 and (n2 - n1) < 1_000_000);
// The unit functions agree (within rounding).
const ns = architecture.nanos();
check("nanos/micros/millis are consistent", diffWithin(architecture.micros(), ns / 1000, 1000) and diffWithin(architecture.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 schedulerTest() void {
log("DANOS-TEST-BEGIN: sched\n", .{});
counters = .{ 0, 0, 0 };
scheduler.spawn(spin0, 4);
scheduler.spawn(spin1, 4);
scheduler.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;
scheduler.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", .{});
scheduler.setPreemption(false);
scheduler.setPriority(1); // above the idle task (0), below the workers — runs last
run_n = 0;
scheduler.spawn(taskLow, 2);
scheduler.spawn(taskMid, 4);
scheduler.spawn(taskHigh, 6);
while (run_n < 3) scheduler.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);
scheduler.setPriority(4);
scheduler.setPreemption(true);
result();
}
var event_wait_queue: scheduler.WaitQueue = .{};
var event_stage: u32 = 0;
fn eventWaiter() void {
event_stage = 1; // reached the wait
scheduler.wait(&event_wait_queue); // block until woken
event_stage = 3; // woken and resumed
scheduler.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;
scheduler.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) scheduler.yield();
check("waiter reached the wait and blocked", event_stage == 1);
scheduler.wake(&event_wait_queue);
check("wake resumed the blocked waiter (preempting)", event_stage == 3);
result();
}
var channel: ipc.Channel(u64, 4) = .{};
var receive_sum: u64 = 0;
var receive_count: u64 = 0;
fn producer() void {
var i: u64 = 1;
while (i <= 100) : (i += 1) channel.send(i);
scheduler.exit();
}
fn consumer() void {
var n: u64 = 0;
while (n < 100) : (n += 1) {
receive_sum += channel.receive();
receive_count += 1;
}
scheduler.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-receive paths. The messages must arrive intact.
fn ipcTest() void {
log("DANOS-TEST-BEGIN: ipc\n", .{});
channel = .{};
receive_sum = 0;
receive_count = 0;
scheduler.spawn(consumer, 5); // above this task (4) so they run and we observe after
scheduler.spawn(producer, 5);
var spins: u64 = 0;
while (receive_count < 100 and spins < 2_000_000_000) : (spins += 1) scheduler.yield();
check("all 100 messages received", receive_count == 100);
check("messages arrived intact (sum 1..100 == 5050)", receive_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 = architecture.millis();
scheduler.sleep(50);
const elapsed = architecture.millis() - t0;
check("sleep(50) blocked for ~50 ms", elapsed >= 50 and elapsed <= 70);
result();
}
// --- SMP parallelism ------------------------------------------------------
var seen_core = [_]bool{false} ** 8;
/// Whether the core this worker ran on had SMEP on in its own CR4. Recorded per
/// core because CR4 is per-core state: the boot processor enabling it says
/// nothing about the ones the trampoline brought up, and the whole hardening is
/// only as wide as its narrowest core.
var seen_core_smep = [_]bool{false} ** 8;
/// The same, for SMAP. A core that came up without it would still be able to read
/// and write user pages from ring 0 while every other core could not — a hole
/// whose only symptom is that the tripwire never trips there.
var seen_core_smap = [_]bool{false} ** 8;
var smp_running: bool = true;
/// A worker that, while running, records which core it's executing on. Spread across
/// spawned workers and idle APs, these should land on more than one core.
fn smpWorker() void {
const p: *volatile bool = &smp_running;
while (p.*) {
const c = scheduler.currentCpuIndex();
if (c < seen_core.len) {
seen_core[c] = true;
seen_core_smep[c] = architecture.supervisorExecutePreventionEnabled();
seen_core_smap[c] = architecture.supervisorAccessPreventionEnabled();
}
}
scheduler.exit();
}
/// Prove tasks run **in parallel** on multiple cores (not just interleaved on one).
/// Spawn several CPU-bound workers; each stamps the core it runs on into `seen_core`.
/// With the application processors online, more than one core should show up — which
/// can only happen if work is genuinely running at the same time on different cores.
/// (Run with QEMU `-smp N`; on a single core this would see just one and fail.)
fn smpTest() void {
log("DANOS-TEST-BEGIN: smp\n", .{});
seen_core = .{false} ** 8;
smp_running = true;
var i: usize = 0;
while (i < 4) : (i += 1) scheduler.spawn(smpWorker, 4);
// Let the workers run across cores for a stretch of real time.
var spins: u64 = 0;
while (spins < 2_000_000_000) spins +%= 1;
smp_running = false;
var cores_seen: u32 = 0;
for (seen_core) |s| {
if (s) cores_seen += 1;
}
log("DANOS-SMP: workers ran on {d} distinct core(s)\n", .{cores_seen});
check("tasks ran on multiple cores in parallel", cores_seen >= 2);
// Every core that ran work must have had SMEP on, not just the one that
// booted: an application processor climbs through the trampoline with CR4
// bare and sets the bit itself on the way in, so a hardening that reached
// only the boot processor would leave every other core able to execute a
// user page in ring 0 — and would look identical from the boot log. Only
// asserted where the platform has SMEP at all, so a machine without it
// still passes rather than failing for the one reason that is not a bug.
if (architecture.supervisorExecutePreventionEnabled()) {
var hardened: u32 = 0;
for (seen_core, seen_core_smep) |ran, smep| {
if (ran and smep) hardened += 1;
}
log("DANOS-SMP: {d} of {d} core(s) had supervisor execute prevention\n", .{ hardened, cores_seen });
check("every core that ran work had SMEP enabled", hardened == cores_seen);
}
// And the same for SMAP, for the same reason and with one more of its own: the
// bit is refused until the boot processor has patched the `clac` into the shared
// interrupt entry, so a core reporting SMAP on is also a core confirming it came
// up after that patch — the ordering the whole scheme rests on, checked from the
// far end. Gated on the running core the same way, so a CPU without SMAP passes.
if (architecture.supervisorAccessPreventionEnabled()) {
var hardened: u32 = 0;
for (seen_core, seen_core_smap) |ran, smap| {
if (ran and smap) hardened += 1;
}
log("DANOS-SMP: {d} of {d} core(s) had supervisor access prevention\n", .{ hardened, cores_seen });
check("every core that ran work had SMAP enabled", hardened == cores_seen);
}
// Bring-up is done, so the trampoline frame must be inert: zeroed (no stale code)
// and non-executable (W^X restored). It's armed only while a core is climbing.
const tramp = architecture.trampolinePage();
check("trampoline frame reserved", tramp != 0);
if (tramp != 0) {
const bytes: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(tramp));
var zeroed = true;
for (0..4096) |b| {
if (bytes[b] != 0) zeroed = false;
}
check("trampoline page zeroed when dormant", zeroed);
check("trampoline page non-executable when dormant", !architecture.pageExecutable(tramp));
}
result();
}
// --- affinity: a pinned task never migrates -------------------------------
var affinity_cores = [_]bool{false} ** 8;
var affinity_running: bool = true;
fn affinityWorker() void {
const p: *volatile bool = &affinity_running;
while (p.*) {
const c = scheduler.currentCpuIndex();
if (c < affinity_cores.len) affinity_cores[c] = true;
}
scheduler.exit();
}
/// A task pinned to a core must run **only** on that core. Pin a busy worker to
/// core 1 and let it run through many preemptions; it must have stamped core 1 and no
/// other. An *unpinned* task scatters across cores (that's what the smp test shows),
/// so a broken pin fails this deterministically — over this many time slices a
/// free-floating task will land on some other core.
fn affinityTest() void {
log("DANOS-TEST-BEGIN: affinity\n", .{});
affinity_cores = .{false} ** 8;
affinity_running = true;
if (!scheduler.spawnOn(affinityWorker, 4, 1)) {
check("worker pinned to core 1 (run with -smp)", false);
result();
return;
}
// Let many time slices pass so the scheduler runs the pinned worker across ticks.
// Wait on the wall clock, not a raw iteration count: a fixed-count busy-loop's
// wall-time is a codegen lottery (the optimiser may elide or vectorise it), so an
// unrelated change elsewhere in this file could swing this test from ~4 s to ~50 s.
const run_until = architecture.millis() + 400;
while (architecture.millis() < run_until) {}
affinity_running = false;
const settle_until = architecture.millis() + 50;
while (architecture.millis() < settle_until) {} // let the worker see the flag and exit
var others: u32 = 0;
for (affinity_cores, 0..) |seen, c| {
if (seen and c != 1) others += 1;
}
log("DANOS-AFFINITY: pinned worker touched core 1={}, other cores={d}\n", .{ affinity_cores[1], others });
check("pinned task ran on its core (1)", affinity_cores[1]);
check("pinned task never migrated to another core", others == 0);
result();
}
// --- SMP stress: hammer the big kernel lock across cores ------------------
const stress_pairs = 4; // producer/consumer pairs (8 tasks; fits the 16-task pool)
const stress_msgs = 100_000; // messages per pair
const stress_cap = 4; // small channel -> constant block/wake, more lock churn
var stress_chan = [_]ipc.Channel(u64, stress_cap){.{}} ** stress_pairs;
var stress_receive = [_]u64{0} ** stress_pairs; // messages received per pair
var stress_order_ok = [_]bool{true} ** stress_pairs; // FIFO order held per pair
var stress_cores = [_]bool{false} ** 8; // cores that ran a consumer
var stress_prod_claim: usize = 0;
var stress_cons_claim: usize = 0;
fn stressProducer() void {
// Claim a unique pair index (atomic: producers start on different cores).
const index = @atomicRmw(usize, &stress_prod_claim, .Add, 1, .monotonic);
var v: u64 = 1;
while (v <= stress_msgs) : (v += 1) stress_chan[index].send(v);
scheduler.exit();
}
fn stressConsumer() void {
const index = @atomicRmw(usize, &stress_cons_claim, .Add, 1, .monotonic);
var expected: u64 = 1;
while (expected <= stress_msgs) : (expected += 1) {
const got = stress_chan[index].receive();
if (got != expected) stress_order_ok[index] = false; // lost/reordered => lock broke
const c = scheduler.currentCpuIndex();
if (c < stress_cores.len) stress_cores[c] = true;
stress_receive[index] = expected;
}
scheduler.exit();
}
/// Stress the big kernel lock under sustained cross-core contention. Each pair drives
/// `stress_msgs` sequenced messages through a 4-slot channel — every send and receive
/// takes the lock, and the small buffer forces constant block/wake (so the scheduler
/// churns too). A single-producer/single-consumer channel must deliver in strict FIFO
/// order; if the lock let two cores into a critical section at once, the ring buffer
/// corrupts and the consumer sees a wrong or out-of-order value (or the run hangs /
/// faults). Passing means ~320k lock acquisitions across the cores stayed consistent.
fn stressTest() void {
log("DANOS-TEST-BEGIN: smp-stress\n", .{});
stress_chan = [_]ipc.Channel(u64, stress_cap){.{}} ** stress_pairs;
stress_receive = [_]u64{0} ** stress_pairs;
stress_order_ok = [_]bool{true} ** stress_pairs;
stress_cores = [_]bool{false} ** 8;
stress_prod_claim = 0;
stress_cons_claim = 0;
var i: usize = 0;
while (i < stress_pairs) : (i += 1) scheduler.spawn(stressConsumer, 4);
i = 0;
while (i < stress_pairs) : (i += 1) scheduler.spawn(stressProducer, 4);
// Drop below the workers so they get the cores; wake periodically to check for
// completion. A broken lock instead hangs here (harness timeout) or faults.
scheduler.setPriority(1);
var spins: u64 = 0;
while (spins < 40_000_000_000) : (spins += 1) {
var done = true;
for (stress_receive) |n| {
if (n < stress_msgs) done = false;
}
if (done) break;
}
scheduler.setPriority(4);
var total: u64 = 0;
for (stress_receive) |n| total += n;
var order_ok = true;
for (stress_order_ok) |ok| {
if (!ok) order_ok = false;
}
var cores: u32 = 0;
for (stress_cores) |s| {
if (s) cores += 1;
}
log("DANOS-STRESS: {d}/{d} pairs complete on {d} cores\n", .{ total, @as(u64, stress_pairs) * stress_msgs, cores });
check("every message delivered", total == @as(u64, stress_pairs) * stress_msgs);
check("strict FIFO order held (no lock corruption)", order_ok);
check("contention was genuinely cross-core", cores >= 2);
result();
}
/// Retry: `main` forced the first AP wake attempt to fail (architecture.testFailNextWakes),
/// so a core missed its first INIT-SIPI-SIPI. The boot retry must have brought it back
/// anyway — every enumerated core should be online. If retry were broken, that core
/// would be parked and the count would fall short.
fn smpRetryTest() void {
log("DANOS-TEST-BEGIN: smp-retry\n", .{});
const total = platform.cpus().len;
const online = scheduler.onlineCount();
log("DANOS-RETRY: {d}/{d} cores online after a forced first-wake failure\n", .{ online, total });
check("multiple cores enumerated (run with -smp)", total >= 2);
check("retry brought every core online despite a failed first wake", online == total);
result();
}
// --- ring 3 (user mode) -----------------------------------------------------
/// The mmap/munmap grant path: create a fresh address space, hand out pages into
/// its mmap arena the way the `mmap` system_call does, prove they are real (write and
/// read them back through the physmap), then release them via the `munmap` path
/// (translate -> unmap -> free) and tear the address space down. The frame count
/// must return exactly to where it started — a leak or a double-free would show
/// as drift. Exercises the new `translate`/`unmapUserPageInto` primitives that
/// back munmap, without needing a user binary that calls the syscalls.
fn userMemTest() void {
log("DANOS-TEST-BEGIN: usermem\n", .{});
const base_free = pmm.stats().free_frames;
const address_space = architecture.createAddressSpace() orelse {
check("created a fresh address space", false);
result();
return;
};
check("created a fresh address space", address_space != 0);
// Grant three pages into the arena, mapped RW + NX (the mmap contract).
const npages = 3;
const arena = process.heap_arena_base;
var frames: [npages]u64 = undefined;
var mapped: usize = 0;
while (mapped < npages) : (mapped += 1) {
frames[mapped] = pmm.alloc() orelse break;
architecture.mapUserPageInto(address_space, arena + mapped * abi.page_size, frames[mapped], true, false);
}
check("granted three user pages", mapped == npages);
// Each page resolves back to the frame we mapped, and is writable RAM.
var translate_ok = true;
var rw_ok = true;
for (0..npages) |i| {
const va = arena + i * abi.page_size;
const physical = architecture.translate(address_space, va) orelse {
translate_ok = false;
continue;
};
if (physical != frames[i]) translate_ok = false;
const p: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical));
p[0] = 0xA5;
if (p[0] != 0xA5) rw_ok = false;
}
check("translate resolves each grant to its frame", translate_ok);
check("granted pages are writable RAM", rw_ok);
// Release them the way munmap does, then tear down the address space.
for (0..npages) |i| {
const va = arena + i * abi.page_size;
if (architecture.translate(address_space, va)) |physical| {
architecture.unmapUserPageInto(address_space, va);
pmm.free(physical);
}
}
check("munmap unmapped every grant", architecture.translate(address_space, arena) == null);
architecture.destroyAddressSpace(address_space);
check("no frames leaked (free count restored)", pmm.stats().free_frames == base_free);
result();
}
/// The checked copy layer (system/kernel/user-memory.zig), against a scratch
/// address space built here rather than a live process — so the refusals can be
/// provoked exactly: a kernel-half address, an unmapped user page, and a user
/// page mapped read-only. Then the fixture proves the same refusals reach ring 3
/// as -errno instead of a kernel fault.
fn userMemoryTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: user-memory\n", .{});
const base_free = pmm.stats().free_frames;
const address_space = architecture.createAddressSpace() orelse {
check("created a scratch address space", false);
result();
return;
};
check("created a scratch address space", address_space != 0);
// Three consecutive pages: two writable, the third read-only — so a copy that
// straddles into the third proves the write check applies per page, not just
// to the first one the walk touches.
const writable_pages = 2;
const total_pages = 3;
const arena = process.heap_arena_base;
var frames: [total_pages]u64 = undefined;
var mapped: usize = 0;
while (mapped < total_pages) : (mapped += 1) {
frames[mapped] = pmm.alloc() orelse break;
architecture.mapUserPageInto(address_space, arena + mapped * abi.page_size, frames[mapped], mapped < writable_pages, false);
}
check("mapped two writable and one read-only user page", mapped == total_pages);
if (mapped == total_pages) {
const read_only = arena + writable_pages * abi.page_size;
const unmapped = arena + total_pages * abi.page_size;
const kernel_half: u64 = 0xFFFF_8000_0000_0000;
var out: [16]u8 = undefined;
const pattern = [_]u8{ 0xC0, 0xDE, 0xF0, 0x0D, 0xBA, 0xAD, 0xF0, 0x0D };
// A round trip through the writable page: what copyToUser placed is what
// copyFromUser brings back, and the frame really holds it.
const wrote = user_memory.copyToUser(address_space, arena + 32, &pattern);
const read_back = user_memory.copyFromUser(address_space, arena + 32, out[0..pattern.len]);
const frame_view: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frames[0] + 32));
check("copyToUser/copyFromUser round trip", wrote and read_back and
eql(out[0..pattern.len], &pattern) and eql(frame_view[0..pattern.len], &pattern));
// Straddling the 4 KiB boundary between the two writable pages.
const straddle = arena + abi.page_size - 4;
check("a page-straddling round trip", user_memory.copyToUser(address_space, straddle, &pattern) and
user_memory.copyFromUser(address_space, straddle, out[0..pattern.len]) and
eql(out[0..pattern.len], &pattern));
// Kernel-half addresses are refused by the range check, before any walk.
check("copyToUser refuses a kernel-half address", !user_memory.copyToUser(address_space, kernel_half, &pattern));
check("copyFromUser refuses a kernel-half address", !user_memory.copyFromUser(address_space, kernel_half, out[0..pattern.len]));
check("a range running off the end of the user half is refused", !user_memory.copyToUser(address_space, user_memory.user_half_end - 4, &pattern));
// An unmapped-but-in-range page: the latent kernel fault H1 exists to kill.
check("copyToUser refuses an unmapped user page", !user_memory.copyToUser(address_space, unmapped, &pattern));
check("copyFromUser refuses an unmapped user page", !user_memory.copyFromUser(address_space, unmapped, out[0..pattern.len]));
// The leaf permission bits: a read-only user page may be read, never written.
check("copyToUser refuses a read-only user mapping", !user_memory.copyToUser(address_space, read_only, &pattern));
check("copyFromUser accepts a read-only user mapping", user_memory.copyFromUser(address_space, read_only, out[0..pattern.len]));
check("a write straddling into a read-only page is refused", !user_memory.copyToUser(address_space, read_only - 4, &pattern));
// The kernel's own address space is not a user address space.
check("copyToUser refuses address space 0", !user_memory.copyToUser(0, arena, &pattern));
check("copyFromUser refuses address space 0", !user_memory.copyFromUser(0, arena, out[0..pattern.len]));
}
var i: usize = 0;
while (i < mapped) : (i += 1) {
const va = arena + i * abi.page_size;
architecture.unmapUserPageInto(address_space, va);
pmm.free(frames[i]);
}
architecture.destroyAddressSpace(address_space);
check("no frames leaked (free count restored)", pmm.stats().free_frames == base_free);
// Now the ring-3 half: the fixture aims bad pointers at the converted system
// calls and must get failures back with the machine still running.
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
check("user-memory-test spawned", spawnNamed(rd, "user-memory-test"));
result();
}
// --- synchronous IPC --------------------------------------------------------
var ipc_endpoint: *ipcsync.Endpoint = undefined;
var ipc_replies_ok: bool = false;
var ipc_done: bool = false;
/// Echo-increment server: reply to each request with request+1, forever.
fn ipcServer() void {
var reply_buffer: [8]u8 = undefined;
var reply_len: u64 = 0;
var badge: u64 = 0;
var received_cap: u64 = abi.no_cap;
while (true) {
var receive: [8]u8 = undefined;
const n = ipcsync.replyWait(ipc_endpoint, @intFromPtr(&reply_buffer), reply_len, @intFromPtr(&receive), receive.len, abi.no_cap, &badge, &received_cap);
if (n < 0) scheduler.exit();
const v = std.mem.readInt(u64, receive[0..8], .little);
std.mem.writeInt(u64, reply_buffer[0..8], v + 1, .little);
reply_len = 8;
}
}
/// Client: make 100 synchronous calls, checking every reply is request+1.
fn ipcClient() void {
var ok = true;
var i: u64 = 0;
while (i < 100) : (i += 1) {
var message: [8]u8 = undefined;
std.mem.writeInt(u64, message[0..8], i, .little);
var reply: [8]u8 = undefined;
var received_cap: u64 = abi.no_cap;
const n = ipcsync.call(ipc_endpoint, @intFromPtr(&message), 8, @intFromPtr(&reply), reply.len, abi.no_cap, &received_cap);
if (n != 8 or std.mem.readInt(u64, reply[0..8], .little) != i + 1) ok = false;
}
ipc_replies_ok = ok;
ipc_done = true;
scheduler.exit();
}
/// Synchronous IPC: a client and a server (two kernel tasks) ping-pong 100 calls
/// through one Endpoint. Each round exercises the full rendezvous — the client
/// blocks in `call`, the server blocks in `replyWait`, the reply is routed back to
/// the exact caller, and `copyAcross` moves the bytes — so 100 correct replies
/// prove the block/wake and reply-routing paths. (Kernel tasks, so no user ELF.)
fn ipcCallTest() void {
log("DANOS-TEST-BEGIN: ipc-call\n", .{});
ipc_endpoint = ipcsync.createIpcEndpoint().?;
ipc_replies_ok = false;
ipc_done = false;
scheduler.spawn(ipcServer, 5); // above this task, so the workers run
scheduler.spawn(ipcClient, 5);
const done: *volatile bool = &ipc_done;
var spins: u64 = 0;
while (!done.* and spins < 100_000_000) : (spins += 1) scheduler.yield();
check("client completed 100 synchronous calls", ipc_done);
check("every reply was request+1 (rendezvous + reply routing intact)", ipc_replies_ok);
result();
}
// --- IPC capability passing (M13) -------------------------------------------
var cap_endpoint: *ipcsync.Endpoint = undefined;
var cap_ep_x: *ipcsync.Endpoint = undefined; // client mints, sends to the server
var cap_ep_y: *ipcsync.Endpoint = undefined; // server mints, sends back to the client
var cap_server_got_x: bool = false;
var cap_client_got_y: bool = false;
var cap_done: bool = false;
/// Server half of the "open" pattern: receive one request carrying a capability,
/// verify it, then reply handing back a capability of its own.
fn capServer() void {
const me = scheduler.current();
cap_ep_y = ipcsync.createIpcEndpoint().?;
const h_y = ipcsync.installHandle(me, cap_ep_y); // the handle to send back in the reply
var reply_buffer: [8]u8 = .{0} ** 8;
var receive: [8]u8 = undefined;
var badge: u64 = 0;
var received: u64 = abi.no_cap;
// Phase 1: no reply owed yet — receive the client's request, which carries ep_x.
_ = ipcsync.replyWait(cap_endpoint, @intFromPtr(&reply_buffer), 0, @intFromPtr(&receive), receive.len, abi.no_cap, &badge, &received);
cap_server_got_x = received != abi.no_cap and
ipcsync.resolveHandle(me, received) == cap_ep_x and
cap_ep_x.refcount == 2; // shared (client's handle + this one), not moved
// Phase 2: reply to the held client, handing it ep_y; then block for a next
// request that never comes (so this replyWait does not return).
_ = ipcsync.replyWait(cap_endpoint, @intFromPtr(&reply_buffer), 8, @intFromPtr(&receive), receive.len, @intCast(h_y), &badge, &received);
scheduler.exit();
}
/// Client half of "open": mint a capability, send it in a call, receive one back.
fn capClient() void {
const me = scheduler.current();
cap_ep_x = ipcsync.createIpcEndpoint().?;
const h_x = ipcsync.installHandle(me, cap_ep_x);
var message: [8]u8 = .{0} ** 8;
var reply: [8]u8 = undefined;
var received: u64 = abi.no_cap;
_ = ipcsync.call(cap_endpoint, @intFromPtr(&message), 8, @intFromPtr(&reply), reply.len, @intCast(h_x), &received);
cap_client_got_y = received != abi.no_cap and
ipcsync.resolveHandle(me, received) == cap_ep_y and
cap_ep_y.refcount == 2;
cap_done = true;
scheduler.exit();
}
/// IPC capability passing: a client hands the server an endpoint in a `call`, and the
/// server hands one back in its reply — the primitive that lets a bus driver give a
/// class driver a private channel to one device (M13). Two kernel tasks (no user ELF);
/// each verifies the endpoint it received is the *same* object the peer sent (resolves
/// equal) and was *shared*, not moved (refcount bumped to 2).
fn capabilityTest() void {
log("DANOS-TEST-BEGIN: ipc-cap\n", .{});
cap_endpoint = ipcsync.createIpcEndpoint().?;
cap_server_got_x = false;
cap_client_got_y = false;
cap_done = false;
scheduler.spawn(capServer, 5);
scheduler.spawn(capClient, 5);
const done: *volatile bool = &cap_done;
var spins: u64 = 0;
while (!done.* and spins < 100_000_000) : (spins += 1) scheduler.yield();
check("capability test completed", cap_done);
check("server received the client's endpoint (same object, shared not moved)", cap_server_got_x);
check("client received the server's endpoint back (same object, shared not moved)", cap_client_got_y);
result();
}
/// DMA memory (M14): the properties a bus-mastering driver needs — physically
/// contiguous, a known physical address, correct cacheability, pinned, and reclaimed
/// on teardown. Exercises the kernel mechanism directly (`pmm.allocContiguous` +
/// `mapUserDmaInto`); the `dma_alloc`/`dma_free` syscalls are thin wrappers over it,
/// following the tested `mmap`/`mmio_map` shape, and land their first real use with the
/// first DMA driver.
fn dmaTest() void {
log("DANOS-TEST-BEGIN: dma\n", .{});
const base_free = pmm.stats().free_frames;
// This case boots WITHOUT an IOMMU device, so it is the explicit witness of the
// fail-open posture: no unit found, and the kernel said so at boot (the harness
// asserts the boot line; this check pins the recorded state to it).
check("no IOMMU present: DMA runs fail-open", !platform.platformInformation().iommu_present);
// A contiguous run: aligned, and it consumed exactly that many frames.
const frames = 4;
const phys = pmm.allocContiguous(frames, ~@as(u64, 0)) orelse {
check("allocContiguous(4) succeeded", false);
result();
return;
};
check("contiguous run is page-aligned", phys % abi.page_size == 0);
check("contiguous run consumed 4 frames", pmm.stats().free_frames == base_free - frames);
// The below-4G cap is honoured (legacy 32-bit DMA engines).
const low = pmm.allocContiguous(2, @as(u64, 4) << 30) orelse 0;
check("below-4G run stays under 4 GiB", low != 0 and low + 2 * abi.page_size <= (@as(u64, 4) << 30));
// Map the run into a fresh address space as coherent DMA and translate each page
// back: the same physical run, in order — proving contiguity and the mapping.
const address_space = architecture.createAddressSpace().?;
architecture.mapUserDmaInto(address_space, process.dma_arena_base, phys, frames * abi.page_size);
var mapped_ok = true;
for (0..frames) |i| {
const va = process.dma_arena_base + i * abi.page_size;
const got = architecture.translate(address_space, va) orelse {
mapped_ok = false;
break;
};
if (got != phys + i * abi.page_size) mapped_ok = false;
}
check("DMA pages translate to the contiguous physical run", mapped_ok);
// Teardown must reclaim the DMA RAM (the leaves carry no device_grant, so
// freeSubtree frees them as ordinary frames) — a driver that just dies leaks none.
architecture.destroyAddressSpace(address_space);
for (0..2) |i| pmm.free(low + i * abi.page_size);
check("no frames leaked after DMA teardown", pmm.stats().free_frames == base_free);
result();
}
/// MSI (M15): a per-device, edge-triggered interrupt vector delivered as an IPC
/// notification. QEMU's HPET has no MSI, so this exercises the vector-routing path with
/// a self-IPI standing in for the device's MSI memory write — proving the kernel
/// allocates a vector, `dispatch` recognises it as MSI (EOI + notify, no mask cycle),
/// and the bound endpoint is notified. The `msi_bind` syscall wraps `irq.msiBind` with
/// the device-claim check and lands its first real use with the first PCI driver.
fn msiTest() void {
log("DANOS-TEST-BEGIN: msi\n", .{});
const endpoint = ipcsync.createIpcEndpoint().?;
const flags = sync.enter();
const vector = irq.msiBind(endpoint, 0) catch {
sync.leave(flags);
check("msiBind allocated a vector", false);
result();
return;
};
sync.leave(flags);
check("msiBind allocated a vector in the device window", vector >= architecture.irq_vector_base and
vector < architecture.irq_vector_base + architecture.irq_vector_count);
// Fire the vector — the stand-in for the device writing its MSI (address, data).
const tail: *volatile u8 = &endpoint.notify_tail;
const before = tail.*;
architecture.selfIpi(vector);
var spins: u64 = 0;
while (tail.* == before and spins < 100_000_000) : (spins += 1) scheduler.yield();
check("self-IPI at the MSI vector notified the bound endpoint", tail.* != before);
result();
}
/// IOMMU (M16): with an emulated VT-d unit present (the harness boots this case with
/// `-device intel-iommu`), danos must find it in the ACPI DMAR table, map its register
/// block, and read back a real version. Detection was M16's honest first step; the
/// IOVA-enforcement track extends this case milestone by milestone (translation
/// enabled, then per-device domains) — see the plan in docs and the fail-open witness
/// in dmaTest.
fn iommuTest() void {
log("DANOS-TEST-BEGIN: iommu\n", .{});
const pinfo = platform.platformInformation();
check("IOMMU found in the firmware tables", pinfo.iommu_present);
check("IOMMU unit has a register base", pinfo.iommu_base != 0);
// The live-unit sanity (the version register reading back nonzero) now
// gates detect itself: an unusable unit stays fail-open, so `enabled()`
// below subsumes the old vendor-gated register check.
log("DANOS-IOMMU: base=0x{x}\n", .{pinfo.iommu_base});
// Translation was enabled at boot (kernel.zig: iommu.init before any driver claims
// a device). The blanket domain keeps every device identity-mapped, so DMA still
// works, but the unit is live — and with only the boot-time mappings present, no
// device should have faulted yet.
check("IOMMU enabled (translation on)", iommu.enabled());
check("no spurious translation faults at idle", iommu.faultDrain() == 0);
// A scratch domain proves the walker + invalidation path end to end: create it,
// identity-map a page, and confirm the mapping resolves; then unmap and destroy.
if (iommu.domainCreate(0, 0)) |scratch| {
const scratch_phys: u64 = 0x0010_0000; // 1 MiB, page-aligned
check("map into a scratch domain succeeds", iommu.map(scratch, scratch_phys, abi.page_size));
check("scratch domain resolves the mapping", iommu.translationOf(scratch, scratch_phys) == scratch_phys);
iommu.unmap(scratch, scratch_phys, abi.page_size);
check("scratch domain drops the mapping", iommu.translationOf(scratch, scratch_phys) == null);
iommu.domainDestroy(scratch);
} else {
check("scratch domain allocated", false);
}
// Delegation moves a device between tasks, and the IOMMU confinement must move
// with it. When it did not, the driver's DMA rings were never bound into the
// device's domain and every transfer faulted — but two worse consequences were
// latent and invisible to those tests: the domain still named the *giver*, so the
// giver's death would tear down a domain a live driver was using, and the
// receiver's death would leave one behind. Asserted directly here rather than
// inferred from the USB cases going green.
// This case runs no pci-bus, so there are no PCI functions to confine — and a
// silently skipped assertion is worse than none. Register one the way pci-bus does:
// a child of the host bridge whose resource 0 is a 4 KiB config window inside the
// bridge's ECAM, which is what `pciAddressOf` derives a requester id from.
var iommu_table: [64]device_abi.DeviceDescriptor = undefined;
const iommu_total = devices_broker.enumerate(&iommu_table);
const bridge: ?device_abi.DeviceDescriptor = for (iommu_table[0..@min(iommu_total, iommu_table.len)]) |d| {
if (d.class == @intFromEnum(device_abi.DeviceClass.pci_host_bridge) and d.resource_count >= 2) break d;
} else null;
check("the kernel seeded a PCI host bridge to parent a function under", bridge != null);
const subject: ?u64 = if (bridge) |b| blk: {
const me_bridge = scheduler.currentId();
if (!claimOk(b.id, me_bridge)) break :blk null;
var function = std.mem.zeroes(device_abi.DeviceDescriptor);
function.class = @intFromEnum(device_abi.DeviceClass.pci_device);
function.pci_class = device_abi.no_pci_class;
function.resource_count = 1;
function.resources[0] = .{
.kind = @intFromEnum(device_abi.ResourceKind.memory),
.start = b.resources[0].start, // the first config slot in the ECAM window
.len = 4096,
};
break :blk devices_broker.register(b.id, me_bridge, &function) catch null;
} else null;
check("a PCI function exists to confine", subject != null);
if (subject) |device_id| {
check("a device starts unconfined", iommu.confinementOwner(device_id) == null);
const me = scheduler.currentId();
check("confining records the owner", iommu.confineDevice(device_id, devices_broker.pciAddressOf(device_id).?, me));
check("the confinement names the confiner", iommu.confinementOwner(device_id) == me);
iommu.reassign(device_id, me + 1000);
check("reassign moves the confinement to the new holder", iommu.confinementOwner(device_id) == me + 1000);
check("and the previous holder no longer owns it", iommu.confinementOwner(device_id) != me);
iommu.releaseAllOwnedBy(me + 1000);
check("the new holder's death tears the domain down", iommu.confinementOwner(device_id) == null);
// The restart hole. A driver's death returns its device to the lender with the
// domain torn down (asserted just above) — so the NEXT delegation of the same
// device finds no active confinement record, and the transfer path's bare
// `reassign` no-ops: the respawned driver would run the device with a V=0
// device-table entry and no domain, silently unconfined. The give path must
// confine afresh in that case, exactly as a first claim would.
check("the lender holds the returned device", claimOk(device_id, me));
const driver: u32 = me + 2000;
check("delegating it confines it to the receiver", process.giveDeviceLocked(device_id, me, driver) == 0);
check("the give's confinement names the receiver", iommu.confinementOwner(device_id) == driver);
// The receiver dies: confinement torn down first, then the broker loans the
// device back to the lender — the same order releaseTaskResourcesLocked runs.
iommu.releaseAllOwnedBy(driver);
devices_broker.releaseAllOwnedBy(driver);
check("death returns the loan to the lender", devices_broker.ownerOf(device_id) == me);
check("and leaves the device unconfined", iommu.confinementOwner(device_id) == null);
// The regression this guards: re-delegation after that death.
const respawned: u32 = me + 3000;
check("re-delegation after the death succeeds", process.giveDeviceLocked(device_id, me, respawned) == 0);
check(
"and the respawned driver's device is confined, not silently naked",
iommu.confinementOwner(device_id) == respawned,
);
iommu.releaseAllOwnedBy(respawned);
devices_broker.releaseAllOwnedBy(respawned);
_ = devices_broker.unclaim(device_id, me);
}
log("DANOS-IOMMU: enabled base=0x{x} domains active\n", .{pinfo.iommu_base});
result();
}
/// Port I/O grants: ring 3 has no `in`/`out`, so a legacy driver reaches its ports
/// through `io_read`/`io_write`, gated by `device_claim` and the device's `io_port`
/// resource exactly like `mmio_map` gates memory. Target the PS/2 controller's status
/// port (0x64) — discovered on every PC and side-effect-free to read. Proves the
/// capability gate (`resolveIoPort` admits an in-range access, refuses out-of-range,
/// over-wide, and unclaimed) and that the kernel actually performs the `in`. The
/// `io_read`/`io_write` syscalls wrap this with the same ring-3 dispatch every device
/// driver already uses.
fn ioPortTest() void {
log("DANOS-TEST-BEGIN: ioport\n", .{});
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
// Post-M20.3 the PS/2 node is registered at runtime by the ring-3 acpi
// service, so it is absent from this boot snapshot. Exercise the same
// io_port claim/resolve mechanism against the acpi-tables node's broad I/O
// grant — the window that now carries port authority (the service uses it
// for exactly this). The PS/2 status port 0x64 is offset 0x64 within it.
var found_id: ?u64 = null;
var found_res: u64 = 0;
outer: for (buffer[0..n]) |d| {
if (d.class != @intFromEnum(device_abi.DeviceClass.acpi_tables)) continue;
for (0..d.resource_count) |ri| {
const r = d.resources[ri];
if (r.kind == @intFromEnum(device_abi.ResourceKind.io_port) and r.start == 0 and r.len > 0x64) {
found_id = d.id;
found_res = ri;
break :outer;
}
}
}
const id = found_id orelse {
check("discovered the acpi-tables I/O window", false);
result();
return;
};
check("discovered the acpi-tables I/O window", true);
const me = scheduler.current();
check("claimed the io_port device", claimOk(id, me.id));
check("an in-range access resolves to port 0x64", process.resolveIoPort(me, id, found_res, 0x64, 1) == 0x64);
check("a 4-byte access at the last port is refused", process.resolveIoPort(me, id, found_res, 0xFFFF, 4) == null);
check("an out-of-range offset is refused", process.resolveIoPort(me, id, found_res, 0x10000, 1) == null);
check("an unclaimed device id is refused", process.resolveIoPort(me, 0xDEAD_BEEF, found_res, 0x64, 1) == null);
// The kernel actually issues the `in`. Reaching this line at all proves it didn't
// fault; a width-1 read must return a single byte.
const status = architecture.pioRead(1, 0x64);
check("reading the PS/2 status port returned a byte", status <= 0xFF);
log("DANOS-IOPORT: PS/2 status = 0x{x}\n", .{status});
result();
}
/// The monotonic clock (the source `clock()` surfaces to user space). It must be
/// calibrated, move forward over a spin, and never run backwards — the guarantees a
/// driver's deadline timeout depends on. The syscall is a thin wrapper over this same
/// `architecture.nanos()`.
fn clockTest() void {
log("DANOS-TEST-BEGIN: clock\n", .{});
const t0 = architecture.nanos();
check("monotonic clock is calibrated (nonzero)", t0 != 0);
var last = t0;
var monotonic = true;
var advanced = false;
var i: u32 = 0;
while (i < 1_000_000) : (i += 1) {
const t = architecture.nanos();
if (t < last) monotonic = false;
if (t > t0) advanced = true;
last = t;
}
check("clock advanced over the spin", advanced);
check("clock never ran backwards (monotonic)", monotonic);
result();
}
/// The TSC clocksource + cross-core warp check (`-smp 4`). This is the real
/// Intel/AMD / KVM path — an invariant, synchronized TSC. TCG (the only x86
/// accelerator on an Apple-Silicon host) won't advertise an invariant TSC, so the
/// boot forces the TSC clocksource on (kernel.zig, gated on this case) to exercise
/// the machinery: the kernel must run the per-AP warp check as each core came up,
/// find the cores' TSCs synchronized, and keep the clock on the TSC (no HPET
/// fallback). The default suite (no force) exercises the HPET fallback instead.
fn tscSyncTest() void {
log("DANOS-TEST-BEGIN: tsc-sync\n", .{});
check("clocksource is the TSC (forced-invariant path)", eql(architecture.clockSourceName(), "tsc"));
check("the cross-core warp check ran on the APs", architecture.warpChecksRun() >= 1);
check("per-core TSCs synchronized (no warp, no HPET fallback)", architecture.clockSynchronized());
// A warp that slipped past the bring-up check would surface as a backward reading.
var last = architecture.nanos();
var monotonic = true;
var advanced = false;
var i: u32 = 0;
while (i < 1_000_000) : (i += 1) {
const t = architecture.nanos();
if (t < last) monotonic = false;
if (t > last) advanced = true;
last = t;
}
check("monotonic clock advanced", advanced);
check("monotonic clock never ran backward", monotonic);
result();
}
var proc_worker_run: bool = true;
var proc_worker_ran: bool = false;
/// A kernel task that spins while a process runs, to prove the two coexist under
/// preemption (a process on its own CR3 does not stall kernel work).
fn procWorker() void {
const running: *volatile bool = &proc_worker_run;
const ran: *volatile bool = &proc_worker_ran;
while (running.*) ran.* = true;
scheduler.exit();
}
/// Real processes: load /system/services/init as TWO scheduled ring-3 processes, each with
/// its own address space at the same virtual addresses, running concurrently
/// with a kernel task. Both must make heartbeat syscalls from CPL 3 — which can
/// only happen if each runs on its own page tables (CR3 switched correctly per
/// process) and preemption interleaves them with the kernel worker. This is the
/// strongest cheap proof of address-space isolation.
fn processTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: process\n", .{});
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
check("initial_ramdisk carries /system/services/init", true);
process.write_count = 0;
process.write_from_user = false;
proc_worker_run = true;
proc_worker_ran = false;
scheduler.spawn(procWorker, 4); // kernel task at the processes' priority
var spawned: u32 = 0;
if (process.spawnProcess(image, 4, &.{"/system/services/init"})) spawned += 1 else |_| {}
if (process.spawnProcess(image, 4, &.{"/system/services/init"})) spawned += 1 else |_| {}
// Wait (real time) for several heartbeats across the two processes. Each
// process sleeps ~1 s between beats, so a few seconds yields several.
scheduler.setPriority(1); // drop below the workers so they get the cores
const deadline = architecture.millis() + 8000;
while (process.write_count < 4 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
proc_worker_run = false;
log("DANOS-PROC: spawned {d} processes, {d} heartbeats\n", .{ spawned, process.write_count });
check("both init processes spawned on their own address spaces", spawned == 2);
check("processes made repeated heartbeat syscalls (>=4)", process.write_count >= 4);
check("heartbeats came from user mode (CPL 3)", process.write_from_user);
check("a kernel task coexisted with the processes (preemption)", proc_worker_ran);
result();
}
/// Isolation: a ring-3 read of a kernel-only page (the LAPIC page — present,
/// supervisor) must page-fault with error code 0x5 (present | user) at the user
/// RIP. The fault report is the pass signal (matched by the harness); if the
/// read is somehow allowed the blob spins and the harness times out.
fn userPfTest() void {
log("DANOS-TEST-BEGIN: user-pf\n", .{});
scheduler.setPreemption(false);
_ = process.run(process.pfBlob()) catch {};
log("DANOS-TEST-RESULT: FAIL (user read of kernel memory did not fault)\n", .{});
}
/// Spawn a real scheduled ring-3 process whose body is the user-pf blob (a read of
/// a kernel-only page, then a spin), so its first instruction raises #PF. Built by
/// hand — address space, code page RO+X, stack page RW+NX — because the blob is a
/// raw code fragment, not an ELF `spawnProcess` could load. Returns false if any
/// allocation fails.
fn spawnFaultingProcess() ?u32 {
const blob = process.pfBlob();
const flags = sync.enter();
defer sync.leave(flags);
const address_space = architecture.createAddressSpace() orelse return null;
const code_frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space);
return null;
};
// Fill through the physmap (the user mapping is read-only); pad with int3 so a
// stray jump traps instead of sliding.
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(code_frame));
@memset(code[0..abi.page_size], 0xCC);
@memcpy(code[0..blob.len], blob);
architecture.mapUserPageInto(address_space, process.code_virtual, code_frame, false, true); // RO + X
const stack_frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space); // frees code_frame too — it's mapped
return null;
};
architecture.mapUserPageInto(address_space, process.stack_base_virtual, stack_frame, true, false); // RW + NX
// Supervised by the calling test task, so exitReasonOf can read the verdict.
const id = scheduler.spawnUserLocked(address_space, process.code_virtual, process.stack_base_virtual + abi.page_size, 0, 4, "fault-probe", scheduler.currentId(), null, 0) orelse {
architecture.destroyAddressSpace(address_space);
return null;
};
return id;
}
/// Fault recovery (docs/resilience.md step 2): a scheduled ring-3 process that
/// faults must be killed — counted, resources reclaimed — while the rest of the
/// system keeps running. init heartbeats before and after the kill are the proof
/// the OS survived; the old behaviour (halt the core) would freeze the beat and
/// time the harness out.
fn faultRecoveryTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: fault-recovery\n", .{});
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
check("initial_ramdisk carries /system/services/init", true);
process.write_count = 0;
process.fault_kill_count = 0;
const spawned = if (process.spawnProcess(image, 4, &.{"/system/services/init"})) true else |_| false;
check("init spawned as the surviving process", spawned);
// A first heartbeat proves init runs before the fault.
scheduler.setPriority(1); // drop below the processes so they get the core
var deadline = architecture.millis() + 8000;
while (process.write_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
check("init heartbeat before the fault", process.write_count >= 1);
const probe = spawnFaultingProcess() orelse 0;
check("faulting process spawned", probe != 0);
// The kill: the faulting process #PFs on its first instruction and the kernel
// reaps it instead of halting.
scheduler.setPriority(1);
deadline = architecture.millis() + 5000;
while (process.fault_kill_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
check("faulting process was killed (not the machine)", process.fault_kill_count == 1);
check("the probe's reason reads segmentation_fault", process.exitReasonOf(scheduler.currentId(), probe) == @intFromEnum(abi.ExitReason.segmentation_fault));
// Life after the kill: init must keep beating on the same core.
const beats_at_kill = process.write_count;
scheduler.setPriority(1);
deadline = architecture.millis() + 8000;
while (process.write_count < beats_at_kill + 2 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
check("init kept heartbeating after the kill", process.write_count >= beats_at_kill + 2);
result();
}
/// Spawn a ring-3 process whose second system call has to return to a NON-canonical
/// address — the SYSRET hazard, staged the way a hostile program would stage it.
/// The blob is copied to the *end* of the last canonical page of the user half, so
/// its final `syscall` occupies the last two bytes ring 3 can execute and the return
/// RIP the CPU hands the kernel is `user_half_end` itself, the first non-canonical
/// address. Hand-built (address space, code page RO+X, stack RW+NX) like
/// `spawnFaultingProcess` — a raw blob is not an ELF `spawnProcess` could load.
/// Returns the process id, or null if any allocation failed.
fn spawnNonCanonicalReturnProcess() ?u32 {
const blob = process.nonCanonicalReturnBlob();
const code_virtual = process.user_half_end - abi.page_size; // the last canonical page
const entry = code_virtual + abi.page_size - blob.len; // ends flush with the page
const flags = sync.enter();
defer sync.leave(flags);
const address_space = architecture.createAddressSpace() orelse return null;
const code_frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space);
return null;
};
// Fill through the physmap (the user mapping is read-only); int3 everywhere the
// blob doesn't cover, so a stray entry traps instead of sliding into it.
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(code_frame));
@memset(code[0..abi.page_size], 0xCC);
@memcpy(code[abi.page_size - blob.len .. abi.page_size], blob);
architecture.mapUserPageInto(address_space, code_virtual, code_frame, false, true); // RO + X
const stack_frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space); // frees code_frame too — it's mapped
return null;
};
architecture.mapUserPageInto(address_space, process.stack_base_virtual, stack_frame, true, false); // RW + NX
// Supervised by the calling test task, so exitReasonOf can read the verdict.
return scheduler.spawnUserLocked(address_space, entry, process.stack_base_virtual + abi.page_size, 0, 4, "sysret-probe", scheduler.currentId(), null, 0) orelse {
architecture.destroyAddressSpace(address_space);
return null;
};
}
/// The SYSRET canonical-RIP guard (docs/os-development/smep-smap.md, "Adjacent,
/// deliberately separate"): a process must not be able to make the kernel fault on
/// its own way out of a system call. `sysretq` with a non-canonical RIP in RCX #GPs
/// *in ring 0* on Intel — on the kernel stack, with the user's GS base already
/// installed — so the exit path checks the return address first and returns through
/// `iretq` instead, which faults in ring 3 where a bad address is just a dead
/// process.
///
/// The probe reaches the hazard the way an attacker would: its `syscall` is the last
/// two bytes of executable address space, so the return address the CPU saves is the
/// first non-canonical one. Three things then have to be true — the guard fired, the
/// *process* died of a protection fault (so the fault landed in ring 3, not in the
/// kernel), and this task is still here to say so.
///
/// The counter is what makes this a regression test rather than a decoration.
/// Measured with the guard's branch commented out (2026-08-01): QEMU's TCG does not
/// model Intel's ring-0 #GP — `sysretq` simply returns to the bad address and the
/// process dies in ring 3 anyway, so every *outcome* check still passed and only the
/// counter noticed. On real Intel silicon the same run takes the kernel down. Assert
/// the mechanism, not just the outcome, whenever the emulator is the softer machine.
/// The probe's first system call is deliberately ordinary: it only reaches the second
/// one by returning correctly from the first, so the fast path is exercised too.
fn sysretCanonicalTest() void {
log("DANOS-TEST-BEGIN: sysret-canonical\n", .{});
const blob = process.nonCanonicalReturnBlob();
check(
"the probe's system call number still matches the ABI",
blob.len > 1 and blob[0] == 0xB8 and blob[1] == @intFromEnum(abi.SystemCall.current_core),
);
const before = architecture.nonCanonicalReturnCount();
check("no return has been refused yet this boot", before == 0);
process.fault_kill_count = 0;
const probe = spawnNonCanonicalReturnProcess() orelse 0;
check("the probe process spawned", probe != 0);
// Drop below the probe so it gets the core, and wait for the kill.
scheduler.setPriority(1);
const deadline = architecture.millis() + 5000;
while (process.fault_kill_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
const refused = architecture.nonCanonicalReturnCount() - before;
check("the guard refused exactly one sysretq", refused == 1);
check("the probe was killed (not the machine)", process.fault_kill_count == 1);
check(
"the probe died of a protection fault — the iretq fallback faulted it in ring 3",
process.exitReasonOf(scheduler.currentId(), probe) == @intFromEnum(abi.ExitReason.protection_fault),
);
log("sysret-canonical: {d} non-canonical return(s) refused; core {d} still running\n", .{ refused, scheduler.currentCpuIndex() });
result();
}
/// Address-space refcount (docs/threading-plan.md M1): every process holds exactly one
/// reference to its address space, released when it dies, so `destroyAddressSpace` runs
/// exactly once per space — no leak, no double-free. Spawn and kill several ring-3
/// processes (the faulting probe, reaped by the kernel) and confirm the count of live
/// address spaces returns to baseline while destructions advance by exactly that many.
/// This is the foundation threads (shared address spaces) build on: the refactor must be
/// invisible while every space still has exactly one task.
fn addressSpaceRefcountTest(boot_information: *const BootInformation) void {
_ = boot_information;
log("DANOS-TEST-BEGIN: address-space-refcount\n", .{});
const base_live = scheduler.liveAddressSpaceCount();
const base_destroyed = scheduler.addressSpaceDestroyCount();
const rounds: u32 = 5;
var killed: u32 = 0;
var round: u32 = 0;
while (round < rounds) : (round += 1) {
process.fault_kill_count = 0;
const probe = spawnFaultingProcess() orelse break;
_ = probe;
// Let the probe fault on its first instruction and be reaped.
scheduler.setPriority(1);
const deadline = architecture.millis() + 5000;
while (process.fault_kill_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
if (process.fault_kill_count >= 1) killed += 1;
}
check("all probes spawned and were killed", killed == rounds);
check("live address-space count returned to baseline", scheduler.liveAddressSpaceCount() == base_live);
check("each address space destroyed exactly once", scheduler.addressSpaceDestroyCount() == base_destroyed + rounds);
if (killed == rounds and scheduler.liveAddressSpaceCount() == base_live and
scheduler.addressSpaceDestroyCount() == base_destroyed + rounds)
log("address-space-refcount: spaces released to baseline ok\n", .{});
result();
}
/// Thread spawn (docs/threading-plan.md M2): the `thread-test` service spawns a worker
/// thread that writes a shared global; the main thread, polling that memory, observes the
/// write — proving `runtime.Thread.spawn` started a task in the **same** address space
/// (a separate process could not touch it). The service's own marker is the verdict.
fn threadSpawnTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-spawn\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
check("thread-test spawned", spawnNamed(rd, "thread-test"));
// Wait for the service's verdict marker (it polls shared memory the worker wrote).
const ok_marker = "thread-test: child ran in shared address space ok";
const fail_marker = "thread-test: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 12000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("a worker thread ran in the shared address space (shared write observed)", bufferHas(ok_marker));
check("the thread path reported no failure", !bufferHas(fail_marker));
result();
}
/// Thread join + parallelism (docs/threading-plan.md M3): `thread-test` in join mode
/// spawns N workers that each do K atomic increments on a shared counter and stamp the
/// core they ran on; it `join`s all N and asserts the total is exactly N*K (every worker
/// ran, join waited for each) and that >1 core was used (genuine parallelism), then a
/// detached worker proves `detach`. Its single verdict marker is the case result.
fn threadJoinTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-join\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Spawn thread-test in join mode (argv selects the mode).
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "join" })) true else |_| false;
break;
}
check("thread-test (join mode) spawned", started);
const ok_marker = "thread-test: join ok";
const fail_marker = "thread-test: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 15000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("N worker threads joined; counter exact (N*K) and >1 core used", bufferHas(ok_marker));
check("no thread failure reported", !bufferHas(fail_marker));
result();
}
/// Futex (docs/threading-plan.md M4): `thread-test` in futex mode has a waiter thread
/// block in `futex_wait` on a word; the main thread publishes the word and `futex_wake`s
/// it. The serial order `waiting → waking → woke` shows the kernel handoff (the waiter
/// parked and was woken, not spun), and a `timedWait` on an unwoken word reports a
/// timeout. The verdict marker is emitted only after both hold.
fn threadFutexTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-futex\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "futex" })) true else |_| false;
break;
}
check("thread-test (futex mode) spawned", started);
const ok_marker = "thread-futex: ok";
const fail_marker = "thread-futex: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 15000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
// Only the freshest marker is checked here — the kernel's log ring buffer may have
// evicted the earlier ones by now. The waiting/waking/woke ordering (the handoff
// proof) is asserted against the full serial stream by the qemu case's regex; the
// verdict marker is emitted by thread-test only after the wake AND the timeout hold.
check("futex handoff + timeout completed (verdict reached, no failure)", bufferHas(ok_marker) and !bufferHas(fail_marker));
result();
}
/// Mutex + Condition (docs/threading-plan.md M5): `thread-test` in mutex mode runs a
/// bounded producer/consumer — P producers and C consumers over one `Mutex` and two
/// `Condition`s move N unique items through a small ring. Every item is produced once;
/// if the lock and condition variables are correct under real cross-core contention,
/// the consumed checksum and tally match exactly (no lost or duplicated item, no
/// overrun). The verdict marker is emitted only when both match.
fn threadMutexTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-mutex\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "mutex" })) true else |_| false;
break;
}
check("thread-test (mutex mode) spawned", started);
const ok_marker = "thread-mutex: ok";
const fail_marker = "thread-mutex: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("producer/consumer over Mutex+Condition moved every item exactly once", bufferHas(ok_marker) and !bufferHas(fail_marker));
result();
}
/// Thread identity (docs/threading-plan.md M6): `thread-test` in id mode spawns two
/// workers that each read `runtime.Thread.getCurrentId`; the main thread confirms all
/// three ids are non-zero and distinct — proof each thread has its own kernel identity.
fn threadIdTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-id\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "id" })) true else |_| false;
break;
}
check("thread-test (id mode) spawned", started);
const ok_marker = "thread-id: ok";
const fail_marker = "thread-id: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 12000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("each thread has a distinct, non-zero getCurrentId", bufferHas(ok_marker) and !bufferHas(fail_marker));
result();
}
/// Thread-safe allocation (docs/threading-plan.md M7): `thread-test` in alloc mode runs N
/// threads that each do many `alloc`/fill/verify/`free` cycles of varied sizes on the
/// shared runtime heap. If the heap lock or the per-address-space mmap arena were unsafe,
/// two threads' blocks would overlap and a thread would read another's pattern; the
/// verdict marker is emitted only when every thread completes with every block intact.
fn threadAllocTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-alloc\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "alloc" })) true else |_| false;
break;
}
check("thread-test (alloc mode) spawned", started);
const ok_marker = "thread-alloc: ok";
const fail_marker = "thread-alloc: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("concurrent heap allocation stayed corruption-free (shared heap + per-address-space arena)", bufferHas(ok_marker) and !bufferHas(fail_marker));
result();
}
/// Per-thread TLS / FS base (docs/threading-plan.md M10): `thread-test` in tls mode has two
/// threads each set their own FS base and write a unique marker to `%fs:8`, then — after
/// both have written — read it back. If the FS base were not per-thread and restored across
/// context switches, the second write would clobber the first and a thread would read the
/// wrong marker. The verdict marker means both read their own value (no cross-talk).
fn threadTlsTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-tls\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "tls" })) true else |_| false;
break;
}
check("thread-test (tls mode) spawned", started);
const ok_marker = "thread-tls: ok";
const fail_marker = "thread-tls: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 12000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("each thread has its own FS-base TLS slot (no cross-talk across switches)", bufferHas(ok_marker) and !bufferHas(fail_marker));
result();
}
/// RwLock (docs/threading-plan.md M11): `thread-test` in rwlock mode runs writers that set
/// two halves of a value under the exclusive lock and readers that check the halves match
/// under the shared lock. If the reader/writer lock were wrong, a reader would observe a
/// half-written value; zero violations across many reads → the lock holds.
fn threadRwlockTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-rwlock\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "rwlock" })) true else |_| false;
break;
}
check("thread-test (rwlock mode) spawned", started);
const ok_marker = "thread-rwlock: ok";
const fail_marker = "thread-rwlock: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
while (architecture.millis() < deadline) {
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("readers/writers over an RwLock never observed a half-written value", bufferHas(ok_marker) and !bufferHas(fail_marker));
result();
}
/// The task reaper (docs/threading-plan.md M8): a dead task's kernel stack used to be
/// leaked ("no reaper yet"). Spawn and kill many ring-3 processes and confirm the total
/// kernel-stack bytes return to baseline — every stack reclaimed, no leak. (Threads exit
/// through the same exitUserLocked path, so this covers them too.)
fn taskReapTest(boot_information: *const BootInformation) void {
_ = boot_information;
log("DANOS-TEST-BEGIN: task-reap\n", .{});
const base = scheduler.liveStackBytes();
const rounds: u32 = 12;
var killed: u32 = 0;
var round: u32 = 0;
while (round < rounds) : (round += 1) {
process.fault_kill_count = 0;
const probe = spawnFaultingProcess() orelse break;
_ = probe;
scheduler.setPriority(1);
const deadline = architecture.millis() + 5000;
while (process.fault_kill_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
if (process.fault_kill_count >= 1) killed += 1;
}
// Reaping is asynchronous — a dead task's stack is freed when its core next switches
// or ticks. Poll (bounded) until the live bytes return to baseline: a correct reaper
// gets there in a few ms; a genuine leak never does and this times out.
scheduler.setPriority(1);
const settle_deadline = architecture.millis() + 3000;
while (scheduler.liveStackBytes() != base and architecture.millis() < settle_deadline) scheduler.yield();
scheduler.setPriority(4);
const final = scheduler.liveStackBytes();
log("task-reap: base={d} final={d} killed={d}/{d}\n", .{ base, final, killed, rounds });
check("all probes spawned and were killed", killed == rounds);
check("kernel stacks reclaimed to baseline (no leak)", final == base);
if (killed == rounds and final == base)
log("task-reap: kernel stacks reclaimed to baseline ok\n", .{});
result();
}
/// The full PID-1 path: the bootloader read /system/services/init off the boot volume and
/// handed it over; load it as a user ELF and spawn it as a real ring-3 process
/// — the same call the normal boot path makes — then confirm it beats. init
/// heartbeats forever, so this proves it reaches ring 3, makes repeated syscalls
/// (write + sleep), and stays alive rather than exiting.
fn initTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: init\n", .{});
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
check("initial_ramdisk carries /system/services/init", true);
process.write_count = 0;
const spawned = if (process.spawnProcess(image, 4, &.{"/system/services/init"})) true else |err| blk: {
log("DANOS-INIT-ERR: {s}\n", .{@errorName(err)});
break :blk false;
};
check("init loaded and spawned as a process", spawned);
// Wait (real time) until the LAST write is a heartbeat — proving init got
// through its boot chatter (heap ok, the /system/configuration/init.csv lookup) and settled
// into its beat-and-sleep loop (~1 s between beats). Waiting on the text
// rather than a raw write count: the boot chatter alone satisfies a count,
// which is exactly the too-early check that used to fail here.
scheduler.setPriority(1);
const prefix = "init: heartbeat";
const deadline = architecture.millis() + 8000;
var beat_ok = false;
while (!beat_ok and architecture.millis() < deadline) {
beat_ok = bufferHas(prefix);
scheduler.yield();
}
scheduler.setPriority(4);
check("init produced repeated heartbeats (>=2)", process.write_count >= 2);
check("heartbeat text arrived intact", beat_ok);
check("heartbeats came from user mode (CPL 3)", process.write_from_user);
check("init is still alive (did not exit)", process.exit_code == 0);
result();
}
/// process_enumerate's kernel half: spawn two init processes next to the kernel
/// tasks and snapshot the table. The snapshot must list both by name with distinct,
/// kernel-supervised ids, include the kernel tasks (id 0, empty name), and report
/// the same total through a too-small buffer (the truncation contract: the caller
/// learns how big a buffer to bring).
fn processListTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: process-list\n", .{});
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
check("initial_ramdisk carries /system/services/init", true);
var spawned: u32 = 0;
if (process.spawnProcess(image, 4, &.{"/system/services/init"})) spawned += 1 else |_| {}
if (process.spawnProcess(image, 4, &.{"/system/services/init"})) spawned += 1 else |_| {}
check("two init processes spawned", spawned == 2);
var table: [32]abi.ProcessDescriptor = undefined;
const total = scheduler.enumerate(&table);
check("enumerate counts the boot task and both processes (>=3)", total >= 3);
var inits: u32 = 0;
var init_ids: [2]u32 = .{ 0, 0 };
var kernel_task_seen = false;
var states_sane = true;
for (table[0..@min(total, table.len)]) |descriptor| {
if (descriptor.state > @intFromEnum(abi.ProcessState.blocked)) states_sane = false;
if (descriptor.name_length == 0) kernel_task_seen = true;
if (eql(descriptor.name[0..descriptor.name_length], "/system/services/init")) {
if (inits < 2) init_ids[inits] = descriptor.id;
inits += 1;
check("init entry is kernel-supervised (supervisor 0)", descriptor.supervisor == 0);
}
}
check("both init processes listed by name", inits == 2);
check("listed processes carry distinct ids", init_ids[0] != init_ids[1]);
check("kernel tasks are listed too (empty name)", kernel_task_seen);
check("every state is a ProcessState value", states_sane);
var one: [1]abi.ProcessDescriptor = undefined;
check("a too-small buffer still learns the true total", scheduler.enumerate(&one) == total);
result();
}
/// process_kill + the exit notification, kernel half. Two victims, two paths:
/// - init, which heartbeats and sleeps: caught blocked, reaped on the killer's
/// own call — and its heartbeat must stop.
/// - process-test's spinner role (from the initial ramdisk), which loops in user
/// mode making no system calls: with more cores it is caught running, taking
/// the deferred path (kill_pending, finished by the victim core's next tick).
/// Each death must post one exit notification badge (exit bit + the child's id)
/// on the endpoint given at spawn; wrong-supervisor and unknown-id kills must be
/// refused. The waits block in replyWait, so a lost notification times the
/// harness out rather than passing vacuously.
fn processKillTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: process-kill\n", .{});
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
check("initial_ramdisk carries /system/services/init", true);
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(ramdisk) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
process.write_count = 0;
const sleeper = process.spawnProcessSupervised(image, 4, &.{"/system/services/init"}, me, endpoint) catch 0;
check("init spawned as the supervised sleeper victim", sleeper != 0);
// Let it reach its heartbeat loop (write, then a 1 s sleep) so the kill most
// likely catches it blocked.
scheduler.setPriority(1);
const deadline = architecture.millis() + 8000;
while (process.write_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
check("victim heartbeat before the kill", process.write_count >= 1);
// Kills that must be refused, before the one that must not be.
check("a non-supervisor may not kill (-EPERM)", process.killProcess(me + 12345, sleeper) == -ipcsync.EPERM);
check("an unknown id misses (-ESRCH)", process.killProcess(me, 0xFFFF_FF00) == -ipcsync.ESRCH);
check("a kernel task is not a killable process (-ESRCH)", process.killProcess(me, 0) == -ipcsync.ESRCH);
check("the supervisor's kill is accepted", process.killProcess(me, sleeper) == 0);
var badge: u64 = 0;
var received_cap: u64 = 0;
var r = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the sleeper's exit notification arrived (length 0)", r == 0);
check("its badge carries the exit bit and the child id", badge == abi.notify_badge_bit | abi.notify_exit_bit | sleeper);
// M17.2: the recorded reason — the notification is the fence, so it is
// already readable, and gated by the same supervisor check as the kill.
check("the sleeper's reason reads killed", process.exitReasonOf(me, sleeper) == @intFromEnum(abi.ExitReason.killed));
check("a non-supervisor may not read the reason (-EPERM)", process.exitReasonOf(me + 12345, sleeper) == -ipcsync.EPERM);
check("an unknown id has no reason (-ESRCH)", process.exitReasonOf(me, 0xFFFF_FF00) == -ipcsync.ESRCH);
const beats_at_kill = process.write_count;
scheduler.sleep(1500); // more than one heartbeat period
check("the heartbeat stopped with the kill", process.write_count == beats_at_kill);
// The spinner: no system calls, so only the tick can deliver a deferred kill.
var spinner: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
spinner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "spinner" }, me, endpoint) catch 0;
break;
}
check("process-test spawned as the supervised spinner victim", spinner != 0);
scheduler.sleep(100); // give another core a chance to be running it
check("the spinner's kill is accepted", process.killProcess(me, spinner) == 0);
r = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the spinner's exit notification arrived (length 0)", r == 0);
check("its badge carries the exit bit and the child id", badge == abi.notify_badge_bit | abi.notify_exit_bit | spinner);
// M17.2: a child that ends on its own must read exited, not killed —
// args-echo with arguments echoes once and returns from main.
var clean: u32 = 0;
i = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "args-echo")) continue;
clean = process.spawnProcessSupervised(item.blob, 4, &.{ "args-echo", "clean-exit" }, me, endpoint) catch 0;
break;
}
check("args-echo spawned as the clean-exit child", clean != 0);
r = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the clean child's exit notification arrived", badge == abi.notify_badge_bit | abi.notify_exit_bit | clean);
check("the clean child's reason reads exited", process.exitReasonOf(me, clean) == @intFromEnum(abi.ExitReason.exited));
var table: [32]abi.ProcessDescriptor = undefined;
const total = scheduler.enumerate(&table);
var still_listed = false;
for (table[0..@min(total, table.len)]) |descriptor| {
if (descriptor.id == sleeper or descriptor.id == spinner) still_listed = true;
}
check("neither victim is listed after its kill", !still_listed);
check("a killed id stays dead (-ESRCH on a second kill)", process.killProcess(me, sleeper) == -ipcsync.ESRCH);
publishedExitChecks(rd, me);
result();
}
/// The mechanism every provider's release-what-a-dead-client-held sweep is built
/// on (docs/process-lifecycle.md, "Who learns of a death"): a **published** exit,
/// fanned out to every subscriber rather than only to the supervisor. The service
/// harness's subscriber sweep, the FAT server's open files, the compositor's
/// layers and the xHCI driver's device tokens all release on exactly this, and
/// several of them are subscribed at once in a normal boot — so what is checked
/// here is the fan-out: three independent subscribers, one death, three
/// notifications carrying the same badge, none of them the supervisor's.
///
/// Run at the end of the process-kill case, because a subscription is for every
/// death from then on and the checks above spawn victims of their own.
fn publishedExitChecks(rd: initial_ramdisk.Reader, me: u32) void {
var subscribers: [3]*ipcsync.Endpoint = undefined;
var subscribed: usize = 0;
while (subscribed < subscribers.len) : (subscribed += 1) {
subscribers[subscribed] = ipcsync.createIpcEndpoint() orelse break;
if (process.subscribeExits(subscribers[subscribed], me) != 0) break;
}
check("three endpoints subscribed to published exits", subscribed == subscribers.len);
if (subscribed != subscribers.len) return;
// A supervised child so the supervisor notification remains distinguishable:
// it lands on `endpoint`, the published ones on the three above.
const supervisor_endpoint = ipcsync.createIpcEndpoint() orelse {
check("supervisor endpoint allocated", false);
return;
};
var child: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "args-echo")) continue;
child = process.spawnProcessSupervised(item.blob, 4, &.{ "args-echo", "published-exit" }, me, supervisor_endpoint) catch 0;
break;
}
check("a clean-exit child spawned for the published exit", child != 0);
if (child == 0) return;
var badge: u64 = 0;
var received_cap: u64 = 0;
_ = ipcsync.replyWait(supervisor_endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the supervisor heard the child end", badge == abi.notify_badge_bit | abi.notify_exit_bit | child);
// The publication happens in the same locked section as the supervisor's
// notification and before it, so all three are already queued: a subscriber
// that had not heard would block here and time the harness out rather than
// pass vacuously.
var heard: usize = 0;
for (subscribers) |subscriber| {
badge = 0;
_ = ipcsync.replyWait(subscriber, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
if (badge == abi.notify_badge_bit | abi.notify_exit_bit | child) heard += 1;
}
check("every subscriber heard the same death, not just the supervisor", heard == subscribers.len);
}
/// M17.1: a dead process's device claims are released by the reap, so a restarted
/// driver can claim its hardware again (docs/process-lifecycle.md iron rule 1).
/// First the broker release in isolation — two owners, one released, the other's
/// claim must survive. Then the death-path wiring with a real child: the claim is
/// made on the child's behalf (the broker is kernel-callable), the child is
/// killed, and once the exit notification arrives — posted last, after release —
/// the device must be unclaimed and claimable again.
fn claimReleaseTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: claim-release\n", .{});
var buffer: [2]device_abi.DeviceDescriptor = undefined;
const total = devices_broker.enumerate(&buffer);
check("the device tree is seeded (>= 2 devices)", total >= 2);
if (total < 2) {
result();
return;
}
// The broker release in isolation.
check("device 0 claimed by owner 111", claimOk(0, 111));
check("device 1 claimed by owner 222", claimOk(1, 222));
devices_broker.releaseAllOwnedBy(111);
check("owner 111's claim is released", devices_broker.ownerOf(0) == null);
check("owner 222's claim survives", (devices_broker.ownerOf(1) orelse 0) == 222);
devices_broker.releaseAllOwnedBy(222);
check("cleanup released owner 222", devices_broker.ownerOf(1) == null);
// The death-path wiring: a real process dies holding a claim.
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
check("initial_ramdisk carries /system/services/init", true);
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = process.spawnProcessSupervised(image, 4, &.{"/system/services/init"}, me, endpoint) catch 0;
check("supervised child spawned", child != 0);
check("device 0 claimed on the child's behalf", claimOk(0, child));
check("the kill is accepted", process.killProcess(me, child) == 0);
var badge: u64 = 0;
var received_cap: u64 = 0;
_ = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the exit notification arrived", badge == abi.notify_badge_bit | abi.notify_exit_bit | child);
check("death released the child's claim", devices_broker.ownerOf(0) == null);
check("the device is claimable again", claimOk(0, me));
devices_broker.releaseAllOwnedBy(me);
result();
}
/// M17.3: the published exit events, proven by a stateful server. The fat
/// server subscribes at startup; a client opens a file on the volume and parks
/// holding the handle; the kill posts the exit event to fat's endpoint; fat
/// releases the dead client's handle and says so — the service-side mirror of
/// iron rule 1 (a service must never depend on clients cleaning up).
fn vfsClientDeathTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: vfs-client-death\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.write_count = 0;
// The full tree: the storage chain must come up for /volumes/usb to exist —
// the fat server (not a router) now owns client file state and its sweep.
process.setInitialRamdisk(image);
const init_ok = if (process.spawnBundled("/system/services/init")) true else |_| false;
check("init spawned (boots the storage chain)", init_ok);
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
var client: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "vfs-test")) continue;
client = process.spawnProcessSupervised(item.blob, 4, &.{ "vfs-test", "park" }, me, endpoint) catch 0;
break;
}
check("parked client spawned (supervised)", client != 0);
// Its heartbeat is the fence: once it beats, the handle is open. The park
// waits out the whole USB->block->fat chain, so give it room; with the full
// tree chattering, the ring (not the last-write buffer) is the evidence.
const parked = "vfstest: parked";
scheduler.setPriority(1);
var deadline = architecture.millis() + 30000;
while (architecture.millis() < deadline) {
if (ringHas(parked)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("client parked holding an open handle", ringHas(parked));
check("the kill is accepted", process.killProcess(me, client) == 0);
var badge: u64 = 0;
var received_cap: u64 = 0;
_ = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
check("the exit notification arrived", badge == abi.notify_badge_bit | abi.notify_exit_bit | client);
// The fat server heard the same published event; its release line in the
// ring is the proof.
const released = "released 1 handle(s) for dead client";
scheduler.setPriority(1);
deadline = architecture.millis() + 10000;
while (architecture.millis() < deadline) {
if (ringHas(released)) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("the fat server released the dead client's handle", ringHas(released));
result();
}
/// M17.4 from ring 3: process-test's signal-run role drives the whole lifecycle
/// surface — the zero-length ping (answered by the harness), signals as
/// statements (reload logged, terminate = clean exit), the one-shot timer, and
/// both endings of the stop sequence (polite -> exited, deaf -> killed at the
/// deadline). Its "process-test: signals ok" is the pass marker.
fn signalsTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: signals\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image); // the parent system_spawns its children by name
_ = spawnRegistry(rd); // the service child binds /protocol/test/process
process.write_count = 0;
var runner: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
runner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "signal-run" }, scheduler.currentId(), null) catch 0;
break;
}
check("signal-run parent spawned", runner != 0);
const pass_marker = "process-test: signals ok";
const fail_marker = "process-test: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 15000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("the signal-run parent reported ok", saw_pass and !saw_fail);
result();
}
/// M18.1: the device manager's restart machinery, end to end. In test-restart
/// mode the manager also supervises crash-test: a fixture that claims device 0,
/// hellos, and faults. The scenario asserts three markers in order — the real
/// xHCI driver hellos clean and stays; crash-test is restarted with backoff
/// (each respawn re-claiming the device the dead instance held, M17.1 through
/// the manager's path); the crash loop caps and the manager gives up.
fn driverRestartTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: driver-restart\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image); // the manager system_spawns drivers by name
_ = spawnRegistry(rd); // the drivers bind their contracts
process.write_count = 0;
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned in test-restart mode", manager != 0);
// The assertions live in the harness: its expect regex requires, in order,
// the xHCI hello ack, a crash-test restart, and the crash-loop cap — read
// from the whole serial capture, immune to the transient-line races a
// write_buffer poll would have here (many processes log concurrently).
result();
}
/// M18.2: bus tree reports, end to end. The manager (test-usb-restart mode)
/// spawns the xHCI driver; the driver maps its BAR, scans the root-hub ports,
/// and reports the two QEMU devices; the manager mirrors them, kills the
/// reporter (the test trigger), prunes both children, restarts the driver with
/// backoff, and the respawned instance re-claims, re-scans, and re-reports.
/// The harness's ordered expect regex is the assertion; this test only
/// orchestrates the spawn.
fn usbReportTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: usb-report\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // drivers reach /protocol/device-manager through it
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned in test-usb-restart mode", manager != 0);
result();
}
/// M18.3: the application surface. device-list enumerates the manager's tree
/// over IPC, subscribes with its endpoint as a capability, and prints every
/// published event; the manager's delayed test-kill of the reporter produces a
/// removed/added storm the subscriber must observe. The harness's ordered
/// expect regex is the assertion.
fn deviceListTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-list\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the fixture opens /protocol/device-manager
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned in test-usb-restart mode", manager != 0);
check("device-list spawned", spawnNamed(rd, "device-list"));
result();
}
/// The driver-side PCI library against a real function: the pci-caps QEMU case adds an
/// e1000e NIC no danos driver claims; the pci-cap-test fixture claims it and exercises
/// header accessors, command bits, the capability walks, MSI programming (the first
/// driver-side `msi_bind` use), the MSI-X table, power state, and FLR. The kernel side
/// only spawns the manager (which spawns pci-bus itself) and the fixture; the substance
/// is asserted by the harness on the fixture's own serial lines.
fn pciCapsTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: pci-caps\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
// Plain mode — no restart drill, whose kill would race the fixture's claim.
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned", manager != 0);
check("pci-cap-test spawned", spawnNamed(rd, "pci-cap-test"));
result();
}
/// IOMMU enforcement, the negative proof: boot with VT-d on and an unclaimed e1000e.
/// The manager spawns pci-bus, the fixture claims the NIC and fires a DMA at an
/// unmapped page; the unit must fault it and the system survive. Substance is asserted
/// by the harness on the kernel's DANOS-IOMMU-FAULT line and the fixture's markers.
fn iommuFaultTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: iommu-fault\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
check("IOMMU enabled for the enforcement test", iommu.enabled());
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned", manager != 0);
check("iommu-fault-test spawned", spawnNamed(rd, "iommu-fault-test"));
result();
}
/// M19.1: the ring-3 PCI scan agrees with the kernel's. The manager spawns
/// pci-bus for the host bridge; the driver walks the same ECAM window through
/// its mmio_map grant and must find exactly the functions the kernel's own
/// enumeration recorded — the equivalence that licenses retiring the kernel
/// walk in M19.3.
fn pciScanTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: pci-scan\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Post-flip (M19.3) ground truth: the kernel no longer enumerates PCI
// functions, so equivalence inverts — every PCI function the broker holds was
// put there by the ring-3 driver, so before the driver runs the broker holds
// none. One reusable descriptor buffer (each snapshot is ~20 KiB; three live
// at once would overflow the 64 KiB bootstrap stack this test runs on).
var buffer: [64]device_abi.DeviceDescriptor = undefined;
check("the kernel seeded no PCI functions (the walk retired)", brokerPciCount(&buffer) == 0);
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-pci-restart" }, scheduler.currentId(), null) catch 0;
break;
}
check("device-manager spawned (test-pci-restart mode)", manager != 0);
// The manager spawns pci-bus, which scans the ECAM window and registers every
// function it finds, so the broker's PCI count climbs from zero and plateaus.
// Wait for it to *settle*: latch N only once the count has held steady for a
// stretch, so a mid-scan sample can't latch a low N that the rest of the same
// scan then appears to exceed. The count is monotonic (registrations only add;
// the table has no unregister) so the plateau is permanent — stability is
// reached the moment the scan finishes and holds indefinitely. Sleep between
// samples rather than busy-yield: the boot context outranks the drivers, and a
// busy spin would starve the very processes it waits on; a sleeping task is
// woken by the timer, so the drivers run in between.
const poll_ms = 5;
var registered: u32 = 0;
var steady: u32 = 0;
var deadline = architecture.millis() + 15000;
while (architecture.millis() < deadline and steady < 60) { // 60 * 5ms = 300ms steady
const now = brokerPciCount(&buffer);
if (now != 0 and now == registered) steady += 1 else steady = 0;
registered = now;
scheduler.sleep(poll_ms);
}
check("the ring-3 scan registered its PCI functions in the broker", registered >= 1);
// The restart drill — the manager kills pci-bus ~1 s after its scan, prunes its
// own child tree, and respawns it to re-claim, re-scan, and re-register — is
// asserted by the harness's ordered regex over the whole serial log, the way
// every restart drill is (see usbReportTest / driverRestartTest): the manager's
// kill/prune/respawn lines are transient and would race a write_buffer poll, and
// the *broker* count can't witness the restart at all — the table has no
// unregister and register is idempotent (devices-broker.zig), so the kill leaves
// the nodes in place and the respawn's re-registration dedupes against them.
//
// That idempotence is exactly this test's kernel-side claim: watch, across the
// whole drill, that the count never grows past N. A broken dedup would append
// the re-scanned functions as duplicates (N -> 2N), and with no unregister that
// overshoot would persist — so a single late sample would catch it; the loop is
// belt-and-braces over the ~2 s the kill + backoff + respawn takes.
var duplicated = false;
deadline = architecture.millis() + 5000;
while (architecture.millis() < deadline and !duplicated) {
if (brokerPciCount(&buffer) > registered) duplicated = true;
scheduler.sleep(20);
}
check("no duplicate PCI nodes after the restart drill", !duplicated);
result();
}
/// M21.3 capstone: orderly shutdown. Boot init with the initial-ramdisk
/// published, so init spawns the full service tree (vfs, input, device-manager
/// -> discovery/acpi); the harness injects a real power-button event via QMP;
/// the acpi service publishes it; init runs the stop sequence over its children
/// and asks the power service for S5; the machine powers off (QEMU exits). The
/// kernel test only spawns init — the ordered chain is the harness assertion.
/// The USB HID chain, end to end: boot the full service tree (init spawns vfs,
/// input, device-manager), and let discovery run — the manager matches the PCI
/// host bridge to pci-bus, pci-bus reports the xHCI controller, usb-xhci-bus
/// enumerates the HID interfaces, and the manager spawns the class drivers. The
/// harness's expect regex requires usb-xhci-bus to register the boot-keyboard
/// interface, the manager to spawn usb-hid-keyboard, and that driver to come up
/// (open its device, ask for boot protocol, subscribe) — proof the transfer
/// protocol works class-driver to controller.
fn usbHidTest(boot_information: *const BootInformation) void {
bootServiceTreeTest(boot_information, "usb-hid");
}
/// The USB storage chain: same full-tree boot, but the harness attaches a
/// usb-storage device and the expect regex requires usb-storage to come up
/// (open its device, run the BOT bring-up, read its capacity, and read block 0).
fn usbStorageTest(boot_information: *const BootInformation) void {
bootServiceTreeTest(boot_information, "usb-storage");
}
/// The FAT mount chain: boot the full tree (init spawns the fat server, which
/// brings up the USB storage chain, mounts the FAT volume, and mounts itself into
/// the VFS at /volumes/usb), then spawn a fat-test client that lists and reads through
/// the mount. The harness attaches a usb-storage device; the expect regex requires
/// the fat mount and the client's success.
fn fatMountTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: fat-mount\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over the initial_ramdisk", false);
result();
return;
}
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(ramdisk) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(ramdisk);
const init_ok = if (process.spawnBundled("/system/services/init")) true else |_| false;
check("init spawned (boots the tree, incl. the fat server)", init_ok);
check("fat-test client spawned", spawnNamed(rd, "fat-test"));
// The badge-scoping probe rides the same boot: it needs the fat server for a
// node id and the compositor for a layer id, and init starts both. It spawns
// its own second process — the intruder — with the ids it holds (P4c).
check("badge-scope-test owner spawned", spawnNamed(rd, "badge-scope-test"));
result();
}
/// Per-sender range confinement (V2a, docs/volume-manager-plan.md): boot the
/// full tree so the USB storage chain is up, then spawn block-range-test, which
/// acquires the block channel, confines ITSELF to a sub-range, and asserts it
/// cannot read past that range or widen it. The fixture's markers are the
/// assertion (the QEMU expect regex matches them); this only boots and spawns.
fn blockRangeTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: block-range\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over the initial_ramdisk", false);
result();
return;
}
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(ramdisk) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(ramdisk);
const spawned = if (process.spawnBundled("/system/services/init")) true else |_| false;
check("init spawned (boots the USB storage chain)", spawned);
check("block-range-test spawned", spawnNamedWithArg(rd, "block-range-test", "run"));
result();
}
fn bootServiceTreeTest(boot_information: *const BootInformation, comptime label: []const u8) void {
log("DANOS-TEST-BEGIN: " ++ label ++ "\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over the initial_ramdisk", false);
result();
return;
}
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
process.setInitialRamdisk(ramdisk);
const spawned = if (process.spawnBundled("/system/services/init")) true else |_| false;
check("init spawned (boots vfs, input, device-manager, and the USB chain)", spawned);
result();
}
fn orderlyShutdownTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: orderly-shutdown\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over the initial_ramdisk", false);
result();
return;
}
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
process.setInitialRamdisk(ramdisk);
const spawned = if (process.spawnBundled("/system/services/init")) true else |_| false;
check("init spawned as PID root of user space", spawned);
result();
}
/// M20.2: the acpi service registers + reports its _HID devices. Boot normally
/// (the manager spawns discovery); the harness's expect regex requires the two
/// PS/2 nodes among the service's report lines, each with its _CRS resources —
/// the ring-3 _CRS/_STA evaluation working end to end. The kernel test only
/// starts the manager.
fn acpiReportTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: acpi-report\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager and the acpi service bind theirs
var spawned = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
_ = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
spawned = true;
break;
}
check("device-manager spawned", spawned);
result();
}
/// M20.1: the ring-3 AML parse works. The manager spawns the discovery service
/// (the acpi build variant); it claims the acpi-tables node, maps the blobs,
/// parses them, and self-verifies it found at least a floor of Device objects,
/// printing "acpi-parse: ok". The kernel no longer parses AML, so there is no
/// kernel count to compare against — the ring-3 parse is now the only one.
fn acpiParseTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: acpi-parse\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Spawn the discovery service directly with a device-count *floor* as argv:
// it parses the blobs in ring 3 and self-verifies it found at least that many
// Device objects, printing "acpi-parse: ok". A floor of 1 just proves the
// parser ran and produced a namespace (the QEMU q35 DSDT has dozens). The
// marker is deterministic — no racing the shared serial buffer.
process.setInitialRamdisk(image);
var spawned = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "discovery")) continue;
// Hand it the acpi-tables node the way the manager would: claim it here, then
// move it to the child. Discovery no longer claims for itself.
var scratch: [64]device_abi.DeviceDescriptor = undefined;
const seen_devices = devices_broker.enumerate(&scratch);
const tables: ?u64 = for (scratch[0..@min(seen_devices, scratch.len)]) |d| {
if (d.class == @intFromEnum(device_abi.DeviceClass.acpi_tables)) break d.id;
} else null;
const me_parse = scheduler.currentId();
if (tables) |node| _ = claimOk(node, me_parse);
const child = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "floor:1" }, me_parse, null) catch 0;
if (tables) |node| devices_broker.transfer(node, me_parse, child) catch {};
spawned = true;
break;
}
check("discovery service spawned", spawned);
result();
}
/// The whole user-side surface at once: spawn process-test's supervisor role,
/// which — entirely from ring 3 — creates an exit endpoint, spawns its two
/// children supervised, sees them in process_enumerate, kills them (one blocked,
/// one spinning), collects both exit notifications, and confirms they are gone.
/// Its "process-test: ok" is the pass marker; any FAIL line is specific.
fn supervisionTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: supervision\n", .{});
check("bootloader handed over an initial_ramdisk", boot_information.initial_ramdisk_len != 0);
if (boot_information.initial_ramdisk_len == 0) {
result();
return;
}
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(ramdisk) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(ramdisk); // the supervisor system_spawns its children by name
process.write_count = 0;
process.write_from_user = false;
var started = false;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
started = if (process.spawnProcess(item.blob, 4, &.{ item.name, "run" })) true else |_| false;
break;
}
check("process-test spawned as the user-space supervisor", started);
const marker = "process-test: ok";
scheduler.setPriority(1);
const deadline = architecture.millis() + 10000;
while (architecture.millis() < deadline) {
if (bufferHas(marker)) break;
scheduler.yield();
}
scheduler.setPriority(4);
const ok = bufferHas(marker);
if (!ok and process.write_len > 0) log("DANOS-SUPERVISION: got \"{s}\"\n", .{process.write_buffer[0..process.write_len]});
check("the supervisor completed every step (spawn/list/kill/notify)", ok);
check("it ran in user mode (CPL 3)", process.write_from_user);
result();
}
/// The initial_ramdisk path: the bootloader handed over an image bundling extra user
/// binaries; parse it, spawn every program, and confirm one (the vfs stub)
/// reaches ring 3 and heartbeats — proving the whole ferry-parse-spawn pipeline.
fn initialRamdiskTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: initial_ramdisk\n", .{});
check("bootloader handed over an initial_ramdisk", boot_information.initial_ramdisk_len != 0);
if (boot_information.initial_ramdisk_len == 0) {
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
check("initial_ramdisk image is valid", true);
check("initial_ramdisk contains at least one binary", rd.count >= 1);
process.write_count = 0;
process.write_from_user = false;
var programs: u32 = 0;
var spawned: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
// The boot tree ferries data files too (/system/configuration/devices.csv,
// /system/configuration/init.csv — served read-only by the kernel VFS,
// never spawned); only the /system and /test trees hold programs, and
// /system/configuration holds none, so only the rest counts toward the
// spawn-everything sweep.
const is_program = (std.mem.startsWith(u8, item.name, "/system/") and
!std.mem.startsWith(u8, item.name, "/system/configuration/")) or
std.mem.startsWith(u8, item.name, "/test/");
if (!is_program) continue;
programs += 1;
if (process.spawnProcess(item.blob, 4, &.{item.name})) spawned += 1 else |err| {
log("DANOS-INITRD-ERR: {s}: {s}\n", .{ item.name, @errorName(err) });
}
}
check("every initial_ramdisk program spawned", programs >= 1 and spawned == programs);
// Wait for the spawned programs to run and make syscalls (they write + sleep).
scheduler.setPriority(1);
const deadline = architecture.millis() + 8000;
while (process.write_count < 2 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
check("initial_ramdisk processes ran and made syscalls (>=2)", process.write_count >= 2);
check("syscalls came from user mode (CPL 3)", process.write_from_user);
result();
}
/// The full VFS path: spawn the user-space VFS server and a client from the
/// initial_ramdisk. The client opens a file through the runtime file API, writes, seeks, reads
/// it back, and — only if the round trip matched — heartbeats "vfstest: ok". So
/// seeing that marker proves client open/write/read reached the server over IPC
/// and came back correct. (The client retries until the server registers.)
fn vfsTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: vfs\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.write_count = 0;
process.write_from_user = false;
// Seed the kernel VFS (/system) — the router the client exercises.
process.setInitialRamdisk(image);
// Spawn just the client: the kernel itself is the VFS root it exercises
// (resolve + fs_node over /system through the plain runtime.fs API).
_ = spawnNamed(rd, "vfs-test");
// Wait for the client's success heartbeat (it round-trips, then beats ~1/s).
const prefix = "vfstest: ok";
scheduler.setPriority(1);
const deadline = architecture.millis() + 10000;
while (architecture.millis() < deadline) {
if (bufferHas(prefix) and process.write_count >= 2) break;
scheduler.yield();
}
scheduler.setPriority(4);
const ok = bufferHas(prefix);
check("client completed the VFS round trip (open/write/read matched)", ok);
check("the round trip ran repeatedly (server stays up)", process.write_count >= 2);
check("client syscalls came from user mode (CPL 3)", process.write_from_user);
result();
}
/// The full input path: spawn the input service, a synthetic source, and a subscriber from
/// the initial_ramdisk. The source publishes keyboard, mouse, and joystick events in turn;
/// the service routes them (with the asynchronous ipc_send) to the subscriber, which took
/// all three classes and — only once it has received one — heartbeats "input-test: ok".
/// Seeing that marker proves an event travelled source -> service -> subscriber over IPC,
/// exercising the async buffered-send primitive, capability-passing subscription, and
/// per-device routing. The source and service stay silent after startup so the subscriber's
/// line is the one left in the shared evidence buffer.
fn inputTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: input\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.write_count = 0;
process.write_from_user = false;
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the input service binds /protocol/input
_ = spawnNamed(rd, "input"); // the fan-out service
_ = spawnNamed(rd, "input-source"); // a synthetic keyboard publishing events
_ = spawnNamed(rd, "input-test"); // the subscriber whose "ok" line is the marker
// Wait for the subscriber's success heartbeat (it beats once per received event).
const prefix = "input-test: ok";
scheduler.setPriority(1);
const deadline = architecture.millis() + 12000;
while (architecture.millis() < deadline) {
if (bufferHas(prefix) and process.write_count >= 2) break;
scheduler.yield();
}
scheduler.setPriority(4);
const ok = bufferHas(prefix);
check("a subscriber received a broadcast key event over IPC (source -> service -> subscriber)", ok);
check("events kept flowing (service + async send stay up)", process.write_count >= 2);
check("client syscalls came from user mode (CPL 3)", process.write_from_user);
result();
}
/// D2 — the display service comes up. Spawn it from the initial_ramdisk; it claims the
/// framebuffer the kernel seeded (D1), maps it write-combining, allocates a cacheable
/// back buffer, and proves the double-buffer path by clearing that buffer and presenting
/// it. Its `display: online WxH` + `display: presented frame 0` heartbeats are the
/// markers — seeing them proves a user-space compositor took the framebuffer and pushed
/// a whole composed frame to the screen, without ever drawing straight to the LFB.
fn displayServiceTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-service\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// init (the registry) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the compositor binds /protocol/display
// Spawn the compositor and hand it the core. Its own serial heartbeats — `display:
// online WxH` and `display: presented frame 0` — are what the harness matches (it
// reads serial directly, like the fault cases). We don't poll for them in-kernel: a
// single service that comes up and blocks doesn't reschedule this bring-up context
// (there is no other runnable task to bounce control back through), so the honest
// observation point is the service's output itself, not a check() proxy here.
if (!spawnNamed(rd, "display")) {
log("display-service: could not spawn the display service\n", .{});
result();
return;
}
scheduler.setPriority(1); // below the service, so it runs and comes up first
while (true) scheduler.yield();
}
/// The threaded compositor tracks a mouse (docs/threading.md, docs/display.md). Spawn the
/// `input` fan-out service, the display (which runs a mouse-listener thread alongside its
/// compositor loop and draws a top-z cursor), and `input-source` in `mouse` mode — a
/// synthetic source publishing pure motion. The display's own marker,
/// `display: cursor tracking mouse ok`, is printed once the cursor has tracked a run of
/// motion end to end (source -> input service -> listener thread -> channel -> render), so
/// like the other display cases we match on serial rather than poll in-kernel.
fn displayCursorTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-cursor\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // input and display bind theirs
if (!spawnNamed(rd, "input")) {
log("display-cursor: could not spawn the input service\n", .{});
result();
return;
}
if (!spawnNamed(rd, "display")) {
log("display-cursor: could not spawn the display service\n", .{});
result();
return;
}
if (!spawnNamedWithArg(rd, "input-source", "mouse")) {
log("display-cursor: could not spawn the mouse source\n", .{});
result();
return;
}
scheduler.setPriority(1); // below the services, so they run
while (true) scheduler.yield();
}
/// D4 — a separate process drives the compositor. Spawn the display service and the
/// hardware-free `display-demo` client, which creates a wallpaper, a moving rectangle,
/// and a cursor and presents a run of frames. Its `display-demo: ok` heartbeat — printed
/// only after it drove frames of motion through the layer client API and the compositor —
/// is the harness's marker (the visible motion itself is a screenshot away via run-x86-64).
/// The demo keeps presenting, so unlike a lone blocking service the scheduler stays busy;
/// we still match on serial rather than poll, for consistency.
fn displayDemoTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-demo\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the compositor binds /protocol/display
if (!spawnNamed(rd, "display")) {
log("display-demo: could not spawn the display service\n", .{});
result();
return;
}
// Spawn the input service too — real boot has it, and it guards the demo's
// independence from input: the demo must animate to `display-demo: ok` on its own
// frame timer even with the input service available (a client that blocks its
// animation loop on a mouse read would stall here, never reaching the marker).
_ = spawnNamed(rd, "input");
_ = spawnNamed(rd, "display-demo");
scheduler.setPriority(1); // below the service + demo, so they run
while (true) scheduler.yield();
}
/// V2 — cross-process shared memory (docs/display-v2.md). Spawn shared-memory-server and shared-memory-client:
/// the client shared_memory_creates a region, writes a pattern, and passes the region's capability to
/// the server as an ipc_call send_cap; the server shared_memory_maps it and confirms the pattern is
/// visible — proving the two processes share the same physical pages, and that the extended
/// capability-passing (endpoints → memory objects) works. Its `shared-memory: shared 4096 bytes ok`
/// heartbeat is the marker.
fn sharedMemoryTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: shared-memory\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// init (the registry, below) reads its manifests through the kernel VFS.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the server binds /protocol/test/shared-memory
if (!spawnNamed(rd, "shared-memory-server")) {
log("shared-memory: could not spawn shared-memory-server\n", .{});
result();
return;
}
_ = spawnNamed(rd, "shared-memory-client");
scheduler.setPriority(1); // below the two, so they run
while (true) scheduler.yield();
}
/// V3 — the virtio-gpu driver, end to end (docs/display-v2.md). Boot the device-manager
/// stack (in its normal mode) so it discovers the virtio-gpu PCI function — present because
/// the harness boots this case with QEMU's `-device virtio-gpu-pci` — and spawns the driver.
/// The driver claims the device, brings up the control virtqueue, creates a 2D scanout,
/// paints a known pattern, flushes it, and waits for the device's used-ring ack. Its serial
/// heartbeats — `virtio-gpu: scanout WxH online` and `virtio-gpu: flush acked, pixel check
/// ok` — are the harness's markers (it reads serial directly, like the display cases). The
/// used-ring ack is the device confirming it consumed the frame; the pixel read-back proves
/// the backing is CPU-visible RAM — together the automated stand-in for "it's on screen".
fn virtioGpuTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: virtio-gpu\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Spawn device-manager in its normal mode: its initialise discovers the PCI host bridge
// from the kernel device tree, spawns pci-bus, and matches the virtio-gpu class triple to
// spawn our driver with the function's device id as argv[1].
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the driver binds /protocol/scanout
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
if (manager == 0) {
log("virtio-gpu: could not spawn device-manager\n", .{});
result();
return;
}
scheduler.setPriority(1); // below the manager and the driver it spawns, so they run
while (true) scheduler.yield();
}
/// V4 — the native backend + hot-attach (docs/display-v2.md). Boot the compositor and the
/// hardware-free `display-demo` client (as displayDemoTest does), then the device-manager
/// stack so it discovers the virtio-gpu function — present via QEMU's `-device
/// virtio-gpu-pci` — and spawns the driver. The driver brings up its scanout, then announces
/// the shared surface to the already-running compositor, which maps it, upgrades off the GOP
/// floor, and presents the composited frame through the native backend. Its serial heartbeats
/// — `display: scanout upgraded to virtio-gpu` and `display: native present verified` — plus
/// the demo's own `display-demo: ok` are the harness's markers. Display is spawned first so
/// it is registered on `.display` before the driver announces.
fn displayNativeTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-native\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // display, the manager, and the driver bind theirs
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
break;
}
if (manager == 0) {
log("display-native: could not spawn device-manager\n", .{});
result();
return;
}
if (!spawnNamed(rd, "display")) {
log("display-native: could not spawn the display service\n", .{});
result();
return;
}
_ = spawnNamed(rd, "display-demo");
scheduler.setPriority(1); // below the compositor, the demo, and the driver, so they run
while (true) scheduler.yield();
}
/// V6 — resilience: the compositor survives the virtio-gpu driver dying and re-attaches when
/// device-manager restarts it (docs/display-v2.md). Same boot as display-native, but the
/// manager runs in "test-scanout-restart" mode: a moment after the driver hellos, it kills it
/// once; the normal restart policy respawns it, the restarted driver re-announces, and the
/// compositor re-attaches to the fresh scanout — logging `display: scanout re-attached` after
/// the initial `display: scanout upgraded to virtio-gpu`. The compositor must not crash.
fn displayReattachTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display-reattach\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // display and the restarted driver bind theirs
var manager: u32 = 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-scanout-restart" }, scheduler.currentId(), null) catch 0;
break;
}
if (manager == 0) {
log("display-reattach: could not spawn device-manager\n", .{});
result();
return;
}
if (!spawnNamed(rd, "display")) {
log("display-reattach: could not spawn the display service\n", .{});
result();
return;
}
_ = spawnNamed(rd, "display-demo");
scheduler.setPriority(1); // below the compositor, the demo, and the driver, so they run
while (true) scheduler.yield();
}
/// Process arguments, end to end: spawn args-echo bare (its argv[0] is the
/// initial-ramdisk name). Instance 1 sees argc == 1 and respawns itself through
/// `system_spawn` with the extra arguments "alpha beta-42" — the syscall argument
/// blob. Instance 2 parses the kernel-built System V entry stack via the runtime
/// and echoes its whole argv in one write, which must arrive exactly as sent.
fn argsTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: args\n", .{});
check("bootloader handed over an initial_ramdisk", boot_information.initial_ramdisk_len != 0);
if (boot_information.initial_ramdisk_len == 0) {
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image); // args-echo respawns itself through system_spawn
process.write_count = 0;
process.write_from_user = false;
check("args-echo spawned from the initial_ramdisk", spawnNamed(rd, "args-echo"));
// Wait for the *second* instance's echo (the first writes nothing).
scheduler.setPriority(1);
const deadline = architecture.millis() + 8000;
while (process.write_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
const expected = "args: /test/system/services/args-echo alpha beta-42\n";
const echoed = process.write_len == expected.len and eql(process.write_buffer[0..process.write_len], expected);
if (!echoed and process.write_len > 0) log("DANOS-ARGS: got \"{s}\"\n", .{process.write_buffer[0..process.write_len]});
check("argv arrived intact (argv[0] = name, argv[1..] = spawn arguments)", echoed);
check("echo came from user mode (CPL 3)", process.write_from_user);
result();
}
/// Spawn the initial_ramdisk binary named `name` as a ring-3 process. Returns false if it
/// isn't in the image or fails to load.
/// The init ELF image out of the initial_ramdisk — init rides the table like
/// every other binary since the loader packs the whole boot tree.
fn bundledInit(boot_information: *const BootInformation) ?[]const u8 {
if (boot_information.initial_ramdisk_len == 0) return null;
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse return null;
const item = rd.find("/system/services/init") orelse return null;
return item.blob;
}
/// The kernel VFS root (M-F): resolve initrd paths to node tokens, read an ELF
/// header through nodeRead, enumerate /system's derived directory table, and
/// verify the read-only + unknown-path refusals. Pure kernel-side — the
/// syscall surface gets its end-to-end coverage when runtime.fs cuts over.
fn kernelVfsTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: kvfs\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over the initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
process.setInitialRamdisk(image); // also seeds the kernel VFS /system and /test mounts
// A file resolves to a kernel node token; its status and bytes are served.
const resolved = kernel_vfs.resolvePath("/system/services/init", false);
const is_file = resolved == .kernel_node;
check("/system/services/init resolves to a kernel node", is_file);
if (is_file) {
const status = kernel_vfs.nodeStatus(resolved.kernel_node);
check("its status is a non-empty regular file", status != null and status.?.kind == abi.file_kind_regular and status.?.size > 0);
var header: [4]u8 = undefined;
const n = kernel_vfs.nodeRead(resolved.kernel_node, 0, &header) orelse 0;
check("its first bytes are an ELF magic", n == 4 and header[0] == 0x7f and header[1] == 'E' and header[2] == 'L' and header[3] == 'F');
}
// Directories resolve and enumerate: /system lists services/drivers/
// configuration (the CSV data files ride the same initrd tree).
const root_directory = kernel_vfs.resolvePath("/system", false);
check("/system resolves to a directory node", root_directory == .kernel_node);
var saw_services = false;
var saw_drivers = false;
var saw_configuration = false;
var saw_stray_in_root = false;
var saw_files_in_services = false;
if (root_directory == .kernel_node) {
var cursor: u64 = 0;
var name: [64]u8 = undefined;
while (kernel_vfs.nodeReaddir(root_directory.kernel_node, cursor, &name)) |entry| : (cursor += 1) {
if (eql(name[0..entry.name_len], "services")) saw_services = true else if (eql(name[0..entry.name_len], "drivers")) saw_drivers = true else if (eql(name[0..entry.name_len], "configuration")) saw_configuration = true else saw_stray_in_root = true;
}
}
check("readdir /system yields services, drivers, configuration", saw_services and saw_drivers and saw_configuration);
check("readdir /system yields nothing else (no /test leakage)", !saw_stray_in_root);
const services = kernel_vfs.resolvePath("/system/services", false);
if (services == .kernel_node) {
var cursor: u64 = 0;
var name: [64]u8 = undefined;
while (kernel_vfs.nodeReaddir(services.kernel_node, cursor, &name)) |entry| : (cursor += 1) {
if (eql(name[0..entry.name_len], "init")) saw_files_in_services = true;
}
}
check("readdir /system/services yields init", saw_files_in_services);
// The /test tree is a second initrd root, resolvable and enumerable like
// /system (the fixtures live at /test/system/services, mirroring the repo).
const test_root = kernel_vfs.resolvePath("/test", false);
check("/test resolves to a directory node", test_root == .kernel_node);
check("/test/system/services/args-echo resolves to a kernel node", kernel_vfs.resolvePath("/test/system/services/args-echo", false) == .kernel_node);
var saw_test_subtree = false;
if (test_root == .kernel_node) {
var cursor: u64 = 0;
var name: [64]u8 = undefined;
while (kernel_vfs.nodeReaddir(test_root.kernel_node, cursor, &name)) |entry| : (cursor += 1) {
if (eql(name[0..entry.name_len], "system")) saw_test_subtree = true;
}
}
check("readdir /test yields system", saw_test_subtree);
// Refusals: unknown paths, and create-intent on the immutable initrd trees.
check("an unknown path does not resolve", kernel_vfs.resolvePath("/system/services/no-such", false) == .not_found);
check("an unmounted absolute path does not resolve", kernel_vfs.resolvePath("/elsewhere", false) == .not_found);
check("create on /system is refused (read-only)", kernel_vfs.resolvePath("/system/services/new-file", true) == .not_found);
check("create on /test is refused (read-only)", kernel_vfs.resolvePath("/test/system/services/new-file", true) == .not_found);
result();
}
// --- shared-fate helpers (docs/shared-fate-plan.md M4) -----------------------
/// Spawn the ramdisk's thread-test with `mode` as argv[1], supervised by the
/// calling test task on `endpoint`. Returns the child (leader) id, or 0.
fn spawnThreadTestSupervised(boot_information: *const BootInformation, mode: []const u8, endpoint: *ipcsync.Endpoint) u32 {
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(ramdisk) orelse return 0;
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
return process.spawnProcessSupervised(item.blob, 4, &.{ "thread-test", mode }, scheduler.currentId(), endpoint) catch 0;
}
return 0;
}
/// Whether any live task still belongs to `leader`'s group.
fn groupListed(leader: u32) bool {
var table: [32]abi.ProcessDescriptor = undefined;
const total = scheduler.enumerate(&table);
for (table[0..@min(total, table.len)]) |descriptor| {
if (descriptor.id == leader or descriptor.leader == leader) return true;
}
return false;
}
/// Poll enumerate for a WORKER of `leader` (same leader, different id) until
/// `deadline` (millis); returns its id, or 0.
fn findWorkerOf(leader: u32, deadline: u64) u32 {
while (architecture.millis() < deadline) {
var table: [32]abi.ProcessDescriptor = undefined;
const total = scheduler.enumerate(&table);
for (table[0..@min(total, table.len)]) |descriptor| {
if (descriptor.leader == leader and descriptor.id != leader) return descriptor.id;
}
scheduler.yield();
}
return 0;
}
/// Block on `endpoint` for the next notification and return its badge.
fn awaitExitBadge(endpoint: *ipcsync.Endpoint) u64 {
var badge: u64 = 0;
var received_cap: u64 = 0;
_ = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
return badge;
}
/// A group death is one notification, badged with the LEADER, arriving only
/// after every member (and the address space) is gone — asserted by every
/// shared-fate case below. "Exactly one": after a settling sleep, a second
/// (buggy, double-posted) badge would still sit in the endpoint's notify ring.
fn checkGroupDead(me: u32, leader: u32, badge: u64, reason: abi.ExitReason, endpoint: *ipcsync.Endpoint) void {
check("one exit notification, badged with the leader", badge == abi.notify_badge_bit | abi.notify_exit_bit | leader);
check("the leader's recorded reason is the group reason", process.exitReasonOf(me, leader) == @intFromEnum(reason));
check("no group member is listed after the death", !groupListed(leader));
scheduler.sleep(200);
check("exactly one exit notification (ring drained)", endpoint.notify_head == endpoint.notify_tail);
}
fn threadFaultGroupTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-fault-group\n", .{});
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const stacks_base = scheduler.liveStackBytes();
const spaces_base = scheduler.liveAddressSpaceCount();
process.fault_kill_count = 0;
const child = spawnThreadTestSupervised(boot_information, "fault-worker", endpoint);
check("thread-test spawned (fault-worker)", child != 0);
if (child == 0) {
result();
return;
}
const badge = awaitExitBadge(endpoint);
checkGroupDead(me, child, badge, .segmentation_fault, endpoint);
check("one fault kill for the whole group", process.fault_kill_count == 1);
const deadline = architecture.millis() + 5000;
while (scheduler.liveStackBytes() > stacks_base and architecture.millis() < deadline) scheduler.yield();
check("address spaces returned to base", scheduler.liveAddressSpaceCount() == spaces_base);
check("kernel stacks returned to base", scheduler.liveStackBytes() == stacks_base);
result();
}
fn killThreadedGroupTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: kill-threaded-group\n", .{});
const me = scheduler.currentId();
var buffer: [2]device_abi.DeviceDescriptor = undefined;
check("the device tree is seeded (>= 2 devices)", devices_broker.enumerate(&buffer) >= 2);
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = spawnThreadTestSupervised(boot_information, "spin-forever", endpoint);
check("thread-test spawned (spin-forever)", child != 0);
if (child == 0) {
result();
return;
}
const worker = findWorkerOf(child, architecture.millis() + 8000);
check("the spinning worker is enumerable with leader = the child", worker != 0);
// Claims for BOTH members: group death must release every member's claims
// before the supervisor hears anything — the worker's by the deferred
// (condemned) path.
check("device 0 claimed for the leader", claimOk(0, child));
check("device 1 claimed for the worker", claimOk(1, worker));
scheduler.sleep(100); // let the worker really be running on another core
check("the supervisor's kill is accepted", process.killProcess(me, child) == 0);
const badge = awaitExitBadge(endpoint);
checkGroupDead(me, child, badge, .killed, endpoint);
check("the leader's claim was released before the notification", devices_broker.ownerOf(0) == null);
check("the worker's claim was released before the notification", devices_broker.ownerOf(1) == null);
check("a dead group stays dead (-ESRCH)", process.killProcess(me, child) == -ipcsync.ESRCH);
result();
}
fn killViaWorkerTidTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: kill-via-worker-tid\n", .{});
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = spawnThreadTestSupervised(boot_information, "spin-forever", endpoint);
check("thread-test spawned (spin-forever)", child != 0);
if (child == 0) {
result();
return;
}
const worker = findWorkerOf(child, architecture.millis() + 8000);
check("the spinning worker is enumerable", worker != 0);
check("a non-supervisor aiming at the worker is refused (-EPERM)", process.killProcess(me + 12345, worker) == -ipcsync.EPERM);
check("the supervisor's kill aimed at the WORKER id is accepted", process.killProcess(me, worker) == 0);
const badge = awaitExitBadge(endpoint);
checkGroupDead(me, child, badge, .killed, endpoint);
result();
}
fn racingTriggersTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: racing-triggers\n", .{});
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
process.fault_kill_count = 0;
const child = spawnThreadTestSupervised(boot_information, "race", endpoint);
check("thread-test spawned (race)", child != 0);
if (child == 0) {
result();
return;
}
const badge = awaitExitBadge(endpoint);
checkGroupDead(me, child, badge, .segmentation_fault, endpoint);
check("two racing faults counted as ONE group kill", process.fault_kill_count == 1);
result();
}
fn exitGroupTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: exit-group\n", .{});
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = spawnThreadTestSupervised(boot_information, "exit-worker", endpoint);
check("thread-test spawned (exit-worker)", child != 0);
if (child == 0) {
result();
return;
}
const badge = awaitExitBadge(endpoint);
checkGroupDead(me, child, badge, .aborted, endpoint);
result();
}
/// leader-thread-exit, thread-exit-solo, and shm-mapping-ref share one shape:
/// the child asserts its own property, prints a marker the harness matches, and
/// exits clean — the kernel side asserts the clean group death.
fn threadTestMarkerCase(boot_information: *const BootInformation, case_name: []const u8, mode: []const u8) void {
log("DANOS-TEST-BEGIN: {s}\n", .{case_name});
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = spawnThreadTestSupervised(boot_information, mode, endpoint);
check("thread-test spawned", child != 0);
if (child == 0) {
result();
return;
}
const badge = awaitExitBadge(endpoint);
checkGroupDead(me, child, badge, .exited, endpoint);
result();
}
/// Bring up the protocol namespace for a scenario that spawns its providers
/// itself. `/protocol` is served by init, PID 1 — but a scenario case wants the
/// naming layer without init's whole service list underneath it, so init is
/// started in its `registry` role: it mounts `/protocol`, reads the grants, and
/// spawns nothing (docs/os-development/protocol-namespace.md; the plan's
/// decision 9). Providers retry their bind, so racing the mount is survivable —
/// but calling this first makes the race rare.
///
/// The caller must have published the initial ramdisk already
/// (`process.setInitialRamdisk`): init reads its manifests out of it, and every
/// `/protocol` resolve goes through the same kernel VFS.
fn spawnRegistry(rd: initial_ramdisk.Reader) bool {
return spawnNamedWithArg(rd, "init", "registry");
}
fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (eql(initial_ramdisk.basename(item.name), name)) {
return if (process.spawnProcess(item.blob, 4, &.{item.name})) true else |_| false;
}
}
return false;
}
/// As `spawnNamed`, but passes one extra argv entry (argv[1]) — e.g. a mode selector like
/// `input-source mouse`.
fn spawnNamedWithArg(rd: initial_ramdisk.Reader, name: []const u8, arg: []const u8) bool {
var i: u32 = 0;
while (i < rd.count) : (i += 1) {
const item = rd.entry(i) orelse continue;
if (eql(initial_ramdisk.basename(item.name), name)) {
return if (process.spawnProcess(item.blob, 4, &.{ item.name, arg })) true else |_| false;
}
}
return false;
}
/// The GSI discovery recorded for the HPET, from the same device table drivers see.
fn hpetGsi() ?u32 {
var buffer: [16]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
for (buffer[0..n]) |d| {
if (d.class != @intFromEnum(device_abi.DeviceClass.timer)) continue;
if (d.parent != device_abi.no_parent) continue; // the block, not a comparator child
for (0..d.resource_count) |j| {
const r = d.resources[j];
if (r.kind == @intFromEnum(device_abi.ResourceKind.irq)) return @intCast(r.start);
}
}
return null;
}
/// `device_register` containment — a kernel security property, tested directly against
/// the broker (no user-space demo driver). A bus driver publishes children of a device
/// it owns; the kernel must **refuse** any child whose resource escapes the parent's
/// grant, or `device_register` would become a system call for mapping arbitrary physical
/// memory. This is the property the old `bus` demo driver proved end-to-end; with the
/// demo gone, the property is asserted where it lives — in the kernel. Also checks the
/// idempotence rule (M19.0): re-registering an identical child returns the same id
/// instead of appending a duplicate.
fn containmentTest() void {
log("DANOS-TEST-BEGIN: containment\n", .{});
const me = scheduler.currentId();
var buffer: [64]device_abi.DeviceDescriptor = undefined;
check("device tree is seeded", devices_broker.enumerate(&buffer) >= 1);
// The kernel-seeded HPET timer block is a device with a memory resource — a natural
// parent to publish sub-window children under, as a PCI bridge or USB hub would.
const parent_id = hpetDeviceId() orelse {
check("found a device with a memory window to parent children under", false);
result();
return;
};
const parent = buffer[@intCast(parent_id)];
var window: ?device_abi.ResourceDescriptor = null;
for (0..parent.resource_count) |j| {
if (parent.resources[j].kind == @intFromEnum(device_abi.ResourceKind.memory)) window = parent.resources[j];
}
const parent_window = window orelse {
check("parent exposes a memory window", false);
result();
return;
};
if (devices_broker.ownerOf(parent_id) != null) {
check("parent device was unclaimed at the start of the test", false);
result();
return;
}
check("claimed the parent device", claimOk(parent_id, me));
defer devices_broker.releaseAllOwnedBy(me);
// A child whose window lies inside the parent's is accepted.
var fits = childDescriptor("cfit", parent_window.start, 0x20);
const before = devices_broker.enumerate(&buffer);
const good = devices_broker.register(parent_id, me, &fits) catch 0;
check("a contained child is registered", good != 0);
check("the contained child was appended to the table", devices_broker.enumerate(&buffer) == before + 1);
// A child whose window escapes the parent's is refused with NotContained.
var escapes = childDescriptor("cesc", parent_window.start, parent_window.len + 0x1000);
const refused = if (devices_broker.register(parent_id, me, &escapes)) |_| false else |err| err == error.NotContained;
check("an out-of-window child is refused (NotContained)", refused);
check("the refused child left the table unchanged", devices_broker.enumerate(&buffer) == before + 1);
// Idempotent on exact match: re-registering the accepted child returns its id and
// appends nothing (M19.0 — a restarted bus re-reports what it rediscovers).
const again = devices_broker.register(parent_id, me, &fits) catch 0;
check("re-registering an identical child returns the same id", again != 0 and again == good);
check("re-registering grew nothing", devices_broker.enumerate(&buffer) == before + 1);
// **There is no per-parent cap.** `maximum_children_per_parent = 16` is gone: it
// was written to stop a driver looping `device_register` and exhausting a shared
// table, and there is no shared table to exhaust — the table grows, and each
// registrar has its own allowance, so a runaway costs only itself. The cap never
// bounded a determined caller anyway (16 per parent, but nothing stopped it
// claiming more parents); what it reliably did was refuse a real PCI bus with more
// than 16 functions, which is how an AMD Ryzen booted with no USB and no storage.
//
// 64 children under one parent — four times the old ceiling.
var filled: u32 = 0;
var registered_children: u32 = 0;
while (filled < 64) : (filled += 1) {
var name: [4]u8 = .{ 'k', 0, 0, 0 };
name[1] = '0' + @as(u8, @intCast(filled / 10));
name[2] = '0' + @as(u8, @intCast(filled % 10));
var extra = childDescriptor(name[0..3], parent_window.start, 0x20);
if (devices_broker.register(parent_id, me, &extra)) |_| {
registered_children += 1;
} else |_| break;
}
check("one parent takes far more children than the old cap allowed", registered_children == 64);
// Idempotency still holds, and still matters: a crashed bus driver is restarted and
// re-registers everything it rediscovers, which must return the ids it had before
// rather than duplicate them.
const readmitted = devices_broker.register(parent_id, me, &fits) catch 0;
check("re-registering an identical child still returns its id", readmitted != 0 and readmitted == good);
// The table itself has no ceiling: it grows. The old `maximum_devices = 64` was a
// guess about someone else's computer, and one driver's enumeration starved every
// other — which is how a Ryzen booted with no USB and no storage. What bounds a
// runaway now is an allowance charged to the registrar, so the damage stays with
// whoever caused it (docs/os-development/device-authority.md).
// The table grows: it starts at 8 entries and this boot holds well past that, so
// the growth path runs every time rather than lying dormant until someone else's
// larger machine finds it — which is how the old ceiling stayed invisible.
const held = devices_broker.enumerate(&buffer);
check("the table grew beyond its initial block", held > 8);
result();
}
/// A minimal child descriptor with one memory resource, for the containment test.
/// Delegation's mechanism. A claim is exclusive (driver-model.md, invariant 1), so
/// handing a device on is a **move**: the giver stops holding it the instant the
/// receiver starts. That is why this is not the M13 capability path, which shares a
/// handle refcounted.
///
/// The rule the kernel enforces is the whole of it: *you may give away what you hold*.
/// It has no idea which task is the device manager and needs none
/// (docs/os-development/device-authority.md).
fn deviceTransferTest(boot_information: *const boot_handoff.BootInformation) void {
var buffer: [8]device_abi.DeviceDescriptor = undefined;
check("the device tree is seeded", devices_broker.enumerate(&buffer) >= 2);
const image = bundledInit(boot_information) orelse {
check("initial_ramdisk carries /system/services/init", false);
result();
return;
};
const me = scheduler.currentId();
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("exit endpoint allocated", false);
result();
return;
};
const child = process.spawnProcessSupervised(image, 4, &.{"/system/services/init"}, me, endpoint) catch 0;
check("supervised child spawned", child != 0);
// You may only give away what you hold — so an unheld device cannot be moved at all,
// which is what stops a transfer being a back door around claiming.
const unheld = if (devices_broker.transfer(0, me, child)) |_| false else |e| e == error.NotHeld;
check("an unheld device cannot be transferred", unheld);
// A second device this test never hands over, so the "no giver" half below is a
// real observation rather than a restatement of the first.
const parentless: u64 = 1;
check("claimed device 0", claimOk(0, me));
// The move itself.
const moved = if (devices_broker.transfer(0, me, child)) |_| true else |_| false;
check("the holder may transfer", moved);
const holder = devices_broker.ownerOf(0) orelse 0;
check("the receiver holds it", holder == child);
check("the giver does not", holder != me);
// Having given it away, the giver cannot give it again. This is the assertion that
// makes it a move rather than a copy.
const again = if (devices_broker.transfer(0, me, child)) |_| false else |e| e == error.NotHeld;
check("a former holder cannot transfer again", again);
// Nor may a stranger move a device it never held.
const stranger = if (devices_broker.transfer(0, 9999, me)) |_| false else |e| e == error.NotHeld;
check("a stranger cannot transfer another task's device", stranger);
// The giver is recorded. One field, and the authority rule follows from it: a
// device that was *given* to someone is delegated hardware, so it may be handed on
// but never taken, and when its holder dies it returns to whoever lent it instead
// of becoming free for anyone. Nothing has a giver until it is handed over —
// which is why the loader's framebuffer needs no exemption from either rule.
check("the giver is recorded on a transfer", devices_broker.giverOf(0) == me);
check("a device nobody handed over has no giver", devices_broker.giverOf(parentless) == null);
const absent = if (devices_broker.transfer(9999, me, child)) |_| false else |e| e == error.NoSuchDevice;
check("a device that does not exist is refused", absent);
// **A grant is a loan.** The child holds device 0 and `me` lent it, so the child's
// death must hand it back rather than release it to nobody. That is what lets the
// device manager re-delegate to a restarted driver — and, once `claim` refuses a
// device that has a giver, it is the only thing that stops a dead driver's hardware
// being stranded forever.
check("the child still holds the lent device", devices_broker.ownerOf(0) == child);
devices_broker.releaseAllOwnedBy(child);
check("the borrower's death returns the device to its lender", devices_broker.ownerOf(0) == me);
check("and it is no longer on loan", devices_broker.giverOf(0) == null);
// A device with no lender still simply frees on death, as it always did.
check("claimed a second device with no lender", claimOk(parentless, me));
devices_broker.releaseAllOwnedBy(me);
check("a device nobody lent is released outright", devices_broker.ownerOf(parentless) == null);
result();
}
/// PCI host-bridge apertures are derived from the *holes* in the firmware memory map,
/// and a registered BAR must fall inside one. So the invariant is not "we find the
/// holes" but "an aperture never covers memory the firmware described" — an aperture
/// over RAM means `device_register` containment admits a child BAR over kernel memory,
/// and its claimant can `mmio_map` it.
///
/// The map's length is the firmware's choice: 60–200 descriptors on a real machine,
/// 15–25 under OVMF, which is why the suite never saw this. The derivation used to
/// copy sub-4 GiB entries into a fixed `[64]` array and skip the rest — and a skipped
/// region is not merely lost, it is one the gap finder concludes is *free*.
fn apertureTest() void {
// 100 described one-page regions, 2 MiB apart: 99 small holes between them, then
// one large hole from the last region up to 4 GiB. Under the old fixed array the
// 36 regions past the 64th vanished, so the "largest hole" ran from ~128 MiB to
// 4 GiB — straight across 36 regions the firmware had described.
const spacing: u64 = 2 << 20;
var regions: [100]boot_handoff.MemoryRegion = undefined;
for (&regions, 0..) |*region, i| {
region.* = .{ .base = @as(u64, i) * spacing, .pages = 1, .kind = .usable };
}
var holes: [3]platform.AddressRange = undefined;
const found = platform.largestHolesBelow4G(&regions, 1 << 20, &holes);
check("apertures were derived from a 100-entry map", found > 0);
var overlaps: usize = 0;
var largest: platform.AddressRange = .{ .base = 0, .end = 0 };
for (holes) |hole| {
if (hole.end <= hole.base) continue;
if (hole.end - hole.base > largest.end - largest.base) largest = hole;
for (regions) |region| {
const region_end = region.base + region.pages * 4096;
if (region.base < hole.end and region_end > hole.base) overlaps += 1;
}
}
check("no aperture overlaps described memory", overlaps == 0);
// The big hole is above the last described region, not across it.
const last_end = (regions.len - 1) * spacing + 4096;
check("the largest aperture starts after the last described region", largest.base == last_end);
check("the largest aperture runs to 4 GiB", largest.end == (1 << 32));
// A map the firmware describes nothing in is one whole hole; a map that describes
// everything has none. Neither may invent an aperture over something described.
var empty: [3]platform.AddressRange = undefined;
check("an empty map yields one hole", platform.largestHolesBelow4G(&.{}, 1 << 20, &empty) == 1);
check("that hole is the whole low space", empty[0].base == 0 and empty[0].end == (1 << 32));
const whole = [_]boot_handoff.MemoryRegion{.{ .base = 0, .pages = (1 << 32) / 4096, .kind = .usable }};
var none: [3]platform.AddressRange = undefined;
check("a fully described map yields no aperture", platform.largestHolesBelow4G(&whole, 1 << 20, &none) == 0);
result();
}
fn childDescriptor(hid: []const u8, start: u64, len: u64) device_abi.DeviceDescriptor {
var child = std.mem.zeroes(device_abi.DeviceDescriptor);
child.class = @intFromEnum(device_abi.DeviceClass.unknown);
child.pci_class = device_abi.no_pci_class;
child.hid_len = @intCast(hid.len);
@memcpy(child.hid[0..hid.len], hid);
child.resource_count = 1;
child.resources[0] = .{ .kind = @intFromEnum(device_abi.ResourceKind.memory), .start = start, .len = len };
return child;
}
/// The device manager (a ring-3 service) enumerates /system/devices, matches each
/// device to a driver, and spawns it. Proof of the whole discover -> match -> spawn ->
/// driver-up chain: boot only the device-manager; it must discover the PCI host bridge,
/// match `pci-bus`, and spawn it (with the bridge id as its argument) — and the spawned
/// pci-bus must reach its own live marker. It uses no special privilege — the same
/// `device_enumerate` any process could call.
/// P2 — the registrar (docs/os-development/protocol-namespace.md). Bring up
/// `/protocol` (init in its registry role) and hand the fixture the core: it
/// asserts that an ungranted bind is refused, that the kernel's reserved prefix
/// holds, that a name a live provider holds cannot be taken, and that killing a
/// provider makes its channel fail while re-resolving the same name reaches the
/// restarted instance.
///
/// It doubles as the security case for PID 1's shared mailbox, since resolving
/// `/protocol` hands every process a sendable handle to it: a forged power
/// payload, a redirected terminate signal, a timer or exit subscription armed on
/// a foreign endpoint, and capability-carrying ping storms against both PID 1 and
/// a harness-run service. Those assertions kill the boot when they regress rather
/// than printing anything, which is the strongest form available here.
///
/// The fixture's `protocol-registry: ok` is the marker; each step also prints its
/// own line, which the harness's ordered regex reads.
fn protocolRegistryTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-registry\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// The fixture spawns its own providers by name, so the ramdisk must be
// published; init then mounts /protocol over the same kernel VFS.
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
check("protocol-registry-test spawned", spawnNamedWithArg(rd, "protocol-registry-test", "run"));
const pass_marker = "protocol-registry: ok";
const fail_marker = "protocol-registry: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no step of the registry contract failed", !saw_fail);
check("the fixture completed every registry assertion", saw_pass);
result();
}
/// P3 — restriction stage one (docs/os-development/protocol-namespace.md). The
/// registrar now checks `open` against `/system/configuration/protocol.csv`, and
/// a caller with no grant is told exactly what a caller asking for a name nobody
/// bound is told.
///
/// The scenario is the assertion's scaffolding: `/protocol` (init in its registry
/// role), the **input service** — which binds a real contract the fixture is
/// deliberately not granted — and the fixture. Without a live provider on the
/// forbidden name, "refused" and "not bound yet" would be the same observation
/// and the case would prove nothing; the fixture reads `/protocol`'s own listing
/// to confirm the name is there before it asks for it.
///
/// The fixture's `protocol-denied: ok` is the marker; each step prints its own
/// line, which the harness's ordered regex reads.
/// The attacker the device suite never had. The audit's finding was that a fully
/// green suite had missed six real defects because it *contains no attacker* — every
/// device case asserts a driver handed its hardware can drive it, and none asks what a
/// process handed **nothing** can do.
///
/// The fixture is spawned with no device and asserts what it therefore cannot do. It
/// runs without the device manager on purpose: nothing here needs a driver, and a boot
/// with fewer moving parts makes the refusals unambiguous.
fn deviceAuthorityTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-authority\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
// The manager must be up: it is what holds the seeded hardware, and without it
// every device would be lying around unheld and the last assertion would have
// nothing to observe — a test that cannot fail.
check("registry (init) spawned", spawnRegistry(rd));
check("device-manager spawned", spawnNamed(rd, "device-manager"));
check("device-authority-test spawned", spawnNamedWithArg(rd, "device-authority-test", "run"));
// The VERDICT prefix matters: the fixture prints one "device-authority: ok <name>"
// line per assertion, so a marker of "device-authority: ok" matches the FIRST
// passing assertion and this loop exits before any later failure is printed — the
// case then passes with failures in it, which it did until this was caught.
const pass_marker = "device-authority: VERDICT ok";
const fail_marker = "device-authority: VERDICT FAILED";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no authority assertion failed", !saw_fail);
check("the attacker completed every assertion", saw_pass);
result();
}
fn protocolDeniedTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-denied\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
// The provider of the contract the fixture may NOT reach. It needs no
// hardware: it binds /protocol/input and waits for subscribers.
check("input service spawned", spawnNamed(rd, "input"));
check("protocol-denied-test spawned", spawnNamedWithArg(rd, "protocol-denied-test", "run"));
const pass_marker = "protocol-denied: ok";
const fail_marker = "protocol-denied: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no step of the restriction contract failed", !saw_fail);
check("the fixture completed every restriction assertion", saw_pass);
result();
}
/// P4a — the reserved verbs, asked of live providers
/// (docs/security-track-plan.md P4a; docs/os-development/protocol-namespace.md).
/// Every protocol rebased onto `envelope.Define` gets `describe` answered from its
/// specification and `-ENOSYS` for a verb it does not define, without its provider
/// implementing either — this case is where that stops being a host unit test of
/// the generated dispatch and becomes an observation of real providers over real
/// IPC.
///
/// The scenario is the assertion's scaffolding, the same shape `protocol-denied`
/// uses: `/protocol` (init in its registry role) plus the providers the fixture is
/// granted to reach — the **input service** and the **compositor**, two protocols
/// of different sizes and different verb counts, so "uniform" means something. The
/// fixture reads `/protocol`'s own listing rather than a list compiled into it, so
/// what it checks is what this boot actually bound; the three other P4a protocols
/// (vfs, block, scanout) sit behind hardware chains this scenario deliberately does
/// not boot, and the fixture names them on serial as unchecked rather than passing
/// over them.
///
/// The fixture's `protocol-conformance: ok` is the marker; each contract it checks
/// prints its own line, which the harness's ordered regex reads.
fn protocolConformanceTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: protocol-conformance\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
process.setInitialRamdisk(image);
check("registry (init) spawned", spawnRegistry(rd));
// The two providers under test. Neither needs hardware beyond the framebuffer
// the kernel already seeded: input binds /protocol/input and waits for
// subscribers, and the compositor binds /protocol/display and composes into
// that framebuffer (the display-service scenario boots it exactly this way).
check("input service spawned", spawnNamed(rd, "input"));
check("display service spawned", spawnNamed(rd, "display"));
check("protocol-conformance-test spawned", spawnNamedWithArg(rd, "protocol-conformance-test", "run"));
const pass_marker = "protocol-conformance: ok";
const fail_marker = "protocol-conformance: FAIL";
scheduler.setPriority(1);
const deadline = architecture.millis() + 20000;
var saw_pass = false;
var saw_fail = false;
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
if (bufferHas(pass_marker)) saw_pass = true;
if (bufferHas(fail_marker)) saw_fail = true;
scheduler.yield();
}
scheduler.setPriority(4);
check("no provider failed the reserved-verb contract", !saw_fail);
check("the fixture conformance-checked every provider its scenario boots", saw_pass);
result();
}
fn deviceManagerTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-manager\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
result();
return;
}
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
const rd = initial_ramdisk.Reader.init(image) orelse {
check("initial_ramdisk image is valid", false);
result();
return;
};
// Let `system_spawn` find bundled binaries by name (the normal boot path does
// this too). Only the device-manager is spawned here — so if `pci-bus` runs at
// all, it's because the manager discovered the PCI host bridge, matched, and
// spawned it.
process.setInitialRamdisk(image);
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
process.write_count = 0;
process.write_from_user = false;
check("device-manager spawned from the initial_ramdisk", spawnNamed(rd, "device-manager"));
// End-to-end proof, read from kernel state — not the racy last-write serial buffer,
// since many services keep logging after pci-bus. The manager must discover the PCI
// host bridge, match pci-bus, and spawn it, and pci-bus must come up: claim the
// bridge, map its ECAM, and register the functions it enumerates as children in the
// device tree.
scheduler.setPriority(1);
const deadline = architecture.millis() + 10000;
var spawned = false;
while (architecture.millis() < deadline) {
if (processRunning("pci-bus")) spawned = true;
if (spawned and pciFunctionsRegistered()) break;
scheduler.yield();
}
scheduler.setPriority(4);
check("device manager discovered the PCI host bridge and spawned pci-bus", spawned);
check("pci-bus came up and registered the functions it enumerated", pciFunctionsRegistered());
check("its syscalls came from user mode (CPL 3)", process.write_from_user);
result();
}
/// Whether a live task was spawned under `name` (its argv[0]) — read from the kernel
/// task table, the same snapshot `process_enumerate` exposes.
fn processRunning(name: []const u8) bool {
var table: [64]abi.ProcessDescriptor = undefined;
const total = scheduler.enumerate(&table);
for (table[0..@min(total, table.len)]) |d| {
// Task names are full binary paths; callers pass either form.
if (std.mem.eql(u8, initial_ramdisk.basename(d.name[0..d.name_length]), name)) return true;
}
return false;
}
/// Whether pci-bus registered at least one function under the PCI host bridge — proof
/// it came up, claimed the bridge, mapped its ECAM, and walked configuration space.
fn pciFunctionsRegistered() bool {
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
var bridge_id: ?u64 = null;
for (buffer[0..n]) |d| {
if (d.class == @intFromEnum(device_abi.DeviceClass.pci_host_bridge)) bridge_id = d.id;
}
const bid = bridge_id orelse return false;
for (buffer[0..n]) |d| {
if (d.parent == bid) return true;
}
return false;
}
/// Device id of the kernel-seeded HPET timer block (the node with a memory resource),
/// from the same device table drivers see. Used as a containment-test parent.
fn hpetDeviceId() ?u64 {
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
for (buffer[0..n]) |d| {
if (d.class != @intFromEnum(device_abi.DeviceClass.timer)) continue;
if (d.parent != device_abi.no_parent) continue; // a comparator child, not the block
for (0..d.resource_count) |j| {
if (d.resources[j].kind == @intFromEnum(device_abi.ResourceKind.memory)) return d.id;
}
}
return null;
}
/// IRQ teardown. When a driver exits, its bindings must be released: the line masked
/// (so a dead driver's device goes quiet instead of storming) and the slot cleared
/// (so an ISR never posts a notification into the endpoint that is about to be freed).
///
/// A long-running driver that never exits wouldn't reach this teardown path, so it
/// gets its own test that binds and releases directly. Two properties, both read back
/// from the hardware rather than from our own state:
///
/// 1. A bound GSI is routed and unmasked.
/// 2. After `releaseOwner` for the binding's owner, that same entry is masked again.
///
/// And one property that can only be checked from kernel state: a *different* owner's
/// binding on the same endpoint survives. Endpoints are shared (a capability passed in a
/// message hands out extra references), so teardown keyed on the endpoint pointer rather
/// than the owning task would mask a live sibling driver's device line.
fn irqFreeTest() void {
log("DANOS-TEST-BEGIN: irqfree\n", .{});
const gsi = hpetGsi() orelse {
check("discovery recorded an IRQ resource for the HPET", false);
result();
return;
};
const endpoint = ipcsync.createIpcEndpoint() orelse {
check("allocated an endpoint", false);
result();
return;
};
// Two owners, one shared endpoint. `other` binds a second line if the I/O APIC has
// one spare; if not, the sharing half of the test is skipped rather than faked.
const owner: u32 = 4242;
const other: u32 = 4343;
const spare: ?u32 = if (gsi + 1 < irq.maximum_gsi and architecture.irqOwnsGsi(gsi + 1)) gsi + 1 else null;
{
const flags = sync.enter();
defer sync.leave(flags);
irq.bind(gsi, endpoint, owner) catch {};
if (spare) |s| irq.bind(s, endpoint, other) catch {};
}
check("bound GSI is routed and unmasked", !entryMasked(gsi));
if (spare) |s| check("second owner's GSI is routed and unmasked", !entryMasked(s));
{
const flags = sync.enter();
defer sync.leave(flags);
irq.releaseOwner(owner);
}
check("exiting owner's line is masked again", entryMasked(gsi));
if (spare) |s| {
check("a sibling owner's binding on the same endpoint survives", !entryMasked(s));
const flags = sync.enter();
defer sync.leave(flags);
irq.releaseOwner(other);
}
result();
}
/// Is redirection entry `gsi` masked? (bit 16 of the low dword; entry index == GSI
/// because this I/O APIC's gsi_base is 0.)
fn entryMasked(gsi: u32) bool {
return architecture.irqRouteRaw(gsi) & (1 << 16) != 0;
}
/// The MMIO-grant teardown fix: a device-granted leaf must NOT be returned to the
/// RAM allocator when its address space is destroyed. Map a real RAM frame as a
/// device grant, tear the address space down, and confirm the frame is still held
/// (only the page tables came back) — then free it explicitly. A regression guard
/// for the freeSubtree device_grant skip that keeps IO passthrough from corrupting
/// the frame pool.
fn ioPassTest() void {
log("DANOS-TEST-BEGIN: iopass\n", .{});
const base_free = pmm.stats().free_frames;
const address_space = architecture.createAddressSpace() orelse {
check("created a fresh address space", false);
result();
return;
};
const frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space);
check("allocated a frame to grant", false);
result();
return;
};
// Map it the way mmio_map does (device grant, strong-uncacheable), then tear the space down.
architecture.mapUserDeviceInto(address_space, process.device_arena_base, frame, abi.page_size, false);
architecture.destroyAddressSpace(address_space);
// The page tables were reclaimed; the device-granted frame must not have been.
check("device-granted frame survived teardown (not reclaimed as RAM)", pmm.stats().free_frames == base_free - 1);
pmm.free(frame);
check("no leak once the frame is explicitly freed", pmm.stats().free_frames == base_free);
result();
}
/// D1 — the framebuffer handoff primitive. The kernel seeds the loader's framebuffer as
/// a claimable `display` device with a write-combining `memory` resource; a display
/// service reaches it over the ordinary claim + mmio_map path. Prove the whole chain:
/// the node is present and correctly shaped, it maps, and — the point of D1 — the
/// mapping is genuinely write-combining, not the strong-uncacheable default that would
/// make a framebuffer blit glacial.
fn displayTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: display\n", .{});
const fb = boot_information.framebuffer;
if (!fb.present()) {
// Headless: nothing to seed. Not a failure of the mechanism, so pass cleanly.
log("display: no framebuffer (headless); skipping\n", .{});
result();
return;
}
// The kernel seeded a display device in kmain, right after devices_broker.init.
const display_id = devices_broker.displayDevice() orelse {
check("a framebuffer display device was seeded", false);
result();
return;
};
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
check("the seeded display id is enumerable", display_id < n);
if (display_id >= n) {
result();
return;
}
const d = buffer[@intCast(display_id)];
check("the node is class display", d.class == @intFromEnum(device_abi.DeviceClass.display));
check("it carries the framebuffer geometry", d.display.width == fb.width and d.display.height == fb.height and d.display.pitch == fb.pitch);
check("it carries the panel refresh rate", d.display.refresh_hz == fb.refresh_hz);
check("it has exactly one resource", d.resource_count == 1);
const r = d.resources[0];
check("that resource is a memory window", r.kind == @intFromEnum(device_abi.ResourceKind.memory));
check("it spans the whole framebuffer", r.start == fb.base and r.len == @as(u64, fb.height) * fb.pitch);
check("it is flagged write-combining", (r.flags & device_abi.resource_flag_write_combining) != 0);
// Walk the real claim + map path a display service would, into a throwaway address
// space, and confirm the leaf's cache type. We never run this space (no CR3 load) —
// we only read back the page-table entries — so aliasing the same physical page at
// two cache types below is inert.
const address_space = architecture.createAddressSpace() orelse {
check("created a fresh address space", false);
result();
return;
};
defer architecture.destroyAddressSpace(address_space);
const page_base = fb.base & ~@as(u64, abi.page_size - 1);
architecture.mapUserDeviceInto(address_space, process.device_arena_base, page_base, abi.page_size, true);
check(
"the framebuffer maps write-combining (PAT entry 4: PAT bit set, PCD/PWT clear)",
architecture.userLeafIsWriteCombining(address_space, process.device_arena_base) == true,
);
// Regression guard: the strong-uncacheable default is still that, so WC is a real
// choice the flag makes, not the only behaviour.
architecture.mapUserDeviceInto(address_space, process.device_arena_base + abi.page_size, page_base, abi.page_size, false);
check(
"a register window still maps strong-uncacheable",
architecture.userLeafIsWriteCombining(address_space, process.device_arena_base + abi.page_size) == false,
);
log("display: mapped {d}x{d} pitch {d} (write-combining)\n", .{ fb.width, fb.height, fb.pitch });
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 SMEP: a ring-0 instruction fetch from a *user*-mapped page must fault.
///
/// The ret2usr shape, staged deliberately — a user code page (present, executable,
/// U/S set) mapped into the address space the kernel itself is running on, then
/// called from ring 0. CR4.SMEP makes the fetch a #PF; without it the `ret` simply
/// returns and the FAIL line below is reached.
///
/// The CPU reports it as: present (bit 0) + instruction fetch (bit 4) = error code
/// 0x11, taken in ring 0, at the user address it tried to fetch from — a #PF on an
/// instruction fetch is raised *at* the instruction that could not be fetched, so
/// the reported IP is the probe page, not the kernel-half `call` that jumped there.
/// The enabled-check up front stops the case passing vacuously on a CPU that has no
/// SMEP (where nothing would fault and nothing would be proved).
fn faultSmep() void {
log("DANOS-TEST-BEGIN: fault-smep\n", .{});
if (!architecture.supervisorExecutePreventionEnabled()) {
log("DANOS-TEST-RESULT: FAIL (SMEP not enabled in this boot)\n", .{});
return;
}
const frame = pmm.alloc() orelse {
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
return;
};
// Fill through the physmap — the user mapping is read-only. A lone `ret` so an
// unenforced fetch lands harmlessly back here (and reports FAIL), int3 padding
// so a stray slide traps instead of running into whatever the frame held.
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(code[0..abi.page_size], 0xCC);
code[0] = 0xC3; // ret
// RO + X *and user-accessible*, in the kernel's own tables: this task is a kernel
// task, so it runs on them (a process would have its own address space).
architecture.mapUserPage(process.code_virtual, frame, false, true);
// Launder the address through empty asm so the backend really forms an indirect
// call rather than folding anything about a known constant target.
var target: u64 = process.code_virtual;
target = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (target),
);
const f: *const fn () void = @ptrFromInt(target);
f(); // ring-0 instruction fetch from a user page -> #PF
log("DANOS-TEST-RESULT: FAIL (SMEP not enforced)\n", .{});
}
/// Verify SMAP: a ring-0 data read from a *user*-mapped page must fault.
///
/// The bug shape this case stands in for is the ordinary one — a syscall that takes
/// the pointer ring 3 handed it and dereferences it. So the probe does exactly that
/// and nothing more: an ordinary user data page (present, user-accessible, read-only,
/// no-execute) mapped into the address space this kernel task is already running on,
/// then read directly rather than through system/kernel/user-memory.zig. With SMAP on
/// and EFLAGS.AC clear the load is a #PF; without it the byte comes back and the FAIL
/// line below reports the value it should never have seen.
///
/// The CPU reports it as error code **0x1**: present (bit 0) alone. A SMAP violation
/// has no error-code bit of its own — bit 1 stays clear because this is a read, and
/// bit 2 (U/S) describes the *access*, which was made in ring 0, not the page. So the
/// report is indistinguishable in its bits from any other supervisor read of a present
/// page; what identifies it is the pairing with `ring 0 (kernel)` and a user CR2.
///
/// The seed write goes through the physmap, which is the point in miniature: that is
/// the route the checked copy layer takes for every legitimate access to this same
/// frame, and SMAP has no objection to it. Only the second access — the one through
/// the *user* virtual address — is refused.
///
/// The enabled-check up front stops the case passing vacuously on a CPU without SMAP,
/// and fails rather than skips: a suite that quietly stops testing the tripwire is
/// exactly the outcome the tripwire exists to prevent.
fn faultSmap() void {
log("DANOS-TEST-BEGIN: fault-smap\n", .{});
if (!architecture.supervisorAccessPreventionEnabled()) {
log("DANOS-TEST-RESULT: FAIL (SMAP not enabled in this boot)\n", .{});
return;
}
const frame = pmm.alloc() orelse {
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
return;
};
const seed: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(seed[0..abi.page_size], 0);
seed[0] = 0x5A; // a sentinel, so an unenforced read is reported as a value, not a guess
// RO + NX *and user-accessible*, in the kernel's own tables: this task is a kernel
// task, so it runs on them (a process would have its own address space).
architecture.mapUserPage(process.code_virtual, frame, false, false);
// Launder the address through empty asm for the same reason `fault-null` does:
// the backend must form a real load from a runtime address rather than reasoning
// about a constant it can see the provenance of.
var target: u64 = process.code_virtual;
target = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (target),
);
const p: *const volatile u8 = @ptrFromInt(target);
const value = p.*; // ring-0 data read from a user page -> #PF
log("DANOS-TEST-RESULT: FAIL (SMAP not enforced; read 0x{x})\n", .{value});
}
/// 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 address: u64 = 0;
address = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (address),
);
const p: *allowzero volatile u64 = @ptrFromInt(address);
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 address: u64 = 0xdeadbeef000; // well above all mapped RAM
const p: *volatile u64 = @ptrFromInt(address);
p.* = 1;
address += 0;
}
fn faultDoubleFault() void {
log("DANOS-TEST-BEGIN: fault-df\n", .{});
architecture.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;
}
var ap_reached_fault: bool = false;
/// A task that faults with a #DF *on whatever core it's pinned to*. Announces the
/// core, then triggers the same double fault as `faultDoubleFault` — which is only
/// survivable on IST1, so it exercises that core's own TSS.
fn apDoubleFaultTask() void {
log("DANOS-AP: task running on core {d}, triggering #DF\n", .{scheduler.currentCpuIndex()});
@atomicStore(bool, &ap_reached_fault, true, .release);
architecture.disableInterrupts();
var bad_sp: u64 = 0x5000000000;
asm volatile (
\\mov %[sp], %%rsp
\\ud2
:
: [sp] "r" (bad_sp),
: .{ .memory = true });
bad_sp += 0;
}
/// Fault on an application processor. Pins a double-faulting task to core 1, so the
/// fault is taken and handled by *that core's own* IDT and TSS/IST — not the BSP's.
/// The harness matches "core N: double fault (vector 8)" with N ≥ 1, which can only
/// appear if the AP caught the #DF on its IST1 (a broken per-core TSS would
/// triple-fault and reset instead). We then show the BSP still runs afterwards, so
/// the fault was *contained* to the AP, not fatal to the system.
fn faultApTest() void {
log("DANOS-TEST-BEGIN: fault-ap-df\n", .{});
if (!scheduler.spawnOn(apDoubleFaultTask, 6, 1)) {
log("DANOS-AP: could not pin to core 1 (run with -smp) - FAIL\n", .{});
architecture.halt();
}
// Wait until the AP is about to fault, then keep running to prove containment.
var spins: u64 = 0;
while (!@atomicLoad(bool, &ap_reached_fault, .acquire) and spins < 5_000_000_000) spins +%= 1;
var settle: u64 = 0;
while (settle < 500_000_000) settle +%= 1; // let the AP take + report the fault
log("DANOS-BSP: core {d} still running after the AP fault (contained)\n", .{scheduler.currentCpuIndex()});
architecture.halt();
}