The Process Table

July 16, 2026 · View on GitHub

The process table holds every live process, a per-CPU current-pid tracks which process is running on each CPU, and a small allocator hands out process ids. This page documents the table and its queries, the per-CPU current process, PID allocation, and the creation path that builds a ProcessControlBlock and registers it. The code is under src/process/core/table/.

The table

The table is a vector of reference-counted PCBs behind a reader-writer lock (src/process/core/table/types.rs:56):

  ProcessTable { inner: RwLock<Vec<Arc<ProcessControlBlock>>> }
  static PROCESS_TABLE: ProcessTable = ...

Every process is an Arc<ProcessControlBlock>, so the table shares ownership rather than holding the only copy, and a caller that looks a process up gets an Arc it can hold across a lock drop. The queries are all linear scans under the read lock (types.rs:60):

  add(pcb)                 push a new process
  find_by_pid(pid)         the PCB with that pid, if live
  get_all_processes()      a clone of the whole vector
  get_children_of(ppid)    every process whose parent is ppid
  has_children(pid)        whether any process has this pid as parent
  is_active_pid / is_active_name   membership tests

The structure is a plain vector, so lookups are O(n) in the number of live processes. That is a deliberate fit for the workload: a NØNOS system runs on the order of dozens of capsules, not thousands of processes, so a linear scan under a shared read lock is simpler and contends less than a map would, and the read lock lets the common case, many concurrent lookups, proceed in parallel.

The current process, per CPU

Which process is running is not a single global; it is per-CPU (types.rs:25):

  CurrentPid { slots: [AtomicU32; MAX_CPUS] }
  static CURRENT_PID: CurrentPid = ...

load, store, and swap all operate on slots[cpu_id()], the slot for the CPU making the call, so each core independently records the process it is currently running. This is what makes "the current process" correct under SMP: two cores can run two different processes at the same time, and each reads its own current pid from its own slot with no coordination. The scheduler updates this slot on every context switch, and current_pid() throughout the kernel reads it.

PID allocation

New ids come from a monotonic counter with wraparound and a liveness check (types.rs:93):

  allocate_tid():
      lock the PID allocation mutex
      loop:
          current = NEXT_PID
          NEXT_PID = (current >= u32::MAX - 1) ? 1 : current + 1
          pid = current == 0 ? 1 : current
          if pid is not an active pid -> return Some(pid)
          after 65536 attempts -> log exhaustion, return None

NEXT_PID starts at 1 and advances on each allocation, wrapping back to 1 rather than overflowing. Because the space wraps, a candidate id could still belong to a live process, so the allocator checks is_active_pid and skips it, and it gives up after a bounded number of attempts rather than looping forever if the space is genuinely full. The allocation is serialised by a dedicated mutex so two cores cannot hand out the same id.

Creating a process

create_process builds a PCB and registers it (src/process/core/table/create.rs:26):

  create_process_with_mem(name, state, prio, mem_kb):
      if name is empty -> Err
      pid = NEXT_PID.fetch_add(1)
      parent = current pid
      caps = compute_inherited_caps(pid, parent)
      pcb = build_pcb(pid, parent, name, state, prio, mem_kb/4, caps)
      address_space::lifecycle::allocate(pcb)
      caps::rebind_address_space(pcb)          re-mint the token with the real ASID
      PROCESS_TABLE.add(pcb)

The new process inherits its capabilities from its parent through compute_inherited_caps, then build_pcb constructs the full control block, an address space is allocated for it, and its capability token is re-minted so subject_asid reflects the real address space rather than the zero the base mint left. That re-mint returns an error if the boot session nonce is not yet set, so a process cannot be created without it, the same fail-closed rule the signing path enforces. Only after all of that does the PCB join the table. Note that this is the generic process constructor; a verified capsule goes through the spawn pipeline, which additionally swaps the inherited token for the manifest-derived one through the one-shot install_spawn gate.

The initial control block

build_pcb (create.rs:76) sets every field's initial value, and several of the defaults are worth stating because they are security-relevant:

  token           minted for pid over the inherited caps (fail-closed on boot nonce)
  io_bitmap       [0xFF; 8192]     every port denied; a PIO grant clears bits to allow
  kernel_stack_top 0               no user mode until a kernel stack is allocated
  pending_user_entry / saved_user_context   None
  exit_signal     17               SIGCHLD to the parent on exit
  cpus_allowed    !0               all CPUs
  umask           0o022            root_dir and cwd "/"
  memory.next_va  0x0000_4000_0000  the base mmap hands out from

