Inboxes
July 16, 2026 ยท View on GitHub
The substrate under every message a capsule receives is the inbox: a named, bounded queue
with an explicit owner. A capsule does not share memory with another capsule and cannot
name another capsule's address space; the only way one reaches another is to enqueue a
message on a registered inbox, and the kernel decides whether that enqueue is allowed. This
page documents the inbox itself, the registry that owns them, the fail-closed enqueue, and
the lifecycle tie to process teardown. The code is under src/ipc/nonos_inbox/.
The inbox
An Inbox (src/ipc/nonos_inbox/inbox.rs:32) is a mutex-guarded VecDeque<IpcMessage>
with a fixed capacity and an owner pid:
struct Inbox {
queue: Mutex<VecDeque<IpcMessage>>,
capacity: usize,
owner: u32, // 0 = kernel-owned, else the capsule pid
stats: InboxStats,
}
Enqueue is non-blocking and bounded: try_enqueue pushes only if len < capacity,
otherwise it records a drop and hands the message back (inbox.rs:75). There is no
unbounded growth and no blocking inside the lock; a full inbox is a fast, visible failure,
not a memory leak or a stall. Dequeue pops the front and records the dequeue. The queue is
FIFO, and the capacity is chosen at registration within fixed bounds,
MIN_INBOX_CAPACITY = 16, DEFAULT_INBOX_CAPACITY = 1024, MAX_INBOX_CAPACITY = 65536
(registry.rs:39).
The registry
Inboxes live in one global registry, a BTreeMap<String, Arc<Inbox>> behind an RwLock
(registry.rs:48), keyed by name. Two names matter: proc.<pid>, the canonical
per-process inbox a capsule drains, and the kernel-owned reply inboxes the spawn pipeline
sets up. The registry's defining rule, stated in its own module doc, is that there is no
auto-registration on the send or receive paths. An inbox exists only because something
explicitly created it:
register_inbox(name, owner_pid) capsule-owned, fails if name taken
register_or_get_bootstrap_inbox(name) kernel-owned reply inbox, idempotent
register_inbox (registry.rs:85) rejects an empty name, rejects a capacity outside the
bounds, and rejects a name that is already registered, so a caller cannot silently take
over an existing queue. register_or_get_bootstrap_inbox (registry.rs:118) is the only
path that creates an inbox without a capsule pid; it stamps the owner as
KERNEL_OWNER = 0 and is reserved for the reply inboxes the
spawn pipeline pre-registers for the kernel to drain. It must
not be called from a normal send or receive.
Fail-closed enqueue
Routing into an inbox goes through try_enqueue_strict (registry.rs:164), which fails
closed on three distinct conditions rather than papering over any of them:
try_enqueue_strict(name, msg):
inbox = registry.get(name) else MissingInbox
if owner != KERNEL_OWNER
and process_table.find_by_pid(owner) is None:
return DeadOwner
inbox.try_enqueue(msg) else QueueFull
MissingInbox means no such queue was ever registered. DeadOwner means the queue exists
but the capsule that owned it has fallen out of PROCESS_TABLE, which is what closes the
race where a destination exits between a caller's service lookup and its enqueue: the
kernel refuses to deliver into a dead capsule's queue. QueueFull means the bounded queue
is at capacity. Each maps to a distinct errno at the syscall layer, so the sender learns
which of the three happened. A kernel-owned inbox (owner == 0) skips the liveness check,
because its drainer is the kernel itself and never exits.
Draining and the receive loop
A receiver drains its own inbox with try_dequeue_existing, which returns None on an
empty or absent queue and never creates one. The blocking behavior lives a layer up, in the
receive syscall (src/syscall/microkernel/ipc/recv.rs:58): it checks the inbox exists
(ENOENT if not), then loops, dequeue, and if empty either returns ETIMEDOUT past the
deadline or calls sched::sleep_until and re-checks on wake, yielding between spins. The
sender wakes a sleeping receiver explicitly after a successful enqueue, so a blocked
receiver does not spin against an empty queue. The scheduler
sleep and wake are the mechanism; the inbox is the rendezvous.
Lifecycle
When a capsule exits, process::exit::teardown calls unregister_for_pid(pid)
(registry.rs:147), which removes that capsule's proc.<pid> inbox and drops whatever was
still queued. The kernel-owned reply inboxes (endpoint.<n>) are deliberately left in
place so a respawn reuses them; stale replies are filtered by the transport's generation
re-check rather than by tearing the inbox down. This is why the DeadOwner check exists:
between a capsule exiting and a caller noticing, the strict enqueue is the backstop that
refuses delivery to the departed owner.
Security analysis
The inbox is the reachability substrate: a capsule shares no memory with another and can name no other address space, so the entire surface one capsule presents to another is a message enqueued on a named, owned queue that the kernel decides to accept or refuse. Three properties draw that bound.
A name is created, never conjured on the path. The registry's stated rule is that there is no
auto-registration on send or receive (registry.rs module doc): an inbox exists only because
register_inbox or register_or_get_bootstrap_inbox made it. register_inbox (registry.rs:85)
rejects an empty name, a capacity outside MIN_INBOX_CAPACITY = 16 .. MAX_INBOX_CAPACITY = 65536, and a
name already taken, so a caller cannot silently take over an existing queue by re-registering it. The
only path that mints an inbox without a capsule pid is register_or_get_bootstrap_inbox
(registry.rs:118), which stamps KERNEL_OWNER = 0 and is reserved for the reply inboxes the
spawn pipeline sets up. Because a send cannot bring a queue into existence,
naming a queue that does not exist is a clean MissingInbox, not an accidental new endpoint.
Every enqueue is fail-closed and owner-checked. Routing goes through try_enqueue_strict
(registry.rs:164), which refuses on three distinct conditions rather than papering over any: an absent
name is MissingInbox, a queue whose owner pid has fallen out of PROCESS_TABLE is DeadOwner, and a
full bounded queue is QueueFull. The DeadOwner check is the one that closes the exit race: between a
destination capsule exiting and a caller noticing, the kernel refuses to deliver into a dead capsule's
queue, so a message cannot be dropped into a departed owner's inbox and later mis-drained by whatever
reuses the pid. A kernel-owned inbox (owner == 0) skips the liveness check because its drainer is the
kernel, which never exits.
Bounded, non-blocking, no unbounded growth. try_enqueue pushes only when len < capacity and
otherwise records a drop and hands the message back (inbox.rs:75), all under the queue's own mutex with
no blocking inside the lock. A full inbox is a fast visible QueueFull, not a memory leak and not a
stall that a sender could induce to wedge a receiver. This is what makes a capsule's inbox a bounded
resource: a hostile or runaway sender fills the queue and then gets refused, it does not grow the
kernel's memory without limit or hold a lock a receiver needs.
The honest boundary: the inbox authenticates the owner of a queue and its liveness, and it enforces the bound, but it does not itself decide whether a given sender is allowed to reach a given endpoint. That decision is the capability check on the routing path, which runs before the strict enqueue. An inbox that is registered, live, and not full will accept whatever routing hands it; keeping the wrong sender out is routing's job, not the queue's.
Debugging inboxes
The three strict-enqueue variants each become a distinct errno at the syscall boundary, so a failed send
tells you which of them fired: MissingInbox and DeadOwner both map to ESRCH (-3) and QueueFull
maps to EAGAIN (-11) at kernel_route_ipc_corr (kernel_ipc.rs:90). The two ESRCH cases are
worth separating in your head even though they share an errno: MissingInbox means the destination name
was never registered, which upstream is usually a service that never called register or an endpoint
resolved to the wrong name, while DeadOwner means the queue is there but its capsule has exited, which
is the race the check exists to catch. EAGAIN means the receiver is not draining fast enough and the
bounded queue filled, which is a throughput problem, not a wiring problem.
On the receive side the tells are different. sys_ipc_recv returns ENOENT (-2) if the inbox does
not exist when the loop starts (recv.rs:65), which for an endpoint != 0 recv means the caller does
not own that endpoint or it was never registered. A recv that returns ETIMEDOUT (-110) after its
deadline (recv.rs:80) drained nothing in time, which pairs with the sender side: if the sender's route
was refused, the message never arrived and the receiver simply times out, so a hung-looking call is
diagnosed by looking at what the sender got, not the receiver. For the traced pids the receive loop
prints [IPC-RECV] ... enter, dequeue, missing inbox, before yield, and after yield
(recv.rs:32), and the send path prints [IPC-SEND] pid= ep= len= target= (send.rs:36), so a hung
IPC call versus a rejected one is read off the trace: a send trace with no matching route, or a recv
stuck cycling before yield/after yield with no dequeue, is a receiver waiting on a message that a
refused or misrouted send never delivered.
Source map
src/ipc/nonos_inbox/inbox.rs the bounded per-owner queue, try_enqueue
src/ipc/nonos_inbox/registry.rs the global name -> inbox map, strict enqueue, capacity bounds, lifecycle
src/ipc/nonos_inbox/error.rs InboxError and the three StrictEnqueueError variants
src/syscall/microkernel/ipc/recv.rs the blocking receive loop and its ENOENT / ETIMEDOUT returns
src/ipc/kernel_ipc.rs where the strict-enqueue variants become ESRCH / EAGAIN
Every reference above is verified against those trees. The capability check that runs before the enqueue and the wake that pairs with the receive loop are on the routing page, the message that gets enqueued is on the envelope page, and the reply inboxes the spawn pipeline pre-registers are set up by the spawn pipeline.