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:
Daniel Samson
2026-07-22 10:49:46 +01:00
parent 1882161cb4
commit 2bc2a0d70d
10 changed files with 565 additions and 43 deletions
+55 -21
View File
@@ -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);
}
+24 -2
View File
@@ -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
+218
View File
@@ -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) {