Open and answer this issue on GitHub.
blocked · opened 2026-10-13 · updated 2026-11-03 · 9 comments
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.
TaskHandle (crates/nikaia-std/src/task.rs): “a task nobody joins still runs”; its result stays in the slot and is dropped with it.spawn fn { ... } is counted against the enclosing function (NK2605 asks main for throws), as if the failure went to the spawner.main() throws, a detached spawn whose body fails emits invalid Rust (Result<i64, Thrown<E>> used as the value); h.join() catch { match error {...} } over a failing task does not compile either (the join’s Result is not flattened). So a task’s failure does not reach anyone at all yet.Restart::decide; a fatal one would be restarted like any crash.overlap: failing siblings join the first error as secondary errors (7.1), a diagnostic only.So: a fatal error in an unjoined task vanishes; in a supervisor child it is restarted.
| 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 |
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:
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.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.
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:
overlap { … } (8.1.2) is already structured: it waits for all branches, the first failure in written order wins, the others go along as secondary. Only the fatal loser (D) is open.task::scope fn(s) { s.spawn ... } (Part II 12.7) is a nursery: it waits for every task, a panic cancels the others and the scope throws task::Crashed. It does not say what happens to a task’s error (as against a panic); it should hand it on as overlap does.spawn (8.2) is unstructured: “a background task may keep running after the function that started it has finished” (8.3) is exactly the sentence E removes. The checker already acts as if the spawner owned the failure (NK2605 counts it against the enclosing function): that is E’s rule, not today’s specification.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.
E as the direction, D now, and for a bare spawn a drain at main’s end.
task::scope hands on a task’s error as overlap does (first in order wins, the rest as secondary).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.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).
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:
spawn whose result is not bound is detached (D2).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.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).join throws what the body throws, plus task::Crashed (D5); a fatal one reaches main and sets the status.main’s end (first one wins); drain/cleanup-deadline unchanged (D6).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).
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.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.detach() (contract + TaskHandle::detach). Test: compiles and runs, task completes during drain.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).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.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
Done
NK2109), 782d043 / e68438d.spawn whose body can fail (its body lowered in the starter’s channel contains a ? or a return) starts nikaia_std::task::FailingTask<T, E>; join hands the body’s error (or Crashed, via From) on in the starter’s channel. The ledger counts the body’s errors at the starter (throws.nika, Expr::Spawn arm in thrown_in_blocks). Ending for Box<dyn Error> lets an Exit in the opaque channel end main with its code. Tests: process_exit.rs exit_in_a_spawn_reaches_the_joiner (status 3), a_task_error_reaches_join.TaskHandle::detach, FailingTask::detach, [fn."TaskHandle::detach"].Still open
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>).join(within:) + task::TimedOut (code 1101 in the C table).NK2109, then adapt programs with unconsumed handles.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).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
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:
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.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
Status after 0.0.909 (7dbc7af), steps A and B done:
catch arm at join takes it. main registers every one of the program’s own error types through task::Cross, an autoref probe that registers only Send types. Carried keeps the error as Box<dyn Error + Send>, and holds/opened find it. A type that cannot cross threads still crosses as its message, status and fatality. This adds no new visible rule and needs no NK code. Test: process_exit::a_typed_error_crosses_the_pool.supervisor::Handed stays, because run’s channel is typed, but it now carries the child’s fatal error. A catch arm that names the child’s own fatal type takes it (error::handed), and the hand-over passes on only when no such arm matches. ADR-346 D7 and Part II 12.8 are updated. One limit: this works where run’s errors arrive in a function’s channel. A catch written directly on supervisor::run(..) still hands only Escalated to the handler.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).
task::scope fn(s) {..} with NK1245, “can’t tell what type task::scope(…) is”. Lambda type inference does not handle throws -> T.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:
|| -> Result<(), CHANNEL> { ..; Ok(()) } in the scope function’s channel.(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).user_parallelism settings.Generated by Claude Code
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:
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 {..}.Joined keeps only the first error. The later errors are dropped, not joined.catch into a typed channel loses its raise site.throws; that is NK1245, still open.catch written directly on supervisor::run still hands only Escalated to its handler. Not looked at in this round.Generated by Claude Code
7400ac5 (0.0.917) did items 1-3 of the open list:
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).error::reopened). On the pool, Carried now carries the site across.Still open:
Thrown<E>’s list only holds E. Fixing it needs a type-erased secondary list or a list on the sum.fn(ref task::Scope) throws -> T, the checker has to infer T, and the emitter has to flatten Result<Result<T,E>,E>.catch placed directly on supervisor::run with an arm naming the child’s type. Opening the box by type works, but the else => throw error and fallback arms then need the child’s type in the function’s channel. That breaks when the channel is the {Escalated, Handed} sum. Tried and reverted.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
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.
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.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.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
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
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:
supervisor::Handed goes;Generated by Claude Code