danos/test/system/services/thread-test/thread-test.zig

661 lines
21 KiB
Zig

//! 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();
}
}