ADR-328: Supervision — 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 Restart

Status: 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)

1. Question

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.

2. How others do it

  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.

3. Options, weighed

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

4. Decision

D1 — 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.

D2 — The list’s order is the start order and the dependency.

A child depends on every child before it. Children start in list order and stop in reverse.

D3 — The strategy says which siblings restart with a crashed child.

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.

D4 — A child’s kind says which ends are restarted.

restart: ended normally panicked or threw
Kind::Permanent (default) restarted restarted
Kind::Transient not restarted restarted
Kind::Temporary not restarted not restarted; reported

D5 — When to restart, an exchangeable policy decides, from a history of fixed size.

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.

D6 — Giving up stops every child first.

The supervisor stops its children in reverse order, their cleanups run (ADR-297), and then run throws supervisor::Escalated. No child outlives its supervisor.

D7 — A child keeps no changeable state across a restart.

A child’s function is called again for every attempt. What it captures:

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.

D8 — A task that crashed is an error to the task that waits for it.

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.

D9 — 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.

D10 — A task’s effect decides how its scope runs it, not 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:

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:

6. What this does not decide

This site is open source. Improve this page.