Page Faults: Demand Paging and Copy-on-Write

July 16, 2026 ยท View on GitHub

The paging manager turns two kinds of page fault into deferred work. A fault on a page that is not present backs it on demand, allocating and mapping a frame the first time it is touched. A write fault on a page that is present makes a private copy, the copy-on-write path. Every other fault is unhandled, which means the fault path treats it as an error and kills the offending capsule or traps a real kernel bug. This page documents the dispatch and both handlers. The code is under src/memory/paging/manager/faults/.

The dispatch

handle_page_fault (faults/handler.rs:25) reads the x86 page-fault error code and routes on two of its bits:

  handle_page_fault(va, error_code, stats):
      record the fault
      if error_code has PF_WRITE and PF_PRESENT   -> copy-on-write
      if error_code has no PF_PRESENT             -> demand paging
      otherwise                                    -> UnhandledPageFault

A write to a page that is present but not writable is a copy-on-write fault. A fault on a page that is not present is a demand fault. Anything else, for example a protection fault that is neither of those, is returned as UnhandledPageFault, and the exception path that called in decides what to do with it. Each branch also records its own statistic before dispatching.

Demand paging

handle_demand_fault (faults/demand.rs:27) backs a not-present user page, and its first two checks are guards, not allocation:

  handle_demand_fault(va, stats):
      if va is not in user space          -> UnhandledPageFault
      pid = current process
      if not demand_cap::charge(pid)       -> UnhandledPageFault
      frame = allocate_frame()             -> FrameAllocationFailed if none
      zero the frame through the direct map
      map va -> frame as READ | WRITE | USER, 4 KiB

The user-space check is a security boundary and the source is explicit about it: a not-present fault in the kernel half is never a legitimate lazy mapping, and backing one silently would hand a capsule memory in the kernel's address range. So a kernel-half demand fault is surfaced as unhandled, which kills the faulting user or traps the real kernel bug rather than papering over it. Only user-space addresses are ever demand-backed.

The budget check is a denial-of-service boundary. Each demand-backed page is charged against the faulting process's demand budget through demand_cap::charge (faults/demand_cap.rs), and a process that has exhausted its budget is refused here and killed by the fault path, so a runaway capsule cannot fault-in pages until it exhausts physical memory. Only after both guards pass does the handler allocate a frame, zero it through the direct map so it cannot carry the contents of a previous allocation, and map it into the faulting address writable and user-accessible.

Copy-on-write

handle_cow_fault (faults/cow.rs:27) handles a write to a present, non-writable page by giving the writer its own copy:

  handle_cow_fault(va, stats):
      new_frame = allocate_frame()          -> FrameAllocationFailed if none
      if translate_address(va) succeeds:
          copy the original page into new_frame through the direct map
      map va -> new_frame as READ | WRITE | USER, 4 KiB

It allocates a fresh frame, translates the faulting virtual address to the physical frame it currently maps, copies that frame's contents into the new one through the direct map, and remaps the faulting address to the new frame as writable and private. After the fault the writer has its own copy and its write proceeds; any other mapping of the original frame is untouched. This is what lets a page be shared read-only between address spaces and split into private copies only when one side writes.

The direct map

Both handlers reach a physical frame's bytes through the direct map: the frame at physical address p is addressable at DIRECTMAP_BASE + p. Demand paging uses it to zero a fresh frame, and copy-on-write uses it to read the original frame and write the copy. The direct map is one of the two kernel-half mappings the unified-VM init confirms before it tears down the bootloader's low half, which is why the fault handlers can rely on it.

What is not handled, and why that is safe

The handler deliberately does little. A kernel-half not-present fault, a present protection fault that is not a write, and anything that is neither demand nor copy-on-write all return UnhandledPageFault. Returning an error rather than guessing is the safe default: the exception path that invoked the handler kills a faulting capsule or halts on a genuine kernel fault, instead of the handler silently backing memory it should not. The handlers only ever create user-space, budget-charged, zeroed or copied mappings, and never touch the kernel half.

Where this connects

The fault handler is invoked from the page-fault exception vector in the interrupt layer, which passes it the faulting address from CR2 and the error code. The frames it allocates come from the physical frame allocator, and the mappings it installs go through the paging manager's map_page under the same interrupt discipline.

