Andrew Kesterson 9bf8bcd357
Some checks failed
libakerror CI Build / cmake_build (push) Successful in 2m47s
libakerror CI Build / coverage (push) Successful in 2m48s
libakerror CI Build / thread_sanitizer (push) Failing after 2m49s
libakerror CI Build / mutation_test (push) Has been cancelled
Fix two comments that misstate exit sites and NULL validity
Both found while auditing the pool for the cross-thread transfer guarantee.

AGENTS.md said the two exit() calls that bypass akerr_exit() are "both in
src/error.c". ENSURE_ERROR_READY's pool-exhaustion abort is in
include/akerror.tmpl.h. The count of abort sites is load-bearing -- it is
the argument against any API that could fail on pool exhaustion -- so
pointing at the wrong file makes it hard to check.

akerr_valid_error_address() returns 1 for NULL, which is right for VALID()
(NULL is how a function reports success) and a trap for anything reading it
as "is this a pool slot". Say so where the function is defined.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:35:26 -04:00
2026-07-29 17:42:56 -04:00

Summary

This library provides a TRY/CATCH style exception handling mechanism for C.

build badge

Upgrading

2.0.1 fixes an unhandled error killing the process and still reporting success: the exit code was the status truncated to a byte, and every consumer status starts at 256. Use akerr_exit() instead of exit() — see Exit status. No ABI break.

2.0.0 makes the library thread safe. That is an ABI break — __akerr_last_ignored became thread-local storage and the pool now takes its own reference — so everything built against a 1.x header must be rebuilt. 1.0.0 replaced the consumer-sized status-name array with a private, ownership-enforced registry. See UPGRADING.md for all three, what was removed, how to migrate, the capacity limits and how to raise them, and the thread-safety rules.

Why?

There is nothing wrong with C as it is. This library does not claim to fix some problem with C.

Instead, this library implements a pragmatic and stylistic choice to assist the programmer in better handling errors in their programs. Vanilla C provides everything you need to do this out of the box, but this library makes it easier to avoid pointing certain guns at your foot, and when you do, it provides better context with those errors to help you more quickly recover.

Why? Because some programmers prefer to have the power of C with just a little bit of help in managing their errors.

Library Architecture

Philosophy of Use

This library has 6 guiding principles:

  • Manually checking every possible return code for every possible meaning of that return code is tedious and prone to miss unpredicted failure cases
  • Functions should return rich descriptive error contexts, not values
  • Uncaught errors should cause program termination with a stacktrace
  • Dynamic memory allocation is the source of many errors and should be avoided if possible
  • Manipulating the call stack directly is error prone and dangerous
  • Declaring, capturing, and reacting to errors should be intuitive and no more difficult than managing return codes

Lifecycle of an error in the AKError library

TL;DR - akerr_ErrorContext objects are filled with error context information and bubbled up through nested control structures until they are handled or reach the top level, where an unhandled error halts program termination with a stack trace

  1. At the point where an error occurs, an akerr_ErrorContext object is initialized and populated with information regarding the failure
  2. The akerr_ErrorContext is returned from the scope where the error was detected
  3. The akerr_ErrorContext enters a control structure provided by the AKError library through a series of macros that examine akerr_ErrorContext objects as they pass through
  4. The control structure checks to see if the akerr_ErrorContext has an error set, and if so, if there are any handlers in the current control structure that can handle it
  5. If the current control structure can handle the akerr_ErrorContext, it does so
  6. If the current control structure can not handle the akerr_ErrorContext, then the current control structure's cleanup code (if any) is executed, and the akerr_ErrorContext object is passed out of the current control structure to the parent control structure
  7. Steps 2-6 are repeated through as many control structures as are necessary to reach the first level of the control structure
  8. When the first level of the control structure is reached, if the akerr_ErrorContext has an error set in it, then the stack trace information in the akerr_ErrorContext object is used to print a stack trace using the configured logging function, and program termination is halted

What is in an Error Context

The Error Context object is a simple object which contains a few things:

  • A numeric error code
  • The name of the file in which the error occurred
  • The name of the function in which the error occurred
  • The line number in the file at which the error occurred
  • A character buffer containing a message about the error in question

