Add runtime.time, drop demo drivers, harden TSC timekeeping

Time is a kernel concern in danos: the kernel owns the scheduling timer and
already exposes monotonic time via the clock/sleep/timer_bind syscalls, so a
userspace time service would be a redundant, slower path. This adds the generic
runtime.time module over those syscalls, retires the two demonstration drivers,
reorganizes the milestone docs, and makes the monotonic clock correct on Intel,
AMD, and inside any VM.

runtime.time (library/runtime/time.zig)
- Instant/Duration interface: now, sleep, spin, after, monotonicNanos, available
- a thin layer over system.clock/sleep/timerOnce; unit-tested arithmetic

Remove the demo drivers hpet and bus (a teaching example belongs in the docs,
not shipped in the tree)
- system/drivers/ now holds only real drivers: pci-bus, ps2-bus, usb-xhci-bus
- device-manager end-to-end test repointed to pci-bus (asserts on kernel state:
  the process table and the device tree, not a racy serial marker)
- device_register containment moved to a new in-kernel `containment` test
- the driver-model worked example moved inline into docs/drivers.md

Reorganize milestone docs into topic docs
- m17-m18 / m19-m20 / m21 plans dissolved into process-lifecycle, device-manager,
  discovery, and acpi docs; new docs/power.md and docs/timers.md; ~20 citations
  repointed; plan docs deleted

TSC reliability (apic.zig, smp.zig, cpu.zig, kernel.zig)
- check the invariant-TSC bit (CPUID 0x80000007 EDX[8]) on Intel and AMD
- cross-core "warp" check at SMP bring-up, pairwise BSP<->AP as each core comes up
- fall back to the HPET clocksource when the TSC is not invariant (a bare VM) or
  not synchronized (a warp), switched continuously so time never jumps
- boot log reports the outcome; new tsc-sync test exercises the TSC + warp path

