The Supervisor and the Reaper

July 16, 2026 ยท View on GitHub

Two things watch over process lifetimes, and they operate at different levels. The init process, pid 1, is the userspace supervisor: it spawns every system capsule and then runs a light residual loop, observing capsule liveness passively rather than polling. The kernel reaper is the second half of exit: the preemption timer drains the zombie pending-list and finalizes each dead process, reclaiming its memory. This page documents both, and it completes the exit story the lifecycle page began.

Init, the userspace supervisor

run_init (src/userspace/init/entry.rs:20) is what pid 1 runs. It brings the whole userland up in a fixed order and then hands off to its supervisor loop:

  run_init() -> !:
      spawn ramfs, then the core capsules that depend on it
      spawn the display core, the drivers, the vfs, the network stack
      spawn the desktop, the market, the apps
      lower_init_priority()      init drops to Priority::Low
      yield_after_spawns()       yield repeatedly to let them start
      launch_final_payload()
      init_loop()                the residual supervisor loop

The spawn order is a dependency order: the ramfs comes up first because later capsules stage in it, the display core and drivers before the desktop that draws on them, the network stack before the capsules that use it. Each is a verified spawn. Once everything is running, init lowers its own priority to Low so it never competes with the capsules it launched, yields to let them initialise, and enters the loop.

The init loop

init_loop (src/userspace/init/supervisor/loop_impl.rs:25) is deliberately light, and its doc comment states the liveness philosophy exactly:

  init_loop() -> !:
      loop:
          if a second has passed since the last tick:
              services::lifecycle::tick()
          yield_now()

It ticks the service lifecycle registry once per second and otherwise yields. The key design point is what it does not do: the kernel does not actively probe capsules for liveness. A capsule that has exited is observed as dead on its next IPC, through the process state machine that already tracks it, so there is no health-check traffic and no polling thread. The supervisor's job is to walk the lifecycle registry on a slow tick, not to interrogate every capsule.

The reaper: the second phase of exit

When a process exits, teardown marks it a zombie, releases its broker resources, takes it off the run queue, and enqueues its pid on a pending list (src/process/exit/pending.rs:18). The pending list is drained and finalized by the kernel reaper, and the reaper is driven by the preemption timer. On each tick the timer interrupt calls drain_pending_teardowns (src/interrupts/isr/timer_trampoline.rs:193), which is drain:

  drain():
      try_lock the pending list, else return           non-blocking in the ISR
      if empty, return
      take all pending pids
      for each: finalize_teardown(pid)

The try_lock matters: the reaper runs in the timer interrupt, so it must never block on the pending-list lock, and if it cannot take it this tick it simply reaps on the next one. Zombies are therefore finalized promptly, on the next timer tick after they are enqueued, without a dedicated reaper thread.

Finalizing a process

finalize_teardown (src/process/exit/finalize.rs:11) does the reclaim that teardown deferred:

  finalize_teardown(pid):
      address_space::lifecycle::release(pcb)      free and zero the frames
      release broker resources for the pid         devices, IRQ, DMA, PIO (again)
      unregister the pid's service endpoints
      unregister the pid's IPC inbox
      clear its interrupt context and FPU state
      reparent_orphans(pid)
      PROCESS_TABLE.terminate_process(pid)          remove it from the table

The first step is where a capsule's memory is actually scrubbed: release clears the VMAs and calls the ASID-scoped cleanup that frees the leaf frames and page tables, and every freed frame is zeroed on the way out, which is the mechanism behind the ZeroState guarantee. The broker release runs a second time here, idempotently, so nothing the process held survives even if teardown was partial. Its service endpoints and IPC inbox are unregistered so no message can be routed to a dead process, its saved interrupt and FPU state are cleared, and finally its entry is removed from the process table.

Reparenting orphans

Before the process leaves the table, reparent_orphans(pid) moves any children it still has to a surviving parent, so a process that exits with live children does not leave them pointing at a pid that is about to be freed. This is the standard orphan-reparenting a process model needs, run at the moment the parent is finalized rather than left for the children to discover.

Security analysis

The supervisor and reaper sit at two trust levels, and the split is the point: init is unprivileged userspace that only spawns and observes, while the reaper is the kernel half that actually reclaims and scrubs. Three properties matter.

The reaper never blocks the timer. drain runs in the timer interrupt via drain_pending_teardowns (timer_trampoline.rs:193) and takes the pending list with try_lock, not a blocking lock (pending.rs:26): if it cannot take the list this tick it simply reaps on the next one. This is the same interrupt discipline the usercopy and preemption paths use, a path that runs at interrupt priority must never wait on a lock, and it means a contended pending list delays a reap by one tick rather than wedging the timer.

Finalize revokes and scrubs, idempotently. finalize_teardown (finalize.rs:11) repeats every broker release from teardown, the device claim, IRQ, DMA, and PIO release_all_for_pid calls, so nothing the process held survives even if teardown was partial, and it clears the pid's saved interrupt and FPU state (clear_interrupt_context, clear_fpu_state) so no stale register state is carried by a reused pid. Its first step, address_space::lifecycle::release, is where the frames are actually freed and zeroed, which is the mechanism behind the ZeroState guarantee. It also unregisters the pid's service endpoints and IPC inbox before removing it from the table, so no message can be routed to a dead process.

No live children left pointing at a freed parent. reparent_orphans(pid) (core/init.rs:40) runs before the parent leaves the table, moving any surviving children to a live parent, so a process that exits with children does not leave them referencing a pid about to be freed. The honest boundary: init's liveness model is passive by design. It does not probe capsules; a dead capsule is observed on its next IPC through the state machine that already tracks it, so a capsule that exits and is never contacted again is simply never noticed as dead by init, and it is the reaper, driven by the exit path enqueueing it, not init, that reclaims its memory. Init running at Priority::Low means it never competes with the capsules it launched, but it also means a busy system can starve the once-per-second lifecycle tick, which the fairness discussion on the selection page frames.

Debugging the supervisor and reaper

A process wedged in Zombie and never reaching Terminated is the headline reaper failure: the pending list is not draining. The two causes are the timer not firing drain_pending_teardowns at all, no ticks, or the try_lock in drain losing every tick to a held pending-list lock, in which case zombies pile up but each individual reap still eventually runs. Because drain reaps all pending pids it manages to take in one pass, a single stuck zombie is more likely a finalize_teardown that faulted partway than a drain that never ran. A capsule's memory not being scrubbed after exit points at finalize_teardown's first step, address_space::lifecycle::release, not reaching the frame free, since that is the only place the zeroing happens. A message routed to a dead pid, or an endpoint that still resolves after exit, means unregister_endpoints_for_pid or unregister_for_pid did not run, which is a finalize that returned early. On the init side, a system that boots but a later capsule never starts is a spawn-order problem in run_init (entry.rs:20), a dependency spawned after the capsule that needs it, and a lifecycle tick that stops advancing is init being starved at Priority::Low rather than the loop itself failing.

Source map

  src/userspace/init/entry.rs                    run_init, the capsule spawn order
  src/userspace/init/supervisor/loop_impl.rs      the init supervisor loop
  src/process/exit/pending.rs                     the zombie pending-list and drain
  src/process/exit/finalize.rs                    finalize_teardown
  src/interrupts/isr/timer_trampoline.rs          the timer-driven reap
  src/process/core/init.rs                        reparent_orphans

Every reference above is verified against those trees. The first phase of exit that enqueues the zombie is on the lifecycle page; the broker releases finalize repeats are on the claim page; the frame zeroing behind reclaim is on the zeroization page; and the Priority::Low fairness note ties to the selection page.