The structure also contains housekeeping information for the library which are of no specific interest to the user. See include/akerror.h for more details.

What are the control structures

The library is structured around a series of macros that construct switch statements that perform logic against an akerr_ErrorContext which exists in the current scope and has been initialized. These macros must be assembled in a specific order to produce a syntactically correct switch statement which performs correct operations against the akerr_ErrorContext to attempt operations, detect failures, perform cleanup operations, handle errors, and then exit a given scope in a success or failure state.

Functions and Return Codes

This library can catch errors from any function or expression that returns an integer value, or from functions that return akerr_ErrorContext *.

Any function which uses the PREPARE_ERROR macro should have a return type of akerr_ErrorContext *. The macros within this library, when they detect an unhandled error, will attempt to pass up the unhandled error to the context of the previous function in the call stack. This allows for errors to propagate up through the call stack in the same way as exceptions. (For example, if you use traditional C error handling in a call stack of a() -> b() -> c(), and c() fails because it runs out of memory, b() will likely detect that error and return some error to a(), but it may or may not return the context of what failed and why. With this, you get that context all the way up in a() without knowing anything about c().

Error codes

The library uses integer values to specify error codes inside of its context. These integer return codes are defined in akerror.h in the form of AKERR_xxxxx where xxxxx is the name of the error code in question. See akerror.h for a list of defined errors and their descriptions.

You can define additional error types as integer constants. Values 0 through 255 are reserved by libakerror (the host's errno values plus the AKERR_* codes); consumers allocate codes starting at AKERR_FIRST_CONSUMER_STATUS (256). Status names are stored sparsely, so any int is a legal status and no compile definition is needed to use large values. Note that no consumer status can be a process exit code — see Exit status.

Every library that may coexist in one process must reserve its range during initialization. akerr_reserve_status_range() and akerr_register_status_name() report failure the way everything else in this library does — they return akerr_ErrorContext *, and they are marked AKERR_NOIGNORE — so a collision is an exception you can CATCH, HANDLE, or PASS up out of your initialization:

#define MYLIB_OWNER "my-library"

akerr_ErrorContext AKERR_NOIGNORE *mylib_init(void)
{
    PREPARE_ERROR(errctx);

    /* Another component owning part of the range propagates to our caller. */
    PASS(errctx, akerr_reserve_status_range(256, 16, MYLIB_OWNER));

    /* Then name each code, quoting the owner you reserved with. */
    PASS(errctx, akerr_register_status_name(MYLIB_OWNER, 256,
                                            "Some Error Code Description"));
    SUCCEED_RETURN(errctx);
}

Reservations are process-local, fixed-capacity, and idempotent when the same owner repeats the exact same range. They preserve compile-time integer constants so HANDLE still works, since case labels require them.

Naming a status outside your reservation raises AKERR_STATUS_NAME_FOREIGN, and naming one nobody reserved raises AKERR_STATUS_NAME_UNRESERVED; a colliding reservation raises AKERR_STATUS_RANGE_OVERLAP, with a message naming the real owner. See UPGRADING.md for the full list of statuses these two functions raise, the capacity limits and how to raise them, and thread-safety rules.

Thread safety

The library is thread safe as built by default. Every entry point may be called from any thread at any time, including the first one: akerr_init() runs exactly once no matter how many threads race into it.

What that covers:

  • The error pool. Finding a free slot in AKERR_ARRAY_ERROR and taking its reference is one operation under a lock, so two threads can never be handed the same context. A context is then owned by exactly one thread at a time, all the way through CATCH, HANDLE, and release.
  • The status registry. Reservations and name registrations are serialized against each other and against lookups. Two threads reserving the same range cannot both win — exactly one gets NULL and the other gets AKERR_STATUS_RANGE_OVERLAP naming the winner.
  • Per-thread state. The context behind IGNORE (__akerr_last_ignored) and the last-ditch context used to report akerr_release_error(NULL) are thread-local, so one thread's ignored error is never another's.
  • Handing a context from one thread to another. A context is not thread state — it lives in AKERR_ARRAY_ERROR, which is process-global — so it outlives the thread that raised it. The reference count is the only field the library reads across threads, and it is only ever touched under the pool lock, so akerr_release_error() does not care which thread checked the slot out. Raise on a worker, queue it, let the worker exit; the collector still has a whole error with a whole stack trace. See Handing an error to another thread.

What it does not cover, and cannot:

  • Two threads inside one context at the same time. A context has one owner, and only one. FAIL rewrites the message, HANDLE rewinds the stack-trace cursor, and none of that is locked — two threads in one context splice their messages together and truncate each other's trace, without crashing. Handing a context from one thread to another is a different thing, and is supported.
  • akerr_log_method and akerr_handler_unhandled_error. Set them during startup, before you spawn threads. They are read on every error and the library never writes them after initialization, so setting one while other threads are raising errors is a race the library cannot mediate.
  • Renaming a status that other threads are looking up. akerr_name_for_status(status, NULL) returns a pointer into the registry, valid for the life of the process; registering a second name for the same status overwrites that buffer in place. Register names during initialization. Registering a new status concurrently is fine.
  • Which unhandled error terminates the process. An error that reaches FINISH_NORETURN unhandled prints its stack trace and calls akerr_handler_unhandled_error, which by default calls akerr_exit(). Each thread's trace is whole — the buffer belongs to its context, and each line is one call to akerr_log_method — but if two threads get there at the same instant, both traces print and the exit status is whichever one won.

There is one lock, it is recursive, and it covers both the pool and the registry. That means error construction is serialized across threads: raising an error is the exceptional path, and correctness there is worth more than throughput. A program that raises errors on its hot path will feel it.

AKERR_THREAD_SAFE in the generated header is 1 for a thread-safe build, so a consumer can check what it linked against:

#if AKERR_THREAD_SAFE
    /* ... start worker threads ... */
#endif

Handing an error to another thread

Transfer an error context; never share one. One thread owns it at a time, and ownership moves in a single step: the thread giving it up stops touching it in the same act that makes it visible to the thread taking it over.

This works because a context is not thread state. It lives in AKERR_ARRAY_ERROR, which is process-global, so a context outlives the thread that raised it — a worker can raise an error, queue it, and exit, and the collector still has a whole error with a whole stack trace. Nothing in the library reads a context through any pointer but the one its current owner handed in. The one field it reads across threads is the reference count, and that is only ever touched under the pool lock, so akerr_release_error() does not care which thread checked the slot out. Release it wherever it ended up.

Always hand it over through something that synchronizes: a mutex, a condition variable, pthread_join, or an acquire/release atomic. The content of a context is written with no lock at all — that is deliberate, error construction should not pay for a lock it does not need — so the handoff itself is what publishes those writes. Push the pointer through a relaxed atomic or a plain global and the receiver can read a half-written message, on a machine you did not test on.

Never touch a context after you have given it away. Not a ->status, not a log line. The receiver may be inside HANDLE rewinding the trace cursor, or inside akerr_release_error() memsetting the slot.

Release it exactly once. A context released twice from a stale pointer takes the refcount-zero branch a second time and wipes the slot again — which by then holds somebody else's live error. Nothing crashes; a different thread's error just quietly goes blank.

/* Producer. Owns the context until queue_push() returns, and not after. */
static void report_unit_failure(int unit)
{
    PREPARE_ERROR(e);

    FAIL(e, MYLIB_UNIT_FAILED, "unit %d stopped responding", unit);
    /* The last thing this thread does to it, so the trace records the crossing.
       The buffer belongs to the context, so it travels with it. */
    AKERR_STACKTRACE_APPEND(e, "%s:%s:%d: queued for the collector\n",
			    __FILE__, __func__, __LINE__);
    queue_push(e);   /* takes the queue mutex; `e` is not ours after this */
}

/* Collector. Owns it from the moment queue_pop() returns. */
static void collect_one(akerr_ErrorContext *e)
{
    ATTEMPT {
    } CLEANUP {
    } PROCESS(e) {
    } HANDLE(e, MYLIB_UNIT_FAILED) {
	restart_unit(e);
    } HANDLE_DEFAULT(e) {
	LOG_ERROR_WITH_MESSAGE(e, "collector: unrecognized failure");
    } FINISH_NORETURN(e);
}

static void *collector(void *unused)
{
    akerr_ErrorContext *e;

    (void)unused;
    while ( (e = queue_pop()) != NULL ) {
	collect_one(e);
    }
    return NULL;
}

Four things about the receiving side:

  • Declare a plain pointer, not PREPARE_ERROR. That macro declares a fresh context variable set to NULL; it cannot adopt one. For the same reason, never CATCH into the variable holding a received context — CATCH assigns over it, and the slot you were handed is gone.
  • In a void helper, use FINISH_NORETURN. FINISH_LOGIC decides whether to propagate at run time, so the compiler still parses its return __err_context even when the second argument is the literal false. FINISH(e, false) in a function returning void therefore draws warning: 'return' with a value, in function returning void from gcc — a constraint violation, and a build failure under -Werror. To propagate inside the collector's own call stack, give the helper an akerr_ErrorContext * return and use FINISH(e, true) as usual — just never let an error propagate out of the thread body itself, whose void * return nobody reads. PASS has the same problem for the same reason: in a thread body it compiles, hands the pointer back as a void *, and leaks the slot.
  • FINISH_NORETURN(e) on a received context still terminates the process. That is right — an unhandled error is unhandled wherever it was raised — but it is now the collector's thread deciding the exit status, not the raiser's.
  • Give the handler blocks their own function. ATTEMPT is a switch, and a break inside one written directly in a loop leaves the switch, not the loop. Same hazard as do not use CATCH or FAIL_*_BREAK inside a loop.

Always bound the queue. A queued error is a checked-out pool slot, and there are AKERR_MAX_ARRAY_ERROR (128) of them in the entire process. Size queue depth + producers with an error in flight well under that. When the pool runs dry the library logs and calls exit(1) from inside FAIL — there is no slot left to raise the failure from, which is exactly why a collector that stops draining takes the process with it.

If you need to keep it as well as report it

There is no copy or retain call, on purpose: a context is a pool slot, and two owners of one slot is the thing this whole section exists to prevent. So either read out what you want to keep — status is an int, message and stacktracebuf are ordinary NUL-terminated strings you can snprintf into your own, much smaller, record — or raise two errors and hand one of them over.

Never copy an akerr_ErrorContext by assignment and keep the copy:

akerr_ErrorContext snapshot = *failed;   /* looks fine. is not. */

stacktracebufptr points into the context's own stacktracebuf, so after that assignment snapshot's cursor still points into failed's buffer. LOG_ERROR(&snapshot) reads the array and prints correctly, so it looks healthy — and then the first AKERR_STACKTRACE_APPEND(&snapshot, ...) writes into a pool slot you no longer own, arbitrarily far from the copy. arrayid has the same shape: it is restored after the wipe, so a copied id makes the destination impersonate the source's slot forever. And the copy is not a pool address, so akerr_valid_error_address() rejects it, every CATCH on it becomes AKERR_BADEXC, and releasing it memsets your own storage while the real slot stays checked out for the life of the process.

Building single threaded

The threading backend is chosen when libakerror is configured. auto (the default) takes POSIX threads, and fails the configure if it cannot find them rather than quietly producing a library that says it is thread safe and is not. To mean it:

cmake -S . -B build -DAKERR_THREADS=none

That builds with no locking and no thread-local storage, stamps AKERR_THREAD_SAFE 0 into the header, and calling the library from more than one thread is then undefined.

Proving it

The thread tests (tests/err_threads_*.c) assert the properties above directly: exclusive ownership of pool slots, exactly one winner for a contested range, every registered name readable back, and — in err_threads_handoff.c — an error raised on one thread arriving whole on another and released there. They run in the normal suite. The run that proves the absence of a data race underneath them is ThreadSanitizer:

scripts/thread_test.sh

which configures build/tsan with -DAKERR_SANITIZE=thread, builds the library and every test with it, and runs the suite. Under that build a sanitizer report fails the test rather than being printed and passed over.

Installation

cmake -S . -B build
cmake --build build
cmake --install build

Templating and autogenerated code

The build process relies upon scripts/generrno.sh which performs the following:

  1. Executes errno --list and gathers up the output
  2. Templates include/akerror.tmpl.h into include/akerror.h to set the AKERR_LAST_ERRNO_VALUE equal to the highest integer defined by errno
  3. Generates src/errno.c which contains a function called by akerr_init which initializes all of the status names for the previously defined values of errno.

Dependencies

This library depends upon stdlib, and upon POSIX threads unless it is built with -DAKERR_THREADS=none (see "Thread safety" above). If you don't want to link against stdlib, you must modify the library code to include headers and link against a library that provides the following:

  • memset function
  • strncpy function
  • strlen function
  • strcmp function
  • sprintf function
  • exit function
  • bool type
  • NULL type
  • INT_MAX constant
  • PATH_MAX constant

... then you can compile it thusly:

cmake -S . -B build -DAKERR_USE_STDLIB=OFF
cmake --build build
cmake --install build

Using the library

Setting up your project

Include it

#include <akerror.h>

Link the library directly, or

cc -lakerror

Using pkg-config, or

pkg-config akerror --cflags
pkg-config akerror --ldflags

Using cmake:

find_package(akerror REQUIRED)
pkg_check_modules(akerror REQUIRED akerror)
target_link_libraries(YOUR_TARGET PRIVATE akerror::akerror)

Using this project as a submodule with cmake:

add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)

target_link_libraries(YOUR_PROJECT PRIVATE akerror::akerror)

(Optional) Configuring the logging function

The default logging function (used for logging stack traces on failure) defaults to a wrapper that calls fprintf(stderr, f, ...). If you want to override this behavior, then set the error handler to a function with a printf-style signature:

void my_logger(const char *fmt, ...)
{
	/* ... do something */
}


/* set your custom error handler */
akerr_log_method = &my_logger;
	
/* proceed to use the library */

Setting Up the Error Context

Before you can use any of these macros you must set up an error context inside of the current scope.

PREPARE_ERROR(errctx);

This will create a akerr_ErrorContext structure inside of the current scope named errctx and initialize it. This structure is used for all operations of the library within the current scope. Attempting to use the library in a given scope before calling this will result in compile-time errors.

Attempting an Operation

ATTEMPT {
	// ... code
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true)

ATTEMPT { ... } is the block within which you will perform operations which may cause errors that need to be caught. See "Capturing errors", below.

CLEANUP { ... } is the block within which you will perform any code which MUST be executed REGARDLESS of whether or not errors were thrown. Closing open file handles, or releasing memory, for example.

PROCESS(errctx) { ... } is the block within which you will handle any errors that were caught inside of the ATTEMPT block. See "Handling Errors" below.

FINISH(errctx, true) terminates the attempt operation. The FINISH macro takes two arguments: the name of the akerr_ErrorContext, and a boolean regarding whether or not to pass unhandled errors up to the calling function. Unless you are inside of your main() method, this should be true. Inside of your main() method, call FINISH_NORExbTURN(errctx) instead.

Capturing errors

Inside of an ATTEMPT block, any operation which could generate or represent an error should be wrapped in one of several macros.

Capturing errors from functions which return akerr_ErrorContext *

For functions that return akerr_ErrorContext *, you should use the CATCH macro.

ATTEMPT {
    CATCH(errctx, errorGeneratingFunction())
} // ...

This will assign the return value of the function in question to the akerr_ErrorContext previously prepared in the current scope. If the function returns an akerr_ErrorContext that indicates any type of error, the ATTEMPT block is immediately exited, and the CLEANUP block begins.

(One caveat: because this exit is implemented with a C break, CATCH must not be used inside a loop within the ATTEMPT block — see the section "Important: do not use CATCH or FAIL_*_BREAK inside a loop" below.)

Setting errors from functions or expressions returning integer

For functions that return integer, such as logical comparisons or most standard library functions, use the FAIL_ZERO_BREAK and FAIL_NONZERO_BREAK macros. These macros allow you to capture an integer return code from an expression or function and set an error code in the current context based off that return.

Here is an example of checking for a NULL pointer

ATTEMPT {
    FAIL_ZERO_BREAK(errctx, (somePointer == NULL), AKERR_NULLPOINTER, "Someone gave me a NULL pointer")
} // ...

Here is an example of checking for two strings that are not equal

ATTEMPT {
    FAIL_NONZERO_BREAK(errctx, strcmp("not", "equal"), AKERR_VALUE, "Strings are not equal")
} // ...

When either of these two macros are used, the ATTEMPT block is immediately exited, and the CLEANUP block begins.

Important: do not use CATCH or FAIL_*_BREAK inside a loop

CATCH, FAIL_ZERO_BREAK, FAIL_NONZERO_BREAK, and FAIL_BREAK leave the ATTEMPT block by executing a C break statement. In C, break only exits the innermost enclosing for, while, do, or switch. Therefore these macros must not be used inside a loop (or a nested switch) that is itself inside an ATTEMPT block. If you do, the break escapes only the loop — not the ATTEMPT — and the rest of the ATTEMPT body then runs with an error already pending:

ATTEMPT {
    for ( int i = 0; i < n; i++ ) {
        CATCH(errctx, process(items[i]));   // WRONG: break exits the for loop, not the ATTEMPT
    }
    // ... this code still executes, with errctx already in an error state ...
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);

Note that moving the loop into a helper function does not fix this on its own — if the helper still wraps the loop in an ATTEMPT and uses CATCH/FAIL_*_BREAK inside it, it has the exact same problem. The fix is to iterate with return-based macros, which are unaffected by loop nesting. Use one of the two patterns below.

Pattern 1 — use PASS (or a FAIL_*_RETURN macro) inside the loop. These exit the enclosing function with a return rather than a break, so loop nesting is irrelevant. Use this when the loop should stop and propagate on the first error:

akerr_ErrorContext AKERR_NOIGNORE *process_all(Item *items, int n)
{
    PREPARE_ERROR(errctx);
    for ( int i = 0; i < n; i++ ) {
        PASS(errctx, process(items[i]));   // returns from process_all on the first error
    }
    SUCCEED_RETURN(errctx);
}

Pattern 2 — move the loop into a helper and CATCH the single call. When you need a CLEANUP block or want to HANDLE the error locally, put the loop in its own akerr_ErrorContext *-returning function (written per Pattern 1) and CATCH that one call. The CATCH is then not inside a loop, so its break scopes to the ATTEMPT correctly:

ATTEMPT {
    CATCH(errctx, process_all(items, n));   // a single CATCH, not looped
} CLEANUP {
    // ... always runs ...
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_VALUE) {
    // ... handle a failure from any iteration ...
} FINISH(errctx, true);

Passing errors

Sometimes you can't actually do anything about the errors that come out of a given method, but you want that error to be propagated back up the call chain, and to be properly reported. If this is your goal, you can avoid using a ATTEMPT ... FINISH block, and simply use the PASS macro.

PREPARE_ERROR(e);
PASS(e, some_method_that_may_fail());
SUCCEED_RETURN(e);

This does the same thing as this, but with less code:

PREPARE_ERROR(e);
ATTEMPT {
    CATCH(e, some_method_that_may_fail());
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);

Handling errors

Inside of the PROCESS { ... } block, you must handle any errors that occurred during the ATTEMPT { ... } block. You do this with HANDLE, HANDLE_GROUP, and HANDLE_DEFAULT.

Handling a specific error with HANDLE

In order to handle a specific error code, use the HANDLE macro.

} PROCESS(errctx) {
} HANDLE(errctx, AKERR_NULLPOINTER) {
    // Something is complaining about a null pointer error. Do something about it.
} // ...

