121 lines
6.9 KiB
Markdown
121 lines
6.9 KiB
Markdown
# 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**: here a `/proc` would be served by
|
|
the VFS server — a user process — which would put the VFS in the path of process
|
|
control. If the VFS (or anything under it) hangs, nothing could be listed or
|
|
killed, *including the hung VFS*. 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 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](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, as on every exit path (no reaper yet).
|
|
- ~~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` (`runtime.process.exitReason`). This is the input to
|
|
restart policy ([process-lifecycle.md](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.
|