ADR-346: Every Task Error Has a Receiver

Status: Accepted Date: October 13, 2026 Answers: what happens to the error of a task nobody joins, fatal ones included, and how a program says that nobody will Related: ADR-345 D4, D5, §7 (fatal errors; the question this record answers); ADR-343 D1 (the ordered end of main); ADR-297 D5 (the drain, cleanup-deadline); ADR-292 D14 (cancel takes the handle); ADR-328 D8 (task::Crashed); Part I 7.1, 8.2, 8.3; Part II 12.7; Part III 17.1, A.2, C.3

1. Question

When a task fails and nobody waits for it, where does the error go?

spawn fn { … } hands back a handle; h.join() waits and receives the value or the error. A handle that is dropped unjoined loses the error, and since ADR-345 process::exit in a task is such an error: a program that meant to exit 1 from a task ends with 0. A supervisor’s child and a task::scope task raise the same question for their owner.

2. How others do it

All rows are from memory (marked (m) in the issue), not checked against a current manual.

Language A task nobody waits for fails Structured form
Rust thread::spawn, tokio::spawn (m) panic printed by the hook; a returned Err in a dropped handle is lost thread::scope, JoinSet
Go (m) an unrecovered panic ends the whole program; an error value is lost errgroup
Erlang/OTP (m) an unlinked crash is logged; a supervisor restarts by strategy up to an intensity, then escalates links, supervisors
Java (m) uncaught handler prints; a Future nobody gets swallows it StructuredTaskScope
Kotlin (m) structured: a failing child fails its parent; GlobalScope goes to a handler coroutineScope, supervisorScope
Swift (m) Task {} whose value nobody reads loses its error async let, task groups
Python asyncio / Trio (m) “Task exception was never retrieved” at GC / no unowned tasks at all TaskGroup, nurseries
C# (m) UnobservedTaskException at finalization, process goes on Task.WhenAll

Each language whose unowned form loses failures later added a structured form. Those that let a task end the program (Go, Java, Rust, C#) do it at an arbitrary moment.

3. Options, weighed

Option Why it lost, or won
A Lost, like any unjoined error a fatal exit in a task silently does nothing: status 0 from a program that meant 1, the failure ADR-345 exists to prevent
B Printed on stderr when the handle dies, status unchanged visible, but the fatal case is still wrong (prints Exit(1), returns 0), and the moment of the message depends on when a handle dies; kept as the rule for an explicitly detached task (D5), not for a forgotten one
C A fatal error of an unjoined task ends the program at once ends main at a moment main did not choose, which ADR-343 and ADR-345 D5 refused; kept only at main’s own end (D5)
D Supervisors do not restart a fatal error; overlap and task::scope hand it on chosen (D6), independent of the rest
E Structured: every task has an owner that receives its error chosen as the direction (D1-D4)
Forbid a bare spawn entirely fire-and-forget is a real need (a background logger, a per-connection task); a statement spawn says nobody waits as plainly as detach() does, so forbidding it adds a word and no information
Let a detached task’s fatal error end the program at once the same objection as C: nothing ends main in the middle (ADR-345 D5)

4. Decision

D1 — Every error of a task has a receiver.

The receiver is whoever joins the handle, the owner of a scope or a supervisor, or, for a task nobody waits for by choice, the program’s log and exit status (D5). No error of a task is dropped without a trace.

D2 — A spawn whose result is not bound is detached.

Changed by ADR-async-5 D13: spawn fn { … } is written async(detached) fn { … }; D2-D6 hold for it as written here.

spawn fn { … } as a statement says that nobody waits. It is the same task as spawn fn { … }.detach().

D3 — A bound handle is used up before it leaves its scope.

Changed by ADR-async-5 D17: h.join(within: d) takes the handle on its success path only; where it throws task::TimedOut the handle is still live, unless the call says on_timeout: task::OnTimeout::Cancel.

let h = spawn fn { … } binds a TaskHandle[T]. h.join(), h.join(within: d), h.detach() and h.cancel() take the handle. A handle that reaches the end of its scope, or a return, throw or break out of it, on any path without having been taken is refused (NK2109); a handle taken only in one branch of an if or match counts as not taken. Handing the handle on is taking it: returning it, storing it in a field, a list or a map, and passing it to a function.

D4 — join(within: d) waits at most d.

Changed by ADR-async-5 D17: when d runs out, join throws task::TimedOut and the task keeps running; it is cancelled, and the handle taken, only with on_timeout: task::OnTimeout::Cancel.

within is an option, written as an option without a positional zone: h.join(within: 5.seconds()). When d runs out, the task is cancelled as cancel() cancels it (its cleanups run) and the call throws task::TimedOut.

D5 — join hands on the errors of the task’s body.

join and join(within:) throw what the body throws, beside task::Crashed (and task::TimedOut for the second). They take the ordinary way: a catch, or throws on the joiner. A fatal one that nobody catches reaches main and sets the exit code there (ADR-345 D4).

D6 — A detached task’s error goes to the log; a fatal one also sets the exit code at main’s end.

A detached task (D2, or detach()) that ends with an error prints it on standard error in the long form of an uncaught error. If the error is fatal, it is printed as well, and its code replaces the exit status the program would otherwise end with, at the end of main (after main returned or an error left it) and never earlier. Of several, the first to arrive counts. The drain until cleanup-deadline (ADR-297 D5) is unchanged; a deadline that expires still ends with 70.

D7 — A supervisor does not restart a fatal error, and task::scope hands errors on.

Changed by ADR-async-5 D21: task::scope is removed; a group’s tasks hand their failures on at the group’s end, and where the group is left by a failure, that failure leads and the children’s failures are its secondary.

Changed by ADR-351 D5: a child’s error set is known, so run throws a child’s fatal error itself; supervisor::Handed is withdrawn.

A supervisor’s child that ends with a fatal error is not restarted: the supervisor ends and hands the error on to its own receiver, as supervisor::Handed: a fatal error that carries the child’s message, its status and the child’s error itself, so that an arm naming the child’s own type takes it. A program that wants a restart catches the type by name in the child (ADR-345 D2). task::scope hands on a task’s error as overlap does: the first in written start order is thrown when the scope ends, the others travel as its secondary errors (Part I 7.1). The scope’s function may throw as well; its error takes the place after every task it started, and waits for them.

5. Reasons

6. Consequences

7. What this does not decide

This site is open source. Improve this page.