Proposal: Crash Recovery and Supervision
How capOS handles unplanned process failure: propagating the death to capability holders, recording a structured crash event, and restarting the service within a bounded policy — all without resurrecting stale authority.
Problem
Live upgrade covers the planned case: a supervisor
quiesces a running service, transfers state, retargets caps, and exits the old
process in a controlled sequence. Unplanned failure is different. A process
that panics, faults, or is killed by the kernel OOM path leaves no quiesce
call, no state handoff, and no ordered exit. The kernel marks the process
dead and epoch-bumps its caps, but nothing in the current model tells callers
what happened or gives the supervisor a policy-bounded path to respawn it.
The gaps are:
-
Stale-cap observability. A caller with an outstanding CALL against a dying server was already completed rather than left hanging — the process teardown path cancels the dead process’s endpoint state — but the completion carried the generic
CAP_ERR_INVOKE_FAILED, which the kernel also posts for many unrelated failures. The caller could not tell “my server died” from “this invocation failed”, and there was no crash record.Status: partially closed by Phase 1. Two distinct questions hide in this gap, and Phase 1 answers only the first:
- “Is my server gone, so that retrying this cap is pointless?” —
closed. The caller now gets the typed
CAP_ERR_SERVER_DIED(see Stale-Cap Propagation). - “Did it crash, or did it terminate in an orderly way?” — open.
The CQE deliberately does not distinguish the two (both mean the same
thing to a caller: re-establish), and the crash record that carries the
distinction reaches only the kernel audit log, which no supervisor can
read. A supervisor therefore still cannot tell a crash from a planned
termination. Closing this needs the Phase 2 delivery of
CrashRecordto the supervisor’sAuditLogcap.
- “Is my server gone, so that retrying this cap is pointless?” —
closed. The caller now gets the typed
-
Crash metadata capture. Panic location, fault address, and last SQE opcode are useful for operators but must not leak raw cap-table contents, local cap IDs, or buffer bytes, which would break the no-ambient-authority invariant.
-
Bounded restart policy. Re-spawning a crashing service without a budget produces crash-loop amplification; re-spawning must use the same broker and manifest authority that the original spawn used, not an escalated path.
-
Watchdog liveness. A process that hangs without crashing is not detected by crash handling alone.
-
Degraded boot. If a critical service fails to start, the system needs a safe fallback rather than a silent hang.
This proposal fills these gaps without touching the live-upgrade protocol and without adding a god-object supervisor.
User Stories
- An operator sees a structured crash record in the audit log when a service
dies unplanned, not a silent stale-cap error. (Phase 1 emits this record for
CPL3 faults and
ProcessHandle.terminate. A Rustpanic!is deliberately not covered yet: thecapos-rtpanic handler exits through the ordinaryexitsyscall, which is a planned exit, soCrashKind::panicis never emitted. Routing panics into the unplanned-death path iscapos-rtwork.) - A client process calling a crashed server is promptly completed with a
typed
CAP_ERR_SERVER_DIEDtransport error rather than a generic invoke failure; the process does not block indefinitely. - Init restarts a failed service up to the configured failure budget, then stops and declares the service permanently failed rather than looping forever.
- A watchdog-registered service that hangs (no panic, no exit) is detected within its timeout and restarted under the same policy.
- If the network stack fails before a shell connects, the manifest-declared emergency shell starts instead of leaving the system unresponsive.
Design
Stale-Cap Propagation
Status: implemented (Phase 1). What follows describes what the kernel does today, and where it deliberately departs from this proposal’s original sketch.
When a process is torn down, the kernel cancels the endpoint state of every
endpoint that process owned. Every queued and in-flight CALL against those
endpoints belongs to some other process and can never be RETURNed, so each is
completed with a death CQE carrying CAP_ERR_SERVER_DIED — a distinct
ring error code (capos-config/src/ring.rs) meaning “the serving process is
gone; re-establish rather than retry”.
From the caller’s perspective an unplanned crash looks the same as a
force-mode live upgrade that did not reattach: the outstanding CALL
completes with CAP_ERR_SERVER_DIED and the caller must reconnect through the
supervisor’s exported endpoint rather than retry. No new CQE opcode is needed.
Deviation: a CQE result code, not a CapException in the caller’s buffer.
This proposal originally specified delivering
CapException { type: disconnected, message: "server-death" } into the
caller’s result buffer. That was not implemented, and should not be. Writing
a CapException into the caller’s result buffer from the server’s death
path is a cross-process write into a buffer whose owner may already have
dropped it — the exact use-after-free class that
make run-endpoint-drop-inflight-uaf exists to prevent. The cancel path
therefore posts only a CQE result code and never touches the caller’s buffer.
The disconnected CapException remains the revocation path’s wire shape and
is unchanged; server death is signalled by the CQE code alone.
Scope: owner death, not every cancellation. Only cancels caused by the
endpoint owner’s process teardown carry CAP_ERR_SERVER_DIED. Endpoint state
torn down while the owner is still alive — an owner-cap RELEASE, a thread
exit, a capability revocation — keeps the pre-existing generic
CAP_ERR_INVOKE_FAILED, because in those cases “the server died” is not the
truth the caller can act on. Planned and unplanned owner death are not
distinguished at this seam: from the caller’s side both mean the server is
gone and the cap must be re-established. The planned/unplanned distinction is
what the crash record below carries, and it is what a Phase 2 supervisor uses
to decide whether to restart.
Invariants:
- A death CQE on an outstanding CALL must be delivered before the kernel recycles any frame that belonged to the dead process. Frame reuse ordering is the same constraint that applies to the force-mode live-upgrade path. Cancellation that cannot be posted immediately (the target ring is momentarily unpostable) is deferred with its result code captured, so a deferred death CQE cannot silently degrade to the generic failure code.
- A cap whose epoch has been bumped must never route a new CALL to the dead process’s address space, even transiently. The epoch check is a load fence on the per-cap generation counter before any ring dispatch.
- Endpoint client facets held by the dead process are revoked at the same epoch bump. Other processes’ client facets to the same endpoint are not affected — they route to the endpoint owner, not to the crashed client.
Crash Record Capture
Status: partially implemented (Phase 1). The CrashKind/CrashRecord
schema below has landed in schema/capos.capnp, and the kernel emits these
fields for every unplanned death. Delivery to the supervisor’s AuditLog
cap is Phase 2; Phase 1 emits the record to the kernel audit log instead, as
a kernel-internal [audit] event=crash record.
AuditEventType deliberately gained no crash enumerant: crash events are
kernel-internal, so no holder of an AuditLog cap can forge a crash record
for a process that never died. This follows the existing debug precedent in
kernel/src/cap/audit_log.rs.
Two fields are honest placeholders in Phase 1. lastSqeOpcode is always 0
(“none in flight”): the kernel retains no per-process last-dispatched opcode,
and reconstructing one from ring state at death would be a guess.
stackOverflow is never emitted: the x86_64 fault classifier sees a stack
guard hit as an ordinary #PF and does not distinguish it from pageFault.
The fault classifier maps #PF to pageFault, #GP to generalProtection,
and #UD to illegalInstruction. #DB (a user-set trap flag) and #BP
(int3) are also folded into illegalInstruction: there is no trap
enumerant, and these are the same class of event as #UD — userspace executed
an instruction the kernel has no capability to make meaningful. A dedicated
trap enumerant would be an additive schema change if the distinction becomes
load-bearing. ProcessHandle.terminate emits kernelKill with a
faultOffsetInBinary of 0, since an externally-killed process has no fault
site. Where a kill races a real fault, the fault’s record wins: the first
noted cause is what actually killed the process.
The record is structured to support operator debugging without leaking internal kernel state:
# schema/capos.capnp (Phase 1, landed)
enum CrashKind {
panic @0; # Rust panic! path
pageFault @1; # unmapped or protection fault
generalProtection @2;
stackOverflow @3;
illegalInstruction @4;
kernelKill @5; # explicit ProcessHandle.terminate
}
struct CrashRecord {
processName @0 :Text;
kind @1 :CrashKind;
# Instruction pointer at death, relative to ELF load base.
# Absolute virtual address is NOT included to avoid leaking
# kernel-side layout or userspace ASLR seeds.
faultOffsetInBinary @2 :UInt64;
# Last SQE opcode dispatched for this process (0 = none in flight).
lastSqeOpcode @3 :UInt8;
# Session context ID of the process (opaque; matches AuditLog sessionId
# for attribution without carrying cap-table or buffer contents).
sessionContextId @4 :Data;
# Monotonic kernel timestamp at death.
timestampNs @5 :UInt64;
}
Fields explicitly not included: raw cap IDs, cap-table slot contents,
userspace buffer bytes, kernel heap pointers, or any data from the process’s
address space beyond the fault offset. The crash record is attributed to the
process’s session context ID so it can be correlated with prior AuditLog
records without exposing the full cap graph.
The Phase 2 target is to deliver the crash record through the same
AuditLog.record path the hardware-audit service already uses: the supervisor
holds the AuditLog cap; the kernel invokes it on the supervisor’s ring (via
a kernel-initiated RECV) rather than on a shared global ring. Phase 1 emits
the same fields to the kernel audit log, which is enough for an operator to
see the crash but does not yet give a supervisor a programmatic signal.
Bounded Restart Policy
The supervisor that spawned a failed process owns the restart decision. The
restart budget is declared in the manifest’s initConfig.services entry and
interpreted by init (or a delegated supervisor):
# CUE representation (illustrative)
restart: {
policy: "on-failure" # never | on-failure | always
maxRestarts: 5 # total budget over the window
windowSecs: 60 # sliding window for the budget
backoffBase: "1s" # initial delay before first restart
backoffMax: "30s" # ceiling on exponential backoff
emergencyFallback: "shell" # service name to promote if budget exhausted
}
Backoff is bounded and service-class aware. The exponential
backoffBase→backoffMax schedule suits user-facing services that should
self-heal without spinning (the Kubernetes CrashLoopBackOff lesson). For
always-available system services, the prior-art note’s systemd lesson favors a
short flat delay so a transient fault recovers fast; such services set
backoffBase == backoffMax for flat RestartSec-style behavior. In both cases
maxRestarts/windowSecs is the hard give-up budget (the OTP
max-restart-intensity lesson), so neither model spins forever.
Crash-loop detection. If maxRestarts attempts exhaust within
windowSecs, the supervisor stops restarting and records a budget-exhausted
event. The service is marked permanently failed until an operator issues an
explicit override through the ProcessHandle or re-spawns via a fresh
manifest reload.
Authority preservation. Each restart uses the original ProcessSpawner
call with the same CapGrant list that was used at initial spawn. The
supervisor does not invent new grants or escalate authority. If a grant source
was a SpawnGrantSource::Kernel DDF handle that is now invalidated (for
example, a DMA buffer whose owner quiesce failed), the restart fails closed
with a spawn-grant-invalid error rather than falling back to an ambient
grant.
No resurrection of stale caps. The restarted process receives a fresh cap
table. The supervisor must call CapRetarget (from
Live Upgrade) to re-point existing
client caps to the new process. If CapRetarget is not yet implemented,
clients observing disconnected must reconnect through the supervisor’s
exported endpoint, which the supervisor re-registers after restart.
Watchdog Capability
A service that can hang without crashing (blocked ring, infinite loop, deadlock on a kernel-held lock it does not own) is not detected by exit-path crash handling. The watchdog provides periodic liveness proof:
# Proposed future addition to schema/capos.capnp (Phase 3)
interface Watchdog {
# Service calls this on every iteration of its main loop to
# reset the deadline. If not called within `timeoutNs` of the
# last kick (or of registration), the supervisor is notified.
kick @0 () -> ();
# Unregister. Safe to call during planned shutdown.
cancel @1 () -> ();
}
interface WatchdogSource {
# Register this process with the given timeout.
# Returns a Watchdog the service holds and kicks.
register @0 (processName :Text, timeoutNs :UInt64) -> (watchdogIndex :UInt16);
}
The supervisor grants a Watchdog cap (minted from a WatchdogSource it
holds) to each service it considers watchdog-registered. If the kernel timer
fires without a kick, the supervisor receives a liveness-failure notification
and treats it identically to an unplanned crash: crash record, restart budget
check, backoff.
The watchdog is an opt-in service-level contract, not a mandatory kernel
mechanism. Services that are inherently event-driven (blocked on cap_enter
waiting for an SQE) do not need a watchdog; they will return disconnected to
callers if they stop processing. Watchdog is primarily useful for services
with internal polling loops or external I/O not driven by the capOS ring.
Degraded Boot
The manifest may declare an emergency fallback service that is promoted when a critical service exhausts its restart budget before the system reaches a usable state:
# CUE (illustrative)
degradedBoot: {
trigger: "net-stack" # if this service fails permanently...
fallback: "shell" # ...promote this service to interactive
timeoutSecs: 30 # deadline from kernel handoff to readiness
}
The init process monitors service readiness. If a declared critical service fails to reach readiness within the timeout and has exhausted its restart budget, init spawns the fallback service with a console cap and an audit cap so an operator can inspect what failed. The fallback service is not granted the failed service’s caps; it is a scoped interactive shell, not a repair agent with escalated authority.
Relevant Research and Prior Art
In-Tree Research Notes
- Crash Recovery and Supervision
is the dedicated prior-art survey for this proposal: supervision trees,
restart budgets (OTP intensity/period, systemd
StartLimit, KubernetesCrashLoopBackOff), dead-server notification (FuchsiaZX_CHANNEL_PEER_CLOSED, seL4 silence, GenodeIpc_error), and coredump-redaction concerns, each verified against primary sources. - OS Error Handling grounds
the stale-cap surface. It records what callers observe when a server dies in
comparable systems: Zircon channels close and the peer observes
ZX_ERR_PEER_CLOSED; Genode capabilities to a dead server become invalid and subsequent invocations produceIpc_error; seL4 routes faults to a per-thread fault endpoint; KeyKOS/EROS routes them to the domain keeper. The shared lesson is that a dead server must surface as a typed transport-level signal, not a hung invocation — which is exactly thedisconnecteddeath CQE this proposal specifies. - Cap’n Proto Error Handling
fixes the meaning of
disconnectedin the four-kind capnp model: “connection to a necessary capability was lost,” with the client response being re-establish-and-retry. This proposal reuses that existing classification rather than minting a new exception kind; the only addition is when the kernel emits it (unplanned death) and the pairedCrashRecord. - EROS, CapROS, Coyotos
documents the EROS/KeyKOS keeper mechanism: a capability to a separate
domain that the kernel invokes on fault, which can inspect, terminate, or
restart the faulting domain (process supervision is an explicit listed use).
capOS’s supervisor-owns-
ProcessHandlemodel is the same shape with capnp typed methods instead of a keeper key, and the kernel never initiates the restart itself. - Genode and
seL4 ground the no-resurrected-authority
invariant: Genode’s parent-supervised component tree with revocable
capabilities, and seL4’s hierarchical delegation plus
Revokeover the capability derivation tree, both establish that a restarted child gets fresh authority and that revocation of the dead instance’s caps is the supervisor’s (parent’s) responsibility, not an ambient lookup.
External Precedent and Lessons
- Erlang/OTP supervision trees. The “let it crash” philosophy plus
supervisor restart strategies (
one_for_one,one_for_all,rest_for_one) and max-restart-intensity (MaxRrestarts withinMaxTseconds, after which the supervisor itself terminates) are the direct precedent for this proposal’s per-service failure budget and crash-loop detection. The lesson: bound restarts over a sliding window and escalate (here: stop and mark permanently failed, optionally promote degraded boot) rather than loop forever. - systemd unit restart policy.
Restart=on-failure|always,RestartSecbackoff, andStartLimitIntervalSec/StartLimitBurstare the precedent for thepolicy/backoffBase/maxRestarts/windowSecsfields. The lesson: separate the whether (policy) from the pacing (backoff) from the give-up threshold (burst limit). - Kubernetes liveness/readiness probes and CrashLoopBackoff. Liveness
probes (kubelet restarts a container that fails its probe) are the precedent
for the
Watchdog.kick/timeout design; readiness gating before promotion is the precedent for the degraded-boot readiness deadline;CrashLoopBackOffwith exponential backoff is the precedent for capped exponential restart delay. The lesson: liveness is opt-in and orthogonal to crash detection — a hung-but-not-dead process needs an explicit liveness signal. - Fuchsia component lifecycle. Component-manager-driven start/stop and
rebinding in the routing graph parallel capOS’s supervisor +
CapRetargetreconnection. The dedicated research note above grounds Fuchsia’s death-observation behavior (ZX_CHANNEL_PEER_CLOSED, no implicit reconnect); a deeper write-up of Fuchsia component-manager restart and escrow semantics remains research-needed (per thedocs/backlog/research-design-gaps.mdconvention) before this proposal cites specific escrow behavior as grounding.
Phasing
Phase 1 — Stale-cap death-CQE propagation and crash record. Landed, with
gap #1 only partly closed.
Callers now observe server death as the typed CAP_ERR_SERVER_DIED transport
error rather than as a generic invoke failure, and unplanned deaths emit a
redacted crash record to the kernel audit log. make run-crash-disconnect
proves both the queued and the in-flight cancel paths against a server that
dies by fault.
What “landed” does not mean: a supervisor still cannot tell a crash from a
planned termination. That half of gap #1 is untouched by Phase 1 and is Phase
2 work — the CQE deliberately does not carry the distinction, and the crash
record that does goes only to the kernel audit log, which no supervisor reads.
Three items originally scoped here moved to Phase 2: delivery of CrashRecord
to the supervisor’s AuditLog cap, a lastSqeOpcode that is not always 0,
and CrashKind::panic (see Crash Record Capture).
SMP concurrent-exit consistency (resolved 2026-07-17 18:53 UTC). Originally a Phase 1
residual: when two threads of the same multithreaded process called exit
concurrently, each decided independently — before either was marked exited —
whether its own exit was also its process’s death. Both could observe a
still-live sibling and classify their in-flight cancellations as an ordinary
teardown, even though the process died moments later. An in-flight call served
by such a thread then completed with CAP_ERR_INVOKE_FAILED instead of
CAP_ERR_SERVER_DIED, while merely queued calls against the same endpoint —
reaped by the process-wide path — still reported CAP_ERR_SERVER_DIED. The
caller never hung; it just got the less specific code.
Deciding this correctly requires knowing the process’s fate at a moment when it
is genuinely not yet determined — a sibling that has not declared its intent may
keep serving — so a tighter read of a live-thread snapshot cannot close it. The
fix instead defers the cancel decision until the fate is known. At a thread
exit, the caller completions for in-flight calls the exiting thread was serving
are not classified in place: they are held on the process as
(caller_thread, user_data) pairs (the in-flight endpoint entry itself is still
removed). Two resolution points settle them. If the process dies,
release_caps_for_exit drains the held pairs as CAP_ERR_SERVER_DIED. If the
process instead stably survives — still in the process table with a live
thread, no thread currently in the exit path, and no whole-process termination
queued — a periodic scheduler sweep (service_periodic_work) drains them as
CAP_ERR_INVOKE_FAILED; a thread exit the process genuinely survived must stay
the generic code (no false positive). A per-process exit-path counter is what
lets the sweep tell “a worker left while the server lives on” from “an exit is
still in progress”.
Resolving lazily, rather than from the exiting thread’s own snapshot, is what
closes the SMP race. A concurrent multithreaded death completes in microseconds
— far inside a single scheduler tick — so the server-death drain always wins the
race against the survivor sweep, and both in-flight calls report
CAP_ERR_SERVER_DIED regardless of the exact interleaving or host load. A
process that truly outlives one worker’s exit sees the deferred completions
resolve to the generic code at the next tick. The CPL3-fault whole-process path
keeps its CAP_ERR_SERVER_DIED result through the pending-termination signal,
which also excludes a dying process from the survivor sweep. A single-threaded
server (the common service shape) was never affected. Proofs:
make run-crash-disconnect (single-threaded) and
make run-crash-disconnect-multithread (two serving threads exiting
concurrently under SMP; both in-flight calls and the queued call observe
CAP_ERR_SERVER_DIED).
Phase 2 — Bounded restart policy and crash-loop detection. Init reads
restart budget fields from initConfig.services, applies exponential backoff,
and stops at budget exhaustion. Requires: delivery of the Phase 1 crash record
to the supervisor’s AuditLog cap, so init can tell a planned death from an
unplanned one (Phase 1 emits the record to the kernel audit log only, which no
supervisor can read); CapRetarget from live-upgrade Phase 1 to reconnect
client caps after restart.
Phase 3 — Watchdog capability. WatchdogSource and Watchdog schema,
kernel timer integration, supervisor-side timeout detection, and liveness-failure
events fed into the same restart budget path as Phase 2.
Phase 4 — Degraded boot. Manifest parser reads degradedBoot fields;
init promotes the fallback service on budget exhaustion during the boot window.
Requires: Phase 2 budget tracking.
Hazards and Invariants
Frame reuse ordering. The kernel must not return frames from a dead
process’s address space to the frame allocator until all outstanding
disconnected CQEs for that process’s caps have been delivered. Violating
this could allow a concurrent FrameAlloc to map recycled memory into a new
process before the old process’s CQEs complete, creating a window where a
stale disconnected CQE arrives after the frame holds new data. The existing
DMA quiesce/scrub ordering in the DMA pool grant path is the model for this
constraint.
No stale authority after restart. A restarted process receives only the
grants declared in the original ProcessSpawner.spawn call. The supervisor
must not silently re-grant caps that were revoked as part of the death epoch
bump. In particular, any DMAPool-derived handle that was in active use at
crash time must be explicitly re-acquired through the grant-source path, not
recycled from the dead process’s cap table.
Restart does not bypass the authority broker. If the original spawn was
gated on an AuthorityBroker-selected session context, the restart uses the
same broker path. The supervisor cannot substitute a broader session context
or an anonymous context to make the restart succeed.
Capability revocation precedes any dump. The death epoch bump that invalidates the crashed process’s caps must complete before any crash record or future coredump is produced. A record produced post-revocation sees only dead cap indices, never live authority; a pre-revocation memory snapshot could otherwise capture live cap indices or ring-buffer contents (the race class behind recent coredump CVEs). Any future coredump extension must run only after revocation and must not be readable by unprivileged dump readers.
Crash record isolation. The crash record must not carry raw cap IDs, cap table slot numbers, or any data read from the process’s address space (stack contents, heap contents, message buffers). The fault offset is relative to the binary load base, not an absolute virtual address, to avoid leaking kernel layout or userspace address randomization.
Watchdog authority is narrow. A Watchdog cap proves liveness for exactly
one registered process. It does not grant the holder any access to the
supervisor, the process’s caps, or any other service. It is a pure liveness
signal, not an authority surface.
Relationship to Adjacent Proposals
- Live Upgrade — covers the planned
case. The
CapRetargetprimitive defined there is consumed by Phase 2 of this proposal to reconnect client caps after an unplanned restart. The force-modedisconnecteddelivery and epoch-revocation paths are shared; this proposal adds the death CQE and crash record on top. - Service Architecture —
defines the supervisor tree and the
RestartPolicytype currently parsed by init. This proposal extends that policy with the budget, backoff, and budget-exhaustion fields, and binds crash handling to the supervisor that owns theProcessHandle, not to a global daemon. - capos-service — defines the
userspace service framework above
capos-rt. The watchdogkickcall and readiness notification in Phase 3 are natural additions to the service lifecycle hooks thatcapos-serviceabstracts. - Error Handling — the
disconnectedclass in the two-level error model is the transport surface for stale-cap delivery. This proposal does not add new error types; it specifies when and howdisconnectedis delivered for an unplanned death. - System Monitoring — crash records, restart events, budget-exhaustion notifications, and watchdog timeouts are all audit-worthy. The monitoring proposal owns the operator visibility surface; this proposal defines the structured events that feed it.
- Resource Accounting and Quotas — the failure budget is a quota: a count consumed by crash events and refilled by the sliding window. The accounting model for this quota follows the same ledger-of-record pattern as memory and scheduling quotas.