# Shared fate: whole-process death (plan) **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 death path (`exit`, `thread_exit`, a ring-3 fault, `process_kill`) tears down exactly one `Task`, and the address-space refcount keeps the space alive for the siblings — so a faulting worker orphans its threads, which keep running in the possibly-corrupted address space (`process.zig` `killCurrentProcess`; `scheduler.zig` `exitUserLocked`). This plan closes that gap the way Linux, Windows, and Fuchsia all did: **the process is the unit of fate; only a voluntary `thread_exit` is per-thread.** The supervisor contract — one exit notification, then `process_exit_reason` — is deliberately unchanged. ## The contract | Event | Who dies | Reason the supervisor reads (leader's record) | |---|---|---| | CPU fault in **any** thread (recoverable vector) | the whole group | the fault class (`segmentation_fault`, …) | | `process_kill` on **any member id** | the whole group | `killed` | | `exit(code)` from **any** thread | the whole group | `exited` (0) / `aborted` (≠0) | | `thread_exit` from a worker | that worker only | — (per-task record: `exited`) | | `thread_exit` from the **leader** | nobody — refused, `-EPERM` (decided, below) | — | | NMI, double fault, machine check | the core halts (unchanged) | — | `exit` gaining group semantics is the `exit_group` lesson from Linux: the runtime's main-return path calls `exit`, and a process whose main returned must not leave workers running. `thread_exit` (what the worker trampoline calls) keeps today's per-thread behavior, refcount and all. **The leader-`thread_exit` rule (decided: refuse).** The syscall is reachable from the leader even though the runtime never does it. Options weighed: (i) **refuse with `-EPERM`** — cheapest and honest; the group ends only through `exit`/fault/kill; (ii) escalate to `exit(0)` — Linux-flavored, but silently turns a (buggy) library call into process death; (iii) a Linux-style zombie leader whose slot survives until the group ends — the most faithful, and by far the most machinery. **(i) chosen at sign-off**; rows and tests below follow it. ## Group identity: a leader id Nothing on `Task` names a process today — threads are tied to their process only by an equal `address_space`, their name is `"thread"`, and their `supervisor` is whichever *task* spawned them (possibly another worker), so supervision links form a chain, not a group. Scanning by `address_space` is also fragile during teardown, because both death paths zero it. So: **add `leader: u32` to `Task`** — the Linux tgid, in danos clothes. `spawnProcessSupervised` sets `leader = own id`; `spawnThreadSupervised` copies the *caller's* leader; kernel tasks keep `leader = 0`, which is never followed. The leader id is exactly the id `system_spawn` returned to the supervisor, so the outside world already speaks it. Group membership = equal `leader`, where a *live member* means `state` ∈ {`.ready`, `.blocked`, `.running`} — the same filter `taskByIdLocked` applies; `.reaping` corpses are excluded. While the field is being introduced, add `leader` to `ProcessDescriptor` too (the ABI is private, so this is cheap now and lets `process_enumerate` consumers group threads). `process_kill` re-derives its authority through the leader — with the existing guard order preserved: a kernel task (`address_space == 0`) is `-ESRCH` *before* any leader resolution (the kernel test asserts exactly that). Then: resolve the target, follow `target.leader`, require `leader.supervisor == caller`. The kill capability becomes per-*process*, aimed at any member id, and the odd today-behavior where a worker can be individually killed by its spawning thread disappears. (Audited: nothing in-tree kills a worker tid or relies on thread-supervisor kill semantics.) ## The group-dying latch `AddressSpaceRef` — one per space, refcounted by its member tasks, recycled with a full struct re-init — gains the group-death state: ```zig dying: bool = false, // set by the first trigger; never cleared group_reason: abi.ExitReason, // what the leader's record will say exit_endpoint: ?*ipc.Endpoint, // the leader's counted ref, moved here leader: u32, ``` The latch answers three attacks the red team confirmed against a latch-free design: - **The spawn gate.** A member already *inside* `thread_spawn` on another core when the fan-out runs (it passed the syscall-entry `kill_pending` check, then spun on the BKL) would otherwise complete the spawn after the fan-out's lock hold ends — a fresh, uncondemned member that escapes the kill and, worse, holds a space reference that keeps the group-death hook from ever firing. Fix: `retainAddressSpace` (equivalently `spawnUserLocked`) **refuses a dying space**; the in-flight `thread_spawn` fails with `-ESRCH` under the same lock that would have created the member. - **Concurrent triggers.** A second member faulting (or exiting) on another core while the first fan-out runs must not re-run the fan-out, double-bump `fault_kill_count`, or re-stamp reasons. Every kill path checks the latch first: already dying → skip straight to `terminateCurrentLocked`, no stamp, no count. First trigger wins, deterministically. `exit_reason` and `fault_kill_count` writes move under the BKL as part of this. - **Notification ownership.** The leader's `exit_endpoint` is a counted birth-to-death reference dropped at notify time. The stamp pass **moves** that reference onto the `AddressSpaceRef` and nulls `Task.exit_endpoint` in the same hold, so the leader's own `releaseTaskResourcesLocked` sees null (no early notify, no double drop); the group-death hook notifies and drops exactly once. ## The fan-out: `killGroupLocked` One new function in `process.zig`, running under a **single BKL hold** (built from the `*Locked` primitives — the lock is non-recursive, and `terminateCurrentLocked` never returns, which forces the shape): ``` killGroupLocked(leader: u32, reason: ExitReason, trigger: ?*Task) 0. Latch: AddressSpaceRef.dying = true, stash {reason, leader, leader's exit_endpoint (moved)}. 1. Stamp pass: the LEADER's exit_reason = reason — the leader's record is the one the supervisor can read, so it carries the group reason even when the trigger is a worker. The trigger also keeps `reason` (its own record tells the truth); every other live member gets .killed. All members get kill_pending. Stamping precedes any teardown, because recordExitLocked snapshots the reason first thing. 2. Reap pass, to fixpoint: reap every member in .ready or .blocked via reapTaskLocked, re-reading Task.state each iteration — a member's teardown can wake another member (-EPEER wakes, joiner wakes), flipping it .blocked → .ready behind the scan cursor. Terminates in ≤ one pass per member: the scrub calls in releaseTaskResourcesLocked (abandonSenderLocked, removeFromWaitQueueLocked, forgetIpcClientLocked, killOwnedEndpointsLocked) run before destroy, so no wake path holds a pointer to a reaped member. 3. Members .running on other cores stay condemned (kill_pending); a condemned member dies at its next syscall entry, at its own core's next tick while in user mode, or — once it blocks or is preempted — at any core's next tick reap. There is no kill IPI. (The entry check reads kill_pending unlocked; benign on x86-TSO — a missed read is caught by the next delivery point — but make the field atomic when touching it.) 4. If the current task is a member (fault, exit, in-group kill): terminateCurrentLocked, last, because it switches away and the reap paths free the kernel stack being stood on. If the caller is outside the group (supervisor kill): return. ``` The invariants this preserves, each load-bearing today: - **Only `.ready`/`.blocked` tasks are reaped synchronously.** A member running on another core can only be condemned — it tears itself down after switching CR3 off the dying page tables (the stack it stands on is freed later by the reap list), and its address-space reference protects the page tables its CR3 still points at. Force-destroying the space under a running sibling is the one unrecoverable mistake available here. - **The refcount decides when the space dies.** Reaping N members drops N references; the last drop — possibly on a condemned sibling's core, a tick later — destroys the space. No path forces it. - **`fault_kill_count` bumps once per group**, not per member (`fault-recovery` asserts `== 1` exactly); the latch is what enforces this under racing faults. - **Per-tid resource sweeps stay per-tid.** Each member's `releaseTaskResourcesLocked` releases what *that tid* owns — claims, GSI/MSI bindings, registered endpoints, handles. That keying is correct under shared fate (and is today's hazard: a lone worker death already yanks its claims out from under live siblings). A worker that *does* carry an `exit_endpoint` (the ABI allows it; the runtime passes `no_cap`) keeps today's per-task posting at its own teardown — only the leader's notification moves. ## When is the group dead? The notification Today each task posts its own exit notification as the *last* step of its release, so a supervisor observes a fully-released child. For a group that guarantee must hold for the **whole group**: if the leader's notification fires while a condemned sibling still runs on another core, the device manager can respawn the driver into a claim conflict with a not-yet-dead sibling. The clean fix falls out of the refcount: **the group is dead exactly when the address space is destroyed.** `releaseAddressSpace`'s last-drop path calls a new `group_exit_hook` (the scheduler already calls up through hooks — `terminate_current_hook` — precisely to keep this layering), which: 1. **re-stamps the leader's exit record** with the stashed `group_reason` — the record is written (again) at group-death time, so "the reason is recorded before the notification posts" stays true and a supervisor can never be notified and then read `-ESRCH` because the burst evicted an old record; 2. posts the leader's exit notification (and subscriber broadcast) from the stashed endpoint, and drops that reference — exactly once. Both `releaseAddressSpace` call sites (`exitUserLocked`, `destroyTaskLocked`) run under the BKL, so the hook does too; its wakes are safe at both (verified). For a single-threaded process the behavior is *externally indistinguishable* from today — the order of notify vs. destroy inverts, but both sit inside one lock hold, so no other core can observe the space destroyed but the notification unposted, or vice versa. That sentence is the correctness argument; it is also the first invariant to re-examine if the BKL is ever split, along with `killGroupLocked`'s single-hold atomicity. (Hand-built spaces that were never retained take the immediate-destroy path and are out of the hook's scope.) Workers' `exit_subscribers` broadcasts still fire per task — the FAT server's dead-client sweep is keyed by tid and needs those. **Signals.** `signal_bind` is per-task and the service harness binds on the main thread, so signals address the leader in practice; that stays. During a group death, `process_signal` may return `0` (accepted by a condemned member — never delivered, every delivery point kills first) or `-ESRCH` (member already reaped); init's stop sequence already tolerates both, and its timer escalation to `process_kill` covers the gap. `process_signal` follows `process_kill`'s leader re-key for consistency. ## The shared-memory frame hazard `dropSharedMemoryReference` frees a region's physical frames when the last *handle* reference drops, but mappings die only with the address space. If the last handle lived in a torn-down member while any task still has the region mapped, that task holds a live mapping onto freed frames — and the red team showed this is **not** group-specific: a plain `thread_exit` of the handle-holding thread, or a last-ref drop by a task *outside* the dying group during the condemned window, hits the same use-after-free. So the fix is a property of the **object**, not the dropper: give `SharedMemoryObject` a per-*mapping* reference — `shared_memory_map` (and create's self-map) retains; each space's destruction releases. "Last reference" then means *no handles and no mappings*, both hazard paths collapse into the existing refcount, and no group-kill special case is needed at all. ## Deliberately unchanged - Worker `thread_exit`: per-thread, full per-tid resource sweep, refcount drop. - The condemned-but-running window: a member on another core can finish its in-flight syscall and run user code for up to a tick before dying — identical to today's single-task `process_kill` semantics ("prompt but asynchronous, like a Unix signal"). A kill IPI would shrink it; it is not part of this plan. - `thread_join` returns 0 for a killed thread; joiners inside a dying group are woken and then reaped like any member. - The `.reaping` state, reap lists, and stack reaper. ## Accepted limits (documented, not fixed here) - **Notify-ring overflow**: a group death posts one subscriber badge per member into 8-slot rings; a >8-member group can drop badges. Group size is bounded by the 48-task table; today's largest production group is 2 (display) and thread-test already reaches 5. - **Exit-record ring pressure**: one 64-entry ring, one record per member — made harmless for the supervisor by the hook's group-death re-stamp. - **Enumerate shows a partial group** mid-death: reaped members vanish at once, condemned members linger up to a tick (audited: no in-tree consumer misbehaves; the `leader` field in `ProcessDescriptor` lets future consumers group correctly). - **Pre-existing reap race, not widened**: a task preempted *mid-syscall* is `.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** — *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. The post-implementation review then found the other half: the shm/DMA page-table walks and their pmm/heap calls ran *outside* the big kernel lock — pre-existing, but fatal once siblings were invited to race them (and a plausible root for the long-standing intermittent AP ring-3 fault at the shm base). All three paths now follow the mmap discipline: metadata and allocation under one hold, the map itself per-page under brief holds. - **Mapping-record slots are never recycled**: 16 per space, one per `shared_memory_create`/`map`, freed only at space destruction (there is no shm unmap). A long-lived compositor that churns surfaces will hit the cap; the failure is a clean refused create, and slot recycling can ride whatever adds `shared_memory_unmap`. - **Two properties lack direct tests**: the spawn gate (an in-flight `thread_spawn` racing the fan-out — inherently nondeterministic to arrange; covered by code inspection and the `-ESRCH` path) and the `process_signal` leader re-key (exercised only implicitly by the signals case). ## Milestones - **M1 — the leader id.** `Task.leader` (kernel tasks: 0, never followed), set on both spawn paths; `leader` added to `ProcessDescriptor`; `process_kill` and `process_signal` re-keyed (kernel-task `-ESRCH` guard *before* leader resolution). No fan-out yet. Existing tests must pass untouched. - **M2 — the latch + fan-out.** `AddressSpaceRef.dying` + stash; `retainAddressSpace` refuses dying spaces; `killGroupLocked`; wire the fault path, `exit`, and `process_kill` into it; leader `thread_exit` → `-EPERM`; `exit_reason`/`fault_kill_count` writes under the BKL; `kill_pending` made atomic. Group notification via the `group_exit_hook` re-stamp + post. - **M3 — shared-memory mapping refs.** `SharedMemoryObject` counts mappings; space destruction releases them; frames free only at zero handles *and* zero mappings. - **M4 — tests + docs.** New `-Dtest-case`s (all `smp: 4` where cross-core matters), driving `thread-test` with new argv modes: - `thread-fault-group`: a worker faults; assert both tasks gone from `enumerate`, `fault_kill_count == 1`, `process_exit_reason(leader) == segmentation_fault`, address-space and stack-bytes counters return to base. - `kill-threaded-group`: `process_kill(leader)` with a worker spinning on another core; assert the worker dies by the deferred path, exactly one exit badge, delivered only after both members are dead, and `process_exit_reason(leader) == .killed`. - `kill-via-worker-tid`: `process_kill(worker)` kills the whole group; `-EPERM` for a non-supervisor aiming at the worker. - `racing-triggers`: two members fault/exit simultaneously on different cores; assert a deterministic leader reason and `fault_kill_count == 1`. - `exit-group`: a *worker* calls `exit(3)`; group dies, leader reason `.aborted`. - `leader-thread-exit`: asserts the chosen rule (`-EPERM`, workers unaffected). - `thread-exit-solo`: regression — worker `thread_exit` still leaves siblings running. - `group-claim-release`: a member claims a device; assert the claim is free and the leader notification arrives only after every member is dead. - `shm-mapping-ref`: last handle dropped by a dying thread; sibling's mapping stays valid until space death (M3 regression). Then update [threading.md](threading.md) (the shared-fate gap note), [process-lifecycle.md](process-lifecycle.md), [process-management.md](process-management.md), and [ipc.md](../device-driver-development/ipc.md)/[drivers.md](../device-driver-development/drivers.md) mentions.