supervisor::run Runs a Supervisor Until It Gives Up, Children in Dependency Order, Restarts by an Exchangeable Policy, and a Child Keeps No Changeable State Across a RestartStatus: Accepted
Date: October 6, 2026 (#95; D10: October 10, 2026)
Answers: #95, what
Part II 12.8’s supervisor is; and what a task that crashed means to the task
that waits for it, now that a panic ends only its task (ADR-326 D4)
Related:
ADR-326 (a panic ends its task; net::serve);
ADR-327 D3 (SharedMut::supervised);
ADR-297 (cleanup on the way out);
ADR-141 D1 (the policy is an enum, not a string);
ADR-094 (the compiler decides whether a value is lent or taken);
Part I 2.1 (a binding is immutable unless mut), Part II 11.2 (spawn, join), 12.7 (task::scope)
What restarts a task that crashed, how often, in what order with its siblings, what does it start from again, and what does a crash mean to whoever waits for the task?
Part II 12.8 showed one call, supervisor::start_link(fn { … }; restart_policy:
…), and nothing was built. A server needs it: the task that accepts
connections, a task that refreshes a cache, and a shared value rebuilt after a
crash (ADR-327 D3) all need someone to restart them and to stop
restarting.
| Restart | Giving up | Siblings | |
|---|---|---|---|
| Erlang/OTP | at once | more than n in t seconds (Elixir: 3 in 5), then the supervisor exits | one_for_one, one_for_all, rest_for_one; children start in list order and stop in reverse |
| systemd | after RestartSec |
StartLimitBurst in StartLimitIntervalSec |
- |
| Kubernetes | growing delay, 10 s up to 5 min | never; it waits longer | - |
| Akka | BackoffSupervisor: growing delay with jitter |
configurable | per strategy |
Rust tokio, std::thread |
- | - | join returns an error value |
| Kotlin, Swift task groups, Trio | - | - | a failing child cancels its siblings and the failure is raised where the group ends |
From memory, not checked against a version. What transfers: OTP’s tree,
strategies and order, a delay that grows, and structured concurrency for a
scope. What does not: start_link’s link, which this language does not have,
and numbers a program has to configure.
| Option | Why it lost |
|---|---|
start_link, returning at once |
names a link that does not exist here; the background case is spawn fn { supervisor::run(…) } |
| Restart at once until n in t | a database away for ten seconds uses the attempts up in milliseconds; and the numbers are configuration |
| A list of every failure as the policy’s input | grows without bound while a fault lasts |
A crashed child re-panics in the task that joins it |
a crash in another task is an event to the one waiting, not a broken state of its own; it should be catchable |
A child may capture only copies and Shared handles |
refuses a large immutable table or configuration for no reason; what must not survive a restart is changeable state, not ownership |
supervisor::run(children; options) runs the supervisor until it gives up.use std::supervisor
fn main() throws {
let config = Config::load()
let cache = SharedMut::supervised(fn { Cache::load() })
supervisor::run([
supervisor::child(fn { refresh_loop(cache, config) }),
supervisor::child(fn { http::listen(config.address) fn(req) { … } }),
]; strategy: supervisor::Strategy::OneForOne)
}
It returns only when it gives up, and then throws supervisor::Escalated. A
supervisor in the background is spawn fn { supervisor::run(…) }. A tree needs
no form of its own: a child whose function calls supervisor::run is a
supervisor below, and its giving up is a crash to the one above.
A child depends on every child before it. Children start in list order and stop in reverse.
OneForOne - the crashed child alone.OneForAll - every other child is stopped, in reverse order, and all start
again in list order.RestForOne - the crashed child and every child after it are stopped, in
reverse order, and start again in list order.A child that crashes during such a restart is a crash of that child like any other. The policy of the child that crashed decides; the siblings taken along keep their history.
restart: |
ended normally | panicked or threw |
|---|---|---|
Kind::Permanent (default) |
restarted | restarted |
Kind::Transient |
not restarted | restarted |
Kind::Temporary |
not restarted | not restarted; reported |
pub trait Restart {
fn decide(ref mut self, history: supervisor::Failures) -> supervisor::Next sync
}
pub enum Next { Immediate, Delay(time::Duration), Escalate }
pub struct Failures {
last_uptime: time::Duration, // how long the last attempt ran
consecutive: i64, // failures in this cycle
since_first: time::Duration, // since this cycle's first failure, on a monotonic clock
}
supervisor::child(fn { … }; policy: …) takes one; a policy that needs more
history keeps it itself. std’s default restarts the first failure at once,
then after a delay that grows, with jitter, up to a ceiling; starts a new cycle
after an attempt that ran long enough to count as healthy; and gives up when a
cycle has lasted long enough. Its numbers are std’s, not the language’s; a
program that wants others passes another policy.
The supervisor stops its children in reverse order, their cleanups run
(ADR-297), and then run throws supervisor::Escalated. No child
outlives its supervisor.
A child’s function is called again for every attempt. What it captures:
mut is lent to every attempt, without a copy;Shared or SharedMut is duplicated, as for spawn;mut binding is refused, and so is a value an attempt would take - keep,
hand on or use up - since the next attempt would not have it. Both are
NK1229, and the help names building the value inside the function or
.clone() there.What an attempt builds inside the function is fresh on every attempt. At
user_parallelism = yes, where an attempt pauses on another thread, the
supervisor owns what it lends and counts its borrowers; nothing is copied.
join on a task that panicked throws task::Crashed, carrying the message and
the site, and is caught as any error is. A task nobody joins reports its crash
through the panic hook only. A task in task::scope that panics cancels the
scope’s other tasks, and the scope throws task::Crashed where it ends.
SharedMut::supervised restarts by the same policy.A door to the value during a delay panics, as a door to a poisoned value does (ADR-327 D2). A value whose policy gives up stays poisoned: every task that needs it ends, and escalates through its own supervisor.
user_parallelism.Changed by ADR-async-5 D20: what this says of a scope’s tasks holds for the tasks of an async group; task::scope is removed.
Changes ADR-005 D5.
Every function is already known to be sync or to pause (Part II 12.1, the
ledger). In task::scope, at every setting:
no, as now.The scope waits for both kinds. A task that panics cancels the others: the
pausing ones are dropped, the others are not started, and the scope throws
task::Crashed (D8). Where tasks run in parallel, the pausing tasks of one
scope run concurrently on one thread, not in parallel: a pausing task that
computes long between its pauses holds back its siblings.
What a scope accepts therefore no longer depends on the setting, as ADR-005’s Group B requires of every verdict.
Rejected:
no only (Part II 12.7 before), which makes a
library written at no fail to compile at yes.spawn and join as the
way out: sound, but it takes away concurrent I/O over borrowed values that
the polled scope gives soundly.A strategy the program names (task::scope(strategy: …)): for a pausing
task that borrows, polling in the scope is the only sound way, and a choice
with one right answer is something the compiler makes.
catch at a
join - and nothing else.join can now fail, so a function that calls it
says throws or catches; a child cannot carry changeable state of its own
across attempts; the supervisor at yes owns and counts what it lends.simple_one_for_one, e.g. one per connection): net::serve isolates its
connection tasks without restarting them (ADR-326 D4).