Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Errors and Cleanup

x2c adds structured errors to C. raise records an Error, a filtered catch selects it by cause, and finally and defer run cleanup when a block exits early. The system macros attach the same defer cleanup to managed locals and to $scope, $let, and $lock bodies. The language reference gives the full statement rules.

One error protocol

The code that first detects a failure raises one structured Error:

raise %(size-limit (operation "Block.reserve") (bytes $requested));

The first item is a compact Symbol naming the cause. The remaining items are (key value) detail pairs. Error also records the source location. A code identifies a cause that a handler can branch on. Put the reporting mechanism and the module that found the cause in the details.

Use the most specific shared cause. For example, <bad-arity> identifies an argument count, <bad-types> identifies unsupported dynamic operand types, <not-found> identifies a missing external resource, and <invariant> identifies an impossible internal state. A raiser may add any detail keys without changing the code. Detail values must be immutable data: Null, numbers, enum values, Symbols, Atoms, Strings, and Lists of those. The reference gives the full rule.

When an Array failure reports both a type and a method action, operation is the Array type String, and action is the method Symbol. For example, a push to a null ArrayInt raises (bad-arg (operation "ArrayInt") (action push)). The action <take-last> means the public method take_last.

When several codes seem to fit, choose in this order: callable shape, dynamic operation, resource or external operation, API contract, then <invariant> for an impossible internal state. The reference lists the 31 shared codes.

Every shared cause aborts by default and never returns to the raising call. It can transfer to a matching filtered catch, after the cleanup between them runs. So a constructor whose preconditions hold returns an initialized object, and a failed open, read, write, or format returns no null, void, or fallback value.

Return values still carry ordinary results. Clean EOF, a missing Map key, iterator exhaustion, and numeric text that does not parse are not Errors. APIs such as File.readline_into, Map.try_get, Iter.try_next, and String.try_long keep those expected outcomes separate from their value output.

Catching by cause

try requires a following filtered catch, finally, or both. Adjacent catch arms select the newest Error by matching %(CODE @DETAIL):

try {
  block.reserve(requested);
}
catch %(size-limit * (bytes ?count) *): {
  printf("could not reserve %ld bytes\n", count);
}
catch %(bad-arg *detail):
  report_bad_argument(detail);
catch:
  report_unknown_error();

The pattern language and binder rules are the same as match. A ?name binder declares a Var, a *name binder declares a List, and arms run in source order. catch: is an optional default arm and must be last. Filters are evaluated once when the try is entered. Like a literal pattern written at a match, each arm’s program is prepared once for the life of the process; an arm whose pattern interpolates a run-time value with $ is prepared on every entry instead.

When no arm matches, the Error continues outward. Selecting an arm consumes the errors accumulated since that catch was registered. Older errors remain available to the enclosing handlers.

The selected catch is removed before its body runs. A handler can therefore translate a lower-level cause without catching its replacement:

try parse_config(source);
catch %(malformed *cause):
  raise %(bad-state (operation "load-config") (cause $cause));

The matched details are borrowed from the selected Error and remain valid through that catch arm. If they must survive the arm, copy them first with Error.snapshot. If translation is unnecessary, omit an arm and let the original cause continue outward.

A wildcard code binder handles any cause while retaining access to its details. Snapshot the details inside the arm when they must survive it:

Symbol code = 0;
List detail = nil;
try raise %(bad-arg (operation "load"));
catch %(?caught *fields): {
  code = caught;
  detail = Error.snapshot(fields);
}
printf("%s %s\n", code.str(), detail.repr());
bad-arg ((operation "load"))

A pattern in the code position selects several codes in one arm, and raise accepts the bound code as $code. This arm rewraps either cause under its own code and lets every other cause continue outward:

try raise %(conv-range (value 300));
catch %((!or ?code bad-enc conv-range) *cause): {
  List lower = cons(code, cause);
  try raise %($code (index 0) (cause $lower));
  catch %(?outer *detail): printf("%s %s\n", outer, detail.repr());
}
conv-range ((index 0) (cause (conv-range (value 300))))

Put a wildcard arm after any specific filters that should handle a cause first. Like any matching arm, it consumes the selected errors and does not catch an error raised by its own body.

Handlers and policy

A filtered catch transfers control out of the raising call. Embedders may also install observing handlers with Error.push. An observing handler runs inside the raising call, receives the errors accumulated since its registration, and returns:

  • <handled> to consume those errors and stop;
  • <declined> to leave the errors accumulated and continue outward;
  • <fatal> to terminate without allocating.

<unwind> is reserved for compiler-generated filtered catches.

An observing handler cannot consume a non-returning cause with <handled>. If it tries, Error terminates the process. Use a filtered catch to recover outside the failed call.

The handler borrows its Errors for the duration of the callback. Error.pop closes the handler and reclaims everything accumulated since its registration, including declined or collected Errors. Use Error.snapshot(value) to retain a value beyond a callback; it copies into the caller’s ordinary Scope and canonical List and String pools. Error.since(mark) also returns a snapshot in the caller’s scope and pools.

If every handler declines, the code’s policy selects the result:

PolicyResult
<abort>render if possible, then terminate without further allocation
<log>render once, consume the newest Error, and continue
<collect>retain the Error without rendering and continue
<ignore>consume the newest Error without rendering and continue

Every shared cause defaults to <abort> and cannot be reconfigured. A process may set policy for unknown and user-defined codes only. The code that raises chooses the cause; the embedding caller chooses the policy.

Error.policy_set accepts only the four dispositions in the table. Passing another disposition raises <bad-arg> and leaves the previous policy unchanged. It also rejects <log>, <collect>, or <ignore> for every non-returning cause, leaving those policies at <abort>. Unknown Error codes remain legal and default to <abort>.

The default Logger registers an observing Error handler. An uncaught Error, one whose policy is <abort>, is reported as one line on standard error, and the process then aborts:

x2c: uncaught <bad-state> in main (config.x:12): operation load-config, path x.toml

The line names the cause, the raising function and source line, and the detail pairs. An Error raised inside the runtime library shows its detail without the runtime’s own source location; the operation detail names the call. A <log> entry is rendered as a structured <err-report> log event. Setting X2C_DEBUG in the environment renders uncaught errors as that event too, with the complete detail and location. A program that installs its own global Logger, or adds a sink to the default one, receives the structured event for both policies, so a configured log never loses information. After rendering, the handler declines, and Error transfers control or consumes the errors as required.

finally and defer

Both forms run cleanup when control leaves their region: on normal exit, on return, break, continue, or an outward goto, and when an Error transfers through the region to a selected catch. They do not run when an uncaught Error aborts the program, or when native code calls exit or longjmp. The two forms differ in what they attach to.

finally attaches to one try and runs after its body or selected handler:

static String first_line(String path) {
  File input = File.open(path, "r");
  String line = NULL;
  try {
    line = input.readline();
  }
  finally {
    input.close();
  }
  return line;
}

The defer statement attaches to the enclosing block. Multiple defer statements in a block run last-in, first-out:

static String first_line(String path) {
  File input = File.open(path, "r");
  defer input.close();
  String line = input.readline();
  return line;
}

Prefer defer when a block owns a resource but has no handler. The release then sits next to the acquisition, so a reader can compare them. Keep the deferred statement small. Use finally when cleanup belongs to an existing try statement.

Use either form to release resources. Recovery belongs in a catch. Keep expected absence, exhaustion, and parse outcomes in return values, and let Errors propagate.

A finally body cannot define a label. Its statements run on every path that leaves the region, so the generated C repeats them, and a label among them would be defined more than once. x2c reports an error at the label. A goto that leaves the region is allowed, as is one inside the try body.

A return, break, or continue that leaves a finally or defer body leaves its frame first. When that body is running for a propagating Error, the transfer discards the Error, as a handler that returns does.

Raising during cleanup

A raise inside a finally or defer body transfers to the enclosing handler, never back into the cleanup that raised it. Cleanup runs at most once per exit path. If that cleanup was already running for an Error, the new Error replaces it: the pending one is discarded with its detail, and only the replacement reaches a catch. With no matching handler anywhere outward, the replacement terminates the program through the error floor like any other unhandled Error. This is why cleanup is for release, not recovery; handle a failure a finalizer can produce inside the finalizer.

Return expressions run before cleanup

A return expression is evaluated and saved before cleanup runs. Cleanup then executes, and the saved value is returned:

static String first_line(String path) {
  File input = File.open(path, "r");
  defer input.close();
  return input.readline();
}

Here readline runs while the File is open, then the File closes, then the saved String is returned. Cleanup cannot change which value the return expression already produced.

break and continue run cleanup only up to their target loop or switch. Cleanup registered outside that boundary remains active until a later normal exit, return, or Error transfer reaches it.

goto follows the same cleanup ancestry rule. A jump within the same cleanup region runs nothing. An outward jump runs every cleanup region it exits. A jump into a protected region, or sideways into a sibling protected region, is a compile-time error because that region’s cleanup was never registered on the source path.

Cleanup and scopes

Error transfer does not release a Scope automatically. Write the region as $scope() whenever a raising call can cross the release; its deferred release runs on the error path too:

static void render(String text, int width) {
  $scope() {
    char *line = Scope.malloc(width + 1);
    if (!text) {
      raise %(bad-arg (operation "render"));
    }
    line[0] = 0;
  }
}

The same rule applies to pushed scope slots, Files, and every other resource with paired teardown. See Scopes and Lifetime for ownership details.

Calling fallible APIs

Distinguish an API’s return value from the Errors it raises:

  • If an operation raises, either provide a matching catch or let the Error continue outward. A handled resumable failure returns the documented sentinel; a non-returning cause transfers and never returns to that call.
  • If an operation reports expected absence or exhaustion, inspect that result; a try does not convert it into an Error.
  • Keep these outcomes distinct when adding a fallback.

For example, raw File readers separate data from clean EOF in their result, while a host read failure raises <io-fail> and transfers:

Block line = Block.new(1);
File input = File.open("/etc/hosts", "r");
defer input.close();
try {
  if (input.readline_into(line) == <eof>)
    printf("empty file\n");
}
catch %(io-fail *):
  printf("read failed\n");

The generated API reference states each callable’s result, Error, and ownership rules.

Process shutdown

Shutdown hooks run in reverse registration order. Error initializes before Logger installs its hook, so later subsystems may still raise during shutdown. Logger then reports any collected root errors once and closes its handler. Finally, Error destroys its empty handler chain, stack, policy, pools, and Scope. A raise after that point terminates without allocating. The raw Scope leak report runs after all shutdown hooks.