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
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.
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.
| 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) |
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.
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().
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.
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.
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).
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.
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.
NK2101, NK2105), and a handle is a value
that must be moved once.spawn already says nobody receives a value; asking
for .detach() there as well is a second word for the same thing. The
refusal is for a handle that was bound and then forgotten, where the
binding shows that someone meant to wait.Exit.main
keeps the when (ADR-345 D5).run’s channel is typed and a child’s is not; it carries the
child’s error so that naming the child’s type still takes it, as “passes it
on” promises. An error crosses threads as itself only where its type allows
it; one that does not (it holds a handle bound to its thread) keeps its
message, its status and its fatality, which is what the receiver acts on.
The scope’s function fails only after starting the tasks before it; its
error in the place after theirs keeps the one order the scope has, the
order things were started in, rather than a second one for the function.let h = spawn … that is never joined no longer compiles; such programs
write .detach(), join, or drop the let.join’s
throws is no longer only task::Crashed.main binds must be joined, cancelled or
detached; nothing waits for it implicitly.std::task gains TaskHandle::detach, join(within:) and
task::TimedOut.SIGTERM) and how it reaches main.main that waits for detached tasks beyond the
drain.