Security analysis

The fault path is where a page fault, an event a hostile capsule can generate at will, is turned into either a bounded, safe mapping or a kill. Three properties keep that conversion tight.

Demand paging is user-space only. handle_demand_fault (faults/demand.rs:37) rejects any not-present fault whose address is not in user space (layout::in_user_space) and returns UnhandledPageFault. This is the boundary that matters most: a not-present fault in the kernel half is never a legitimate lazy mapping, and backing one silently would hand a capsule a page in the kernel's own address range. So the only addresses the handler will ever demand-back are user-space ones, and a kernel-half fault is surfaced as unhandled, which traps the real kernel bug instead of papering over it.

Every backed page is zeroed before it is mapped. Both handlers reach the fresh frame through the direct map and scrub it before it enters the address space: demand paging writes 4 KiB of zeros (faults/demand.rs), and copy-on-write copies the original page's bytes into the new frame (faults/cow.rs). A lazily-backed page therefore never carries the previous tenant of that frame, which is the per-fault half of the zeroization posture. The frame itself was also zeroed on free, so this is defence in depth rather than the only scrub.

The demand budget is a denial-of-service bound. Each backed page is charged against the faulting process through demand_cap::charge (faults/demand_cap.rs), which caps a single process at MAX_DEMAND_PAGES (262144 pages, one gigabyte). A process over budget is refused with UnhandledPageFault and killed by the exception path, so a runaway capsule cannot fault-in pages until it drains physical memory. The honest boundary: the counter table holds MAX_TRACKED (128) processes, and when it saturates with live faulters the charge allows rather than wrongly denies, so the per-process cap always holds but the aggregate is only bounded to the tracked set. Pid 0 (no current process) is charged as always-allowed, which is the kernel's own faults, not a capsule's.

Debugging page faults

A handled demand or copy-on-write fault is silent on purpose: page_fault.rs:46 explains that dumping every one to the serial console makes demand paging pathologically slow and stalls the system while a large allocation is faulted in page by page. So the only faults that narrate are the ones the handler refused, and the exception path (src/interrupts/handlers/exceptions/page_fault.rs) prints them in a fixed shape:

  PF <trap dump>                                   dump_trap: the frame, error code, and CR2
  Guard page violation detected                    the address is a registered guard page
  Segmentation fault: user process accessed ...    a user fault the handler could not back (kill)
  KERNEL PANIC: Page fault at address ...          a kernel-half fault (halt)
    Attempted to execute from non-executable page  instruction-fetch fault (NX / W^X hit)
    Attempted to write to read-only page           write to a read-only mapping
    Attempted to read from non-present page        read of an unmapped address

The [DEMAND-CAP] per-process page budget hit, killing pid=<hex> line comes from demand_cap.rs and is printed exactly once as a process crosses its budget, so a capsule that dies right after a burst of allocation is a budget kill, not a mapping bug. The three "Attempted to ..." lines are chosen off the error-code bits (is_instruction_fetch, is_write), so a kernel panic tells you which access the kernel made: an execute line on a kernel panic is a W^X or NX violation inside the kernel, a write line is a write to a read-only kernel mapping. A user "Segmentation fault" line means the handler saw a fault it will not back (a kernel-half address from ring 3, a protection fault that is neither demand nor copy-on-write), and the process exits with -11 (SIGSEGV). A "Guard page violation detected" means the faulting address was registered as a guard page on the hardening side, so it is a deliberate tripwire, an overrun off a stack or buffer, rather than an ordinary miss.

Source map

  src/memory/paging/manager/faults/handler.rs     the dispatch on the error code
  src/memory/paging/manager/faults/demand.rs       demand paging and its guards
  src/memory/paging/manager/faults/demand_cap.rs   the per-process demand budget
  src/memory/paging/manager/faults/cow.rs          copy-on-write
  src/interrupts/handlers/exceptions/page_fault.rs the exception vector and its markers

Every reference above is verified against those trees. The error code and CR2 come from the interrupt layer, the frames come from the physical frame allocator, the mappings go through the paging manager, and the zero-on-back pairs with the zeroization posture.