ADR-345: Fatal Errors; exit Is One

Status: Accepted Date: October 13, 2026 Answers: how a program ends with an exit code from any depth, and what a catch does with an error that is meant to end the program Supersedes: ADR-343 D2 and D3, and D1’s mechanism Related: ADR-343 D1, D4 (the ordered end of main, the code’s range); ADR-280 D10, D17 (a handler passes an error on, match error over a sum); ADR-023 D4 (the set of error types at a catch is open); ADR-326 D4 (a panic in a task ends the task); Part I 7.1, 8.2; Part III 13.5, 17.1, A.2, C.3

1. Question

Is process::exit a special construct of the compiler, or an error like any other, and if an error, what keeps a catch from swallowing it?

ADR-343 made exit a property of its own: a ledger column exits, derived over the call graph, and a refusal (NK2607) wherever such code could run in a task. A function value carried no such property in its type, so a callback that exits slipped past the refusal. An error already has everything exit needs: it travels from any depth, the frames it leaves run their cleanups, throws declares it, and a function type carries throws. What an ordinary error lacks is protection from a handler that takes everything: load() catch { 0 } would turn the end of the program into a 0.

2. How others do it

Language Its answer What it costs there Source
Python sys.exit raises SystemExit, which derives from BaseException and not from Exception; except Exception lets it through, a bare except: takes it the bare form is a known trap; linters warn about it from memory, not checked
Java Error (e.g. OutOfMemoryError) beside Exception; catch (Exception e) lets it through, catch (Throwable t) takes it System.exit is not one of them: it ends the JVM at once from memory, not checked
Rust, Go exit ends the process at once and runs no destructor no handler can take it, and no cleanup runs either from memory, not checked
Erlang an exit signal is a message a process can trap only by asking (trap_exit) the opt-in is explicit, per process from memory, not checked

What the two exception languages share: a class of error that a handler for “everything” does not take, and a way to take it that names it. Both put the line in the type hierarchy, and both let a handler wide enough to name the root take it anyway.

3. Options, weighed

Option Why it lost, or won
A ADR-343 as built: exit a property of its own, refused in a task (NK2607) a second mechanism beside errors that has to be carried wherever a function value goes; it was not carried through a function type, so a callback slipped past it; a task is refused something that is only a way to end, and the reason (only main ends the program) is kept just as well by an error that ends the task
B exit an ordinary error, nothing more catch { 0 } and else => would swallow the end of the program, and every handler written to catch “anything” would become a place where a tool’s exit code is lost
C A special case for Exit only: a handler never takes it unless it names it right for Exit, but the same need exists for any error a program means as “stop”: a fatal configuration error, a cancelled job. A rule for one type of std is a rule a program cannot use for its own
D Python’s two roots (BaseException): a wide handler that names the root takes it Nikaia has no hierarchy of error types and no root to name; error in a handler is the sum of what arrives, and else is already the widest handler there is
E A fatal error is one whose type says so (impl Fatal for T); a catch takes it only in an arm that names the type; Exit is one chosen

4. Decision

D1 — A type is fatal where impl Fatal for T says so.

Fatal is a trait of the language beside Error, Drop, Cleanup, Eq and Ord. It has no methods: the impl line is the whole statement, as Part I 7.1 has what is thrown implements Error, and the impl line says so. A type that is fatal is an error as well (impl Error for T).

D2 — A catch takes a fatal error only in a match arm that names its type.

An arm whose pattern names a variant of the type (process::Exit(c), ADR-347) takes it. A handler without a match, an else arm, a bare name and throw error do not: the error passes the catch as if it stood not there, the handler does not run for it, and it leaves the function. A function an error passes this way leaves throws, and without it NK2605 refuses the call as for any call that can fail.

D3 — process::exit(code) throws process::Exit(code), a fatal error.

exit is a function that can fail and always does. Its callers declare throws, and a function type that holds it says throws; nothing else marks it, and the ledger has no column for it.

D4 — Exit arriving at the end of main ends the program with its code.

The frames it left ran their cleanups on the way, as for every error. After main, the end is ADR-343 D1’s: the tasks nobody joined run to their end and the parked cleanups run, both under cleanup-deadline; then the process ends with the code. ADR-343 D4 stands: a code outside 0..=255 is a panic, and a constant one is refused (NK1116). Any other error, fatal or not, that leaves main ends the program with status 1, as before.

D5 — In a task, Exit is an error like any other.

A task in which it arrives ends with it, and whoever joins the task receives it. There is no refusal and no rule of its own; NK2607 is retired.

5. Reasons

6. Consequences

7. What this does not decide

This site is open source. Improve this page.