diff --git a/build.zig b/build.zig index 1d9dc58..b6e6dea 100644 --- a/build.zig +++ b/build.zig @@ -69,6 +69,36 @@ fn addUserBinary( acpi_ids_module: *std.Build.Module, name: []const u8, root: []const u8, +) *std.Build.Step.Compile { + return addUserBinaryImpl(b, target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, name, root, false); +} + +/// As `addUserBinary`, but built multi-threaded (`single_threaded = false`) so real +/// atomics/TLS work — required before a binary may call `runtime.Thread.spawn` +/// (docs/threading.md). Threads are a deliberate per-binary opt-in. +fn addThreadedUserBinary( + b: *std.Build, + target: std.Build.ResolvedTarget, + runtime_module: *std.Build.Module, + mmio_module: *std.Build.Module, + xkeyboard_config_module: *std.Build.Module, + acpi_ids_module: *std.Build.Module, + name: []const u8, + root: []const u8, +) *std.Build.Step.Compile { + return addUserBinaryImpl(b, target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, name, root, true); +} + +fn addUserBinaryImpl( + b: *std.Build, + target: std.Build.ResolvedTarget, + runtime_module: *std.Build.Module, + mmio_module: *std.Build.Module, + xkeyboard_config_module: *std.Build.Module, + acpi_ids_module: *std.Build.Module, + name: []const u8, + root: []const u8, + threaded: bool, ) *std.Build.Step.Compile { // Settings (target, optimize, code model, ...) live on the root module only; // the program and runtime modules leave theirs null and inherit them. @@ -93,7 +123,7 @@ fn addUserBinary( .target = target, .optimize = .ReleaseSmall, .code_model = .large, - .single_threaded = true, + .single_threaded = !threaded, // a threaded binary needs real atomics/TLS .sanitize_c = .off, .stack_check = false, .stack_protector = false, @@ -548,6 +578,9 @@ pub fn build(b: *std.Build) void { const args_echo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "args-echo", "system/services/args-echo/args-echo.zig"); const process_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "process-test", "system/services/process-test/process-test.zig"); const log_flush_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "log-flush", "system/services/log-flush/log-flush.zig"); + // The first multi-threaded binary: exercises runtime.Thread over the thread ABI + // (docs/threading.md). Built threaded so its shared-memory poll is real. + const thread_test_exe = addThreadedUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "thread-test", "system/services/thread-test/thread-test.zig"); // Pack the user binaries into the initial_ramdisk image with the host-side Python tool // (the container format is trivial, and Python sidesteps std API churn). Args: @@ -591,6 +624,8 @@ pub fn build(b: *std.Build) void { mk_run.addFileArg(pci_bus_exe.getEmittedBin()); mk_run.addArg("crash-test"); mk_run.addFileArg(crash_test_exe.getEmittedBin()); + mk_run.addArg("thread-test"); + mk_run.addFileArg(thread_test_exe.getEmittedBin()); mk_run.addArg("device-list"); mk_run.addFileArg(device_list_exe.getEmittedBin()); mk_run.addArg("discovery"); diff --git a/docs/threading-plan.md b/docs/threading-plan.md index 4843167..4759c66 100644 --- a/docs/threading-plan.md +++ b/docs/threading-plan.md @@ -120,29 +120,39 @@ full guardrail set passes unchanged — 13/13 (`smoke`, `sched`, `priority`, `sm `vfs-client-death`, `ipc`, `ipc-cap`, `display-service`); default `zig build` clean, `zig build test` green. The reframing is invisible until an aspace is actually shared. -## M2 — `thread_spawn` + `thread_exit`: a thread runs in the shared address space +## M2 — `thread_spawn` + `thread_exit`: a thread runs in the shared address space ✅ Spawn only — no join yet. Prove a second task executes in the **caller's** address space and exits cleanly. -- [ ] [abi.zig](../system/abi.zig): `thread_spawn = 37`, `thread_exit = 38`. Handlers - in process.zig; `thread_spawn` calls the `spawnUserLocked` path with the caller's - aspace (`retainAspace`), `entry`, `user_sp`; `thread_exit` marks the task dead, - hands back its user-stack range, and `releaseAspace`s. -- [ ] `library/runtime/thread.zig` (barrel-exported as `runtime.Thread`): `spawn` maps - a stack (`mmap`), heap-allocates the closure `{fn, args, done}`, writes the - closure pointer to the stack top, calls `thread_spawn(&threadTrampoline, top, - closure)`. `threadTrampoline` reads the closure, calls the function, calls - `thread_exit`. -- [ ] `addUserBinary` gains `threaded: bool = false` → `single_threaded = false`; a - `thread-test` service (threaded) is the first opt-in binary. -- [ ] `-Dtest-case=thread-spawn`: the parent spawns one thread that writes a sentinel - to a **shared** global and sets `done`; the parent bounded-polls `done`, then - asserts the shared global holds the sentinel (same address space) and frames - return to baseline (thread exit released nothing extra — refcount held). +- [x] [abi.zig](../system/abi.zig): `thread_spawn = 37`, `thread_exit = 38`. Handlers in + process.zig; `thread_spawn` calls `scheduler.spawnThread` (shares the caller's + aspace, `retainAspace`); `thread_exit` ends the task like a process `exit(0)` + (`terminateCurrent` → `releaseAspace`). The closure pointer is delivered in the new + thread's **rdi** via a new `jump_to_user_arg` asm path (`t.user_arg`, 0 for a + process) — no naked runtime asm. +- [x] `library/runtime/thread.zig` (barrel-exported as `runtime.Thread`): `spawn` maps a + stack (`mmap`), heap-allocates the `{args}` closure, and calls + `thread_spawn(&Closure.entry, stack_top, closure)`; `Closure.entry` (a plain C-ABI + Zig fn, closure in rdi) runs the function and calls `thread_exit`. Stack top is + 16-aligned-minus-8 for the C entry. +- [x] A `threaded` flag on the user-binary recipe (`addThreadedUserBinary` → + `single_threaded = false`); `thread-test` is the first opt-in binary. +- [x] `-Dtest-case=thread-spawn`: `thread-test` spawns a worker that writes a sentinel to + a **shared** global and release-stores `done`; the main thread acquire-polls `done` + and asserts the shared global holds the sentinel — proof the worker ran in the same + address space. -**Gate:** `python3 test/qemu_test.py thread-spawn` logs `thread: child ran in shared -aspace ok`; guardrail set green. +**Gate (met):** `python3 test/qemu_test.py thread-spawn` passes +(`thread-test: child ran in shared aspace ok` → `DANOS-TEST-RESULT: PASS`); guardrail set +16/16 green (incl. `args`/`init`/`process`, which exercise the new `jump_to_user_arg` +process path with arg 0) plus `aspace-refcount`; `zig build` clean, `zig build test` +green. + +> **Note (deferred to M3+):** the mmap arena is per-*task* (`heap_next`), so two threads +> in one aspace that both `mmap` would collide. Fine for M2 (only the parent maps, for the +> child's stack); make the arena per-aspace and the runtime heap thread-safe alongside the +> `Mutex` work (M5). ## M3 — `join` + `detach` + real parallelism diff --git a/library/runtime/runtime.zig b/library/runtime/runtime.zig index 67d7aee..0533d7c 100644 --- a/library/runtime/runtime.zig +++ b/library/runtime/runtime.zig @@ -70,6 +70,10 @@ pub const panic = start.panic; /// Process entry types: the `Init` handed to `main`, and its `Arguments`. pub const process = @import("process.zig"); +/// Threads: `runtime.Thread`, std.Thread-shaped, over the private thread ABI +/// (docs/threading.md). A binary must be built multi-threaded to spawn. +pub const Thread = @import("thread.zig").Thread; + /// The service harness: one replyWait loop folding requests, signals, and /// notifications into callbacks (docs/process-lifecycle.md). pub const service = @import("service.zig"); diff --git a/library/runtime/thread.zig b/library/runtime/thread.zig new file mode 100644 index 0000000..50f59f5 --- /dev/null +++ b/library/runtime/thread.zig @@ -0,0 +1,89 @@ +//! `runtime.Thread` — threads for danos, shaped like Zig's `std.Thread` but built on +//! danos's private thread ABI (docs/threading.md). Several tasks share one address +//! space; `spawn` starts one, the kernel delivers the closure pointer in the new +//! thread's rdi, and a small Zig trampoline runs the user function and calls +//! `thread_exit`. See docs/threading.md for why this mirrors `std.Thread`'s API rather +//! than being the literal type (the private, renumberable syscall ABI). +//! +//! M2 surface: `spawn` + a `Thread` handle. `join`/`detach` and the `Mutex`/`Condition` +//! family arrive in later milestones (docs/threading-plan.md). A binary must be built +//! multi-threaded (`addThreadedUserBinary`) before it may spawn. + +const std = @import("std"); +const sc = @import("system-call.zig"); +const system = @import("system.zig"); +const heap = @import("heap.zig"); + +/// A thread stack, if the caller does not override it. 64 KiB of mmap'd, zeroed pages. +pub const default_stack_size: usize = 64 * 1024; + +pub const Thread = struct { + /// The kernel task id of the spawned thread. + tid: u32, + + pub const Id = u32; + + pub const SpawnConfig = struct { + /// Bytes of stack, rounded up to whole pages by the kernel's mmap. + stack_size: usize = default_stack_size, + }; + + pub const SpawnError = error{ + /// The closure could not be allocated on the heap. + OutOfMemory, + /// The kernel refused the thread (task table full, or the stack mmap failed). + SystemResources, + }; + + /// Start `function(args...)` on a new thread sharing this address space. Mirrors + /// `std.Thread.spawn`. The thread's return value is discarded (as in `std.Thread`); + /// return data through shared state. + pub fn spawn(config: SpawnConfig, comptime function: anytype, args: anytype) SpawnError!Thread { + const Args = @TypeOf(args); + const Closure = struct { + args: Args, + + /// Entered directly by the kernel with `self` in rdi (C ABI). Runs the + /// user function, then ends the thread — never returns. + fn entry(self_addr: usize) callconv(.c) noreturn { + const self: *@This() = @ptrFromInt(self_addr); + const call_args = self.args; + heap.allocator().destroy(self); // args copied out; closure no longer needed + @call(.auto, function, call_args); + exitThread(); + } + }; + + const closure = heap.allocator().create(Closure) catch return error.OutOfMemory; + errdefer heap.allocator().destroy(closure); + closure.* = .{ .args = args }; + + // Stack: mmap zeroed pages, then hand the kernel a 16-byte-aligned-minus-8 top so + // the C-ABI trampoline sees the alignment a `call` would have left (rsp % 16 == 8). + const base = system.mmap(config.stack_size, system.PROT_READ | system.PROT_WRITE); + if (system.mmapFailed(base)) return error.SystemResources; + errdefer _ = system.munmap(base, config.stack_size); + const stack_top = (base + config.stack_size) - 8; + + const tid = threadSpawn(@intFromPtr(&Closure.entry), stack_top, @intFromPtr(closure)); + if (threadSpawnFailed(tid)) return error.SystemResources; + return .{ .tid = @intCast(tid) }; + } +}; + +/// thread_spawn(entry, stack_top, arg) -> tid, or a wrapped error (see below). +fn threadSpawn(entry: usize, stack_top: usize, arg: usize) usize { + return sc.systemCall3(.thread_spawn, entry, stack_top, arg); +} + +/// The kernel returns a real (small) task id on success and a wrapped `-1` on failure; +/// no valid task id ever exceeds a u32. +inline fn threadSpawnFailed(ret: usize) bool { + return ret > std.math.maxInt(u32); +} + +/// End the calling thread. Never returns. +fn exitThread() noreturn { + _ = sc.systemCall0(.thread_exit); + unreachable; +} diff --git a/system/abi.zig b/system/abi.zig index 8524d8d..db4e8c7 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -63,6 +63,8 @@ pub const SystemCall = enum(u64) { shm_create = 34, // shm_create(len) -> vaddr (rax), handle (rdx): a shareable, zeroed, cacheable RAM region mapped into this AS; the handle is a capability passed to another process as an ipc_call send_cap (docs/display-v2.md) shm_map = 35, // shm_map(cap) -> vaddr: map the shared region named by a received capability into this AS (the same physical pages the creator sees) shm_physical = 36, // shm_physical(cap) -> paddr: the guest-physical base of a shared region held by capability, so a driver can program it into a device (e.g. virtio-gpu attach_backing); the pages are contiguous (docs/display-v2.md) + thread_spawn = 37, // thread_spawn(entry, stack_top, arg) -> tid: start a task that shares the caller's address space at `entry` on `stack_top`, with `arg` in rdi (docs/threading.md) + thread_exit = 38, // thread_exit(): end the calling thread, dropping one reference to its address space (destroyed on the last) _, }; diff --git a/system/kernel/architecture/x86_64/cpu.zig b/system/kernel/architecture/x86_64/cpu.zig index 9fb29be..3625c2a 100644 --- a/system/kernel/architecture/x86_64/cpu.zig +++ b/system/kernel/architecture/x86_64/cpu.zig @@ -687,6 +687,15 @@ pub fn jumpToUser(entry: u64, stack_top: u64) noreturn { jump_to_user(entry, stack_top); } +/// As `jumpToUser`, but delivers `arg0` in the user's `rdi` — how a fresh thread +/// receives its closure pointer (docs/threading.md). A normal process is dropped +/// with `arg0 = 0`, which its `_start` ignores (it reads argv off the stack). +extern fn jump_to_user_arg(rip: u64, rsp: u64, arg0: u64) callconv(.c) noreturn; + +pub fn jumpToUserArg(entry: u64, stack_top: u64, arg0: u64) noreturn { + jump_to_user_arg(entry, stack_top, arg0); +} + /// Route CPU exceptions to `handler`, which receives the trap frame and does not /// return. Until set, faults just halt the core. pub fn setFaultHandler(handler: *const fn (*const CpuState) noreturn) void { diff --git a/system/kernel/architecture/x86_64/isr.s b/system/kernel/architecture/x86_64/isr.s index 5fb2d45..8029822 100644 --- a/system/kernel/architecture/x86_64/isr.s +++ b/system/kernel/architecture/x86_64/isr.s @@ -123,6 +123,22 @@ jump_to_user: swapgs # user GS base (isr_common/syscall swap back on entry) iretq +# jump_to_user_arg(rdi = user rip, rsi = user rsp, rdx = user rdi/arg0): as +# jump_to_user, but delivers arg0 in the user's rdi — how a fresh **thread** +# receives its closure pointer (docs/threading.md). rdi carries the rip only until +# it is pushed into the iretq frame, after which we overwrite it with the arg. +.global jump_to_user_arg +jump_to_user_arg: + cli + push $0x1B # user SS (0x18 | RPL 3) + push %rsi # user RSP + push $0x202 # RFLAGS: IF | reserved-1 + push $0x23 # user CS (0x20 | RPL 3) + push %rdi # user RIP (consumes rdi) + mov %rdx, %rdi # user rdi = arg0 (the thread's closure pointer) + swapgs # user GS base (isr_common/syscall swap back on entry) + iretq + # --- ring 3 entry/exit ------------------------------------------------------ # enter_user(rdi = user rip, rsi = user rsp, rdx = &TSS.rsp0) diff --git a/system/kernel/process.zig b/system/kernel/process.zig index aa41881..041e712 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -227,6 +227,16 @@ fn system_call(state: *architecture.CpuState) void { .shm_create => systemShmCreate(state), .shm_map => systemShmMap(state), .shm_physical => systemShmPhysical(state), + .thread_spawn => systemThreadSpawn(state), + .thread_exit => { + // A thread ends like a process exit(0), but only this task: its + // resources are released and its address-space reference dropped (the + // space survives while sibling threads hold it). docs/threading.md. + if (scheduler.currentIsUserProcess()) { + scheduler.current().exit_reason = .exited; + terminateCurrent(); + } else architecture.userExit(); + }, _ => fail(state), } } @@ -642,6 +652,23 @@ fn systemSpawn(state: *architecture.CpuState) void { fail(state); // no bundled binary by that name } +/// thread_spawn(entry, stack_top, arg) -> tid: start a task that shares the **caller's** +/// address space (docs/threading.md). The runtime supplies `entry` (its thread +/// trampoline), a stack it mmap'd, and the closure pointer, which the kernel delivers in +/// the new thread's rdi. The entry and stack must lie in the user half; the new thread is +/// supervised by the caller and inherits its priority. Only a user process may spawn. +fn systemThreadSpawn(state: *architecture.CpuState) void { + const entry = architecture.systemCallArg(state, 0); + const stack_top = architecture.systemCallArg(state, 1); + const arg = architecture.systemCallArg(state, 2); + const t = scheduler.current(); + if (t.aspace == 0) return fail(state); // kernel tasks own no address space to share + if (entry == 0 or entry >= user_half_end) return fail(state); + if (stack_top == 0 or stack_top > user_half_end) return fail(state); + const tid = scheduler.spawnThread(t.aspace, entry, stack_top, arg, t.priority, t.id) orelse return fail(state); + architecture.setSystemCallResult(state, tid); +} + /// process_enumerate(buffer, maximum) -> total: snapshot the task table into the /// caller's buffer (up to `maximum` `abi.ProcessDescriptor` entries), returning /// the total live-task count — the exact shape of `device_enumerate`, so a `ps` @@ -1433,7 +1460,7 @@ pub fn spawnProcessSupervised(image: []const u8, priority: u3, argv: []const []c architecture.mapUserPageInto(aspace, page_virtual, stack_frame, true, false); // RW + NX } - const child = scheduler.spawnUserLocked(aspace, parsed.entry, user_sp, priority, argv[0], supervisor, if (exit_endpoint) |endpoint| @ptrCast(endpoint) else null) orelse + const child = scheduler.spawnUserLocked(aspace, parsed.entry, user_sp, 0, priority, argv[0], supervisor, if (exit_endpoint) |endpoint| @ptrCast(endpoint) else null) orelse return error.OutOfMemory; // The child holds a reference to its exit endpoint from birth to death. Taken // only now, after nothing can fail; the lock is still held, so the child diff --git a/system/kernel/scheduler.zig b/system/kernel/scheduler.zig index 50de887..4756e3a 100644 --- a/system/kernel/scheduler.zig +++ b/system/kernel/scheduler.zig @@ -78,6 +78,8 @@ pub const Task = struct { aspace: u64 = 0, user_ip: u64 = 0, // user-mode entry point (user task only) user_sp: u64 = 0, // user-mode stack pointer (user task only) + user_arg: u64 = 0, // value delivered in the user's rdi at first entry: 0 for a + // process (its _start ignores it), the closure pointer for a thread (docs/threading.md) // Next free virtual address in this task's mmap grant arena (0 = uninitialised; // process.zig lazily seeds it to the arena base on the first mmap). Bumped up // as the user heap grows; user task only. @@ -388,7 +390,7 @@ pub fn spawnOn(entry: *const fn () void, priority: Priority, cpu: u32) bool { /// out of memory. /// **Caller must hold the kernel lock** (the loader that builds `aspace` holds it /// across the whole spawn, so the address space and the task appear atomically). -pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority, task_name: []const u8, supervisor: u32, exit_endpoint: ?*anyopaque) ?u32 { +pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, user_arg: u64, priority: Priority, task_name: []const u8, supervisor: u32, exit_endpoint: ?*anyopaque) ?u32 { const t = freeSlot() orelse return null; const stack = heap.allocator().alloc(u8, stack_size) catch return null; // Take this task's reference to the address space before we commit the slot, so a @@ -405,6 +407,7 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority .aspace = aspace, .user_ip = entry, .user_sp = user_sp, + .user_arg = user_arg, .supervisor = supervisor, .exit_endpoint = exit_endpoint, }; @@ -421,6 +424,17 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority return t.id; } +/// Spawn a **thread**: a user task that shares an *existing* address space `aspace` +/// (docs/threading.md), starting at `entry` on `user_sp` with `arg` delivered in its +/// rdi. Takes a reference to `aspace` (destroyed only when the last thread on it +/// exits). Acquires the kernel lock itself. `supervisor` is the spawning process. +/// Returns the new thread's id, or null if the task table is full / out of memory. +pub fn spawnThread(aspace: u64, entry: u64, user_sp: u64, arg: u64, priority: Priority, supervisor: u32) ?u32 { + const flags = sync.enter(); + defer sync.leave(flags); + return spawnUserLocked(aspace, entry, user_sp, arg, priority, "thread", supervisor, null); +} + /// The first thing a fresh user task runs (in ring 0, via task_trampoline). It /// drops to ring 3 at the task's recorded entry/stack. Reading them from the /// Task avoids smuggling values through callee-saved registers across the @@ -430,7 +444,7 @@ fn startUserTask() void { // No serial chatter here: this runs on every spawn, unserialized against // user-space writes, and its output used to shear concurrent log lines in // half — the largest source of corrupted markers in the QEMU scenarios. - architecture.jumpToUser(t.user_ip, t.user_sp); // noreturn + architecture.jumpToUserArg(t.user_ip, t.user_sp, t.user_arg); // noreturn (arg0 = 0 for a process) } /// The unlocked task-creation primitive. Caller must hold the kernel lock (or be the diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 64fd8e9..f79b3e8 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -141,6 +141,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { faultRecoveryTest(boot_information); } else if (eql(case, "aspace-refcount")) { aspaceRefcountTest(boot_information); + } else if (eql(case, "thread-spawn")) { + threadSpawnTest(boot_information); } else if (eql(case, "args")) { argsTest(boot_information); } else if (eql(case, "init")) { @@ -1376,7 +1378,7 @@ fn spawnFaultingProcess() ?u32 { architecture.mapUserPageInto(aspace, process.stack_base_virtual, stack_frame, true, false); // RW + NX // Supervised by the calling test task, so exitReasonOf can read the verdict. - const id = scheduler.spawnUserLocked(aspace, process.code_virtual, process.stack_base_virtual + abi.page_size, 4, "fault-probe", scheduler.currentId(), null) orelse { + const id = scheduler.spawnUserLocked(aspace, process.code_virtual, process.stack_base_virtual + abi.page_size, 0, 4, "fault-probe", scheduler.currentId(), null) orelse { architecture.destroyAddressSpace(aspace); return null; }; @@ -1466,6 +1468,42 @@ fn aspaceRefcountTest(boot_information: *const BootInformation) void { result(); } +/// Thread spawn (docs/threading-plan.md M2): the `thread-test` service spawns a worker +/// thread that writes a shared global; the main thread, polling that memory, observes the +/// write — proving `runtime.Thread.spawn` started a task in the **same** address space +/// (a separate process could not touch it). The service's own marker is the verdict. +fn threadSpawnTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: thread-spawn\n", .{}); + if (boot_information.initial_ramdisk_len == 0) { + check("bootloader handed over an initial_ramdisk", false); + result(); + return; + } + const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len]; + const rd = initial_ramdisk.Reader.init(image) orelse { + check("initial_ramdisk image is valid", false); + result(); + return; + }; + + check("thread-test spawned", spawnNamed(rd, "thread-test")); + + // Wait for the service's verdict marker (it polls shared memory the worker wrote). + const ok_marker = "thread-test: child ran in shared aspace ok"; + const fail_marker = "thread-test: FAIL"; + scheduler.setPriority(1); + const deadline = architecture.millis() + 12000; + while (architecture.millis() < deadline) { + if (bufferHas(ok_marker) or bufferHas(fail_marker)) break; + scheduler.yield(); + } + scheduler.setPriority(4); + + check("a worker thread ran in the shared address space (shared write observed)", bufferHas(ok_marker)); + check("the thread path reported no failure", !bufferHas(fail_marker)); + result(); +} + /// The full PID-1 path: the bootloader read /system/services/init off the boot volume and /// handed it over; load it as a user ELF and spawn it as a real ring-3 process /// — the same call the normal boot path makes — then confirm it beats. init diff --git a/system/services/thread-test/thread-test.zig b/system/services/thread-test/thread-test.zig new file mode 100644 index 0000000..abea618 --- /dev/null +++ b/system/services/thread-test/thread-test.zig @@ -0,0 +1,47 @@ +//! thread-test — the first multi-threaded danos binary (docs/threading-plan.md M2). +//! +//! Proves `runtime.Thread.spawn` starts a task in the **same address space**: the main +//! thread spawns a worker, the worker writes a shared global and signals `done`, and the +//! main thread — polling that shared memory — observes the write. Seeing the write proves +//! the two tasks share one address space (a separate process could not touch this memory). +//! The `thread-test: child ran in shared aspace ok` line is the case's marker. +//! +//! Built multi-threaded (`addThreadedUserBinary`), so the poll below is a real atomic +//! load the compiler must re-read — under a single-threaded build it could be hoisted. + +const std = @import("std"); +const runtime = @import("runtime"); + +/// Written by the worker thread, read by main — the shared-address-space evidence. +var shared_value: u32 = 0; +/// Release/acquire handshake: publishes the `shared_value` write to the reader. +var done = std.atomic.Value(u32).init(0); + +const sentinel: u32 = 0xA5A5; + +fn worker() void { + shared_value = sentinel; // a plain write to a global we share with main + done.store(1, .release); // ...published by this release store +} + +pub fn main() void { + _ = runtime.system.write("thread-test: starting\n"); + + _ = runtime.Thread.spawn(.{}, worker, .{}) catch { + _ = runtime.system.write("thread-test: FAIL spawn refused\n"); + return; + }; + + // Bounded wait for the worker to run and publish. yield() keeps the core useful; + // the acquire load pairs with the worker's release store. + var spins: usize = 0; + while (done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) { + runtime.system.yield(); + } + + if (done.load(.acquire) == 1 and shared_value == sentinel) { + _ = runtime.system.write("thread-test: child ran in shared aspace ok\n"); + } else { + _ = runtime.system.write("thread-test: FAIL worker did not update shared memory\n"); + } +} diff --git a/test/qemu_test.py b/test/qemu_test.py index 2556441..be45627 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -300,6 +300,13 @@ CASES = [ "timeout": 60, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + + # docs/threading-plan.md M2: runtime.Thread.spawn — a worker thread runs in the + # caller's address space (a shared-memory write, observed by the main thread). + {"name": "thread-spawn", + "timeout": 60, + "expect": r"DANOS-TEST-RESULT: PASS", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # Process arguments: argv arrives on the SysV entry stack (argv[0] = the spawned # name, argv[1..] = the system_spawn argument blob) and echoes back intact. {"name": "args",