Issue #605: Unjoined tasks: what happens to an error nobody waits for (fatal ones included)

Open and answer this issue on GitHub.

blocked · opened 2026-10-13 · updated 2026-11-03 · 9 comments

What this is about, from zero

spawn fn { ... } starts a task and hands back a handle; h.join() waits for it and gets back what the task produced, or its error. Most of the time someone waits. Sometimes nobody does:

use std::process

fn main() throws {
    spawn fn { process::exit(2) }   // handle dropped: nobody joins
    println("main goes on")
}

Since ADR-345 (#606), process::exit throws the fatal error process::Exit, and in a task it ends the task and goes to whoever joins it (D5). Here nobody joins. The same goes for a supervisor’s child (the supervisor reads the failure, not a person) and an overlap branch that fails after a sibling already failed.

Where this comes from: the owner’s decision of 2026-10-13 asked to check this before building, and to put it to the owner if a fatal error could vanish. Nothing in the repository needs the answer today (no program spawns an exit), but a fatal error that vanishes silently is the kind of failure ADR-345 exists to prevent.

What is true today (checked on develop, 0.0.883)

So: a fatal error in an unjoined task vanishes; in a supervisor child it is restarted.

Options

  Rule Consequence
A Lost, like any unjoined error simplest; an exit in a fire-and-forget task silently does nothing
B Every unjoined task error is printed (long form) on stderr when the handle is dropped or at program end; exit status unchanged visible, no change of control flow
C A fatal error of an unjoined task ends the program after the drain (Exit with its code) exit from anywhere ends the program in order; drops ADR-343’s “only main ends the program”, which ADR-345 D5 kept
D Supervisors never restart a fatal error (the supervisor fails with it, escalates); overlap hands on a fatal loser instead of the first error consistent with “a catch takes a fatal error only by name”
E Structured: every task belongs to a scope whose end waits for it (see below) “nobody waits” no longer exists

How other languages do it

Every row is from memory (marked (m)): no web access was used in this round. The well-known core (Go’s crash, Python’s GC message, C#’s event, Java’s Future) is stable across versions; details of logging and defaults are where memory may be wrong, and should be checked before an ADR quotes them.

Language / API Error or panic of a task nobody waits for Child under a supervisor / scope “End the program” from inside the task
Rust std::thread::spawn (m) the panic hook prints the panic on stderr when it happens; dropping the JoinHandle detaches, the Err is lost; a returned Result is lost silently std::thread::scope joins every thread at its end and re-panics if one panicked std::process::exit ends the process at once from any thread, no destructors
Rust tokio::spawn (m) panic printed by the panic hook; the JoinError sits in the dropped handle and is lost; an Err return is lost silently JoinSet: results collected with join_next; dropping the set aborts its tasks; no restart process::exit ends at once; the runtime is not shut down
Go goroutine (m) an unrecovered panic in any goroutine ends the whole program (exit 2, stack trace); an error return has nowhere to go and is lost no supervisor; errgroup returns the first error to Wait and cancels the context os.Exit ends at once from any goroutine; deferred calls do not run
Erlang/OTP (m) an unlinked process that crashes is logged (crash report), nothing else happens links propagate abnormal exits, monitors deliver 'DOWN'; a supervisor restarts by strategy (one_for_one, one_for_all, rest_for_one) up to an intensity, then dies and escalates; reason normal does not kill linked processes, an abnormal one does exit(Reason) ends only that process and signals its links; init:stop()/halt() end the node
Java threads (m) Thread.UncaughtExceptionHandler (default: stack trace on stderr), the thread dies, the program goes on ExecutorService.submit keeps the exception in the Future until get(): swallowed if nobody calls it (execute goes to the uncaught handler); StructuredTaskScope (preview) joins and can shut down on failure System.exit from any thread ends the JVM (shutdown hooks run)
Kotlin coroutines (m) structured: a failing launch fails its parent, which cancels the siblings; async keeps it until await but also fails the parent; in GlobalScope it goes to the CoroutineExceptionHandler / thread’s uncaught handler SupervisorJob / supervisorScope: a child’s failure cancels neither siblings nor parent, the handler sees it; no restart built in exitProcess = System.exit, from anywhere
Swift structured concurrency (m) async let and task groups: the scope waits, the error comes out at await; an unstructured Task { } whose value nobody reads loses its error silently a throwing task group cancels the rest when the body rethrows exit() ends the process at once
Python asyncio (m) the exception stays in the Task; when it is garbage-collected unread, “Task exception was never retrieved” is logged on stderr; SystemExit/KeyboardInterrupt in a task are re-raised out of the loop and end asyncio.run TaskGroup (3.11): the first failure cancels the others, all failures raised as an ExceptionGroup sys.exit = SystemExit, see left; os._exit ends at once
Python Trio (m) no unowned tasks: every task starts in a nursery the nursery’s block waits for all children; a failure cancels the siblings and is raised as an exception group SystemExit travels like any exception, through the nursery
C# / .NET Task (m) kept in the Task; if never observed, TaskScheduler.UnobservedTaskException fires when the task is finalized; since .NET 4.5 the process goes on (4.0 ended it) Task.WhenAll gathers an AggregateException; no supervisor Environment.Exit from any thread

What the table shows. Three families:

  1. Loss with a late trace (Rust, Java Future, asyncio, C#, Swift Task {}): the failure is kept in the handle and lost or logged when it dies. Each of them later added a structured form (thread::scope/JoinSet, StructuredTaskScope, TaskGroup, task groups) because the unowned form loses failures.
  2. Crash (Go for panics, Erlang for linked processes): an unhandled failure ends the program or the linked group. Visible, but one task takes everything down.
  3. Structured (Kotlin, Trio, Swift groups, TaskGroup): a task always has an owner that waits, so “nobody waits” does not arise.

For “end the program”: everyone except Erlang lets any thread end the process, at once and mostly without cleanup. Erlang’s exit ends only the process, which is the choice ADR-345 D5 made.

The options in detail

A — Lost. Pro: simplest; only the lowering fix to build; no output nobody asked for. Con: exit in a fire-and-forget task does nothing and nobody notices; contradicts what ADR-345 exists for. Who: Rust (Result return), Swift Task {}, Go for an error value. Worst case: silent loss of a fatal error: status 0 from a program that meant to exit 1.

B — Printed on stderr when the handle is dropped or at program end, status unchanged. Pro: visible, no control flow changes; the long-standing practice of asyncio, C#, Java’s default handler, Erlang’s crash report. Con: the moment of the message depends on when the handle dies (asyncio’s GC message is known to come late or never); a program that knowingly ignores failures gets noise and needs a way to say so (detach, or catch in the task); the status still says success. Worst case: noise on stderr, and a fatal exit that is shown but not obeyed: status 0 after a printed Exit(1).

C — A fatal error of an unjoined task ends the program after the drain. Pro: exit means exit everywhere, as in Go, Rust, Java, C#; the status is right. Con: any task, a library’s included, can end the program, which ADR-343 and ADR-345 D5 refused on purpose; the end comes at a moment main did not choose. Who: Go/Java/Rust/C# (immediately, no drain), asyncio (SystemExit out of the loop). Worst case: unexpected program end in the middle of main’s work.

D — Supervisors never restart a fatal error; overlap hands on a fatal loser. Pro: consistent with ADR-345 D2 (a fatal error is taken only by name); Erlang’s supervisor likewise does not restart forever (intensity). Con: a supervisor tree whose child calls exit ends; whoever wants a restart catches Exit by name in the child. Worst case: a supervised service ends because one child exited, which is what that child asked for. Independent of A, B, C and E; covers the owned cases only.

E — Structured: every task belongs to a scope, and the scope’s end waits for it. A bare spawn goes into an enclosing scope (Kotlin’s coroutineScope, Trio’s nursery, Swift’s task group); main is a scope; a task’s failure goes to the scope, which cancels the siblings and throws at its end, fatal included. Pro: no lost error, no stray program end, no noise; the error arrives at a place written in the source; where Rust, Java, Python and Swift all moved later. Con: a language change: fire-and-forget (a background logger, a per-connection task) needs an explicit long-lived scope passed down, as Trio’s nursery parameter; spawn’s move and crossing rules (8.3, Part II 11.2) must be re-read; existing spawn programs change meaning. Worst case: a program that waits at a scope end for a task that never finishes: a hang, visible, not a wrong value.

How close Nikaia is to E today (Part I 8, Part II 12.7). Closer than it looks, not there:

So E means making the task::scope rule the only one and giving main an implicit scope that a bare spawn joins. That changes 8.2/8.3 and needs its own decision; it needs no new machinery.

Recommendation (revised after the comparison)

E as the direction, D now, and for a bare spawn a drain at main’s end.

  1. Now: D, and task::scope hands on a task’s error as overlap does (first in order wins, the rest as secondary).
  2. Now, for a bare spawn: at main’s end the drain joins every unjoined task. The first error (fatal ones first) is printed in long form; if it is fatal, main ends with its code; a non-fatal one is printed and the status stays. This is C limited to the moment main already ends: main keeps the when, so ADR-345 D5’s reason holds where it matters, nothing ends main in the middle. A dropped handle no longer loses anything.
  3. Next, as its own decision: whether a bare spawn becomes a spawn into main’s scope (full E), which turns step 2 into the ordinary scope rule.

Why not B+D as before: B leaves the fatal case wrong (the program prints Exit(1) and returns 0), which in the table only asyncio’s GC message tolerates, and Python replaced it with TaskGroup. Why not C: everyone who has it (Go, Java, Rust) ends the program at an arbitrary point, which ADR-343 refused. Risk of the recommendation: step 2 makes main’s end wait for every still-running unjoined task (a hang where today the program ends and kills them); a long-running background task then needs an explicit cancel or detach. A visible cost, the one Trio and Kotlin accepted. Against it: a server whose main never returns gets nothing from step 2; for it only E (or B’s print at drop) helps.

Whatever is decided, the lowering defect above has to be fixed; it blocks the run-time test “exit in spawn, the joiner gets Exit” of #606 (only the checker half is tested there).

Comments

keywan-ghadami, 2026-10-13

Decision (owner, 2026-10-13)

Structured concurrency as the direction: every task error has a receiver. Recorded in ADR-346 (D1-D7); the rule is in Part I 8.2, Part II 12.7 (scope, supervisor), Part III A.2 (“How the program ends”), 17.1 (std::task), the C ABI code table (task::TimedOut = 1101) and C.3 (NK2109). Version 0.0.889 (commit 782d043).

In short:

  1. A spawn whose result is not bound is detached (D2).
  2. A bound handle leaving its scope on some path without join(), join(within: d), detach() or cancel(), and not handed on (returned, stored, passed), is NK2109; the message names join() and detach() (D3). Taken in one branch only = not taken.
  3. h.join(within: 5.seconds()) (an option without a positional zone, Part I 6): on time-out the task is cancelled like cancel() and join throws task::TimedOut (D4).
  4. join throws what the body throws, plus task::Crashed (D5); a fatal one reaches main and sets the status.
  5. Detached: the error is printed in long form on stderr; a fatal one also replaces the exit status at main’s end (first one wins); drain/cleanup-deadline unchanged (D6).
  6. Supervisors do not restart a fatal error; task::scope hands errors on like overlap (D7).

Rejected: A, B, C, forbidding a bare spawn, exiting at once from a detached task (ADR-346 §3).

To build

  1. (a) Body errors to join. The ledger carries the body’s error set on TaskHandle[T]; join’s throws = body’s + task::Crashed; the body lowers with ? into a Result the slot holds; join flattens it. Tests: h.join() catch { match error { E::X => … } } over a failing body runs; NK2605 asks the joiner (not the spawner) for throws; turn on “exit in spawn, the joiner gets Exit” in crates/nikaia/tests/process_exit.rs.
  2. (b) join(within:) with task::TimedOut (contract, std type, runtime timer + cancel). Tests: a task sleeping longer than within → TimedOut and its cleanup runs; a fast task → its value.
  3. (c) detach() (contract + TaskHandle::detach). Test: compiles and runs, task completes during drain.
  4. (d) NK2109: path-sensitive check; tests for: never used, used in one branch only, return h, stored in a struct/list, passed to a function, cancel(). Adapt existing programs/tests that bind an unjoined handle (grep let .* = spawn in crates/nikaia/tests, examples/, crates/nikaia-std/src/tools/*.nika).
  5. (e) Detached errors: runtime records a detached task’s error, prints it long form; a fatal one’s code replaces the status after main, before the drain; first one wins. Tests: detached exit(3) → status 3 and main ran to its end; detached non-fatal error → stderr line, status 0.
  6. (f) Supervisor/fatal and task::scope: a fatal child error escalates without asking the policy; task::scope throws the first task error in start order with the rest as secondary. Tests for both.

The last commit of this list says Fixes #605.


Generated by Claude Code

keywan-ghadami, 2026-10-14

Build status after the first session (3b49d1a, 0.0.892)

Done

Still open

  1. (a) rest: h.join() catch { match error { Bad::Oops(c) => … } } does not compile: join’s error set in the ledger is ["?", "task::Crashed"], so the catch sees Box<dyn Error>. The handle’s type needs the body’s error set (checker: Expr::Spawn in check/mod.rs ~8612 builds TaskHandle[T]; carry the set and let join’s contribution in contracts/throws.rs::contribution name it). Also: a failing body at user_parallelism = yes with the opaque channel fails Send (Box<dyn Error>).
  2. (b) join(within:) + task::TimedOut (code 1101 in the C table).
  3. (d) NK2109, then adapt programs with unconsumed handles.
  4. (e) log + exit-code override for detached tasks (idea: shared state between the FailingTask wrapper and its Drop/detach; a first-wins global code read by process::ended, and emit_entry.nika must call it for every main).
  5. (f) supervisor/fatal, task::scope error hand-on.

Note: contracts::a_detached_lambda_may_not_use_from failed on this tree for a reason unrelated to this work (Seq::filter_map’s from(f)).


Generated by Claude Code

keywan-ghadami, 2026-10-14

Built in 32d4c23 (0.0.906): every impl Fatal for T type now counts as fatal in tasks, not just Exit. A detached task’s fatal error sets status 1 at main’s end, a box learns a type is fatal through a registry main fills, and a supervisor does not restart a fatal child. In that case supervisor::run throws the new fatal supervisor::Handed, which a plain catch does not take.

Still missing:

  1. task::scope hands body errors on (ADR-346 D7). Scope::spawn takes fn(), so a lambda that can fail is refused with NK2606. To build it: change the contract to fn() throws, and give the four runtime scopes (Scope, PoolScope, PausedScope, PausedPoolScope) an error list where the first error in start order wins and the others become secondaries. Then decide which channel scope returns in: a fn() throws lambda fails in the box, and ? from the box into a typed sum does not compile. Tests: a typed catch over task::scope at both parallelism settings.
  2. (g) On the pool, BoxedTask/Carried keep only the message, the Exit code and the fatal code, so a typed catch at join still sees only Exit. To fix it, require Send of the body’s errors and carry them as Box<dyn Error + Send>.

Generated by Claude Code

keywan-ghadami, 2026-10-14

Status after 0.0.909 (7dbc7af), steps A and B done:

C is still open (task::scope handing body errors on). What I tried: changing the contracts to body: fn() throws (Scope::spawn) and fn(ref task::Scope) throws -> T (task::scope).

  1. The checker then refuses task::scope fn(s) {..} with NK1245, “can’t tell what type task::scope(…) is”. Lambda type inference does not handle throws -> T.
  2. The emitter does not write a lambda passed to a throws parameter in the caller’s channel. For example, each(fn { work(7) }) lowers to async || { work(7) }, which returns Thrown<Bad> while the parameter expects Box<dyn Error>.

To build:

  1. Fix (1) in check.
  2. Write the spawned lambda as || -> Result<(), CHANNEL> { ..; Ok(()) } in the scope function’s channel.
  3. Make the four scope runtimes generic over E, keeping (start index, E) in each scope. The first error in start order is thrown, and the rest join it as secondary errors (error::Joined). On the pool with the box channel, errors cross as Carried (A).
  4. Add a test: a scope with three tasks, two of which fail. A typed arm takes the first started, at both user_parallelism settings.

Generated by Claude Code

keywan-ghadami, 2026-10-14

Step C landed in c463fed (0.0.915). task::scope now passes its tasks’ errors on at both settings and in all four runtimes: the first error in start order is thrown and the later ones are joined to it. A catch on the scope opens the box by type, and a fatal error passes a plain catch.

Still missing before this can close:

  1. E0373. When a catch sits directly on task::scope(..), the scope’s channel is Box<dyn Error>. In that case a failing task that borrows a local of the function is refused by rustc: the borrow must be 'static. The cause has not been found yet. Without a catch on the scope, the typed channel borrows fine. To reproduce: let data = [1, 7] + task::scope fn(s) { s.spawn fn { work(data[1]) } 5 } catch {..}.
  2. Sums drop later errors. For a sum channel, Joined keeps only the first error. The later errors are dropped, not joined.
  3. Site lost. A fatal error that passes a scope catch into a typed channel loses its raise site.
  4. The scope function’s own body cannot throw yet. Its contract has no throws; that is NK1245, still open.
  5. Supervisor catch. A catch written directly on supervisor::run still hands only Escalated to its handler. Not looked at in this round.

Generated by Claude Code

keywan-ghadami, 2026-10-14

7400ac5 (0.0.917) did items 1-3 of the open list:

  1. A catch placed directly on task::scope now lets a task borrow a local. The scope’s channel is now task::Boxed, a newtype with no lifetime of its own. With Box<dyn Error>, rustc inferred the object lifetime inside the function, so the for<'scope> closure required every borrow to be 'static (E0373).
  2. A sum channel now joins a later failure of the same member.
  3. A fatal error that passes a scope catch into a typed channel keeps the site it was raised at (error::reopened). On the pool, Carried now carries the site across.

Still open:

Separately: in tests/language, defects_found_running.nika:958 (walk.down(6) fn(k), Kept vs Fn(i64)) fails on top of ee4fa94. 7400ac5 does not touch keeps.


Generated by Claude Code

keywan-ghadami, 2026-10-14

Decision basis: a catch written directly on supervisor::run(..) (item 5)

What it is. supervisor::run([...]) runs children until it gives up. A child that ends with a fatal error is not restarted: run throws supervisor::Handed, which carries the child’s own error (ADR-346 D7, Part II 12.8). The spec says: “a catch arm that names the child’s own error type takes it there”.

enum Doom { Over(i64) }
impl Fatal for Doom {}
impl Error for Doom { fn message(ref self) -> String { return "doom" } }

fn main() throws {
    supervisor::run([supervisor::child(fn { work() })]) catch {
        match error {
            Doom::Over(n) => println(f"over {n}"),   // (a) an arm naming the child's type
            supervisor::Escalated => …,
            else => throw error,                     // (b) a fallback that passes it on
        }
    }
}

Where it comes from. #605’s open list (item 5), found while building D7 (7400ac5: “tried, reverted”). Nothing in the repository needs it today: no program, test or example writes a catch directly on run; it is the spec’s promise.

What is settled, what is not. D7 and 12.8 settle (a): an arm naming the child’s type takes it. They do not say what error is in such a catch, and so not what (b) throw error, an else arm, or {error} mean. Today error is typed as run’s channel, Escalated | Handed; an arm Doom::Over(n) compiles, but making the generated code open the box means throw error / fallback arms would put a Doom back into a channel that only allows Escalated and Handed - where the previous attempt broke (no Doom member in the function’s channel).

Options.

  1. error widens to Escalated | Handed | <every fatal type the children declare>. The checker reads the children’s lambdas’ error sets, keeps the fatal ones, and the catch’s sum includes them; throw error of a child’s type needs the enclosing function’s channel to take that type. + (a) and (b) mean what they read as; exhaustiveness is honest. − The children’s types leak into the caller’s channel; new checker inference across supervisor::child lambdas; a child list built elsewhere (Vec[Child] passed in) has no visible types, so it works only where the list is literal.
  2. error stays Escalated | Handed; a child-type arm is Handed plus opening it. throw error in any arm rethrows the Handed. + No channel widening; works for lists built elsewhere. − Doom::Over(n) => throw error throws a Handed, not a Doom: the arm’s pattern type and error’s type differ.
  3. Refuse a child-type arm directly on run (new code); the program matches Handed and opens it explicitly. + Simplest. − Contradicts 12.8’s sentence; the spec changes.

Risk if wrong. Option 2 wrong: a rethrown fatal error arrives upstream as Handed; the exit status is the same (Handed ends the program as the child’s error would), so the harm is a different type at a further catch, not a wrong process outcome. Option 1 wrong: a caller’s channel gains types it did not expect (a refusal, not a wrong value).

Recommendation: Option 2 (keeps 12.8 true, no checker inference, exit behaviour already right), with one added sentence in 12.8: in a catch on run, an arm naming a child’s type binds that error; error and throw error stay run’s own (Handed). Option 1 only if a program needs to rethrow the child’s type itself.

Status: this item needs the owner (label decision); items 2b and 4 are built.


Generated by Claude Code

keywan-ghadami, 2026-10-14

Item 5: how others do it (addendum to the decision basis above)

What does the code around a supervisor see when a child fails in a way the supervisor will not handle?

System What the caller receives The child’s own error
Erlang/OTP supervisor the supervisor exits with reason shutdown once its restart limit is reached logged by the supervisor, not handed to the caller
Akka (classic) Escalate the parent’s strategy receives the child’s exception itself yes, the original exception
Kotlin supervisorScope nothing: a child’s failure does not cancel the scope; it goes to a CoroutineExceptionHandler only through the handler
Swift withThrowingTaskGroup the group rethrows the first child error itself yes, the original error
Java StructuredTaskScope.ShutdownOnFailure throwIfFailed() throws an ExecutionException wrapping the child’s exception wrapped; getCause() opens it

So two families: hand on the original (Akka, Swift: option 1’s reading of error) and wrap it (Java’s ExecutionException, which is exactly option 2: the arm can open the wrapper, but rethrowing rethrows the wrapper). Erlang and Kotlin hand nothing on, which ADR-346 already rules out (every task error has a receiver).

Option 2 is Java’s model; option 1 is Swift’s, which works there because Swift’s throws is untyped, so widening a channel costs nothing. Nikaia’s channels are typed, which is the cost option 1 names.


Generated by Claude Code

keywan-ghadami, 2026-10-14

Blocked by #620 (owner, 2026-10-14).

Item 5’s options rest on a child’s error set being unknown. ADR-346 §5 says so for D7: “because run’s channel is typed and a child’s is not”. The owner does not accept that premise: a function value should keep the error set the compiler inferred for it, kept or not, as Part I 7.1 has it and as D5 already does for TaskHandle. #620 prepares that decision. It also records a checker defect: a kept function’s errors are counted where it is stored, not where it is called. Separately, net::serve drops a handler’s fatal error.

Item 5 is not decided here:


Generated by Claude Code

Back to Open Issues