Handling a group of errors with HANDLE_GROUP

In order to handle a group of related errors that all require the same failure behavior, use HANDLE followed by HANDLE_GROUP. For example, to handle a scenario where an IO error, key error, and index error all need to be handled the same way:

} PROCESS(errctx) {
} HANDLE(errctx, AKERR_IO) {
} HANDLE_GROUP(errctx, AKERR_KEY) {
} HANDLE_GROUP(errctx, AKERR_INDEX) {
    // error handling code goes here
}

This creates a fallthrough mechanism where all 3 errors get the same error handling code. Note that while the cases fall through, you can still (if desired) put some code specific to each error in that error's HANDLE or HANDLE_GROUP block; but this is not required, only the final handler needs to get any code.

The fallthrough behavior stops as soon as another HANDLE macro is encountered. For example, in this example, AKERR_IO, AKERR_KEY and AKERR_INDEX are all handled as a group, but AKERR_RELATIONSHIP is not.

} PROCESS(errctx) {
} HANDLE(errctx, AKERR_IO) {
} HANDLE_GROUP(errctx, AKERR_KEY) {
} HANDLE_GROUP(errctx, AKERR_INDEX) {
    // This code handles 3 error cases
} HANDLE(errctx, AKERR_RELATIONSHIP) {
    // This code handles 1 error case
}

