kernel: a refusal names its rule, and two bounds stop failing open

An AMD Ryzen booted to a working compositor with no USB and no storage,
and the log said only "register refused". A tree-wide audit of every
compile-time ceiling followed: 235 of them, 139 on quantities the machine
or a file decides rather than us, 5 documented anywhere, 171 silent when
reached. docs/fixed-bounds-audit.md has the inventory.

Errno attribution. The errno space was split between the kernel and the
envelope, free to drift; it is now one list in system/abi.zig, restated on
both sides, with a comptime check in library/device/driver where the two
halves are visible. device_register's six refusals and device_claim's three
are distinct codes, so a bus driver can say which rule stopped it, and
BadParent splits into NoSuchParent and NotYourParent. pci-bus reconciles
found against registered instead of counting refused functions as found.

Idempotency ordering. The child cap was checked before the identity match,
so a restarted bus was refused its own devices — the supervision restart the
system leans on ratcheted toward a degraded machine. A re-registration
consumes no slot and is now admitted first.

IOMMU fail-closed. confineDevice returned success for a device id past the
confinement table, leaving the device outside every domain while the caller
believed it confined — unreachable only while ids stop at 64, which both the
inventory move and a hardware-reported domain count would change. It refuses
now, and the coupling to the broker's device cap is a comptime assert rather
than a sentence in a comment.

PCI apertures. The bridge's MMIO apertures are derived from the holes in the
firmware memory map, and the derivation copied sub-4 GiB entries into a
fixed [64] array and skipped the rest. A skipped region is not merely lost:
the gap finder concludes it is free, so a real machine's 60-200 entry map
yields an aperture over live RAM, and containment then admits a child BAR
covering kernel memory. Rewritten to walk the map in place, with the hole
finder extracted as a pure function and driven by a synthetic 100-entry map
in a new test case. Both new tests were verified to fail on the old code.

parameters.zig gains the rationale it was missing and loses a stale sentence
pointing at the wrong file; vdso.md documents the errno space, including
EPEER, which had no written meaning anywhere.

docs/os-development/bounds.md is how a ceiling is declared from here.
docs/bounds-track-plan.md is the plan to remove the ones we invented.

Suite 114 -> 115.
This commit is contained in:
Daniel Samson
2026-08-08 11:09:54 +01:00
parent 7db5fba884
commit a86559648e
25 changed files with 1520 additions and 168 deletions
+51
View File
@@ -150,6 +150,57 @@ they are wire values a Rust program needs verbatim. What stays private in
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
numbers and the trap convention.
## Errors: the errno space
A failed call returns `-errno`. The runtime detects failure the way Linux
does — a return value in the top 4096 — so every code stays inside 1..4095.
These are **public**: unlike the call numbers, a caller must be able to read
them verbatim, and they are the same vocabulary whether the number came from
the kernel or from a user-space provider answering over IPC.
They are defined once in `system/abi.zig`. The kernel restates them in
`system/kernel/ipc-synchronous.zig` and the envelope restates the
provider-facing subset in `library/protocol/envelope/envelope.zig` (the
`protocol` package deliberately depends on nothing, so it cannot import
`abi`); a comptime check in `library/device/driver/driver.zig` makes drift a
compile error.
| # | Name | Meaning |
|---|------|---------|
| 1 | `EBADF` | bad handle |
| 2 | `E2BIG` | an argument exceeds its maximum (a message, a descriptor's resource count) |
| 3 | `EFAULT` | buffer unmapped, or outside the user half |
| 4 | `ENOENT` | no such name |
| 5 | `ENOSPC` | a kernel table is full (handles, devices) |
| 6 | `ENOMEM` | out of memory |
| 7 | `EPEER` | the peer died before replying — its process exited or was killed |
| 8 | `ESRCH` | no such process |
| 9 | `EPERM` | not permitted: the caller is not the owner or supervisor |
| 10 | `ENOSYS` | this protocol has no such operation |
| 11 | `EPROTO` | malformed packet: shorter than the verb it names |
| 12 | `EBUSY` | the thing asked for is held by someone still alive |
| 13 | `ENODEV` | no such device id |
| 14 | `ECHILDREN` | this parent already holds as many children as it can |
| 15 | `ERANGE` | a resource escapes the window it must fall inside |
| 16 | `ECONFINE` | the device could not be placed under IOMMU translation |
`EPEER` is the one with no POSIX counterpart and it is worth stating plainly:
synchronous IPC blocks the caller until the server replies, so the caller
needs an answer for "the server died while I was waiting." It is not a
transport error and not a refusal — the request may well have been carried
out — it says only that no reply is coming. A client that treats it as
"retry" can duplicate work; the honest response is to re-resolve the protocol
name, because the provider it held is gone.
**A refusal names the rule that refused it.** This is a rule and not a
courtesy. `device_register` alone can fail six ways, and until each got its
own code a bus driver could only report "refused" — which is how an AMD
desktop came to boot with a working display, no USB and no storage, with
three independent causes indistinguishable in the log. See
[fixed-bounds-audit.md](../fixed-bounds-audit.md). A new failure mode that
does not fit an existing code gets a new one here rather than borrowing the
nearest.
## Enforcement, and an honest threat model
Renumbering only has teeth if the kernel **refuses syscalls that don't come