The io_bitmap default is the important one: it is all ones, which on x86 means every port is denied, so a fresh process has no port-IO permission at all, and a PIO grant works by clearing the specific bits it authorises. kernel_stack_top starts zero, which the scheduler reads as "no user mode expected" until a kernel stack is allocated, and the token is minted at construction so the PCB is never live without authenticated authority.

Threads versus processes

spawn_thread (create.rs:58) creates a schedulable thread inside the current process rather than a new process. The thread inherits the parent's address space through address_space::lifecycle::inherit, so it runs on the same CR3, joins the parent's thread group by copying its tgid, and takes a caller-provided user entry point and stack. The distinction that matters for teardown is stated in the source comment: a thread has no VMAs of its own, so tearing it down frees nothing of the shared address space, which belongs to the process and is only reclaimed when the last member exits. Unlike a process, a thread is given its kernel stack and initial user context inline and added straight to the run queue.

Security analysis

The table decides what "the current process" means and hands out the identity every authority check keys on, so its correctness under SMP and its no-live-without-a-token rule are the security properties. Three hold.

Per-CPU current pid, no cross-core confusion. CURRENT_PID is not a global; it is [AtomicU32; MAX_CPUS] (types.rs:25), and load, store, and swap all index slots[cpu_id()], the calling CPU's own slot. So two cores running two processes each read their own current pid with no coordination, and no core can be tricked into acting as another core's current process. Every kernel path that asks "who is calling" resolves it through this per-CPU slot, which is what makes an authority check attributed to the right capsule under real parallelism.

A process never joins the table without an authenticated token. create_process (table/create.rs:26) builds the PCB with a token minted for the pid, then re-mints it through caps::rebind_address_space so subject_asid reflects the real address space, and only then calls PROCESS_TABLE.add. The re-mint fails closed if the boot session nonce is not set, so a process cannot be created before the session is established, the same fail-closed rule the signing path enforces. build_pcb (create.rs:76) sets the security-relevant defaults: io_bitmap all-ones (every port denied), kernel_stack_top zero (no user mode until a stack is allocated), and a token minted at construction, so a PCB is never live without authority.

PID allocation cannot collide across cores or alias a live process. allocate_tid (types.rs:93) advances NEXT_PID under a dedicated mutex, so two cores cannot hand out the same id, and because the u32 space wraps back to 1 rather than overflowing, a candidate could still name a live process; the allocator checks is_active_pid and skips it, giving up after MAX_ATTEMPTS (65536) rather than looping forever. The honest boundary: the queries are O(n) linear scans under a read lock, which is a deliberate fit for dozens of capsules, not thousands of processes, and the table shares ownership through Arc, so a lookup returns an Arc a caller can hold across a lock drop. A pid returned by a lookup is a live-at-lookup snapshot; the process can exit while the caller holds the Arc, which keeps the PCB alive but does not keep it schedulable.

Debugging the process table

The wrong current pid on one CPU under SMP, an authority check attributed to the wrong capsule, points straight at the per-CPU CURRENT_PID slot and whether the scheduler updated slots[cpu_id()] on the last switch rather than a global. PID allocation returning None is the exhaustion path: allocate_tid logs [PROCESS] PID space exhausted after 65536 attempts (types.rs:107) when every candidate in a full sweep was already active, which on a dozens-of-capsules system means pids are being allocated but never reaped, so the trail leads to the reaper, not the allocator. A process that seems to exist but no lookup finds it, or the reverse, is an add that never ran or a terminate_process that ran early: find_by_pid scans only live entries, so a pid missing from get_all_processes was either never added, created before the boot nonce (the fail-closed create_process error), or already removed by finalize. A capsule that comes up with unexpected port access or reaches user mode when it should not is a build_pcb default gone wrong: the two to check are io_bitmap (all-ones is correct) and kernel_stack_top (zero until a stack is allocated).

Source map

  src/process/core/table/types.rs    ProcessTable, CurrentPid, PID allocation
  src/process/core/table/create.rs   create_process, spawn_thread, build_pcb
  src/process/core/table/inherit.rs  compute_inherited_caps
  src/process/core/table/ops.rs      the table operation wrappers

Every reference above is verified against those trees. The per-CPU current pid is updated by the scheduler on every switch; the token minted at creation and its fail-closed rule are on the capability and signing pages; the verified capsule path that swaps the inherited token for the manifest one is on the capsule trust page; and the build_pcb defaults are read on the PCB and context switch pages.