Returning success or failure from functions returning akerr_ErrorContext *

If at all possible, when using this library, your functiions should return akerr_ErrorContext *. When returning from such functions, you should use the SUCCEED_RETURN and FAIL_RETURN macros.

SUCCEED_RETURN

This macro is used when your function has reached the end of its happy code path and is prepared to exit successfully. This sets the akerr_ErrorContext to a successful state and exits the function.

PREPARE_ERROR(errctx);
ATTEMPT {
    // ... stuff
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);

FAIL_RETURN

If the code path in the current function reaches a state wherein an error must be set and the function must return early, you can use FAIL_RETURN to accomplish this. Note that this should not be used inside of an ATTEMPT { ... } block; this immediately exits the function, preventing a CLEANUP { ... } block from executing. This can be safely used from inside of a CLEANUP or PROCESS block, or from anywhere within the function not inside of an ATTEMPT { ... } block.

The function allows you to provide printf-style variable arguments to provide a meaningful failure message.

PREPARE_ERROR(errctx);
FAIL_RETURN(AKERR_BEHAVIOR, "Something went horribly wrong!")

Conditionally failing and returning

In addition to FAIL_RETURN you can also test for zero or non-zero conditions, set an error, and return from the function immediately. Use the FAIL_ZERO_RETURN and FAIL_NONZERO_RETURN macros for this. These macros can be used anywhere that FAIL_RETURN can be used.

PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (somePointer == NULL), AKERR_NULLPOINTER, "Someone gave me a NULL pointer")
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx, strcmp("not", "equal"), AKERR_VALUE, "Strings are not equal")

Uncaught errors

Misbehaving methods

Any function which returns akerr_ErrorContext * and completes successfully MUST call SUCCEED_RETURN(errctx). Failure to do this may result in an invalid akerr_ErrorContext * being returned, which will cause an AKERR_BEHAVIOR error to be triggered from your code.

Ensuring that all error codes are captured

Any function which returns akerr_ErrorContext * should also be marked with AKERROR_NOIGNORE.

akerr_ErrorContext AKERROR_NOIGNORE *f(...);

This will cause a compile-time error if the return value of such a function is not used. "Used" here means assigned to a variable - it does not necessarily mean that the value is checked. However assuming that such functions are called inside of ATTEMPT { ... } blocks, it is safe to assume that such returns will be caught with CATCH(...); therefore this error is a generally effective safeguard against careless coding where errors are not checked.

Beware that AKERROR_NOIGNORE is not a failsafe - it implements the warn_unused_result mechanic. By design users may explicitly ignore an error code from a function marked with warn_unused_result by explicitly casting the return to void.

#define AKERROR_NOIGNORE __attribute__((warn_unused_result))

