reorg: move test fixtures to test/system/services (source + boot volume)
The 11 QEMU-suite fixtures lived mixed into system/services/ with their binaries bundled at /system/tests/<name>. Now the repo path is the boot path, like every real service: test/system/services/<name>. fat-test moves out of the fat server's directory into its own; display-demo stays a boot service. - kernel VFS: setInitialRamdisk derives one read-only initrd mount per top-level tree named by the ramdisk entry paths (/system, /test), registers ancestors generically with self-parented roots, and refuses backend shadowing of any initrd tree - EFI loader: the fallback walk also enumerates \test (optional — a volume without fixtures still boots); manifest and capsule unchanged - path literals: vfs-test self-open + create probe, process-test process_enumerate matches, the args-echo argv[0] expectation; the kvfs case now covers the /test root end to end - docs: DFHS /test rows, tree diagrams, loader prose, and the location convention gain the third home; fixed the input-source link 100/100 QEMU cases pass.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
//! args-echo — a test fixture for process arguments (bundled in the
|
||||
//! initial-ramdisk, spawned only by the `args` test case). Run with no arguments,
|
||||
//! it respawns itself *with* some via `spawnWithArguments` — exercising the
|
||||
//! system_spawn argument blob. Run with arguments, it burns more stack than one
|
||||
//! page could hold (proving the multi-page stack: on a single-page stack the
|
||||
//! recursion would hit the guard and the process would be killed before echoing),
|
||||
//! then echoes its whole argv in one `debug_write` the kernel test asserts on —
|
||||
//! proving the kernel-built System V entry stack (argc, argv pointers,
|
||||
//! NUL-terminated strings) and the runtime's parsing of it, end to end.
|
||||
|
||||
|
||||
const process = @import("process");
|
||||
const logging = @import("logging");
|
||||
/// Recurse with a real frame each level: `depth` levels of ~0.5 KiB, touched
|
||||
/// through a volatile pointer so no optimiser can flatten the frames away.
|
||||
fn burnStack(depth: usize) u8 {
|
||||
var frame: [512]u8 = undefined;
|
||||
const touch: *volatile [512]u8 = &frame;
|
||||
touch[0] = @truncate(depth);
|
||||
touch[511] = touch[0];
|
||||
if (depth == 0) return touch[511];
|
||||
return touch[0] +% burnStack(depth - 1);
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
if (init.arguments.count <= 1) {
|
||||
// First instance: spawn the second with real arguments, then exit.
|
||||
_ = process.spawnWithArguments("args-echo", &.{ "alpha", "beta-42" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ~16 x 0.5 KiB frames: comfortably past one page, well inside the 32 KiB stack.
|
||||
_ = burnStack(16);
|
||||
|
||||
// Second instance: echo "args: <argv0> <argv1> ..." for the test to match.
|
||||
var buffer: [128]u8 = undefined;
|
||||
const prefix = "args:";
|
||||
@memcpy(buffer[0..prefix.len], prefix);
|
||||
var len: usize = prefix.len;
|
||||
var iterator = init.arguments.iterate();
|
||||
while (iterator.next()) |argument| {
|
||||
if (len + 1 + argument.len + 1 > buffer.len) break;
|
||||
buffer[len] = ' ';
|
||||
len += 1;
|
||||
@memcpy(buffer[len..][0..argument.len], argument);
|
||||
len += argument.len;
|
||||
}
|
||||
buffer[len] = '\n';
|
||||
len += 1;
|
||||
_ = logging.write(buffer[0..len]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! crash-test — a test fixture, not a driver: claims the device it is assigned,
|
||||
//! hellos the device manager, announces itself, then faults on purpose. The
|
||||
//! driver-restart scenario drives the manager's whole restart machinery with
|
||||
//! it: fault → exit reason → backoff → respawn → the **same claim succeeding
|
||||
//! again** (claim release on death, M17.1, through the manager's path) → the
|
||||
//! crash-loop cap. Spawned bare (the initial-ramdisk sweep starts every bundled
|
||||
//! binary), it exits silently so it cannot derange other tests.
|
||||
|
||||
const std = @import("std");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
const device = @import("driver");
|
||||
const logging = @import("logging");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
const argument = init.arguments.get(1) orelse return; // bare: stay silent
|
||||
const assigned = std.fmt.parseInt(u64, argument, 10) catch return;
|
||||
|
||||
// The respawn only reaches this line because the kernel released the
|
||||
// previous instance's claim at death. A failed claim exits cleanly — the
|
||||
// manager reads "meant to stop" and the scenario fails loudly by silence.
|
||||
if (!device.claim(assigned)) {
|
||||
_ = logging.write("crash-test: claim failed\n");
|
||||
return;
|
||||
}
|
||||
|
||||
var manager: ?ipc.Handle = null;
|
||||
var tries: u32 = 0;
|
||||
while (manager == null and tries < 100) : (tries += 1) {
|
||||
manager = ipc.lookup(.device_manager);
|
||||
if (manager == null) time.sleepMillis(20);
|
||||
}
|
||||
const h = manager orelse return;
|
||||
const hello = device_manager_protocol.Hello{ .role = @intFromEnum(device_manager_protocol.Role.device), .device_id = assigned };
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(h, std.mem.asBytes(&hello), &reply) catch return;
|
||||
|
||||
_ = logging.write("crash-test: faulting now\n");
|
||||
const poison: *volatile u32 = @ptrFromInt(0xdead0000);
|
||||
poison.* = 1; // the restart machinery's fuel: a real segmentation fault
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! device-list — the `ps` analog for the device tree (docs/device-manager.md
|
||||
//! M18.3): asks the device manager for the tree over IPC, prints it, then
|
||||
//! subscribes and prints every published add/remove event. The manager is the
|
||||
//! one answer to "what devices exist" for user space; nothing here touches a
|
||||
//! device_* system call.
|
||||
|
||||
const std = @import("std");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const logging = @import("logging");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
|
||||
fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
|
||||
var line: [96]u8 = undefined;
|
||||
_ = logging.write(std.fmt.bufPrint(&line, fmt, arguments) catch return);
|
||||
}
|
||||
|
||||
pub fn main() void {
|
||||
var manager: ?ipc.Handle = null;
|
||||
var tries: u32 = 0;
|
||||
while (manager == null and tries < 200) : (tries += 1) {
|
||||
manager = ipc.lookup(.device_manager);
|
||||
if (manager == null) time.sleepMillis(20);
|
||||
}
|
||||
const h = manager orelse {
|
||||
_ = logging.write("device-list: no device manager\n");
|
||||
return;
|
||||
};
|
||||
|
||||
// The snapshot — polled briefly, because at boot the bus drivers may still
|
||||
// be scanning: an empty first answer usually just means "too early".
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
var count: u32 = 0;
|
||||
var length: usize = 0;
|
||||
tries = 0;
|
||||
while (tries < 20) : (tries += 1) {
|
||||
const request = device_manager_protocol.Enumerate{};
|
||||
length = ipc.call(h, std.mem.asBytes(&request), &reply) catch 0;
|
||||
if (length >= @sizeOf(device_manager_protocol.EnumerateReply)) {
|
||||
count = std.mem.bytesToValue(device_manager_protocol.EnumerateReply, reply[0..@sizeOf(device_manager_protocol.EnumerateReply)]).count;
|
||||
if (count != 0) break;
|
||||
}
|
||||
time.sleepMillis(100);
|
||||
}
|
||||
writeLine("device-list: {d} devices\n", .{count});
|
||||
var offset: usize = @sizeOf(device_manager_protocol.EnumerateReply);
|
||||
var index: u32 = 0;
|
||||
while (index < count and offset + @sizeOf(device_manager_protocol.ChildEntry) <= length) : (index += 1) {
|
||||
const entry = std.mem.bytesToValue(device_manager_protocol.ChildEntry, reply[offset..][0..@sizeOf(device_manager_protocol.ChildEntry)]);
|
||||
writeLine("device-list: device {d} port {d} identity {d}\n", .{ entry.parent, entry.bus_address, entry.identity });
|
||||
offset += @sizeOf(device_manager_protocol.ChildEntry);
|
||||
}
|
||||
|
||||
// The subscription: our endpoint rides as the call's capability; events
|
||||
// arrive as buffered messages carrying the same structs the bus sends.
|
||||
const endpoint = ipc.createIpcEndpoint() orelse {
|
||||
_ = logging.write("device-list: no endpoint\n");
|
||||
return;
|
||||
};
|
||||
const subscribe = device_manager_protocol.Subscribe{};
|
||||
_ = ipc.callCap(h, std.mem.asBytes(&subscribe), &reply, endpoint) catch {
|
||||
_ = logging.write("device-list: subscribe failed\n");
|
||||
return;
|
||||
};
|
||||
_ = logging.write("device-list: subscribed\n");
|
||||
|
||||
var receive: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
while (true) {
|
||||
const got = ipc.replyWait(endpoint, &.{}, &receive, null);
|
||||
if (!got.isMessage() or got.len < 1) continue;
|
||||
switch (receive[0]) {
|
||||
@intFromEnum(device_manager_protocol.Operation.child_added) => {
|
||||
if (got.len < device_manager_protocol.child_added_size) continue;
|
||||
const event = std.mem.bytesToValue(device_manager_protocol.ChildAdded, receive[0..device_manager_protocol.child_added_size]);
|
||||
writeLine("device-list: added (device {d} port {d})\n", .{ event.parent, event.bus_address });
|
||||
},
|
||||
@intFromEnum(device_manager_protocol.Operation.child_removed) => {
|
||||
if (got.len < device_manager_protocol.child_removed_size) continue;
|
||||
const event = std.mem.bytesToValue(device_manager_protocol.ChildRemoved, receive[0..device_manager_protocol.child_removed_size]);
|
||||
writeLine("device-list: removed (device {d} port {d})\n", .{ event.parent, event.bus_address });
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! test/system/services/fat-test — a client that proves the FAT mount end to end:
|
||||
//! it waits for the fat server to mount the USB volume at /mnt/usb, lists the
|
||||
//! root directory through the VFS (which routes /mnt/usb to the fat backend), and
|
||||
//! reads a known file off it. Shipped in the initial_ramdisk; the `fat-mount`
|
||||
//! kernel test spawns it alongside init.
|
||||
|
||||
const std = @import("std");
|
||||
const fs = @import("file-system");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
const logging = @import("logging");
|
||||
|
||||
fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
|
||||
var line: [128]u8 = undefined;
|
||||
_ = logging.write(std.fmt.bufPrint(&line, fmt, arguments) catch return);
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
_ = init;
|
||||
|
||||
// Wait for /mnt/usb to be mounted — the fat server races us at boot (it must
|
||||
// bring up the whole USB storage chain first).
|
||||
var opened: ?fs.Directory = null;
|
||||
var tries: u32 = 0;
|
||||
while (opened == null and tries < 1400) : (tries += 1) {
|
||||
opened = fs.openDirectory("/mnt/usb");
|
||||
if (opened == null) time.sleepMillis(50);
|
||||
}
|
||||
var dir = opened orelse {
|
||||
_ = logging.write("fat-test: /mnt/usb never became available\n");
|
||||
return;
|
||||
};
|
||||
|
||||
var count: u32 = 0;
|
||||
var entry: fs.Entry = .{};
|
||||
while (dir.next(&entry)) {
|
||||
writeLine("fat-test: entry '{s}' kind={d} size={d}\n", .{ entry.name(), @intFromEnum(entry.kind), entry.size });
|
||||
count += 1;
|
||||
if (count > 32) break;
|
||||
}
|
||||
dir.close();
|
||||
writeLine("fat-test: listed {d} entries\n", .{count});
|
||||
|
||||
// Read a known file off the boot volume through the mount (best effort): the
|
||||
// kernel image is an ELF, so its first bytes are the ELF magic.
|
||||
if (fs.open("/mnt/usb/system/kernel", .{})) |opened_file| {
|
||||
var file = opened_file;
|
||||
var magic: [4]u8 = undefined;
|
||||
const n = file.read(&magic) orelse 0;
|
||||
file.close();
|
||||
if (n == 4 and magic[0] == 0x7F and magic[1] == 'E' and magic[2] == 'L' and magic[3] == 'F') {
|
||||
_ = logging.write("fat-test: read /mnt/usb/system/kernel ELF magic ok\n");
|
||||
} else {
|
||||
writeLine("fat-test: /mnt/usb/system/kernel read {d} bytes (not ELF magic)\n", .{n});
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise directory + file mutation through the mount: mkdir, create a file
|
||||
// inside it, read it back, then remove it — proof mkdir/unlink reach the engine.
|
||||
if (fs.makeDirectory("/mnt/usb/TESTDIR")) {
|
||||
var wrote = false;
|
||||
if (fs.open("/mnt/usb/TESTDIR/HELLO.TXT", .{ .create = true, .truncate = true })) |created| {
|
||||
var f = created;
|
||||
wrote = (f.writeAll("mutation-ok") orelse 0) == "mutation-ok".len;
|
||||
f.close();
|
||||
}
|
||||
// The created file carries a real modification time (stamped from the RTC).
|
||||
var mtime_ok = false;
|
||||
if (fs.attributes("/mnt/usb/TESTDIR/HELLO.TXT")) |attrs| {
|
||||
writeLine("fat-test: mtime {d}\n", .{attrs.mtime});
|
||||
mtime_ok = attrs.mtime > 1_577_836_800; // after 2020-01-01
|
||||
}
|
||||
if (mtime_ok) _ = logging.write("fat-test: mtime ok\n");
|
||||
|
||||
// Rename it, then read from the new name and confirm the old name is gone.
|
||||
const renamed = fs.rename("/mnt/usb/TESTDIR/HELLO.TXT", "/mnt/usb/TESTDIR/RENAMED.TXT");
|
||||
const old_gone = !fs.exists("/mnt/usb/TESTDIR/HELLO.TXT");
|
||||
if (renamed and old_gone) _ = logging.write("fat-test: rename ok\n");
|
||||
var readback = false;
|
||||
if (fs.open("/mnt/usb/TESTDIR/RENAMED.TXT", .{})) |reopened| {
|
||||
var f = reopened;
|
||||
var buf: [16]u8 = undefined;
|
||||
const got = f.read(&buf) orelse 0;
|
||||
f.close();
|
||||
readback = std.mem.eql(u8, buf[0..got], "mutation-ok");
|
||||
}
|
||||
const removed = fs.remove("/mnt/usb/TESTDIR/RENAMED.TXT");
|
||||
const gone = !fs.exists("/mnt/usb/TESTDIR/RENAMED.TXT");
|
||||
if (wrote and mtime_ok and renamed and old_gone and readback and removed and gone) {
|
||||
_ = logging.write("fat-test: mutations ok\n");
|
||||
} else {
|
||||
writeLine("fat-test: mutations FAILED (wrote={} mtime={} renamed={} oldgone={} read={} removed={} gone={})\n", .{ wrote, mtime_ok, renamed, old_gone, readback, removed, gone });
|
||||
}
|
||||
} else {
|
||||
_ = logging.write("fat-test: mkdir /mnt/usb/TESTDIR failed\n");
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
while (true) {
|
||||
_ = logging.write("fat-test: ok\n");
|
||||
time.sleepMillis(1000);
|
||||
}
|
||||
}
|
||||
_ = logging.write("fat-test: root listing was empty\n");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! test/system/services/input-source — a hardware-free synthetic input source, used to exercise
|
||||
//! the input service end to end without a real PS/2 controller (the `input` test case, and
|
||||
//! any bring-up where there is no hardware). It stands in for a driver: it connects to the
|
||||
//! input service and publishes a rolling stream that cycles through all device classes —
|
||||
//! keyboard, mouse, and joystick/gamepad — which the service routes to interested
|
||||
//! subscribers.
|
||||
//!
|
||||
//! It stays silent after startup (no per-event logging) so it can share the boot serial
|
||||
//! transcript with a subscriber whose output is the test's success marker. The real
|
||||
//! keyboard and mouse drivers publish their own synthetic streams today; swapping in
|
||||
//! decoded hardware is a follow-up (see docs/input.md).
|
||||
|
||||
const std = @import("std");
|
||||
const input = @import("input");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
const logging = @import("logging");
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
var source = input.connectSource() orelse {
|
||||
_ = logging.write("input-source: input service unavailable\n");
|
||||
return;
|
||||
};
|
||||
|
||||
// "mouse" mode publishes a steady stream of pure motion (dx=dy=+1), for driving a
|
||||
// cursor (the `display-cursor` test). The default "rotate" mode cycles all device
|
||||
// classes to exercise the service's per-device routing (the `input` test).
|
||||
const mode = init.arguments.get(1) orelse "rotate";
|
||||
if (std.mem.eql(u8, mode, "mouse")) {
|
||||
_ = logging.write("input-source: publishing synthetic mouse motion\n");
|
||||
while (true) {
|
||||
_ = source.publishMouseEvent(.{
|
||||
.kind = @intFromEnum(input.MouseEventKind.motion),
|
||||
.button = 0,
|
||||
.dx = 1,
|
||||
.dy = 1,
|
||||
.scroll_x = 0,
|
||||
.scroll_y = 0,
|
||||
.buttons = 0,
|
||||
});
|
||||
time.sleepMillis(20); // ~50 events/sec: moves the cursor briskly
|
||||
}
|
||||
}
|
||||
|
||||
_ = logging.write("input-source: publishing synthetic input events\n");
|
||||
var step: usize = 0;
|
||||
while (true) : (step +%= 1) {
|
||||
// Rotate across the device classes so every publish path (and the service's
|
||||
// per-device routing) is exercised.
|
||||
switch (step % 3) {
|
||||
0 => _ = source.publishKeyboardEvent(input.syntheticKeyEvent(step)),
|
||||
1 => _ = source.publishMouseEvent(input.syntheticMouseEvent(step)),
|
||||
else => _ = source.publishJoystickEvent(input.syntheticJoystickEvent(step)),
|
||||
}
|
||||
time.sleepMillis(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! test/system/services/input-test — the input service's client and test oracle, the input
|
||||
//! counterpart of vfs-test. It subscribes to *all* device classes and loops receiving the
|
||||
//! events a source broadcasts, logging each with its class. It emits the success marker
|
||||
//! `"input-test: ok"` only **after it has received at least one of each class** (keyboard,
|
||||
//! mouse, and joystick), then heartbeats it. So the in-kernel `input` test case seeing that
|
||||
//! marker proves not just that IPC delivery works but that the service *routed* all three
|
||||
//! device classes to one subscription — source -> service -> subscriber, per device.
|
||||
|
||||
const std = @import("std");
|
||||
const input = @import("input");
|
||||
const logging = @import("logging");
|
||||
|
||||
fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
|
||||
var line: [128]u8 = undefined;
|
||||
_ = logging.write(std.fmt.bufPrint(&line, fmt, arguments) catch return);
|
||||
}
|
||||
|
||||
pub fn main() void {
|
||||
var listener = input.subscribeAll() orelse {
|
||||
_ = logging.write("input-test: could not subscribe\n");
|
||||
return;
|
||||
};
|
||||
_ = logging.write("input-test: subscribed\n");
|
||||
|
||||
var seen_keyboard = false;
|
||||
var seen_mouse = false;
|
||||
var seen_joystick = false;
|
||||
while (true) {
|
||||
const event = listener.next() orelse continue;
|
||||
// Decode the class-specific payload from the tagged envelope and note the class.
|
||||
if (event.asKeyboard()) |key| {
|
||||
seen_keyboard = true;
|
||||
writeLine("input-test: got keyboard code={d} char={d}\n", .{ key.keycode, key.character });
|
||||
} else if (event.asMouse()) |mouse| {
|
||||
seen_mouse = true;
|
||||
writeLine("input-test: got mouse dx={d} dy={d} buttons={d}\n", .{ mouse.dx, mouse.dy, mouse.buttons });
|
||||
} else if (event.asJoystick()) |joystick| {
|
||||
seen_joystick = true;
|
||||
writeLine("input-test: got joystick control={d} value={d}\n", .{ joystick.control, joystick.value });
|
||||
} else {
|
||||
writeLine("input-test: got device={d}\n", .{event.device});
|
||||
}
|
||||
|
||||
// The success marker: only once every class has been routed here does this appear,
|
||||
// and then it heartbeats. Seeing "input-test: ok" proves per-device fan-out works.
|
||||
if (seen_keyboard and seen_mouse and seen_joystick) {
|
||||
_ = logging.write("input-test: ok all classes received (keyboard, mouse, joystick)\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! process-test — a test fixture for process management (bundled in the
|
||||
//! initial-ramdisk, driven by the `supervision` test case). One binary, three
|
||||
//! roles picked by argv, so the whole user-side surface is exercised end to end:
|
||||
//!
|
||||
//! - `process-test run` — the supervisor: spawns the two children below with an
|
||||
//! exit-notification endpoint, sees them in `process_enumerate`, kills them,
|
||||
//! receives both exit notifications, and confirms they are gone. Prints
|
||||
//! "process-test: ok" for the kernel test to match, or a FAIL line naming the
|
||||
//! step that broke.
|
||||
//! - `process-test sleeper` — a child that blocks in `sleep` forever: its kill
|
||||
//! exercises the immediate reap of a blocked task.
|
||||
//! - `process-test spinner` — a child that spins in user mode making no system
|
||||
//! calls: its kill exercises the deferred path (kill_pending, finished by the
|
||||
//! timer tick).
|
||||
//!
|
||||
//! Spawned with no arguments (the initial-ramdisk sweep test starts every bundled
|
||||
//! binary bare), it exits silently so it cannot derange other tests' output.
|
||||
|
||||
const std = @import("std");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const service = @import("service");
|
||||
const time = @import("time");
|
||||
const logging = @import("logging");
|
||||
|
||||
fn fail(step: []const u8) noreturn {
|
||||
_ = logging.write("process-test: FAIL ");
|
||||
_ = logging.write(step);
|
||||
_ = logging.write("\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/// Whether process `id` appears in a fresh `process_enumerate` snapshot, named
|
||||
/// `name` (an id present under the wrong name is a table mix-up, not a pass).
|
||||
fn listed(id: u32, name: []const u8) bool {
|
||||
var table: [32]process.ProcessDescriptor = undefined;
|
||||
const total = process.processes(&table);
|
||||
for (table[0..@min(total, table.len)]) |descriptor| {
|
||||
if (descriptor.id != id) continue;
|
||||
return std.mem.eql(u8, descriptor.name[0..descriptor.name_length], name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Block on the exit endpoint until a child-exit notification arrives; returns
|
||||
/// the ended child's id. A wrong wake-up (there should be none — nothing else
|
||||
/// knows this endpoint) fails the test rather than looping forever.
|
||||
fn awaitChildExit(endpoint: ipc.Handle) u32 {
|
||||
var scratch: [8]u8 = undefined;
|
||||
const received = ipc.replyWait(endpoint, scratch[0..0], &scratch, null);
|
||||
if (!received.isChildExit()) fail("expected a child-exit notification");
|
||||
return received.childProcessId();
|
||||
}
|
||||
|
||||
/// The harness-run child of the signals test: echoes requests, logs the two
|
||||
/// signals it handles. Terminate makes run() return, and returning from main is
|
||||
/// the clean exit the parent reads as ExitReason.exited.
|
||||
fn echo(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
_ = sender;
|
||||
_ = capability;
|
||||
const n = @min(message.len, reply.len);
|
||||
@memcpy(reply[0..n], message[0..n]);
|
||||
return n;
|
||||
}
|
||||
|
||||
fn onReload() void {
|
||||
_ = logging.write("process-test: reloaded\n");
|
||||
}
|
||||
|
||||
fn onTerminate() void {
|
||||
_ = logging.write("process-test: terminating\n");
|
||||
}
|
||||
|
||||
/// The parent of the signals test: drives ping, echo, reload, the one-shot
|
||||
/// timer, and both endings of the stop sequence (polite -> exited; deaf ->
|
||||
/// killed at the deadline). Prints "process-test: signals ok" as the marker.
|
||||
fn signalRun() void {
|
||||
const endpoint = ipc.createIpcEndpoint() orelse fail("create exit endpoint");
|
||||
const child = process.spawnSupervised("process-test", &.{"service"}, endpoint) orelse fail("spawn service child");
|
||||
|
||||
// Reach the child's endpoint through the registry (retry: it may not be up).
|
||||
var service_handle: ?ipc.Handle = null;
|
||||
var tries: u32 = 0;
|
||||
while (service_handle == null and tries < 200) : (tries += 1) {
|
||||
service_handle = ipc.lookup(.input);
|
||||
if (service_handle == null) time.sleepMillis(20);
|
||||
}
|
||||
const h = service_handle orelse fail("service child never registered");
|
||||
|
||||
// The universal ping: a zero-length call answered zero-length by the harness.
|
||||
var reply: [16]u8 = undefined;
|
||||
const pong = ipc.call(h, &.{}, &reply) catch fail("ping call failed");
|
||||
if (pong != 0) fail("ping reply not empty");
|
||||
|
||||
// An ordinary request still reaches on_message.
|
||||
const n = ipc.call(h, "echo!", &reply) catch fail("echo call failed");
|
||||
if (n != 5 or !std.mem.eql(u8, reply[0..5], "echo!")) fail("echo mismatch");
|
||||
|
||||
// reload: a statement — the child logs it; the kernel test reads the serial.
|
||||
if (!process.sendSignal(child, .reload)) fail("send reload");
|
||||
time.sleepMillis(200);
|
||||
|
||||
// The one-shot timer: armed on our endpoint, lands as isTimer.
|
||||
if (!time.timerOnce(endpoint, 100)) fail("arm timer");
|
||||
var scratch: [8]u8 = undefined;
|
||||
const landing = ipc.replyWait(endpoint, scratch[0..0], &scratch, null);
|
||||
if (!landing.isTimer()) fail("expected the timer landing");
|
||||
|
||||
// The stop sequence, polite path: terminate, clean exit inside the deadline.
|
||||
process.stop(child, 2000, endpoint);
|
||||
if ((process.exitReason(child) orelse .killed) != .exited) fail("service child reason not exited");
|
||||
|
||||
// The deaf child: binds nothing, hears nothing — the deadline kills it.
|
||||
const deaf = process.spawnSupervised("process-test", &.{"sleeper"}, endpoint) orelse fail("spawn deaf child");
|
||||
time.sleepMillis(50); // let it reach its sleep
|
||||
process.stop(deaf, 300, endpoint);
|
||||
if ((process.exitReason(deaf) orelse .exited) != .killed) fail("deaf child reason not killed");
|
||||
|
||||
_ = logging.write("process-test: signals ok\n");
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
const role = init.arguments.get(1) orelse return; // spawned bare (ramdisk sweep): stay silent
|
||||
if (std.mem.eql(u8, role, "sleeper")) {
|
||||
while (true) time.sleepMillis(500);
|
||||
}
|
||||
if (std.mem.eql(u8, role, "service")) {
|
||||
// Borrowed well-known id: the input service is not part of this scenario.
|
||||
service.run(64, .{
|
||||
.service = .input,
|
||||
.on_message = echo,
|
||||
.on_reload = onReload,
|
||||
.on_terminate = onTerminate,
|
||||
});
|
||||
return; // terminate arrived; returning is the clean exit
|
||||
}
|
||||
if (std.mem.eql(u8, role, "signal-run")) {
|
||||
signalRun();
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, role, "spinner")) {
|
||||
var beat: u64 = 0;
|
||||
const touch: *volatile u64 = &beat;
|
||||
while (true) touch.* +%= 1; // user mode only — no system calls to die at
|
||||
}
|
||||
|
||||
// The supervisor ("run").
|
||||
const endpoint = ipc.createIpcEndpoint() orelse fail("create exit endpoint");
|
||||
|
||||
const sleeper = process.spawnSupervised("process-test", &.{"sleeper"}, endpoint) orelse fail("spawn sleeper");
|
||||
const spinner = process.spawnSupervised("process-test", &.{"spinner"}, endpoint) orelse fail("spawn spinner");
|
||||
|
||||
time.sleepMillis(100); // let the sleeper block and the spinner get a core
|
||||
if (!listed(sleeper, "/test/system/services/process-test")) fail("sleeper not in process_enumerate");
|
||||
if (!listed(spinner, "/test/system/services/process-test")) fail("spinner not in process_enumerate");
|
||||
|
||||
// Kills that must be refused: a kernel task (id 0), and an id that was never
|
||||
// issued — both -ESRCH. (-EPERM needs a second supervisor; the kernel-level
|
||||
// `process-kill` test covers it.)
|
||||
if (process.kill(0)) fail("killing a kernel task was allowed");
|
||||
if (process.kill(0xFFFF_FFF0)) fail("killing an unknown id was allowed");
|
||||
|
||||
// The blocked child: usually reaped on the spot (it sits in `sleep`). The
|
||||
// notification is the fence — after it, the child is certainly gone, so the
|
||||
// second kill must miss (its id is never reused).
|
||||
if (!process.kill(sleeper)) fail("kill sleeper");
|
||||
if (awaitChildExit(endpoint) != sleeper) fail("sleeper exit notification");
|
||||
if (process.kill(sleeper)) fail("double kill was allowed");
|
||||
|
||||
// The running child: the deferred path — condemned now, dead by the next tick.
|
||||
if (!process.kill(spinner)) fail("kill spinner");
|
||||
if (awaitChildExit(endpoint) != spinner) fail("spinner exit notification");
|
||||
|
||||
if (listed(sleeper, "/test/system/services/process-test")) fail("sleeper still listed after kill");
|
||||
if (listed(spinner, "/test/system/services/process-test")) fail("spinner still listed after kill");
|
||||
|
||||
// M17.2: both children were killed by us, and the reason says so — the whole
|
||||
// restart-policy input, read through the runtime like a real supervisor would.
|
||||
if ((process.exitReason(sleeper) orelse .exited) != .killed) fail("sleeper reason not killed");
|
||||
if ((process.exitReason(spinner) orelse .exited) != .killed) fail("spinner reason not killed");
|
||||
if (process.exitReason(0xFFFF_FFF0) != null) fail("unknown id had a reason");
|
||||
|
||||
_ = logging.write("process-test: ok\n");
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! test/system/services/shared-memory-client — the creating half of the shared-memory test (docs/display-v2.md V2).
|
||||
//! It `shared_memory_create`s a shared region, writes a known pattern into it, and hands the region's
|
||||
//! capability to `shared-memory-server` as an `ipc_call` send_cap. The server maps that capability and
|
||||
//! confirms the pattern is visible — proving cross-process shared memory over the extended
|
||||
//! capability-passing path.
|
||||
|
||||
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const memory = @import("memory");
|
||||
const logging = @import("logging");
|
||||
const pattern_len = 4096;
|
||||
|
||||
/// The pattern the server checks — must match shared-memory-server.zig.
|
||||
fn expected(i: usize) u8 {
|
||||
return @truncate(i *% 7 +% 3);
|
||||
}
|
||||
|
||||
fn lookupServer() ?ipc.Handle {
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.shared_memory_test)) |h| return h;
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn main() void {
|
||||
const region = memory.sharedCreate(pattern_len) orelse {
|
||||
_ = logging.write("shared-memory: create failed\n");
|
||||
return;
|
||||
};
|
||||
var i: usize = 0;
|
||||
while (i < pattern_len) : (i += 1) region.ptr[i] = expected(i);
|
||||
|
||||
const server = lookupServer() orelse {
|
||||
_ = logging.write("shared-memory: no server\n");
|
||||
return;
|
||||
};
|
||||
// A non-empty message (so it reaches on_message, not the ping path), carrying the shared-memory
|
||||
// region's capability. The reply is empty; we just need the round trip.
|
||||
var reply: [64]u8 = undefined;
|
||||
_ = ipc.callCap(server, "shared-memory", &reply, region.handle) catch {
|
||||
_ = logging.write("shared-memory: call failed\n");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! test/system/services/shared-memory-server — the receiving half of the shared-memory test (docs/display-v2.md V2).
|
||||
//! It registers under `ServiceId.shared_memory_test`; when `shared-memory-client` calls it carrying a
|
||||
//! shared-memory capability, it `shared_memory_map`s that capability and checks the client's pattern
|
||||
//! is visible through the mapping — proving the two processes share the same physical pages
|
||||
//! (not a copy). On success it prints `shared-memory: shared 4096 bytes ok`, the test's marker.
|
||||
|
||||
|
||||
const ipc = @import("ipc");
|
||||
const service = @import("service");
|
||||
const memory = @import("memory");
|
||||
const logging = @import("logging");
|
||||
const pattern_len = 4096;
|
||||
|
||||
/// The pattern the client writes — must match shared-memory-client.zig.
|
||||
fn expected(i: usize) u8 {
|
||||
return @truncate(i *% 7 +% 3);
|
||||
}
|
||||
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
_ = message;
|
||||
_ = reply;
|
||||
_ = sender;
|
||||
const cap = capability orelse {
|
||||
_ = logging.write("shared-memory: shared FAILED (no capability)\n");
|
||||
return 0;
|
||||
};
|
||||
const ptr = memory.sharedMap(cap) orelse {
|
||||
_ = logging.write("shared-memory: shared FAILED (map)\n");
|
||||
return 0;
|
||||
};
|
||||
var i: usize = 0;
|
||||
while (i < pattern_len) : (i += 1) {
|
||||
if (ptr[i] != expected(i)) {
|
||||
_ = logging.write("shared-memory: shared FAILED (mismatch)\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
_ = logging.write("shared-memory: shared 4096 bytes ok\n");
|
||||
return 0; // empty reply — the client only needs the round trip to unblock
|
||||
}
|
||||
|
||||
pub fn main() void {
|
||||
service.run(64, .{ .service = .shared_memory_test, .on_message = onMessage });
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
//! thread-test — danos's multi-threaded exerciser (docs/threading-plan.md M2, M3).
|
||||
//!
|
||||
//! Two modes, chosen by argv[1] (default "spawn"):
|
||||
//! spawn — M2: one worker writes a shared global; the main thread observes it, proving
|
||||
//! `Thread.spawn` started a task in the **same** address space.
|
||||
//! join — M3: N workers each do K atomic increments on a shared counter and stamp the
|
||||
//! core they ran on; the main thread `join`s all N and checks the total is
|
||||
//! exactly N*K (every worker ran, join waited) and that >1 core was used
|
||||
//! (genuine parallelism). Then a detached worker proves `detach` runs and
|
||||
//! needs no join.
|
||||
//!
|
||||
//! Built multi-threaded (`addThreadedUserBinary`) so atomics/shared reads are real.
|
||||
|
||||
const std = @import("std");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
const memory = @import("memory");
|
||||
const logging = @import("logging");
|
||||
const Thread = @import("thread").Thread;
|
||||
|
||||
fn write(comptime s: []const u8) void {
|
||||
_ = logging.write(s);
|
||||
}
|
||||
|
||||
// --- M2: spawn mode ---------------------------------------------------------
|
||||
|
||||
var shared_value: u32 = 0;
|
||||
var spawn_done = std.atomic.Value(u32).init(0);
|
||||
const sentinel: u32 = 0xA5A5;
|
||||
|
||||
fn spawnWorker() void {
|
||||
shared_value = sentinel;
|
||||
spawn_done.store(1, .release);
|
||||
}
|
||||
|
||||
fn runSpawnMode() void {
|
||||
write("thread-test: starting\n");
|
||||
_ = Thread.spawn(.{}, spawnWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
var spins: usize = 0;
|
||||
while (spawn_done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
|
||||
process.yield();
|
||||
}
|
||||
if (spawn_done.load(.acquire) == 1 and shared_value == sentinel) {
|
||||
write("thread-test: child ran in shared address space ok\n");
|
||||
} else {
|
||||
write("thread-test: FAIL worker did not update shared memory\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- M3: join mode ----------------------------------------------------------
|
||||
|
||||
const worker_count: u32 = 4;
|
||||
const iterations: u64 = 100_000;
|
||||
|
||||
var counter = std.atomic.Value(u64).init(0);
|
||||
var cores_seen = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn joinWorker() void {
|
||||
var i: u64 = 0;
|
||||
while (i < iterations) : (i += 1) {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
if (i % 1000 == 0) stampCore(); // periodic: catches cross-core migration too
|
||||
}
|
||||
stampCore();
|
||||
}
|
||||
|
||||
fn stampCore() void {
|
||||
const core = Thread.currentCore();
|
||||
if (core < 32) _ = cores_seen.fetchOr(@as(u32, 1) << @intCast(core), .monotonic);
|
||||
}
|
||||
|
||||
var detach_done = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn detachWorker() void {
|
||||
detach_done.store(1, .release);
|
||||
}
|
||||
|
||||
fn noopWorker() void {}
|
||||
|
||||
fn runJoinMode() void {
|
||||
write("thread-test: join mode starting\n");
|
||||
|
||||
var threads: [worker_count]Thread = undefined;
|
||||
var spawned: u32 = 0;
|
||||
while (spawned < worker_count) : (spawned += 1) {
|
||||
threads[spawned] = Thread.spawn(.{}, joinWorker, .{}) catch break;
|
||||
}
|
||||
if (spawned != worker_count) {
|
||||
write("thread-test: FAIL could not spawn all workers\n");
|
||||
return;
|
||||
}
|
||||
for (threads[0..spawned]) |t| t.join();
|
||||
|
||||
const total = counter.load(.acquire);
|
||||
const cores = @popCount(cores_seen.load(.acquire));
|
||||
if (total != worker_count * iterations) {
|
||||
write("thread-test: FAIL counter mismatch (a worker was lost or join did not wait)\n");
|
||||
return;
|
||||
}
|
||||
if (cores <= 1) {
|
||||
write("thread-test: FAIL workers never ran on more than one core\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// detach: the worker runs and we never join it.
|
||||
const dt = Thread.spawn(.{}, detachWorker, .{}) catch {
|
||||
write("thread-test: FAIL detach spawn refused\n");
|
||||
return;
|
||||
};
|
||||
dt.detach();
|
||||
var spins: usize = 0;
|
||||
while (detach_done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
|
||||
process.yield();
|
||||
}
|
||||
if (detach_done.load(.acquire) != 1) {
|
||||
write("thread-test: FAIL detached worker did not run\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prove join needs no per-thread kernel endpoint (M9): many spawn+join cycles. Under
|
||||
// the old per-thread-endpoint scheme these leaked handles and would exhaust the
|
||||
// 16-slot handle table well before 40; here they all succeed.
|
||||
var cycle: u32 = 0;
|
||||
while (cycle < 40) : (cycle += 1) {
|
||||
const th = Thread.spawn(.{}, noopWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn exhausted across join cycles (endpoint leak?)\n");
|
||||
return;
|
||||
};
|
||||
th.join();
|
||||
}
|
||||
|
||||
write("thread-test: join ok\n"); // the M3/M9 verdict marker
|
||||
}
|
||||
|
||||
// --- M4: futex mode ---------------------------------------------------------
|
||||
|
||||
const Futex = Thread.Futex;
|
||||
|
||||
var futex_word = std.atomic.Value(u32).init(0);
|
||||
var waiter_parked = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn futexWaiter() void {
|
||||
write("thread-futex: waiting\n");
|
||||
waiter_parked.store(1, .release);
|
||||
// Block while the word is still 0; the waker sets it to 1 and wakes us.
|
||||
while (futex_word.load(.acquire) == 0) {
|
||||
Futex.wait(&futex_word, 0);
|
||||
}
|
||||
write("thread-futex: woke\n");
|
||||
}
|
||||
|
||||
fn runFutexMode() void {
|
||||
write("thread-futex: starting\n");
|
||||
|
||||
const waiter = Thread.spawn(.{}, futexWaiter, .{}) catch {
|
||||
write("thread-futex: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
// Let the waiter reach its wait, then give it a beat to actually park in-kernel.
|
||||
var spins: usize = 0;
|
||||
while (waiter_parked.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
|
||||
process.yield();
|
||||
}
|
||||
time.sleepMillis(50);
|
||||
|
||||
// The handshake: publish the value, then wake the parked waiter.
|
||||
futex_word.store(1, .release);
|
||||
write("thread-futex: waking\n");
|
||||
Futex.wake(&futex_word, 1);
|
||||
|
||||
waiter.join(); // returns once the waiter woke and printed "woke"
|
||||
|
||||
// Timeout: nobody ever wakes this word, so timedWait must report a timeout.
|
||||
var lonely = std.atomic.Value(u32).init(0);
|
||||
if (Futex.timedWait(&lonely, 0, 100_000_000)) |_| {
|
||||
write("thread-futex: FAIL timedWait did not time out\n");
|
||||
return;
|
||||
} else |_| {}
|
||||
write("thread-futex: timeout ok\n");
|
||||
|
||||
write("thread-futex: ok\n"); // the M4 verdict marker
|
||||
}
|
||||
|
||||
// --- M5: mutex mode (bounded producer/consumer over Mutex + Condition) ------
|
||||
|
||||
const Mutex = Thread.Mutex;
|
||||
const Condition = Thread.Condition;
|
||||
|
||||
const producers: u32 = 2;
|
||||
const consumers: u32 = 2;
|
||||
const per_producer: u32 = 1000;
|
||||
const per_consumer: u32 = 1000; // producers*per_producer == consumers*per_consumer (balanced)
|
||||
const total_items: u32 = producers * per_producer;
|
||||
const ring_cap: usize = 8; // small, so producers block on full and consumers on empty
|
||||
|
||||
var ring: [ring_cap]u32 = undefined;
|
||||
var ring_count: usize = 0;
|
||||
var ring_head: usize = 0;
|
||||
var ring_tail: usize = 0;
|
||||
|
||||
var pc_mutex = Mutex{};
|
||||
var not_full = Condition{};
|
||||
var not_empty = Condition{};
|
||||
|
||||
// Verified outside the lock: the checksum and tally of everything consumed.
|
||||
var consumed_sum = std.atomic.Value(u64).init(0);
|
||||
var consumed_count = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn producer(base: u32) void {
|
||||
var i: u32 = 0;
|
||||
while (i < per_producer) : (i += 1) {
|
||||
const item = base + i;
|
||||
pc_mutex.lock();
|
||||
while (ring_count == ring_cap) not_full.wait(&pc_mutex);
|
||||
ring[ring_tail] = item;
|
||||
ring_tail = (ring_tail + 1) % ring_cap;
|
||||
ring_count += 1;
|
||||
pc_mutex.unlock();
|
||||
not_empty.signal();
|
||||
}
|
||||
}
|
||||
|
||||
fn consumer() void {
|
||||
var i: u32 = 0;
|
||||
while (i < per_consumer) : (i += 1) {
|
||||
pc_mutex.lock();
|
||||
while (ring_count == 0) not_empty.wait(&pc_mutex);
|
||||
const item = ring[ring_head];
|
||||
ring_head = (ring_head + 1) % ring_cap;
|
||||
ring_count -= 1;
|
||||
pc_mutex.unlock();
|
||||
not_full.signal();
|
||||
_ = consumed_sum.fetchAdd(item, .monotonic);
|
||||
_ = consumed_count.fetchAdd(1, .monotonic);
|
||||
}
|
||||
}
|
||||
|
||||
fn runMutexMode() void {
|
||||
write("thread-mutex: starting\n");
|
||||
|
||||
var threads: [producers + consumers]Thread = undefined;
|
||||
var n: usize = 0;
|
||||
var p: u32 = 0;
|
||||
while (p < producers) : (p += 1) {
|
||||
threads[n] = Thread.spawn(.{}, producer, .{p * per_producer}) catch {
|
||||
write("thread-mutex: FAIL producer spawn\n");
|
||||
return;
|
||||
};
|
||||
n += 1;
|
||||
}
|
||||
var c: u32 = 0;
|
||||
while (c < consumers) : (c += 1) {
|
||||
threads[n] = Thread.spawn(.{}, consumer, .{}) catch {
|
||||
write("thread-mutex: FAIL consumer spawn\n");
|
||||
return;
|
||||
};
|
||||
n += 1;
|
||||
}
|
||||
for (threads[0..n]) |t| t.join();
|
||||
|
||||
// Every item 0..total_items-1 was produced exactly once; if the mutex/condition are
|
||||
// correct, each was consumed exactly once, so the checksum matches.
|
||||
const expected_sum: u64 = @as(u64, total_items) * (total_items - 1) / 2;
|
||||
if (consumed_count.load(.acquire) != total_items) {
|
||||
write("thread-mutex: FAIL wrong number of items consumed\n");
|
||||
return;
|
||||
}
|
||||
if (consumed_sum.load(.acquire) != expected_sum) {
|
||||
write("thread-mutex: FAIL checksum mismatch (item lost or duplicated)\n");
|
||||
return;
|
||||
}
|
||||
write("thread-mutex: ok\n"); // the M5 verdict marker
|
||||
}
|
||||
|
||||
// --- M6: id mode (getCurrentId identity) ------------------------------------
|
||||
|
||||
var worker_ids: [2]std.atomic.Value(u32) = .{ std.atomic.Value(u32).init(0), std.atomic.Value(u32).init(0) };
|
||||
|
||||
fn idWorker(slot: usize) void {
|
||||
worker_ids[slot].store(Thread.getCurrentId(), .release);
|
||||
}
|
||||
|
||||
fn runIdMode() void {
|
||||
write("thread-id: starting\n");
|
||||
const main_id = Thread.getCurrentId();
|
||||
|
||||
const t0 = Thread.spawn(.{}, idWorker, .{@as(usize, 0)}) catch {
|
||||
write("thread-id: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
const t1 = Thread.spawn(.{}, idWorker, .{@as(usize, 1)}) catch {
|
||||
write("thread-id: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
t0.join();
|
||||
t1.join();
|
||||
|
||||
const id0 = worker_ids[0].load(.acquire);
|
||||
const id1 = worker_ids[1].load(.acquire);
|
||||
// Each thread has its own kernel task id: all three distinct and non-zero.
|
||||
if (main_id == 0 or id0 == 0 or id1 == 0) {
|
||||
write("thread-id: FAIL a thread reported id 0\n");
|
||||
return;
|
||||
}
|
||||
if (id0 == id1 or id0 == main_id or id1 == main_id) {
|
||||
write("thread-id: FAIL thread ids collided\n");
|
||||
return;
|
||||
}
|
||||
write("thread-id: ok\n"); // the M6 verdict marker
|
||||
}
|
||||
|
||||
// --- M7: alloc mode (concurrent heap allocation) ----------------------------
|
||||
|
||||
const alloc_threads: u32 = 4;
|
||||
const allocs_per_thread: u32 = 500;
|
||||
var allocs_clean = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn allocWorker(seed: u32) void {
|
||||
const gpa = memory.allocator();
|
||||
var rng: u32 = seed | 1;
|
||||
var round: u32 = 0;
|
||||
while (round < allocs_per_thread) : (round += 1) {
|
||||
rng = rng *% 1664525 +% 1013904223; // cheap LCG for varied sizes
|
||||
const size: usize = 16 + (rng % 4080); // 16..4095 bytes
|
||||
const buf = gpa.alloc(u8, size) catch return; // OOM: don't count this thread clean
|
||||
const pattern: u8 = @truncate(seed +% round);
|
||||
@memset(buf, pattern);
|
||||
// Nothing else should touch our block; if a concurrent allocation overlapped it,
|
||||
// one of us would read the other's pattern here.
|
||||
var ok = true;
|
||||
for (buf) |b| {
|
||||
if (b != pattern) ok = false;
|
||||
}
|
||||
gpa.free(buf);
|
||||
if (!ok) return; // corruption — leave without counting clean
|
||||
}
|
||||
_ = allocs_clean.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
fn runAllocMode() void {
|
||||
write("thread-alloc: starting\n");
|
||||
var threads: [alloc_threads]Thread = undefined;
|
||||
var n: u32 = 0;
|
||||
while (n < alloc_threads) : (n += 1) {
|
||||
threads[n] = Thread.spawn(.{}, allocWorker, .{n +% 1}) catch {
|
||||
write("thread-alloc: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
}
|
||||
for (threads[0..alloc_threads]) |t| t.join();
|
||||
|
||||
// Every thread must have completed all rounds with each block intact — proof the
|
||||
// shared heap and the per-address_space mmap arena are safe under concurrent allocation.
|
||||
if (allocs_clean.load(.acquire) != alloc_threads) {
|
||||
write("thread-alloc: FAIL corruption or OOM under concurrent allocation\n");
|
||||
return;
|
||||
}
|
||||
write("thread-alloc: ok\n"); // the M7 verdict marker
|
||||
}
|
||||
|
||||
// --- M10: tls mode (per-thread FS base storage) -----------------------------
|
||||
|
||||
fn writeTlsSlot(value: u64) void {
|
||||
asm volatile ("movq %[v], %%fs:8"
|
||||
:
|
||||
: [v] "r" (value),
|
||||
: .{ .memory = true });
|
||||
}
|
||||
|
||||
fn readTlsSlot() u64 {
|
||||
return asm volatile ("movq %%fs:8, %[out]"
|
||||
: [out] "=r" (-> u64),
|
||||
:
|
||||
: .{ .memory = true });
|
||||
}
|
||||
|
||||
var tls_written = std.atomic.Value(u32).init(0);
|
||||
var tls_ok = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn tlsWorker(marker: u64) void {
|
||||
writeTlsSlot(marker);
|
||||
_ = tls_written.fetchAdd(1, .release);
|
||||
// Wait until both threads have written their own slot. If the FS base were shared, the
|
||||
// second write would clobber the first, and the read below would return the wrong
|
||||
// marker — cross-talk. A per-thread FS base keeps each thread's slot private.
|
||||
var spins: usize = 0;
|
||||
while (tls_written.load(.acquire) < 2 and spins < 50_000_000) : (spins += 1) {
|
||||
process.yield();
|
||||
}
|
||||
if (readTlsSlot() == marker and Thread.getCurrentId() != 0) {
|
||||
_ = tls_ok.fetchAdd(1, .monotonic);
|
||||
}
|
||||
}
|
||||
|
||||
fn runTlsMode() void {
|
||||
write("thread-tls: starting\n");
|
||||
const t0 = Thread.spawn(.{}, tlsWorker, .{@as(u64, 0xAAAA_0000)}) catch {
|
||||
write("thread-tls: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
const t1 = Thread.spawn(.{}, tlsWorker, .{@as(u64, 0xBBBB_0000)}) catch {
|
||||
write("thread-tls: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
t0.join();
|
||||
t1.join();
|
||||
if (tls_ok.load(.acquire) == 2) {
|
||||
write("thread-tls: ok\n"); // the M10 verdict marker
|
||||
} else {
|
||||
write("thread-tls: FAIL cross-talk (FS base not per-thread)\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- M11: rwlock mode (readers/writers over an RwLock) ----------------------
|
||||
|
||||
const RwLock = Thread.RwLock;
|
||||
|
||||
var rwlock = RwLock{};
|
||||
var rw_a: u64 = 0;
|
||||
var rw_b: u64 = 0; // invariant while any lock is held: rw_a == rw_b
|
||||
var rw_stop = std.atomic.Value(u32).init(0);
|
||||
var rw_violations = std.atomic.Value(u32).init(0);
|
||||
var rw_reads = std.atomic.Value(u64).init(0);
|
||||
|
||||
fn rwWriter() void {
|
||||
var v: u64 = 1;
|
||||
while (rw_stop.load(.acquire) == 0) : (v +%= 1) {
|
||||
rwlock.lock(); // exclusive: no reader may observe the gap between the two writes
|
||||
rw_a = v;
|
||||
rw_b = v;
|
||||
rwlock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
fn rwReader() void {
|
||||
const reads: u64 = 50_000;
|
||||
var i: u64 = 0;
|
||||
while (i < reads) : (i += 1) {
|
||||
rwlock.lockShared();
|
||||
if (rw_a != rw_b) _ = rw_violations.fetchAdd(1, .monotonic); // saw a half-write!
|
||||
rwlock.unlockShared();
|
||||
}
|
||||
_ = rw_reads.fetchAdd(reads, .monotonic);
|
||||
}
|
||||
|
||||
fn runRwlockMode() void {
|
||||
write("thread-rwlock: starting\n");
|
||||
var writers: [2]Thread = undefined;
|
||||
var readers: [3]Thread = undefined;
|
||||
for (&writers) |*w| {
|
||||
w.* = Thread.spawn(.{}, rwWriter, .{}) catch {
|
||||
write("thread-rwlock: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
}
|
||||
for (&readers) |*r| {
|
||||
r.* = Thread.spawn(.{}, rwReader, .{}) catch {
|
||||
write("thread-rwlock: FAIL spawn\n");
|
||||
return;
|
||||
};
|
||||
}
|
||||
for (readers) |r| r.join();
|
||||
rw_stop.store(1, .release); // readers done → stop the writers
|
||||
for (writers) |w| w.join();
|
||||
|
||||
if (rw_violations.load(.acquire) == 0 and rw_reads.load(.acquire) > 0) {
|
||||
write("thread-rwlock: ok\n"); // the M11 verdict marker
|
||||
} else {
|
||||
write("thread-rwlock: FAIL reader observed a half-written value\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared-fate modes (docs/shared-fate-plan.md M4) -------------------------
|
||||
|
||||
fn faultNullPage() void {
|
||||
@as(*volatile u32, @ptrFromInt(0x10)).* = 1; // unmapped null page: #PF, group death
|
||||
}
|
||||
|
||||
fn faultingWorker() void {
|
||||
write("thread-test: worker faulting\n");
|
||||
faultNullPage();
|
||||
}
|
||||
|
||||
/// fault-worker: a worker faults; shared fate must end the whole group, so the
|
||||
/// main thread parks forever and never prints anything more.
|
||||
fn runFaultWorkerMode() void {
|
||||
write("thread-test: spawning faulting worker\n");
|
||||
_ = Thread.spawn(.{}, faultingWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
while (true) process.yield();
|
||||
}
|
||||
|
||||
fn spinningWorker() void {
|
||||
while (true) {} // no system calls: only a tick can deliver a deferred kill
|
||||
}
|
||||
|
||||
/// spin-forever: a kill target. The worker spins without syscalls (the condemned
|
||||
/// path); the main thread yields (the parked path).
|
||||
fn runSpinForeverMode() void {
|
||||
_ = Thread.spawn(.{}, spinningWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
write("thread-test: spinning\n");
|
||||
while (true) process.yield();
|
||||
}
|
||||
|
||||
fn exitingWorker() void {
|
||||
write("thread-test: worker exiting the process\n");
|
||||
process.exit(3); // exit from ANY thread is group death (.aborted)
|
||||
}
|
||||
|
||||
/// exit-worker: a WORKER calls exit(3); the group must die with the leader's
|
||||
/// reason reading .aborted.
|
||||
fn runExitWorkerMode() void {
|
||||
_ = Thread.spawn(.{}, exitingWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
while (true) process.yield();
|
||||
}
|
||||
|
||||
var race_go = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn racingWorker() void {
|
||||
while (race_go.load(.acquire) == 0) {}
|
||||
faultNullPage();
|
||||
}
|
||||
|
||||
/// race: two members fault as near-simultaneously as user space can arrange —
|
||||
/// the group-dying latch must make the two triggers count as one death.
|
||||
fn runRaceMode() void {
|
||||
_ = Thread.spawn(.{}, racingWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
write("thread-test: racing\n");
|
||||
race_go.store(1, .release);
|
||||
faultNullPage();
|
||||
}
|
||||
|
||||
var leader_exit_done = std.atomic.Value(u32).init(0);
|
||||
|
||||
fn patientWorker() void {
|
||||
while (leader_exit_done.load(.acquire) == 0) process.yield();
|
||||
}
|
||||
|
||||
/// leader-exit: the MAIN thread asks for thread_exit; the kernel must refuse
|
||||
/// (-EPERM) and the worker must be entirely unaffected.
|
||||
fn runLeaderExitMode() void {
|
||||
const worker = Thread.spawn(.{}, patientWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
Thread.tryExitCurrent(); // refused: we are the leader
|
||||
write("thread-test: leader thread_exit refused ok\n");
|
||||
leader_exit_done.store(1, .release);
|
||||
worker.join();
|
||||
}
|
||||
|
||||
fn promptWorker() void {}
|
||||
|
||||
/// solo: regression — a WORKER's thread_exit stays per-thread; the sibling
|
||||
/// (main) survives it.
|
||||
fn runSoloMode() void {
|
||||
const worker = Thread.spawn(.{}, promptWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
worker.join();
|
||||
write("thread-test: solo sibling survived ok\n");
|
||||
}
|
||||
|
||||
const shm_pattern_length: usize = 4096;
|
||||
var shm_region_base = std.atomic.Value(usize).init(0);
|
||||
|
||||
fn patternByte(i: usize) u8 {
|
||||
return @truncate(i *% 31 +% 7);
|
||||
}
|
||||
|
||||
fn shmWorker() void {
|
||||
const region = memory.sharedCreate(shm_pattern_length) orelse {
|
||||
write("thread-shm: FAIL create refused\n");
|
||||
return;
|
||||
};
|
||||
for (0..shm_pattern_length) |i| region.ptr[i] = patternByte(i);
|
||||
shm_region_base.store(@intFromPtr(region.ptr), .release);
|
||||
// Returning thread_exits this worker: its handle table — holding the
|
||||
// region's ONLY handle — closes. The sibling's view must survive on the
|
||||
// mapping reference (docs/shared-fate-plan.md M3).
|
||||
}
|
||||
|
||||
/// shm-worker: the M3 use-after-free regression. The worker creates and fills a
|
||||
/// shared-memory region and dies; the main thread churns the frame allocator and
|
||||
/// then checks the mapping is intact — freed frames would have been reused and
|
||||
/// scribbled on.
|
||||
fn runShmWorkerMode() void {
|
||||
const worker = Thread.spawn(.{}, shmWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
worker.join();
|
||||
const base = shm_region_base.load(.acquire);
|
||||
if (base == 0) return; // the worker already printed the failure
|
||||
var churn: usize = 0;
|
||||
while (churn < 8) : (churn += 1) {
|
||||
const noise = memory.sharedCreate(shm_pattern_length) orelse break;
|
||||
@memset(noise.ptr[0..shm_pattern_length], 0xFF);
|
||||
}
|
||||
const view: [*]const u8 = @ptrFromInt(base);
|
||||
var intact = true;
|
||||
for (0..shm_pattern_length) |i| {
|
||||
if (view[i] != patternByte(i)) intact = false;
|
||||
}
|
||||
if (intact) {
|
||||
write("thread-shm: mapping survives creator ok\n");
|
||||
} else {
|
||||
write("thread-shm: FAIL mapping corrupted after creator death\n");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
const mode = init.arguments.get(1) orelse "spawn";
|
||||
if (std.mem.eql(u8, mode, "join")) {
|
||||
runJoinMode();
|
||||
} else if (std.mem.eql(u8, mode, "futex")) {
|
||||
runFutexMode();
|
||||
} else if (std.mem.eql(u8, mode, "mutex")) {
|
||||
runMutexMode();
|
||||
} else if (std.mem.eql(u8, mode, "id")) {
|
||||
runIdMode();
|
||||
} else if (std.mem.eql(u8, mode, "alloc")) {
|
||||
runAllocMode();
|
||||
} else if (std.mem.eql(u8, mode, "tls")) {
|
||||
runTlsMode();
|
||||
} else if (std.mem.eql(u8, mode, "rwlock")) {
|
||||
runRwlockMode();
|
||||
} else if (std.mem.eql(u8, mode, "fault-worker")) {
|
||||
runFaultWorkerMode();
|
||||
} else if (std.mem.eql(u8, mode, "spin-forever")) {
|
||||
runSpinForeverMode();
|
||||
} else if (std.mem.eql(u8, mode, "exit-worker")) {
|
||||
runExitWorkerMode();
|
||||
} else if (std.mem.eql(u8, mode, "race")) {
|
||||
runRaceMode();
|
||||
} else if (std.mem.eql(u8, mode, "leader-exit")) {
|
||||
runLeaderExitMode();
|
||||
} else if (std.mem.eql(u8, mode, "solo")) {
|
||||
runSoloMode();
|
||||
} else if (std.mem.eql(u8, mode, "shm-worker")) {
|
||||
runShmWorkerMode();
|
||||
} else {
|
||||
runSpawnMode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! /test/system/services/vfs-test — a ring-3 client that proves the kernel VFS
|
||||
//! end to end through the plain `file_system` API: resolve its OWN binary under
|
||||
//! the kernel-served /test mount, check its metadata, read its ELF magic, and
|
||||
//! list /system/services. On success it heartbeats "vfstest: ok" so the kernel
|
||||
//! test can observe it; on failure it reports what went wrong.
|
||||
//!
|
||||
//! The "park" role (the fat-client-death test): open a file on the FAT volume,
|
||||
//! then hold the handle forever without closing — the kill and the fat
|
||||
//! server's release-on-death sweep are the point.
|
||||
|
||||
const std = @import("std");
|
||||
const fs = @import("file-system");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
const logging = @import("logging");
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
if (init.arguments.count > 1) {
|
||||
park();
|
||||
return;
|
||||
}
|
||||
|
||||
// Our own binary, resolved through the kernel mount table.
|
||||
const self_path = "/test/system/services/vfs-test";
|
||||
var file = fs.open(self_path, .{}) orelse {
|
||||
_ = logging.write("vfstest: open of own binary failed\n");
|
||||
return;
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const attributes = file.attributes() orelse {
|
||||
_ = logging.write("vfstest: attributes failed\n");
|
||||
return;
|
||||
};
|
||||
if (attributes.kind != .regular or attributes.size == 0) {
|
||||
_ = logging.write("vfstest: bad attributes\n");
|
||||
return;
|
||||
}
|
||||
|
||||
var header: [4]u8 = undefined;
|
||||
const n = file.read(&header) orelse 0;
|
||||
if (n != 4 or header[0] != 0x7f or header[1] != 'E' or header[2] != 'L' or header[3] != 'F') {
|
||||
_ = logging.write("vfstest: ELF magic mismatch\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// The write refusal: the initrd trees are read-only by construction.
|
||||
if (file.write("x") != null or fs.open("/test/system/services/new-file", .{ .create = true }) != null) {
|
||||
_ = logging.write("vfstest: the initrd tree accepted a write\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Listing: /system/services contains init.
|
||||
var saw_init = false;
|
||||
if (fs.openDirectory("/system/services")) |listing| {
|
||||
var directory = listing;
|
||||
defer directory.close();
|
||||
var entry: fs.Entry = .{};
|
||||
while (directory.next(&entry)) {
|
||||
if (std.mem.eql(u8, entry.name(), "init")) saw_init = true;
|
||||
}
|
||||
}
|
||||
if (!saw_init) {
|
||||
_ = logging.write("vfstest: /system/services listing missed init\n");
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
_ = logging.write("vfstest: ok\n");
|
||||
time.sleepMillis(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn park() void {
|
||||
// The storage chain (usb -> block -> fat -> mounts) takes a few seconds;
|
||||
// retry until the volume appears.
|
||||
var parked: ?fs.File = null;
|
||||
var tries: u32 = 0;
|
||||
while (parked == null and tries < 1000) : (tries += 1) {
|
||||
parked = fs.open("/mnt/usb/parked", .{ .create = true });
|
||||
if (parked == null) time.sleepMillis(20);
|
||||
}
|
||||
if (parked == null) {
|
||||
_ = logging.write("vfstest: park open failed\n");
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
_ = logging.write("vfstest: parked\n");
|
||||
time.sleepMillis(500);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user