kernel+tests: M4 shared-fate — nine group-death test cases, per-space arena cursors
Eight new QEMU cases (thread-fault-group, kill-threaded-group, kill-via-worker-tid, racing-triggers, exit-group, leader-thread-exit, thread-exit-solo, shm-mapping-ref) driving seven new thread-test modes; a shared checkGroupDead asserts the contract everywhere: one notification, badged with the leader, reason on the leader's record, no member listed, claims released first. The shm-mapping-ref case flushed out the per-task DMA/shared-memory arena cursor bug directly (a sibling's regions mapped over the worker's), so both cursors moved to the AddressSpaceRef like the mmap/MMIO cursors before them (threading M7 pattern). Runtime gains Thread.tryExitCurrent for the leader -EPERM refusal path. Docs updated: threading.md's shared-fate gap is closed, process-management.md and process-lifecycle.md describe the leader re-key, plan status = implemented. Full suite: 100/100.
This commit is contained in:
parent
1882161cb4
commit
2bc2a0d70d
|
|
@ -55,6 +55,9 @@ pattern reused. Signals are the same pattern reused a third time.
|
|||
- **`process_signal(id, signal)`** — posts the signal as an asynchronous
|
||||
notification to the target's bound endpoint: badge = `notify_badge_bit |
|
||||
notify_signal_bit | pending signals`. Non-blocking for the sender, always.
|
||||
Signals address the *process*: `id` may name any member of a threaded process
|
||||
and resolves to its leader — whose endpoint the harness binds — with authority
|
||||
mirroring `process_kill` ([shared-fate-plan.md](shared-fate-plan.md)).
|
||||
- **Pending signals coalesce** in a per-process bitmask while the target has no
|
||||
signal endpoint bound, and the whole mask arrives as one notification at bind —
|
||||
POSIX's own semantics for non-realtime signals (two pending SIGTERMs are one
|
||||
|
|
|
|||
|
|
@ -57,9 +57,13 @@ dangle even if the supervisor dies first.
|
|||
|
||||
### `process_kill(id) -> 0 / -ESRCH / -EPERM`
|
||||
|
||||
Only the supervisor may kill; kernel tasks are not killable processes. Like a
|
||||
signal, delivery is prompt but asynchronous — 0 means the kill is accepted and
|
||||
irrevocable; the exit notification confirms completion.
|
||||
Only the supervisor may kill; kernel tasks are not killable processes. The kill
|
||||
is a **whole-process** kill ([shared-fate-plan.md](shared-fate-plan.md)): `id`
|
||||
may name any member of a threaded process — it resolves to the group's leader,
|
||||
authorization is checked against the *leader's* supervisor, and every thread
|
||||
dies. Like a signal, delivery is prompt but asynchronous — 0 means the kill is
|
||||
accepted and irrevocable; the exit notification (badged with the leader, posted
|
||||
once the last member is gone) confirms completion.
|
||||
|
||||
## How a kill lands (the kernel mechanics)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
# Shared fate: whole-process death (plan)
|
||||
|
||||
**Status: approved 2026-07-22; leader `thread_exit` → `-EPERM`. Implementation in
|
||||
progress (M1–M4 below).**
|
||||
**Status: implemented 2026-07-22 (branch shared-fate), M1–M4 all landed; leader
|
||||
`thread_exit` → `-EPERM` as decided. One scope addition forced by M4: the
|
||||
per-task DMA/shared-memory arena cursors moved to the per-space object (the
|
||||
`shm-mapping-ref` test could not distinguish corruption-by-remap from
|
||||
corruption-by-free while sibling threads overlapped the arena) — the same move
|
||||
the mmap/MMIO cursors made in threading M7.**
|
||||
|
||||
[threading.md](threading.md) promises that a process dies *whole* — a fault in any
|
||||
thread, or a kill, takes down every thread. The kernel doesn't do that yet: every
|
||||
|
|
@ -244,10 +248,10 @@ refcount, and no group-kill special case is needed at all.
|
|||
`.ready` with `in_system_call = true`, and the tick's reap loop will reap it —
|
||||
an existing hazard the fan-out inherits but must not add new instances of.
|
||||
Filed to investigate separately.
|
||||
- **Per-task DMA/shm cursors** (`dma_map_next`, `shared_memory_map_next`): two
|
||||
sibling threads allocating overlap the same arena — pre-existing thread bug,
|
||||
adjacent to but not part of this plan (the mmap/MMIO cursors already moved
|
||||
per-space for exactly this reason).
|
||||
- **Per-task DMA/shm cursors** — *fixed during M4 after all*: the
|
||||
`shm-mapping-ref` test tripped the overlap (the sibling's churn regions mapped
|
||||
over the worker's region), so both cursors moved to the `AddressSpaceRef`
|
||||
like the mmap/MMIO cursors before them.
|
||||
|
||||
## Milestones
|
||||
|
||||
|
|
|
|||
|
|
@ -252,20 +252,18 @@ stays single-threaded and lean.
|
|||
halts" property intact under lock contention — no busy-wait.
|
||||
- **Lifecycle** ([process-lifecycle.md](process-lifecycle.md)): the contract is that
|
||||
killing a process kills *all* its threads and only then drops the last address-space
|
||||
ref. **The kernel does not implement that fan-out yet**: `process_kill` reaps only
|
||||
the one task it resolves, and no death path loops over the tasks sharing an address
|
||||
space — the refcount keeps the space (and the sibling threads) alive and running.
|
||||
The gap is hit in practice: of the only threaded binaries (the `display` service and
|
||||
the `thread-test` harness), `display` is a boot service that handles no `.terminate`
|
||||
signal, so init's stop sequence escalates to `process_kill` on every orderly
|
||||
shutdown — benign only because poweroff follows. Whether to implement the fan-out or
|
||||
amend the contract is a decision still to be made.
|
||||
ref — and the kernel now implements exactly that
|
||||
([shared-fate-plan.md](shared-fate-plan.md)): every death path (`exit` from any
|
||||
thread, a fault, `process_kill` aimed at any member id) fans out through the whole
|
||||
group via a `dying` latch on the address space; the supervisor's one exit
|
||||
notification — badged with the leader — fires only when the last member is gone.
|
||||
A worker's voluntary `thread_exit` stays per-thread; the leader's is refused
|
||||
(`-EPERM`).
|
||||
- **Resilience** ([resilience.md](resilience.md)): by the same contract, a faulting
|
||||
thread kills its whole process (shared fate); the supervisor restarts the
|
||||
**process**, which respawns its threads from a known-good state — restart
|
||||
granularity stays the process. Today a CPU fault kills only the faulting task
|
||||
(`killCurrentProcess` tears down a single task), so sibling threads keep running —
|
||||
the same implementation gap as above.
|
||||
granularity stays the process. The leader's recorded exit reason carries the fault
|
||||
class even when a worker faulted, so restart policy is unchanged.
|
||||
- **IPC — two consequences threads forced ([ipc.md](ipc.md)):**
|
||||
- *Handles do not cross threads.* The handle table lives on the `Task`
|
||||
([scheduler.zig](../system/kernel/scheduler.zig)), so a handle number is meaningful
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@ pub const Thread = struct {
|
|||
return @intCast(sc.systemCall0(.current_core));
|
||||
}
|
||||
|
||||
/// Ask the kernel to end the calling thread. A WORKER never returns from this;
|
||||
/// the process's MAIN thread gets the kernel's refusal (-EPERM — the group ends
|
||||
/// only through exit, a fault, or process_kill, docs/shared-fate-plan.md) and
|
||||
/// the call returns. Exists for exactly that refusal path; workers end through
|
||||
/// the spawn trampoline, and a process ends through `system.exit`.
|
||||
pub fn tryExitCurrent() void {
|
||||
_ = sc.systemCall0(.thread_exit);
|
||||
}
|
||||
|
||||
/// `std.Thread.Futex`-shaped block/wake on a `u32` atomic — the primitive the
|
||||
/// blocking `Mutex`/`Condition`/`Semaphore` are built on. Waiters park in the
|
||||
/// kernel (no busy-wait), so an idle core still halts (docs/halting.md).
|
||||
|
|
|
|||
|
|
@ -78,15 +78,15 @@ pub const device_arena_end: u64 = device_arena_base + (4 << 30);
|
|||
|
||||
/// The DMA arena: where `dma_alloc` places coherent DMA buffers, in PML4[228] — a
|
||||
/// user-exclusive region distinct from the MMIO arena. Unlike MMIO grants these back
|
||||
/// real RAM (contiguous frames), so they are reclaimed on teardown. Per-process cursor
|
||||
/// in `Task.dma_map_next`.
|
||||
/// real RAM (contiguous frames), so they are reclaimed on teardown. Per-SPACE cursor
|
||||
/// on the AddressSpaceRef (sibling threads share the arena).
|
||||
pub const dma_arena_base: u64 = 0x0000_7200_0000_0000;
|
||||
pub const dma_arena_end: u64 = dma_arena_base + (256 << 20); // 256 MiB per process
|
||||
|
||||
/// The shared-memory arena: where `shared_memory_create`/`shared_memory_map` place shared cacheable regions, in
|
||||
/// PML4[230] — a user-exclusive region distinct from the DMA arena. The frames are owned by
|
||||
/// a refcounted shared-memory object and freed when its last capability drops, not on teardown, so the
|
||||
/// mapping carries `device_grant`. Per-process cursor in `Task.shared_memory_map_next` (docs/display-v2.md).
|
||||
/// mapping carries `device_grant`. Per-SPACE cursor on the AddressSpaceRef (docs/display-v2.md).
|
||||
pub const shared_memory_arena_base: u64 = 0x0000_7300_0000_0000;
|
||||
pub const shared_memory_arena_end: u64 = shared_memory_arena_base + (256 << 20); // 256 MiB per process
|
||||
|
||||
|
|
@ -501,19 +501,31 @@ fn systemDmaAlloc(state: *architecture.CpuState) void {
|
|||
const max_phys: u64 = if (flags & abi.dma_below_4g != 0) (@as(u64, 4) << 30) else ~@as(u64, 0);
|
||||
const phys = pmm.allocContiguous(pages, max_phys) orelse return fail(state);
|
||||
|
||||
if (t.dma_map_next == 0) t.dma_map_next = dma_arena_base;
|
||||
const base_v = t.dma_map_next;
|
||||
if (base_v + pages * page_size > dma_arena_end) {
|
||||
for (0..pages) |i| pmm.free(phys + i * page_size); // arena exhausted; give the frames back
|
||||
return fail(state);
|
||||
// Reserve arena virtual space from the per-SPACE cursor, under the lock —
|
||||
// sibling threads must hand out disjoint windows of the one shared arena.
|
||||
var base_v: u64 = 0;
|
||||
{
|
||||
const lock_flags = sync.enter();
|
||||
const cursor = scheduler.addressSpaceDmaNextPtr(t.address_space) orelse {
|
||||
sync.leave(lock_flags);
|
||||
for (0..pages) |i| pmm.free(phys + i * page_size);
|
||||
return fail(state);
|
||||
};
|
||||
if (cursor.* == 0) cursor.* = dma_arena_base;
|
||||
base_v = cursor.*;
|
||||
if (base_v + pages * page_size > dma_arena_end) {
|
||||
sync.leave(lock_flags);
|
||||
for (0..pages) |i| pmm.free(phys + i * page_size); // arena exhausted; give the frames back
|
||||
return fail(state);
|
||||
}
|
||||
cursor.* = base_v + pages * page_size;
|
||||
sync.leave(lock_flags);
|
||||
}
|
||||
|
||||
// Zero through the physmap (the frames aren't mapped in the caller yet), then map.
|
||||
const kernel_view: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(phys));
|
||||
@memset(kernel_view[0 .. pages * page_size], 0);
|
||||
architecture.mapUserDmaInto(t.address_space, base_v, phys, pages * page_size);
|
||||
|
||||
t.dma_map_next = base_v + pages * page_size;
|
||||
architecture.setSystemCallResult(state, base_v); // virtual address for the CPU
|
||||
architecture.setSystemCallResult2(state, phys); // physical address for the device
|
||||
}
|
||||
|
|
@ -557,10 +569,24 @@ fn systemSharedMemoryCreate(state: *architecture.CpuState) void {
|
|||
const pages: usize = @intCast((len + page_size - 1) / page_size);
|
||||
if (pages == 0 or pages > maximum_shared_memory_pages) return fail(state);
|
||||
|
||||
// Reserve arena virtual space up front, so a mapping failure needs no rollback.
|
||||
if (t.shared_memory_map_next == 0) t.shared_memory_map_next = shared_memory_arena_base;
|
||||
const base_v = t.shared_memory_map_next;
|
||||
if (base_v + pages * page_size > shared_memory_arena_end) return fail(state); // arena exhausted
|
||||
// Reserve arena virtual space up front (per-SPACE cursor: sibling threads
|
||||
// hand out disjoint windows), so a mapping failure needs no rollback.
|
||||
var base_v: u64 = 0;
|
||||
{
|
||||
const lock_flags = sync.enter();
|
||||
const cursor = scheduler.addressSpaceSharedMemoryNextPtr(t.address_space) orelse {
|
||||
sync.leave(lock_flags);
|
||||
return fail(state);
|
||||
};
|
||||
if (cursor.* == 0) cursor.* = shared_memory_arena_base;
|
||||
base_v = cursor.*;
|
||||
if (base_v + pages * page_size > shared_memory_arena_end) {
|
||||
sync.leave(lock_flags);
|
||||
return fail(state); // arena exhausted
|
||||
}
|
||||
cursor.* = base_v + pages * page_size;
|
||||
sync.leave(lock_flags);
|
||||
}
|
||||
|
||||
const phys = pmm.allocContiguous(pages, ~@as(u64, 0)) orelse return fail(state);
|
||||
// Zero through the physmap (the frames aren't mapped in the caller yet).
|
||||
|
|
@ -595,7 +621,6 @@ fn systemSharedMemoryCreate(state: *architecture.CpuState) void {
|
|||
}
|
||||
|
||||
architecture.mapUserSharedInto(t.address_space, base_v, phys, pages * page_size);
|
||||
t.shared_memory_map_next = base_v + pages * page_size;
|
||||
architecture.setSystemCallResult(state, base_v); // virtual_address for the CPU
|
||||
architecture.setSystemCallResult2(state, @intCast(handle)); // capability handle to pass on
|
||||
}
|
||||
|
|
@ -610,25 +635,34 @@ fn systemSharedMemoryMap(state: *architecture.CpuState) void {
|
|||
if (t.address_space == 0) return fail(state);
|
||||
|
||||
const shared_memory = ipc.resolveSharedMemory(t, cap) orelse return fail(state); // not a shared-memory handle we hold
|
||||
if (t.shared_memory_map_next == 0) t.shared_memory_map_next = shared_memory_arena_base;
|
||||
const base_v = t.shared_memory_map_next;
|
||||
const size = shared_memory.pages * page_size;
|
||||
if (base_v + size > shared_memory_arena_end) return fail(state);
|
||||
|
||||
// This mapping holds its own reference, recorded on the space and dropped at
|
||||
// its destruction (docs/shared-fate-plan.md M3) — the handle's reference is
|
||||
// Reserve arena space (per-SPACE cursor) and record the mapping's own
|
||||
// reference in one locked section — the reference is dropped at space
|
||||
// destruction (docs/shared-fate-plan.md M3); the handle's reference is
|
||||
// separate and may be closed while the mapping lives on.
|
||||
var base_v: u64 = 0;
|
||||
{
|
||||
const flags = sync.enter();
|
||||
const cursor = scheduler.addressSpaceSharedMemoryNextPtr(t.address_space) orelse {
|
||||
sync.leave(flags);
|
||||
return fail(state);
|
||||
};
|
||||
if (cursor.* == 0) cursor.* = shared_memory_arena_base;
|
||||
base_v = cursor.*;
|
||||
if (base_v + size > shared_memory_arena_end) {
|
||||
sync.leave(flags);
|
||||
return fail(state); // arena exhausted
|
||||
}
|
||||
if (!scheduler.recordSpaceMappingLocked(t.address_space, @ptrCast(shared_memory))) {
|
||||
sync.leave(flags);
|
||||
return fail(state); // mapping table full: refuse rather than map unrecorded
|
||||
}
|
||||
ipc.retainSharedMemory(shared_memory);
|
||||
cursor.* = base_v + size;
|
||||
sync.leave(flags);
|
||||
}
|
||||
architecture.mapUserSharedInto(t.address_space, base_v, shared_memory.phys, size);
|
||||
t.shared_memory_map_next = base_v + size;
|
||||
architecture.setSystemCallResult(state, base_v);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,8 +121,10 @@ pub const Task = struct {
|
|||
ipc_reply_ptr: u64 = 0, // client: reply buffer (virtual_address)
|
||||
ipc_reply_cap: u64 = 0,
|
||||
ipc_status: i64 = 0, // client: reply length / -errno, written by the replier
|
||||
dma_map_next: u64 = 0, // bump pointer into this task's DMA arena (0 = unseeded)
|
||||
shared_memory_map_next: u64 = 0, // bump pointer into this task's shared-memory arena (0 = unseeded)
|
||||
// The DMA and shared-memory arena cursors moved to the per-address-space object
|
||||
// (`AddressSpaceRef`) like the mmap/MMIO cursors before them — per-TASK cursors made
|
||||
// sibling threads hand out overlapping windows of the one shared arena
|
||||
// (docs/shared-fate-plan.md M4 tripped exactly that).
|
||||
ipc_send_cap: u64 = ~@as(u64, 0), // handle to transfer with this message (abi.no_cap = none)
|
||||
ipc_received_cap: u64 = ~@as(u64, 0), // client: handle the reply's transferred cap landed at (abi.no_cap = none)
|
||||
next: ?*Task = null, // ready-queue link (also the endpoint sender-FIFO link)
|
||||
|
|
@ -173,6 +175,8 @@ const AddressSpaceRef = struct {
|
|||
count: u32 = 0,
|
||||
mmap_next: u64 = 0,
|
||||
device_map_next: u64 = 0,
|
||||
dma_next: u64 = 0, // bump pointer into this space's DMA arena (0 = unseeded)
|
||||
shared_memory_next: u64 = 0, // bump pointer into this space's shared-memory arena (0 = unseeded)
|
||||
// Group-death state (docs/shared-fate-plan.md), set once by the first kill
|
||||
// trigger and never cleared while the entry lives. `dying` gates
|
||||
// retainAddressSpace — no new member may join a dying group (closing the
|
||||
|
|
@ -395,6 +399,24 @@ pub fn addressSpaceDeviceMapNextPtr(root: u64) ?*u64 {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Pointer to the DMA arena cursor for address space `root` (see
|
||||
/// `addressSpaceMmapNextPtr`). Caller holds the kernel lock.
|
||||
pub fn addressSpaceDmaNextPtr(root: u64) ?*u64 {
|
||||
for (&address_space_refs) |*entry| {
|
||||
if (entry.count != 0 and entry.root == root) return &entry.dma_next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Pointer to the shared-memory arena cursor for address space `root` (see
|
||||
/// `addressSpaceMmapNextPtr`). Caller holds the kernel lock.
|
||||
pub fn addressSpaceSharedMemoryNextPtr(root: u64) ?*u64 {
|
||||
for (&address_space_refs) |*entry| {
|
||||
if (entry.count != 0 and entry.root == root) return &entry.shared_memory_next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var next_id: u32 = 1;
|
||||
|
||||
/// Per-CPU scheduler state: the task each core is running, its own idle task, and a
|
||||
|
|
|
|||
|
|
@ -146,6 +146,22 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
|||
faultRecoveryTest(boot_information);
|
||||
} else if (eql(case, "address-space-refcount")) {
|
||||
addressSpaceRefcountTest(boot_information);
|
||||
} else if (eql(case, "thread-fault-group")) {
|
||||
threadFaultGroupTest(boot_information);
|
||||
} else if (eql(case, "kill-threaded-group")) {
|
||||
killThreadedGroupTest(boot_information);
|
||||
} else if (eql(case, "kill-via-worker-tid")) {
|
||||
killViaWorkerTidTest(boot_information);
|
||||
} else if (eql(case, "racing-triggers")) {
|
||||
racingTriggersTest(boot_information);
|
||||
} else if (eql(case, "exit-group")) {
|
||||
exitGroupTest(boot_information);
|
||||
} else if (eql(case, "leader-thread-exit")) {
|
||||
threadTestMarkerCase(boot_information, "leader-thread-exit", "leader-exit");
|
||||
} else if (eql(case, "thread-exit-solo")) {
|
||||
threadTestMarkerCase(boot_information, "thread-exit-solo", "solo");
|
||||
} else if (eql(case, "shm-mapping-ref")) {
|
||||
threadTestMarkerCase(boot_information, "shm-mapping-ref", "shm-worker");
|
||||
} else if (eql(case, "thread-spawn")) {
|
||||
threadSpawnTest(boot_information);
|
||||
} else if (eql(case, "thread-join")) {
|
||||
|
|
@ -3167,6 +3183,208 @@ fn kernelVfsTest(boot_information: *const BootInformation) void {
|
|||
result();
|
||||
}
|
||||
|
||||
// --- shared-fate helpers (docs/shared-fate-plan.md M4) -----------------------
|
||||
|
||||
/// Spawn the ramdisk's thread-test with `mode` as argv[1], supervised by the
|
||||
/// calling test task on `endpoint`. Returns the child (leader) id, or 0.
|
||||
fn spawnThreadTestSupervised(boot_information: *const BootInformation, mode: []const u8, endpoint: *ipcsync.Endpoint) u32 {
|
||||
const ramdisk = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||
const rd = initial_ramdisk.Reader.init(ramdisk) orelse return 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "thread-test")) continue;
|
||||
return process.spawnProcessSupervised(item.blob, 4, &.{ "thread-test", mode }, scheduler.currentId(), endpoint) catch 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Whether any live task still belongs to `leader`'s group.
|
||||
fn groupListed(leader: u32) bool {
|
||||
var table: [32]abi.ProcessDescriptor = undefined;
|
||||
const total = scheduler.enumerate(&table);
|
||||
for (table[0..@min(total, table.len)]) |descriptor| {
|
||||
if (descriptor.id == leader or descriptor.leader == leader) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Poll enumerate for a WORKER of `leader` (same leader, different id) until
|
||||
/// `deadline` (millis); returns its id, or 0.
|
||||
fn findWorkerOf(leader: u32, deadline: u64) u32 {
|
||||
while (architecture.millis() < deadline) {
|
||||
var table: [32]abi.ProcessDescriptor = undefined;
|
||||
const total = scheduler.enumerate(&table);
|
||||
for (table[0..@min(total, table.len)]) |descriptor| {
|
||||
if (descriptor.leader == leader and descriptor.id != leader) return descriptor.id;
|
||||
}
|
||||
scheduler.yield();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Block on `endpoint` for the next notification and return its badge.
|
||||
fn awaitExitBadge(endpoint: *ipcsync.Endpoint) u64 {
|
||||
var badge: u64 = 0;
|
||||
var received_cap: u64 = 0;
|
||||
_ = ipcsync.replyWait(endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
|
||||
return badge;
|
||||
}
|
||||
|
||||
/// A group death is one notification, badged with the LEADER, arriving only
|
||||
/// after every member (and the address space) is gone — asserted by every
|
||||
/// shared-fate case below.
|
||||
fn checkGroupDead(me: u32, leader: u32, badge: u64, reason: abi.ExitReason) void {
|
||||
check("one exit notification, badged with the leader", badge == abi.notify_badge_bit | abi.notify_exit_bit | leader);
|
||||
check("the leader's recorded reason is the group reason", process.exitReasonOf(me, leader) == @intFromEnum(reason));
|
||||
check("no group member is listed after the death", !groupListed(leader));
|
||||
}
|
||||
|
||||
fn threadFaultGroupTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: thread-fault-group\n", .{});
|
||||
const me = scheduler.currentId();
|
||||
const endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("exit endpoint allocated", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
const stacks_base = scheduler.liveStackBytes();
|
||||
const spaces_base = scheduler.liveAddressSpaceCount();
|
||||
process.fault_kill_count = 0;
|
||||
const child = spawnThreadTestSupervised(boot_information, "fault-worker", endpoint);
|
||||
check("thread-test spawned (fault-worker)", child != 0);
|
||||
if (child == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const badge = awaitExitBadge(endpoint);
|
||||
checkGroupDead(me, child, badge, .segmentation_fault);
|
||||
check("one fault kill for the whole group", process.fault_kill_count == 1);
|
||||
const deadline = architecture.millis() + 5000;
|
||||
while (scheduler.liveStackBytes() > stacks_base and architecture.millis() < deadline) scheduler.yield();
|
||||
check("address spaces returned to base", scheduler.liveAddressSpaceCount() == spaces_base);
|
||||
check("kernel stacks returned to base", scheduler.liveStackBytes() == stacks_base);
|
||||
result();
|
||||
}
|
||||
|
||||
fn killThreadedGroupTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: kill-threaded-group\n", .{});
|
||||
const me = scheduler.currentId();
|
||||
var buffer: [2]device_abi.DeviceDescriptor = undefined;
|
||||
check("the device tree is seeded (>= 2 devices)", devices_broker.enumerate(&buffer) >= 2);
|
||||
const endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("exit endpoint allocated", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
const child = spawnThreadTestSupervised(boot_information, "spin-forever", endpoint);
|
||||
check("thread-test spawned (spin-forever)", child != 0);
|
||||
if (child == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const worker = findWorkerOf(child, architecture.millis() + 8000);
|
||||
check("the spinning worker is enumerable with leader = the child", worker != 0);
|
||||
// Claims for BOTH members: group death must release every member's claims
|
||||
// before the supervisor hears anything — the worker's by the deferred
|
||||
// (condemned) path.
|
||||
check("device 0 claimed for the leader", devices_broker.claim(0, child));
|
||||
check("device 1 claimed for the worker", devices_broker.claim(1, worker));
|
||||
scheduler.sleep(100); // let the worker really be running on another core
|
||||
check("the supervisor's kill is accepted", process.killProcess(me, child) == 0);
|
||||
const badge = awaitExitBadge(endpoint);
|
||||
checkGroupDead(me, child, badge, .killed);
|
||||
check("the leader's claim was released before the notification", devices_broker.ownerOf(0) == null);
|
||||
check("the worker's claim was released before the notification", devices_broker.ownerOf(1) == null);
|
||||
check("a dead group stays dead (-ESRCH)", process.killProcess(me, child) == -ipcsync.ESRCH);
|
||||
result();
|
||||
}
|
||||
|
||||
fn killViaWorkerTidTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: kill-via-worker-tid\n", .{});
|
||||
const me = scheduler.currentId();
|
||||
const endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("exit endpoint allocated", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
const child = spawnThreadTestSupervised(boot_information, "spin-forever", endpoint);
|
||||
check("thread-test spawned (spin-forever)", child != 0);
|
||||
if (child == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const worker = findWorkerOf(child, architecture.millis() + 8000);
|
||||
check("the spinning worker is enumerable", worker != 0);
|
||||
check("a non-supervisor aiming at the worker is refused (-EPERM)", process.killProcess(me + 12345, worker) == -ipcsync.EPERM);
|
||||
check("the supervisor's kill aimed at the WORKER id is accepted", process.killProcess(me, worker) == 0);
|
||||
const badge = awaitExitBadge(endpoint);
|
||||
checkGroupDead(me, child, badge, .killed);
|
||||
result();
|
||||
}
|
||||
|
||||
fn racingTriggersTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: racing-triggers\n", .{});
|
||||
const me = scheduler.currentId();
|
||||
const endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("exit endpoint allocated", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
process.fault_kill_count = 0;
|
||||
const child = spawnThreadTestSupervised(boot_information, "race", endpoint);
|
||||
check("thread-test spawned (race)", child != 0);
|
||||
if (child == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const badge = awaitExitBadge(endpoint);
|
||||
checkGroupDead(me, child, badge, .segmentation_fault);
|
||||
check("two racing faults counted as ONE group kill", process.fault_kill_count == 1);
|
||||
result();
|
||||
}
|
||||
|
||||
fn exitGroupTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: exit-group\n", .{});
|
||||
const me = scheduler.currentId();
|
||||
const endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("exit endpoint allocated", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
const child = spawnThreadTestSupervised(boot_information, "exit-worker", endpoint);
|
||||
check("thread-test spawned (exit-worker)", child != 0);
|
||||
if (child == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const badge = awaitExitBadge(endpoint);
|
||||
checkGroupDead(me, child, badge, .aborted);
|
||||
result();
|
||||
}
|
||||
|
||||
/// leader-thread-exit, thread-exit-solo, and shm-mapping-ref share one shape:
|
||||
/// the child asserts its own property, prints a marker the harness matches, and
|
||||
/// exits clean — the kernel side asserts the clean group death.
|
||||
fn threadTestMarkerCase(boot_information: *const BootInformation, case_name: []const u8, mode: []const u8) void {
|
||||
log("DANOS-TEST-BEGIN: {s}\n", .{case_name});
|
||||
const me = scheduler.currentId();
|
||||
const endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("exit endpoint allocated", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
const child = spawnThreadTestSupervised(boot_information, mode, endpoint);
|
||||
check("thread-test spawned", child != 0);
|
||||
if (child == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const badge = awaitExitBadge(endpoint);
|
||||
checkGroupDead(me, child, badge, .exited);
|
||||
result();
|
||||
}
|
||||
|
||||
fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
|
|
|
|||
|
|
@ -469,6 +469,157 @@ fn runRwlockMode() void {
|
|||
}
|
||||
}
|
||||
|
||||
// --- 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");
|
||||
_ = runtime.Thread.spawn(.{}, faultingWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
while (true) runtime.system.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 {
|
||||
_ = runtime.Thread.spawn(.{}, spinningWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
write("thread-test: spinning\n");
|
||||
while (true) runtime.system.yield();
|
||||
}
|
||||
|
||||
fn exitingWorker() void {
|
||||
write("thread-test: worker exiting the process\n");
|
||||
runtime.system.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 {
|
||||
_ = runtime.Thread.spawn(.{}, exitingWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
while (true) runtime.system.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 {
|
||||
_ = runtime.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) runtime.system.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 = runtime.Thread.spawn(.{}, patientWorker, .{}) catch {
|
||||
write("thread-test: FAIL spawn refused\n");
|
||||
return;
|
||||
};
|
||||
runtime.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 = runtime.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 = runtime.shared_memory.create(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 = runtime.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 = runtime.shared_memory.create(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: runtime.process.Init) void {
|
||||
const mode = init.arguments.get(1) orelse "spawn";
|
||||
if (std.mem.eql(u8, mode, "join")) {
|
||||
|
|
@ -485,6 +636,20 @@ pub fn main(init: runtime.process.Init) void {
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,6 +327,71 @@ CASES = [
|
|||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: a worker's CPU fault kills the whole group —
|
||||
# one notification (badged with the leader), the leader's reason is the fault
|
||||
# class, and every counter returns to base.
|
||||
{"name": "thread-fault-group",
|
||||
"smp": 4,
|
||||
"timeout": 60,
|
||||
"expect": r"thread-test: worker faulting[\s\S]*DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: process_kill on a threaded group — the
|
||||
# spinning worker dies by the deferred (condemned) path, both members'
|
||||
# device claims are free before the single leader-badged notification.
|
||||
{"name": "kill-threaded-group",
|
||||
"smp": 4,
|
||||
"timeout": 60,
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: process_kill aimed at a WORKER tid kills the
|
||||
# whole group (the capability is per-process), still badged with the leader.
|
||||
{"name": "kill-via-worker-tid",
|
||||
"smp": 4,
|
||||
"timeout": 60,
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: two members fault near-simultaneously on
|
||||
# different cores; the dying latch makes them one group death
|
||||
# (fault_kill_count == 1) with a deterministic leader reason.
|
||||
{"name": "racing-triggers",
|
||||
"smp": 4,
|
||||
"timeout": 60,
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: exit(3) from a WORKER is group death with the
|
||||
# leader's reason reading .aborted (the exit_group lesson).
|
||||
{"name": "exit-group",
|
||||
"smp": 4,
|
||||
"timeout": 60,
|
||||
"expect": r"thread-test: worker exiting the process[\s\S]*DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: the LEADER's thread_exit is refused (-EPERM);
|
||||
# the worker is unaffected and the process then exits clean.
|
||||
{"name": "leader-thread-exit",
|
||||
"timeout": 60,
|
||||
"expect": r"thread-test: leader thread_exit refused ok[\s\S]*DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M4: regression — a WORKER's thread_exit stays
|
||||
# per-thread; the sibling survives it.
|
||||
{"name": "thread-exit-solo",
|
||||
"timeout": 60,
|
||||
"expect": r"thread-test: solo sibling survived ok[\s\S]*DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/shared-fate-plan.md M3/M4: a shared-memory region whose only HANDLE
|
||||
# died with its creator thread survives on the MAPPING reference — the
|
||||
# sibling's view stays intact through allocator churn.
|
||||
{"name": "shm-mapping-ref",
|
||||
"timeout": 60,
|
||||
"expect": r"thread-shm: mapping survives creator ok[\s\S]*DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"thread-shm: FAIL|DANOS-TEST-RESULT: FAIL"},
|
||||
|
||||
# docs/threading-plan.md M4: futex — a thread parks in futex_wait and is woken by
|
||||
# futex_wake (serial order waiting/waking/woke), and timedWait reports a timeout.
|
||||
{"name": "thread-futex",
|
||||
|
|
|
|||
Loading…
Reference in New Issue