2249 lines
97 KiB
Zig
2249 lines
97 KiB
Zig
//! 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 devices_broker = @import("devices-broker.zig");
|
|
const platform = @import("platform");
|
|
const pmm = @import("pmm.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");
|
|
|
|
/// Formatted write straight to serial, independent of the framebuffer console.
|
|
fn log(comptime fmt: []const u8, args: anytype) void {
|
|
var buffer: [128]u8 = undefined;
|
|
architecture.serialWrite(std.fmt.bufPrint(&buffer, fmt, args) catch return);
|
|
}
|
|
|
|
var passed: u32 = 0;
|
|
var failed: u32 = 0;
|
|
|
|
fn check(name: []const u8, ok: bool) void {
|
|
if (ok) {
|
|
passed += 1;
|
|
log("[PASS] {s}\n", .{name});
|
|
} else {
|
|
failed += 1;
|
|
log("[FAIL] {s}\n", .{name});
|
|
}
|
|
}
|
|
|
|
/// Emit the overall result line the harness matches, then the done sentinel.
|
|
fn result() void {
|
|
log("DANOS-TEST-RESULT: {s} ({d} passed, {d} failed)\n", .{
|
|
if (failed == 0) "PASS" else "FAIL",
|
|
passed,
|
|
failed,
|
|
});
|
|
log("DANOS-TEST-DONE\n", .{});
|
|
}
|
|
|
|
pub fn run(case: []const u8, boot_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, "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, "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, "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, "usermem")) {
|
|
userMemTest();
|
|
} else if (eql(case, "user-pf")) {
|
|
userPfTest();
|
|
} else if (eql(case, "fault-recovery")) {
|
|
faultRecoveryTest(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, "initial-ramdisk")) {
|
|
initialRamdiskTest(boot_information);
|
|
} else if (eql(case, "vfs")) {
|
|
vfsTest(boot_information);
|
|
} else if (eql(case, "input")) {
|
|
inputTest(boot_information);
|
|
} else if (eql(case, "hpet")) {
|
|
hpetTest(boot_information);
|
|
} else if (eql(case, "iopass")) {
|
|
ioPassTest();
|
|
} else if (eql(case, "irqfree")) {
|
|
irqFreeTest();
|
|
} else if (eql(case, "bus")) {
|
|
busTest(boot_information);
|
|
} else if (eql(case, "device-manager")) {
|
|
deviceManagerTest(boot_information);
|
|
} else if (eql(case, "poweroff")) {
|
|
powerTest(.off);
|
|
} else if (eql(case, "reboot")) {
|
|
powerTest(.reboot);
|
|
} else {
|
|
log("DANOS-TEST-RESULT: FAIL (unknown case '{s}')\n", .{case});
|
|
}
|
|
}
|
|
|
|
fn platformHal() platform.Hal {
|
|
return .{
|
|
.mapMmio = 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.
|
|
fn powerTest(comptime action: enum { off, reboot }) void {
|
|
const name = if (action == .off) "poweroff" else "reboot";
|
|
log("DANOS-TEST-BEGIN: {s}\n", .{name});
|
|
const hal = platformHal();
|
|
log("DANOS-POWER: attempting {s}\n", .{name});
|
|
switch (action) {
|
|
.off => platform.shutdown(hal),
|
|
.reboot => platform.reboot(hal),
|
|
}
|
|
check("power transition took effect", false);
|
|
result();
|
|
}
|
|
|
|
const BootInformation = boot_handoff.BootInformation;
|
|
|
|
fn eql(a: []const u8, b: []const u8) bool {
|
|
return std.mem.eql(u8, a, b);
|
|
}
|
|
|
|
/// Non-destructive checks of the memory map and frame allocator.
|
|
fn smoke(boot_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 ACPI parsing 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), FADT (PM/reset registers), and the AML parse
|
|
/// (the sleep type, plus the integrity check that every byte was consumed).
|
|
fn discoveryTest() void {
|
|
log("DANOS-TEST-BEGIN: discovery\n", .{});
|
|
const pinfo = platform.platformInformation();
|
|
const pw = platform.powerInformation();
|
|
const am = platform.amlStats();
|
|
|
|
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("S5 sleep type found (AML)", pw.s5 != null);
|
|
check("AML parsed completely (consumed == total)", am.total > 0 and am.consumed == am.total);
|
|
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).
|
|
var buffer: [64]device_abi.DeviceDescriptor = undefined;
|
|
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
|
|
var pci_functions: u32 = 0;
|
|
var pci_config_ok = true;
|
|
for (buffer[0..n]) |d| {
|
|
if (d.class != @intFromEnum(device_abi.DeviceClass.pci_device)) continue;
|
|
pci_functions += 1;
|
|
const has_config = d.resource_count >= 1 and
|
|
d.resources[0].kind == @intFromEnum(device_abi.ResourceKind.memory) and
|
|
d.resources[0].len == abi.page_size;
|
|
if (!has_config) pci_config_ok = false;
|
|
}
|
|
check("PCI functions were enumerated (MCFG/ECAM)", pci_functions >= 1);
|
|
check("each PCI function exposes its ECAM config space as resource 0", pci_config_ok);
|
|
|
|
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 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;
|
|
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;
|
|
}
|
|
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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
var spins: u64 = 0;
|
|
while (spins < 3_000_000_000) spins +%= 1; // many time slices across the cores
|
|
affinity_running = false;
|
|
var settle: u64 = 0;
|
|
while (settle < 200_000_000) settle +%= 1; // 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 aspace = architecture.createAddressSpace() orelse {
|
|
check("created a fresh address space", false);
|
|
result();
|
|
return;
|
|
};
|
|
check("created a fresh address space", aspace != 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(aspace, 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(aspace, 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(aspace, va)) |physical| {
|
|
architecture.unmapUserPageInto(aspace, va);
|
|
pmm.free(physical);
|
|
}
|
|
}
|
|
check("munmap unmapped every grant", architecture.translate(aspace, arena) == null);
|
|
architecture.destroyAddressSpace(aspace);
|
|
|
|
check("no frames leaked (free count restored)", pmm.stats().free_frames == base_free);
|
|
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;
|
|
|
|
// 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 aspace = architecture.createAddressSpace().?;
|
|
architecture.mapUserDmaInto(aspace, 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(aspace, 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(aspace);
|
|
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. This is *detection*, the honest first step —
|
|
/// no translation domains are programmed yet, so DMA is still unprotected; enforcement
|
|
/// lands with the first DMA driver (docs/driver-model.md M16).
|
|
fn iommuTest() void {
|
|
log("DANOS-TEST-BEGIN: iommu\n", .{});
|
|
const pinfo = platform.platformInformation();
|
|
check("IOMMU found in the DMAR table", pinfo.iommu_present);
|
|
check("VT-d unit has a register base", pinfo.iommu_base != 0);
|
|
check("VT-d version register reads back nonzero (real, mappable unit)", pinfo.iommu_version != 0);
|
|
log("DANOS-IOMMU: base=0x{x} version=0x{x} capabilities=0x{x}\n", .{ pinfo.iommu_base, pinfo.iommu_version, pinfo.iommu_capabilities });
|
|
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);
|
|
|
|
var found_id: ?u64 = null;
|
|
var found_res: u64 = 0;
|
|
outer: for (buffer[0..n]) |d| {
|
|
for (0..d.resource_count) |ri| {
|
|
const r = d.resources[ri];
|
|
if (r.kind == @intFromEnum(device_abi.ResourceKind.io_port) and r.start == 0x64 and r.len >= 1) {
|
|
found_id = d.id;
|
|
found_res = ri;
|
|
break :outer;
|
|
}
|
|
}
|
|
}
|
|
const id = found_id orelse {
|
|
check("discovered the PS/2 status port (io_port 0x64)", false);
|
|
result();
|
|
return;
|
|
};
|
|
check("discovered the PS/2 status port (io_port 0x64)", true);
|
|
|
|
const me = scheduler.current();
|
|
check("claimed the io_port device", devices_broker.claim(id, me.id));
|
|
check("an in-range access resolves to port 0x64", process.resolveIoPort(me, id, found_res, 0, 1) == 0x64);
|
|
check("an over-wide access is refused", process.resolveIoPort(me, id, found_res, 0, 2) == null);
|
|
check("an out-of-range offset is refused", process.resolveIoPort(me, id, found_res, 1, 1) == null);
|
|
check("an unclaimed device id is refused", process.resolveIoPort(me, 0xDEAD_BEEF, found_res, 0, 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();
|
|
}
|
|
|
|
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", .{});
|
|
check("bootloader handed over /system/services/init", boot_information.init_len != 0);
|
|
if (boot_information.init_len == 0) {
|
|
result();
|
|
return;
|
|
}
|
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len];
|
|
|
|
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 aspace = architecture.createAddressSpace() orelse return null;
|
|
const code_frame = pmm.alloc() orelse {
|
|
architecture.destroyAddressSpace(aspace);
|
|
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(aspace, process.code_virtual, code_frame, false, true); // RO + X
|
|
|
|
const stack_frame = pmm.alloc() orelse {
|
|
architecture.destroyAddressSpace(aspace); // frees code_frame too — it's mapped
|
|
return null;
|
|
};
|
|
architecture.mapUserPageInto(aspace, 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(aspace, process.code_virtual, process.stack_base_virtual + abi.page_size, 4, "fault-probe", scheduler.currentId(), null) orelse {
|
|
architecture.destroyAddressSpace(aspace);
|
|
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", .{});
|
|
check("bootloader handed over /system/services/init", boot_information.init_len != 0);
|
|
if (boot_information.init_len == 0) {
|
|
result();
|
|
return;
|
|
}
|
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len];
|
|
|
|
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();
|
|
}
|
|
|
|
/// 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", .{});
|
|
check("bootloader handed over /system/services/init", boot_information.init_len != 0);
|
|
if (boot_information.init_len == 0) {
|
|
result();
|
|
return;
|
|
}
|
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len];
|
|
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) for at least two heartbeats — proving it runs, writes,
|
|
// and sleeps repeatedly (init sleeps ~1 s between beats).
|
|
scheduler.setPriority(1);
|
|
const deadline = architecture.millis() + 8000;
|
|
while (process.write_count < 2 and architecture.millis() < deadline) scheduler.yield();
|
|
scheduler.setPriority(4);
|
|
|
|
const prefix = "init: heartbeat";
|
|
const beat_ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix);
|
|
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", .{});
|
|
check("bootloader handed over /system/services/init", boot_information.init_len != 0);
|
|
if (boot_information.init_len == 0) {
|
|
result();
|
|
return;
|
|
}
|
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len];
|
|
|
|
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", .{});
|
|
check("bootloader handed over /system/services/init", boot_information.init_len != 0);
|
|
if (boot_information.init_len == 0 or boot_information.initial_ramdisk_len == 0) {
|
|
check("bootloader handed over an initial_ramdisk", boot_information.initial_ramdisk_len != 0);
|
|
result();
|
|
return;
|
|
}
|
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len];
|
|
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(item.name, "process-test")) continue;
|
|
spinner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "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(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);
|
|
result();
|
|
}
|
|
|
|
/// 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", devices_broker.claim(0, 111));
|
|
check("device 1 claimed by owner 222", devices_broker.claim(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.
|
|
check("bootloader handed over /system/services/init", boot_information.init_len != 0);
|
|
if (boot_information.init_len == 0) {
|
|
result();
|
|
return;
|
|
}
|
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.init_base)))[0..boot_information.init_len];
|
|
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", devices_broker.claim(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", devices_broker.claim(0, me));
|
|
devices_broker.releaseAllOwnedBy(me);
|
|
result();
|
|
}
|
|
|
|
/// M17.3: the published exit events, proven by their first subscriber. The VFS
|
|
/// subscribes at startup; a client opens a file and parks holding the handle;
|
|
/// the kill posts the exit event to the VFS's endpoint; the VFS 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 after themselves).
|
|
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;
|
|
check("vfs spawned", spawnNamed(rd, "vfs"));
|
|
|
|
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(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.
|
|
const parked = "vfstest: parked";
|
|
scheduler.setPriority(1);
|
|
var deadline = architecture.millis() + 10000;
|
|
while (architecture.millis() < deadline) {
|
|
if (process.write_len >= parked.len and eql(process.write_buffer[0..parked.len], parked)) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
check("client parked holding an open handle", process.write_len >= parked.len and eql(process.write_buffer[0..parked.len], 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 VFS heard the same published event; its release line is the proof.
|
|
const released = "vfs: released 1 handle(s) for dead client";
|
|
scheduler.setPriority(1);
|
|
deadline = architecture.millis() + 10000;
|
|
while (architecture.millis() < deadline) {
|
|
if (process.write_len >= released.len and eql(process.write_buffer[0..released.len], released)) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
check("the VFS released the dead client's handle", process.write_len >= released.len and eql(process.write_buffer[0..released.len], released));
|
|
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(item.name, "process-test")) continue;
|
|
started = if (process.spawnProcess(item.blob, 4, &.{ "process-test", "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 (process.write_len >= marker.len and eql(process.write_buffer[0..marker.len], marker)) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
|
|
const ok = process.write_len >= marker.len and eql(process.write_buffer[0..marker.len], 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 spawned: u32 = 0;
|
|
var i: u32 = 0;
|
|
while (i < rd.count) : (i += 1) {
|
|
const item = rd.entry(i) orelse continue;
|
|
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 binary spawned", spawned == rd.count);
|
|
|
|
// 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;
|
|
// Spawn just the server and its client (other initial_ramdisk binaries would write to
|
|
// the shared evidence buffer and confuse the marker check).
|
|
_ = spawnNamed(rd, "vfs");
|
|
_ = 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 (process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix) and process.write_count >= 2) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
|
|
const ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], 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;
|
|
_ = 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 (process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix) and process.write_count >= 2) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
|
|
const ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], 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();
|
|
}
|
|
|
|
/// 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: 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.
|
|
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(item.name, name)) {
|
|
return if (process.spawnProcess(item.blob, 4, &.{item.name})) true else |_| false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// IO passthrough + IRQ-as-IPC: a user-space driver drives real hardware and is
|
|
/// *woken by it*. Spawn hpet, which claims the HPET, maps its registers into its
|
|
/// own ring-3 address space, arms a level-triggered comparator, binds the interrupt
|
|
/// to an IPC endpoint, and then blocks. It prints "hpet: ok" only after being woken
|
|
/// `target_ticks` times — it cannot reach that line by polling, because the loop's
|
|
/// only exit is through `replyWait` returning a notification badge.
|
|
///
|
|
/// The interesting assertion is the last one, which doesn't trust hpet at all: it
|
|
/// reads the I/O APIC's redirection entry back and checks the kernel really routed
|
|
/// the line (our vector, level-triggered) and really left it unmasked after the
|
|
/// driver's final `irq_ack`. hpet disables its comparator on the last interrupt, so
|
|
/// that state is quiescent and not a race.
|
|
fn hpetTest(boot_information: *const BootInformation) void {
|
|
log("DANOS-TEST-BEGIN: hpet\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;
|
|
check("hpet spawned from the initial_ramdisk", spawnNamed(rd, "hpet"));
|
|
|
|
const prefix = "hpet: ok";
|
|
scheduler.setPriority(1);
|
|
const deadline = architecture.millis() + 10000;
|
|
while (architecture.millis() < deadline) {
|
|
if (process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix) and process.write_count >= 2) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
|
|
const ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix);
|
|
check("user driver mapped HPET MMIO and was woken by its interrupt", ok);
|
|
check("driver syscalls came from user mode (CPL 3)", process.write_from_user);
|
|
check("kernel routed and re-armed the HPET's line at the I/O APIC", hpetRouteOk());
|
|
result();
|
|
}
|
|
|
|
/// Read back the I/O APIC redirection entry for the HPET's GSI and confirm the
|
|
/// kernel programmed it: a vector in the device window, level-triggered, unmasked.
|
|
/// Independent of anything the driver reported about itself.
|
|
fn hpetRouteOk() bool {
|
|
const gsi = hpetGsi() orelse return false;
|
|
if (gsi >= architecture.irqRouteCount()) return false;
|
|
const low = architecture.irqRouteRaw(gsi); // entry index == GSI (this I/O APIC's gsi_base is 0)
|
|
const vector: u8 = @truncate(low & 0xFF);
|
|
const masked = low & (1 << 16) != 0;
|
|
const level = low & (1 << 15) != 0;
|
|
return vector >= architecture.irq_vector_base and
|
|
vector < architecture.irq_vector_base + architecture.irq_vector_count and
|
|
level and !masked;
|
|
}
|
|
|
|
/// The GSI discovery recorded for the HPET, from the same device table the driver saw.
|
|
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;
|
|
}
|
|
|
|
/// Bus driver: a user process claims a device that contains other devices, enumerates
|
|
/// them from the hardware, and publishes each as a child via `device_register` — the
|
|
/// primitive a PCI bridge or USB hub driver is built from.
|
|
///
|
|
/// `bus` treats the HPET's register block as a bus and its comparators as children,
|
|
/// giving each a 0x20 sub-window. It checks its own work (children come back from the
|
|
/// table with the right parent and a strictly narrower window) and, importantly, that
|
|
/// the kernel **refuses** a child whose window escapes the parent's — without that,
|
|
/// `device_register` would be a system_call for mapping arbitrary physical memory. It prints
|
|
/// "bus: ok" only if all of that holds.
|
|
///
|
|
/// The kernel-side check here is the one bus can't make: that the children really did
|
|
/// land in the device table with the containment invariant intact.
|
|
fn busTest(boot_information: *const BootInformation) void {
|
|
log("DANOS-TEST-BEGIN: bus\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;
|
|
check("bus spawned from the initial_ramdisk", spawnNamed(rd, "bus"));
|
|
|
|
const prefix = "bus: ok";
|
|
scheduler.setPriority(1);
|
|
const deadline = architecture.millis() + 10000;
|
|
while (architecture.millis() < deadline) {
|
|
if (process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix)) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
|
|
const ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix);
|
|
check("bus driver published children and the kernel refused an out-of-window one", ok);
|
|
check("driver syscalls came from user mode (CPL 3)", process.write_from_user);
|
|
check("every registered child is contained in its parent", childrenContained());
|
|
result();
|
|
}
|
|
|
|
/// The device manager (a ring-3 service) enumerates /system/devices, matches each
|
|
/// device to a driver, and — eventually — spawns it. This increment only checks the
|
|
/// discovery+matching half: it must find the HPET (a timer) and decide `hpet` serves
|
|
/// it, printing "device-manager: ok". It uses no special privilege — the same
|
|
/// `device_enumerate` any process could call. (Spawning is the next increment.)
|
|
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 `hpet` runs at all,
|
|
// it's because the manager discovered the timer, matched, and spawned it.
|
|
process.setInitialRamdisk(image);
|
|
|
|
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: the driver the manager spawned reaches its own live marker.
|
|
// `hpet: ok` is hpet's final, stable message (it claims the timer, maps its MMIO,
|
|
// binds its IRQ, services one, then sleeps) — nothing overwrites the buffer after,
|
|
// so it's race-free to poll for. Its arrival means the whole
|
|
// discover -> match -> system_spawn -> driver-up chain worked.
|
|
const prefix = "hpet: ok";
|
|
scheduler.setPriority(1);
|
|
const deadline = architecture.millis() + 10000;
|
|
while (architecture.millis() < deadline) {
|
|
if (process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix)) break;
|
|
scheduler.yield();
|
|
}
|
|
scheduler.setPriority(4);
|
|
|
|
const ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix);
|
|
check("device manager matched the timer and system_spawn'd hpet, which came up", ok);
|
|
check("its syscalls came from user mode (CPL 3)", process.write_from_user);
|
|
result();
|
|
}
|
|
|
|
/// Every child `bus` registered must have each of its resources inside a parent
|
|
/// resource of the same kind — the invariant `device_register` exists to maintain,
|
|
/// checked from the kernel's own table rather than the driver's word for it.
|
|
///
|
|
/// Only *registered* children are checked, not the whole tree. Firmware topology is
|
|
/// trusted and doesn't obey containment: a PCI function's BAR is not inside its host
|
|
/// bridge's `bus_range`, because a bus-number range isn't an address window.
|
|
fn childrenContained() bool {
|
|
var buffer: [64]device_abi.DeviceDescriptor = undefined;
|
|
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
|
|
|
|
const bus_id = hpetDeviceId() orelse return false;
|
|
const p = buffer[@intCast(bus_id)];
|
|
|
|
var children: usize = 0;
|
|
for (buffer[0..n]) |d| {
|
|
if (d.parent != bus_id) continue;
|
|
children += 1;
|
|
for (0..d.resource_count) |i| {
|
|
const r = d.resources[i];
|
|
var ok = false;
|
|
for (0..p.resource_count) |j| {
|
|
const pr = p.resources[j];
|
|
if (pr.kind != r.kind) continue;
|
|
if (r.kind == @intFromEnum(device_abi.ResourceKind.irq)) {
|
|
if (pr.start == r.start) ok = true;
|
|
} else if (r.len != 0 and r.start >= pr.start and
|
|
r.start + r.len <= pr.start + pr.len) ok = true;
|
|
}
|
|
if (!ok) return false;
|
|
}
|
|
}
|
|
return children > 0; // bus must have published at least one
|
|
}
|
|
|
|
/// Device id of the HPET (the bus bus claims), from the same table drivers see.
|
|
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).
|
|
///
|
|
/// This is the path `hpet` never takes — it runs forever — so it gets its own test.
|
|
/// 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 (ipc_register hands out
|
|
/// 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 aspace = architecture.createAddressSpace() orelse {
|
|
check("created a fresh address space", false);
|
|
result();
|
|
return;
|
|
};
|
|
const frame = pmm.alloc() orelse {
|
|
architecture.destroyAddressSpace(aspace);
|
|
check("allocated a frame to grant", false);
|
|
result();
|
|
return;
|
|
};
|
|
// Map it the way mmio_map does (device grant), then tear the space down.
|
|
architecture.mapUserDeviceInto(aspace, process.device_arena_base, frame, abi.page_size);
|
|
architecture.destroyAddressSpace(aspace);
|
|
|
|
// 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();
|
|
}
|
|
|
|
fn faultInvalidOpcode() void {
|
|
log("DANOS-TEST-BEGIN: fault-ud\n", .{});
|
|
asm volatile ("ud2");
|
|
}
|
|
|
|
/// Verify NX: fetching an instruction from a data page (mapped no-execute) faults.
|
|
fn faultNoExecute() void {
|
|
log("DANOS-TEST-BEGIN: fault-nx\n", .{});
|
|
var scratch: u64 = 0xC3; // a lone `ret` — harmless if NX somehow let it run
|
|
const f: *const fn () void = @ptrFromInt(@intFromPtr(&scratch));
|
|
f(); // instruction fetch from an NX page -> #PF before it executes
|
|
log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{});
|
|
}
|
|
|
|
/// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults.
|
|
fn faultNull() void {
|
|
log("DANOS-TEST-BEGIN: fault-null\n", .{});
|
|
// Launder the address through empty asm so the compiler no longer knows it's
|
|
// 0 (otherwise it folds a null-pointer safety panic instead of doing the real
|
|
// access). `allowzero` skips the same null check on the cast. The write then
|
|
// hits the unmapped page 0 and takes a real hardware #PF.
|
|
var 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();
|
|
}
|