Verified: zig build; zig build test; 60/60 QEMU cases (incl. new containment and
tsc-sync).
This commit is contained in:
Daniel Samson
2026-07-13 11:56:43 +01:00
parent 5b63a841ba
commit 452080e997
36 changed files with 1150 additions and 1267 deletions
+143 -179
View File
@@ -102,6 +102,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
stressTest();
} else if (eql(case, "smp-retry")) {
smpRetryTest();
} else if (eql(case, "tsc-sync")) {
tscSyncTest();
} else if (eql(case, "fault-ud")) {
faultInvalidOpcode();
} else if (eql(case, "fault-pf")) {
@@ -162,14 +164,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
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, "containment")) {
containmentTest();
} else if (eql(case, "device-manager")) {
deviceManagerTest(boot_information);
} else if (eql(case, "poweroff")) {
@@ -213,7 +213,7 @@ fn eql(a: []const u8, b: []const u8) bool {
/// Whether the captured last-write buffer *contains* `needle`. Markers are
/// matched as substrings, not prefixes, so a service's source-path debug prefix
/// (`system/drivers/hpet: ok`) still satisfies a marker like `hpet: ok`.
/// (`system/drivers/pci-bus: ...`) still satisfies a marker like `pci-bus: `.
fn bufferHas(needle: []const u8) bool {
return std.mem.indexOf(u8, process.write_buffer[0..process.write_len], needle) != null;
}
@@ -1211,6 +1211,35 @@ fn clockTest() void {
result();
}
/// The TSC clocksource + cross-core warp check (`-smp 4`). This is the real
/// Intel/AMD / KVM path — an invariant, synchronized TSC. TCG (the only x86
/// accelerator on an Apple-Silicon host) won't advertise an invariant TSC, so the
/// boot forces the TSC clocksource on (kernel.zig, gated on this case) to exercise
/// the machinery: the kernel must run the per-AP warp check as each core came up,
/// find the cores' TSCs synchronized, and keep the clock on the TSC (no HPET
/// fallback). The default suite (no force) exercises the HPET fallback instead.
fn tscSyncTest() void {
log("DANOS-TEST-BEGIN: tsc-sync\n", .{});
check("clocksource is the TSC (forced-invariant path)", eql(architecture.clockSourceName(), "tsc"));
check("the cross-core warp check ran on the APs", architecture.warpChecksRun() >= 1);
check("per-core TSCs synchronized (no warp, no HPET fallback)", architecture.clockSynchronized());
// A warp that slipped past the bring-up check would surface as a backward reading.
var last = architecture.nanos();
var monotonic = true;
var advanced = false;
var i: u32 = 0;
while (i < 1_000_000) : (i += 1) {
const t = architecture.nanos();
if (t < last) monotonic = false;
if (t > last) advanced = true;
last = t;
}
check("monotonic clock advanced", advanced);
check("monotonic clock never ran backward", monotonic);
result();
}
var proc_worker_run: bool = true;
var proc_worker_ran: bool = false;
@@ -2258,68 +2287,7 @@ fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
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 (bufferHas(prefix) and process.write_count >= 2) break;
scheduler.yield();
}
scheduler.setPriority(4);
const ok = bufferHas(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.
/// The GSI discovery recorded for the HPET, from the same device table drivers see.
fn hpetGsi() ?u32 {
var buffer: [16]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
@@ -2334,87 +2302,87 @@ fn hpetGsi() ?u32 {
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", .{});
/// `device_register` containment — a kernel security property, tested directly against
/// the broker (no user-space demo driver). A bus driver publishes children of a device
/// it owns; the kernel must **refuse** any child whose resource escapes the parent's
/// grant, or `device_register` would become a system call for mapping arbitrary physical
/// memory. This is the property the old `bus` demo driver proved end-to-end; with the
/// demo gone, the property is asserted where it lives — in the kernel. Also checks the
/// idempotence rule (M19.0): re-registering an identical child returns the same id
/// instead of appending a duplicate.
fn containmentTest() void {
log("DANOS-TEST-BEGIN: containment\n", .{});
// M19.0: device_register is idempotent on exact match — a restarted
// registering bus must not duplicate its children. Driven directly against
// the broker: claim an unclaimed node, register the same (class, hid,
// resourceless) child twice, expect one id and one table entry.
{
const me = scheduler.currentId();
var probe: [1]device_abi.DeviceDescriptor = undefined;
const total = devices_broker.enumerate(&probe);
check("device tree is seeded for the idempotence check", total >= 1);
if (devices_broker.ownerOf(0) == null) {
check("claimed device 0 for the idempotence check", devices_broker.claim(0, me));
var child = std.mem.zeroes(device_abi.DeviceDescriptor);
child.class = @intFromEnum(device_abi.DeviceClass.unknown);
child.pci_class = device_abi.no_pci_class;
child.hid_len = 4;
child.hid[0..4].* = "idem".*;
const first = devices_broker.register(0, me, &child) catch 0;
check("first register succeeded", first != 0);
const before = devices_broker.enumerate(&probe);
const second = devices_broker.register(0, me, &child) catch 0;
check("re-register returned the same id", second == first);
check("re-register grew nothing", devices_broker.enumerate(&probe) == before);
devices_broker.releaseAllOwnedBy(me);
} else {
check("device 0 unexpectedly claimed before the idempotence check", false);
}
}
const me = scheduler.currentId();
var buffer: [64]device_abi.DeviceDescriptor = undefined;
check("device tree is seeded", devices_broker.enumerate(&buffer) >= 1);
if (boot_information.initial_ramdisk_len == 0) {
check("bootloader handed over an initial_ramdisk", false);
// The kernel-seeded HPET timer block is a device with a memory resource — a natural
// parent to publish sub-window children under, as a PCI bridge or USB hub would.
const parent_id = hpetDeviceId() orelse {
check("found a device with a memory window to parent children under", false);
result();
return;
};
const parent = buffer[@intCast(parent_id)];
var window: ?device_abi.ResourceDescriptor = null;
for (0..parent.resource_count) |j| {
if (parent.resources[j].kind == @intFromEnum(device_abi.ResourceKind.memory)) window = parent.resources[j];
}
const 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);
const parent_window = window orelse {
check("parent exposes a memory window", 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 (bufferHas(prefix)) break;
scheduler.yield();
if (devices_broker.ownerOf(parent_id) != null) {
check("parent device was unclaimed at the start of the test", false);
result();
return;
}
scheduler.setPriority(4);
check("claimed the parent device", devices_broker.claim(parent_id, me));
defer devices_broker.releaseAllOwnedBy(me);
// A child whose window lies inside the parent's is accepted.
var fits = childDescriptor("cfit", parent_window.start, 0x20);
const before = devices_broker.enumerate(&buffer);
const good = devices_broker.register(parent_id, me, &fits) catch 0;
check("a contained child is registered", good != 0);
check("the contained child was appended to the table", devices_broker.enumerate(&buffer) == before + 1);
// A child whose window escapes the parent's is refused with NotContained.
var escapes = childDescriptor("cesc", parent_window.start, parent_window.len + 0x1000);
const refused = if (devices_broker.register(parent_id, me, &escapes)) |_| false else |err| err == error.NotContained;
check("an out-of-window child is refused (NotContained)", refused);
check("the refused child left the table unchanged", devices_broker.enumerate(&buffer) == before + 1);
// Idempotent on exact match: re-registering the accepted child returns its id and
// appends nothing (M19.0 — a restarted bus re-reports what it rediscovers).
const again = devices_broker.register(parent_id, me, &fits) catch 0;
check("re-registering an identical child returns the same id", again != 0 and again == good);
check("re-registering grew nothing", devices_broker.enumerate(&buffer) == before + 1);
const ok = bufferHas(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();
}
/// A minimal child descriptor with one memory resource, for the containment test.
fn childDescriptor(hid: []const u8, start: u64, len: u64) device_abi.DeviceDescriptor {
var child = std.mem.zeroes(device_abi.DeviceDescriptor);
child.class = @intFromEnum(device_abi.DeviceClass.unknown);
child.pci_class = device_abi.no_pci_class;
child.hid_len = @intCast(hid.len);
@memcpy(child.hid[0..hid.len], hid);
child.resource_count = 1;
child.resources[0] = .{ .kind = @intFromEnum(device_abi.ResourceKind.memory), .start = start, .len = len };
return child;
}
/// The device manager (a ring-3 service) enumerates /system/devices, matches each
/// device to a driver, and — 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.)
/// device to a driver, and spawns it. Proof of the whole discover -> match -> spawn ->
/// driver-up chain: boot only the device-manager; it must discover the PCI host bridge,
/// match `pci-bus`, and spawn it (with the bridge id as its argument) — and the spawned
/// pci-bus must reach its own live marker. It uses no special privilege — the same
/// `device_enumerate` any process could call.
fn deviceManagerTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: device-manager\n", .{});
if (boot_information.initial_ramdisk_len == 0) {
@@ -2430,70 +2398,65 @@ fn deviceManagerTest(boot_information: *const BootInformation) void {
};
// 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.
// this too). Only the device-manager is spawned here — so if `pci-bus` runs at
// all, it's because the manager discovered the PCI host bridge, matched, and
// spawned it.
process.setInitialRamdisk(image);
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";
// End-to-end proof, read from kernel state — not the racy last-write serial buffer,
// since many services keep logging after pci-bus. The manager must discover the PCI
// host bridge, match pci-bus, and spawn it, and pci-bus must come up: claim the
// bridge, map its ECAM, and register the functions it enumerates as children in the
// device tree.
scheduler.setPriority(1);
const deadline = architecture.millis() + 10000;
var spawned = false;
while (architecture.millis() < deadline) {
if (bufferHas(prefix)) break;
if (processRunning("pci-bus")) spawned = true;
if (spawned and pciFunctionsRegistered()) break;
scheduler.yield();
}
scheduler.setPriority(4);
const ok = bufferHas(prefix);
check("device manager matched the timer and system_spawn'd hpet, which came up", ok);
check("device manager discovered the PCI host bridge and spawned pci-bus", spawned);
check("pci-bus came up and registered the functions it enumerated", pciFunctionsRegistered());
check("its syscalls came from user mode (CPL 3)", process.write_from_user);
result();
}
/// 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;
}
/// Whether a live task was spawned under `name` (its argv[0]) — read from the kernel
/// task table, the same snapshot `process_enumerate` exposes.
fn processRunning(name: []const u8) bool {
var table: [64]abi.ProcessDescriptor = undefined;
const total = scheduler.enumerate(&table);
for (table[0..@min(total, table.len)]) |d| {
if (std.mem.eql(u8, d.name[0..d.name_length], name)) return true;
}
return children > 0; // bus must have published at least one
return false;
}
/// Device id of the HPET (the bus bus claims), from the same table drivers see.
/// Whether pci-bus registered at least one function under the PCI host bridge — proof
/// it came up, claimed the bridge, mapped its ECAM, and walked configuration space.
fn pciFunctionsRegistered() bool {
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
var bridge_id: ?u64 = null;
for (buffer[0..n]) |d| {
if (d.class == @intFromEnum(device_abi.DeviceClass.pci_host_bridge)) bridge_id = d.id;
}
const bid = bridge_id orelse return false;
for (buffer[0..n]) |d| {
if (d.parent == bid) return true;
}
return false;
}
/// Device id of the kernel-seeded HPET timer block (the node with a memory resource),
/// from the same device table drivers see. Used as a containment-test parent.
fn hpetDeviceId() ?u64 {
var buffer: [64]device_abi.DeviceDescriptor = undefined;
const n = @min(devices_broker.enumerate(&buffer), buffer.len);
@@ -2511,8 +2474,9 @@ fn hpetDeviceId() ?u64 {
/// (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:
/// A long-running driver that never exits wouldn't reach this teardown path, so it
/// gets its own test that binds and releases directly. Two properties, both read back
/// from the hardware rather than from our own state:
///
/// 1. A bound GSI is routed and unmasked.
/// 2. After `releaseOwner` for the binding's owner, that same entry is masked again.