7.3 KiB
Process Management
How danos lists, supervises, and kills processes — the microkernel answer to
ps, kill, and SIGCHLD/wait.
Why system calls, not /proc
Unix systems sit on a spectrum. Classic BSD/macOS list processes through
syscalls (sysctl(KERN_PROC)) and kill through kill(2); Linux renders the
process table as /proc for reading but still kills through a syscall; Plan 9
made the file tree the whole interface (echo kill > /proc/n/ctl). Microkernels
mostly abandon ambient PIDs: Minix and QNX route everything through a user-space
process-manager server, and Fuchsia/seL4 control processes only through handles.
danos rules out /proc as the primitive: the path router lives in the
kernel (fs_resolve), but what is mounted under a path is served by a
user-process filesystem server (the way FAT serves /mnt/usb) — a /proc
would be one more such server, which would put a user process in the path of
process control. If that server (or anything under it) hangs, nothing could be
listed or killed, including the hung server. The control plane for processes
must not depend on a process. So the primitives are kernel system calls; a
read-only /proc rendering can be layered on later, and a POSIX-style
process-manager server can be built from these primitives when one is needed.
The three primitives
process_enumerate(buffer, maximum) -> total
A snapshot of the task table into a caller buffer of abi.ProcessDescriptor
(id, supervisor, state, priority, name) — the exact shape of
device_enumerate, so ps is a user program over a snapshot, not a kernel
service. The total may exceed what fit; call again with a larger buffer. Kernel
tasks are included with an empty name — an honest listing shows the idle tasks
too. Ungated and read-only: what is running is not a secret between cooperating
bring-up processes.
system_spawn(..., exit_endpoint) -> child id, and the supervision link
system_spawn records the caller as the child's supervisor and returns the
child's process id (ids are monotonic, never reused — a stale id can only miss).
That link is the kill authority: it answers "who may kill process 7?" without
inventing users or permissions, the same way a device claim is the capability
for mmio_map. It composes with the supervision hierarchy the device manager
already forms: init supervises the services it starts, the device manager
supervises the drivers it matches. (A transferable process handle — Fuchsia
style — can replace the id once the handle table grows types beyond endpoints.)
exit_endpoint (a handle, or abi.no_cap) is the supervisor's death-watch: when
the child ends — clean exit, CPU fault, or process_kill — the kernel posts an
asynchronous notification to that endpoint, exactly like a bound IRQ. The badge
carries abi.notify_badge_bit | abi.notify_exit_bit | child_id, so one endpoint
supervises many children and can even share with IRQ notifications. This is the
microkernel's SIGCHLD: no new mechanism, just the IRQ-as-IPC pattern reused, and
a supervisor's event loop (ipc.replyWait) already knows how to receive it. The
child holds a reference to the endpoint from birth, so the notification cannot
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.
How a kill lands (the kernel mechanics)
Everything below runs under the big kernel lock, where task states cannot move.
-
Target ready or blocked (not on any core): reaped on the killer's own call. The reap releases what death always releases (IRQ bindings first, then a client the target still owed a reply to is failed with
-EPEER, IPC handles closed, the exit notification posted last) — plus the unlinking only a remote death needs: out of the ready queue, out of an endpoint's sender FIFO (Task.ipc_wait_endpoint), out of a receive wait queue (Task.wait_queue), and out of any server's owed-reply slot, so nothing ever dequeues a dangling pointer. Destroying the address space is safe because no core can have it loaded: every switch away from a task loads the next task's tables. -
Target running on another core: it cannot be torn down mid-instruction, so it is condemned (
Task.kill_pending) and dies at whichever comes first:- its next system_call entry — checked before dispatch, so a condemned process cannot spawn, claim, or message anything on its way out;
- its core's next timer tick — but only when the task is not inside one
of its own system calls (
Task.in_system_call): the tick may have interrupted kernel code mid-operation, where teardown would leak whatever the operation held. User-mode execution is always a safe kill point. The tick-time terminate abandons the interrupt frame exactly like the fault path (the LAPIC is acknowledged before the tick hook runs); - any core's tick finding it blocked or ready (it entered a syscall and parked after being condemned) — reaped by the same remote-reap path.
A pure user-mode spin loop that never makes a system call therefore dies within one tick; nothing a process does can outrun the kill.
The scheduler stays below the process layer: finishing a kill (IRQ bindings,
handles, the notification) is called up through two hooks process.zig
registers at boot (terminate_current_hook, reap_task_hook), mirroring how
the architecture layer calls up into tick.
Known gaps (bring-up honesty)
Device claims are not released on deathClosed (M17.1): every path out of a process releases its device claims alongside its IRQ and MSI bindings (releaseTaskResourcesLocked), so a restarted driver can claim its hardware again — the cleanup half of process-lifecycle.md's iron rule 1. Theclaim-releasetest proves the kill → release → re-claim cycle.Kernel stacks of dead tasks are leakedClosed (threading-plan M8): a task exiting on its own core queues on the core's reap list in.reapingstate, and the next switch away (or tick) frees its kernel stack; one killed while off-CPU has its stack freed synchronously by the reap itself. Both paths are accounted bylive_stack_bytes, which returns to baseline when no extra tasks are live.There is no exit status in the notificationClosed (M17.2): the kernel records how every process ends — exited, a fault class, or killed — before it posts the exit notification, and the supervisor reads it withprocess_exit_reason(runtime.process.exitReason). This is the input to restart policy (process-lifecycle.md); an exit code for the clean case can still ride alongside later.- Enumerate writes through the caller's raw pointer under the bring-up trust
model, like
device_enumerate(an unmapped page is a self-DoS, not an isolation break).
Tests
process-list (enumerate), process-kill (kernel-level kill paths, refusals,
notifications), supervision (the whole user-side surface via the process-test
service: spawn supervised → enumerate → kill blocked and spinning children →
notifications → gone), claim-release (a killed claim-holder's device is
claimable again). See test/qemu_test.py.