Stack Traces

Whenever an error is captured using the FAIL_* or CATCH methods, and is unhandled such that it manages to propagate all the way to the top of the caller stack without being managed, the last FINISH macro to touch the error will trigger a stack trace and kill the program.

Consider the tests/err_trace.c program which intentionally triggers this behavior. It produces output like this:

tests/err_trace.c:func2:7: 1 (Null Pointer Error) : This is a failure in func2
tests/err_trace.c:func2:10
tests/err_trace.c:func1:18: Detected error 0 from array (refcount 1)
tests/err_trace.c:func1:18
tests/err_trace.c:func1:21
tests/err_trace.c:main:30: Detected error 0 from array (refcount 1)
tests/err_trace.c:main:30
tests/err_trace.c:main:33: Unhandled Error 1 (Null Pointer Error): This is a failure in func2

From bottom to top, we have:

  • The last line printed is the FINISH macro call that triggered the stacktrace.
  • Above that, the CATCH() inside of main() which caught the exception from func1() but did not handle it
  • Above that, a statement that the error was detected in the CATCH() statement at the same line
  • Above that, the FINISH() macro in the func1 method which detected the presence of an unhandled error and returned it up the calling stack
  • Above that, the CATCH() macro in the func1 method which caught the error coming out of func2()
  • Above that, a statement that the error was detected in the CATCH() statement at the same line
  • Above that, the FINISH() macro in func2() which detected an unhandled error and passed it out of the function
  • Above that, a reference to the line where the FAIL() macro set the error code and provided the message which is printed here

