Skip to content
You are reading next, which describes the unreleased compiler. Khora is unstable before v1: syntax, standard-library APIs and behavior may change. The current release is documented under v0.4.

Traps

A trap is an unrecoverable programming error or violated invariant. Traps are intentionally separate from typed raises failures: they are not part of a function’s failure row and cannot be handled with catch.

Checked integer arithmetic traps when the mathematical result does not fit the type:

let total = left + right;
let product = width * height;

Integer division and remainder trap when the divisor is zero, and when the quotient does not fit — which for a signed type is the one pair minimum / -1:

let each = total / people;
let over = total % people;

Array access traps when the index is outside the array:

let item = Array::get(items, index);

Other operations trap on a violated invariant, and this is the whole list:

operation when
String::from_bytes the bytes are not valid UTF-8
String::byte the offset is outside the string
Vector::at the index is outside the vector
Decimal arithmetic addition, subtraction, multiplication, division, rescaling or truncation overflows
Shared::update / Shared::modify the change function re-enters the same cell, which would otherwise deadlock — see Sharing

An assertion that fails in a test is not a trap. assert reports the failing line and the test continues to the end of the run, because a test suite that stopped at the first failure would report one problem per run.

A trap is appropriate when continuing would mean the program itself is wrong. It is not appropriate for ordinary external input that a caller is expected to reject.

Not a trap — the operating system ends the process and there is nothing to unwind onto — but it is reported the same way, because a program that stops without saying why is the worst of both:

khora: the stack ran out

on standard error, followed by the platform’s stack-overflow exit status rather than 134.

The message covers every stack Khora code runs on: the one the program started on, a fiber’s on either backend, and a test’s under khora test, on Linux, macOS and Windows. Reporting from an exhausted stack needs room set aside before the fault, and the runtime sets it aside on each of those threads, as an alternate signal stack. That includes a stack that runs out while the runtime is starting a thread or a program: the C library blocks signals for the moment it does that, and a fault in that moment cannot be reported, so the runtime checks for 16 KB of stack first and, when it is not there, stops the program before the start with the message above. On macOS a thread’s stack overflow arrives as a different signal from the main thread’s, and both are reported. A C library that starts its own threads deep in a Khora stack is outside what the runtime can check.

How deep is deep. A fiber’s stack is eight megabytes on either backend, the same as main gets from a default ulimit -s on Linux and macOS. What that buys depends on the frame: a function that matches a list cell and recurses on the tail went about 100,000 calls deep in a debug build and 260,000 in a release build on x86-64 Linux, in main and on a fiber alike. A larger stack is not available, by setting or by spawn option. A walk deeper than that is one to write as a loop.

Khora does not guarantee tail-call optimization, so a function that recurses once per element of its input uses a frame per element. See Known limitations for what that means for List in practice, and which operations are unaffected.

A function does not declare arithmetic overflow in its raises row:

fn area(width: Int, height: Int) -> Int {
width * height
}

If the multiplication overflows, area traps. Adding catch around the call does not turn that trap into a recoverable failure:

// `catch` handles declared failures, not traps.
let result = load()! catch {
LoadError::Missing => fallback(),
};

Use raises for expected conditions a caller can make a decision about. Use a trap for an invariant the program was required to uphold.

When modular arithmetic is the intended operation—a hash, checksum, PRNG, or wire algorithm—use the wrapping operation by name instead of relying on build-mode overflow behavior:

let mixed = Int::wrapping_add(left, right);

Checked arithmetic traps in every build; release builds do not silently change to wrapping arithmetic.

By default, a trap:

  1. writes a diagnostic to standard error;
  2. identifies the operation or invalid index;
  3. identifies the spawned fiber when the trap occurred on one;
  4. terminates the process with status 134.

A trap with no backtrace says how to get one, so the first thing a person sees tells them what to do next:

khora: Int addition overflowed on fiber 7
note: re-run with KHORA_BACKTRACE=1 to see where
Terminal window
KHORA_BACKTRACE=1 khora run .

RUST_BACKTRACE=1 is honored too, because a person who has debugged a Rust program will try it. KHORA_BACKTRACE is the name to use and the one the runtime’s own note gives.

When debug information is available, the trace includes Khora source frames and locations. The runtime’s own frames are trimmed from the top of it, so the first frame is the line in your program that trapped. Standard output is flushed before the process ends, so what the program had already printed is not lost.

A process-fatal trap is not normal structured failure unwinding. Do not depend on regions, catch, or application finalizers to recover from it. Resource correctness must come from normal return, typed failure, and cancellation paths; a fatal trap means the process is ending.

A trap is one of several ways a program ends without returning from main, and they are told apart from outside by the status:

status what happened
the value main returned an Int, truncated to what the platform’s status holds. () is 0.
1 An error reached the entry point and nothing handled it. main has nowhere to hand one, so it ends here.
130 A cancellation reached the entry point. 128 + SIGINT, which is what a shell already means by “interrupted”.
134 A trap, everything above — and the runtime’s own fatal errors. 128 + SIGABRT, and 134 on Windows too rather than a native abort code.
the platform’s The stack ran out: SIGSEGV on Unix, which a shell reports as 139; STATUS_STACK_OVERFLOW on Windows, which a POSIX shell reports as 127.

