danos/docs/os-development/process-management.md

7.6 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 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. The kill is a whole-process kill (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)

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 death Closed (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. The claim-release test proves the kill → release → re-claim cycle.
  • Kernel stacks of dead tasks are leaked Closed (threading-plan M8): a task exiting on its own core queues on the core's reap list in .reaping state, 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 by live_stack_bytes, which returns to baseline when no extra tasks are live.
  • There is no exit status in the notification Closed (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 with process_exit_reason (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.