Exit status

Never call exit() with an akerr status. Call akerr_exit().

void akerr_exit(int status);

This applies everywhere you are leaving the process on account of a status, not just in an unhandled-error handler: a CLI's top-level HANDLE block, an initialization routine that cannot continue, a main() that ends by reporting the status it finished with. One function owns the mapping so that a given status produces the same exit code no matter which of your exits it left by.

The mapping exists because a process exit status is one byte wide. exit() accepts an int and the kernel keeps the low 8 bits of it — _exit(), _Exit(), quick_exit() and the raw exit_group syscall all behave identically, and even waitid(), whose si_status is a full int, sees the truncated value because the truncation happened before the parent looked. There is no wider exit() to reach for.

That leaves 0 through 255 as the only statuses an exit code can carry, and consumer statuses start at AKERR_FIRST_CONSUMER_STATUS (256) — so no consumer status can be an exit code:

status                  exit code
0                       0                                     (success)
1 .. 255                the status
negative, or > 255      AKERR_EXIT_STATUS_UNREPRESENTABLE (125)

Passing the low byte instead would have made status 256 exit 0 and report success to the shell, and status 300 exit 44 — an unrelated error's code. 125 is the conventional "the tool itself failed" status; 126, 127 and 128+n already belong to the shell.

Status 0 exits 0, because 0 is this library's success status. That is not a hole in the rule that an unhandled error never exits 0: PROCESS opens with case 0, which marks a zero status handled, so a successful context cannot reach FINISH_NORETURN's call to the handler at all.