130 is the one that surprises people, because a cancellation is not a failure and there is no catch for it. The common way to reach it is a join on a fiber that was canceled: a canceled fiber has no answer, so the join unwinds the joiner along with it, and if the joiner is main the program ends there. join_all joins, so it ends the same way. Fiber::wait waits without asking for an answer and does not do this — reach for it when what you needed was “not before that finishes” rather than the value, and Fiber::detach when you are no longer waiting at all. A cancellation that reaches a spawned fiber’s root stops that fiber and nothing else; only one that reaches main ends the program.

The runtime’s fatal errors exit 134 rather than aborting. abort() leaves the status to the platform, and on Windows a POSIX shell reports that as 127 – which is also what it reports for a stack overflow. Two unrelated deaths arriving as one number is what this table exists to prevent, so the runtime picks 134 the way a trap does. Stack exhaustion is the one death that cannot pick anything: its handler runs with the stack already gone, so it may not allocate, lock, or call exit. It reports and lets the platform end the process, which is why its row says “the platform’s”.

A supervisor should treat 130 as a deliberate stop and 134 as a crash. They are different outcomes and this is where the difference is visible.

Read them with waitpid, not $?, where the difference matters. 128 + N is a shell’s way of reporting a child killed by signal N; a Khora program that exits 130 was not signaled, it chose that number. POSIX keeps the two apart – WIFSIGNALED is true of a real SIGINT, and WIFEXITED with status 130 is true of this – but a shell collapses both into one $?. So a script cannot tell “somebody pressed Ctrl-C” from “a cancellation reached the entry point”, and a supervisor that cares should read the wait status rather than the shell. A signaled Khora program produces both, in order: the first SIGTERM or SIGINT becomes a cancellation and the program exits 130, and a second one restores the default disposition and re-raises, so what a supervisor then sees is a genuine WIFSIGNALED — not an exit imitating one. On Windows there are no signals in this sense at all: the numbers above are Khora’s own, chosen to match what a Unix shell would have said.

A host embedding a Khora shared library may explicitly opt into trap containment at the export boundary:

khora_set_trap_policy(1);
int64_t value = price(units, scale);
if (khora_trapped()) {
khora_clear_trap();
/* the host process is still running */
}

The default remains process-fatal. Containment occurs only after the host enables it and only while entering through a generated pub extern fn export wrapper.

When an exported call is contained, the call is discarded and allocations created by that guarded call are released before control returns to the host. The trap is still reported; containment prevents that one bug from terminating the embedding process.

This is not a general exception mechanism and is not equivalent to raises.

An exported C function has a deliberately narrow signature: scalars and Ptr cross the boundary; exported functions are not generic and do not expose Khora values, capability rows, or typed failures to the host. That makes the exported call a boundary whose newly created Khora allocations can be discarded without leaving a Khora object in the caller.

If a guarded exported call spawns a fiber, containment is disarmed for that call. A spawned child may outlive the C call and may hold references to allocations made by it; freeing those allocations underneath the child would be unsound. In that case a trap retains the ordinary process-fatal behavior.

There is currently no general request-fiber or arbitrary-fiber trap containment mechanism. Do not design application recovery around trapping inside a fiber and continuing the service.

A trap in a request handler ends the server, not the request. This is the consequence of everything above, and it is worth stating plainly because the code around a handler reads as though it were not true.

Router::listen serves each connection on its own fiber and wraps your handler so that a typed failure becomes a 500 rather than a dropped connection. That wrapper handles raises. A trap is not a raise: it does not unwind, so it does not reach the wrapper, and the process exits with status 134. Every other connection being served at that moment ends with it, including ones that had nothing to do with the request that trapped.

Design for it in three places.

Validate at the boundary, not in the handler. An integer that came from a request is external input until a schema has read it. std::schema rejects a port outside 1 to 65535 as a 400; the same value multiplied inside a handler traps and takes the process down. This is the reason the standard library’s own HTTP reader caps a Content-Length before multiplying it, answering 413 rather than trapping.

Run more than one process. Since the blast radius of a trap is the process, the unit of redundancy is the process. A server behind a load balancer with at least two instances survives one trapping; a single instance does not. This is ordinary operational practice and Khora does not change it, but a language that contained traps per request would let you skip it, and this one does not.

Expect a restart, and make it cheap. Status 134 is a crash as far as a supervisor, container runtime or service manager is concerned, and the right response is a restart. Hold no state in the process that a restart cannot rebuild or that a second instance cannot serve from.

What you get in exchange is that a trap is never silently swallowed. A handler that overflows is a bug in your program, and the process ending with a message naming the line is a louder and shorter path to fixing it than a 500 that happens to a fraction of requests and is discovered in a log a week later.

Validate external data before invariant operations

Section titled “Validate external data before invariant operations”

Untrusted input should become an ordinary value or typed failure before it reaches an operation whose precondition may trap:

fn item_at(items: Array<Item>, index: Int) -> Item
raises InputError
{
if index < 0 || index >= Array::length(items) {
raise InputError::InvalidIndex(index)
}
Array::get(items, index)
}

At that boundary, an invalid client-supplied index is an expected failure; the array access after validation is an invariant the program can safely rely on.

See Failures for recoverable errors and FFI for the exported C boundary.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource