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:
| Policy | Result |
|---|---|
<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
catchor let theErrorcontinue 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
trydoes not convert it into anError. - 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.