Above 255, the exit code tells you the process died of an error, not which one. 125 is inside the library's reserved band and so is also some host's errno, and every status above 255 collapses onto it. The stack trace is what identifies the error — it is printed before the handler runs, carrying the status at full width along with its registered name.

Replacing the handler

After the trace is printed, FINISH_NORETURN calls akerr_handler_unhandled_error. The default implementation, akerr_default_handler_unhandled_error(), hands errctx->status to akerr_exit() (a NULL context exits 1). Replace it if you need something else to happen first — a core dump, a crash reporter, a flush — and finish by calling akerr_exit() so the exit code still means what it means everywhere else:

static void mylib_handler(akerr_ErrorContext *e)
{
    mylib_flush_telemetry();
    if ( e == NULL ) {
	akerr_exit(AKERR_API);
    }
    akerr_exit(e->status);
}

akerr_handler_unhandled_error = &mylib_handler;

akerr_exit() is declared AKERR_NORETURN, so the compiler knows a handler ending in one of those calls is complete rather than falling off the end.

Set the handler once, before you start any threads. tests/err_custom_handler.c installs one that does not exit at all, which is how the test suite asserts on unhandled errors without dying.

Description
C error handling library
Readme 691 KiB
Languages
C 74.9%
Python 16.3%
CMake 6.9%
Shell 1.9%