Files
libakerror/UPGRADING.md
Andrew Kesterson be24f80022 Make the error pool and status registry thread safe
Every entry point may now be called from any thread. akerr_init() runs
exactly once however many threads race into it, the pool hands each slot
to exactly one thread, and reservations, registrations and lookups are
serialized against each other.

One recursive lock covers both tables (src/lock.h, private). Recursive
because raising an error re-enters the library -- FAIL needs a pool slot
and a status name -- and single because two locks would mean an ordering
to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN
macros are split into *_locked functions behind wrappers that take and
release the lock on one path; consumer callbacks are never called under
it.

This is an ABI break, hence 2.0.0 and SOVERSION 2:

- akerr_next_error() now returns a context that already holds its
  reference. Finding a free slot and claiming it has to be one operation,
  or two threads scanning at once are handed the same slot.
  ENSURE_ERROR_READY no longer increments.
- __akerr_last_ignored is thread-local, as is the last-ditch context used
  to report akerr_release_error(NULL).

The threading backend is chosen at configure time by AKERR_THREADS
(auto, pthread, none). auto fails the configure when it cannot find
POSIX threads rather than quietly building a library that reports itself
thread safe and is not. generrno.sh stamps the decision into the
generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree
with the library about it.

Tests: err_threads_init, err_threads_pool and err_threads_registry
assert exclusive slot ownership, exactly one winner for a contested
range, and every registered name readable back under contention.
AKERR_SANITIZE builds the library and the tests with any sanitizer;
scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs
it. Removing the pool lock makes both the sanitizer and the plain
assertions fail, so the tests are not vacuous.

Documented in README.md and UPGRADING.md, including what this does not
cover: renaming a status while another thread looks it up, and which of
two simultaneous unhandled errors sets the exit status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00

14 KiB
Raw Blame History

Upgrade notice: thread safety (2.0.0)

2.0.0 makes the library thread safe. Every entry point may be called from any thread, akerr_init() runs exactly once however many threads race into it, and the error pool and the status registry are serialized.

This is an ABI break. Rebuild libakerror and everything that includes its header; the soname moved to libakerror.so.2 so the two cannot be mixed by accident.

What moved at the ABI:

  • __akerr_last_ignored is thread-local storage. An ignored error is a fact about the thread that ignored it, and one shared slot had two threads overwriting each other's. The IGNORE macro expands at your call site, so your objects reference the symbol under whichever storage model your header said — which is why this cannot be mixed.
  • akerr_next_error() returns a context that already holds one reference. Finding a free slot and claiming it has to be one operation under the pool lock, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY therefore no longer increments the count. Code compiled against a 1.x header and linked against 2.x would count every reference twice and never return a slot to the pool.

What changed in the API: nothing you call, unless you call akerr_next_error() yourself. If you do, you now own a reference and must release it — the same thing you were doing already if you were using the context for anything.

New build options, both on libakerror itself:

Option Default Meaning
AKERR_THREADS auto Threading backend: auto, pthread, or none
AKERR_SANITIZE (empty) Sanitizers for the library and its tests, e.g. thread

auto takes POSIX threads and fails the configure when it cannot find them, rather than quietly building a library that reports itself thread safe and is not. -DAKERR_THREADS=none is how you say you meant it: no locking, no thread-local storage, undefined if you then use more than one thread.

The generated header records which one you built, as AKERR_THREAD_SAFE (1 or 0), so a consumer can test what it linked against and cannot disagree with the library about it.

What thread safety here does and does not mean

Safe from any thread, with no coordination on your part:

  • Raising, catching, handling, passing, ignoring and releasing errors.
  • akerr_reserve_status_range() and akerr_register_status_name(). Two threads reserving overlapping ranges cannot both succeed: one gets NULL, the other gets AKERR_STATUS_RANGE_OVERLAP naming the winner.
  • akerr_name_for_status(status, NULL) lookups, concurrently with each other and with registrations of other statuses.
  • akerr_init(), from any number of threads at once.

Still yours to coordinate:

  • One error context is owned by one thread. The library hands it to the thread that raised it. Handing it to another thread is your synchronization.
  • akerr_log_method and akerr_handler_unhandled_error are read on every error and written by nobody but you. Set them during startup, before spawning.
  • Renaming a status while another thread looks it up. akerr_name_for_status() returns a pointer into the registry — stable for the life of the process, which is what makes it usable from a stack trace — and registering a second name for the same status overwrites that buffer in place. Register names during initialization. This is the one registry operation the lock cannot make safe, because the reader is outside the lock by the time it reads the string.
  • Which unhandled error wins. Two threads reaching FINISH_NORETURN with unhandled errors at the same instant both print a complete stack trace (the buffer belongs to the context, and each line is a single akerr_log_method call) and both call akerr_handler_unhandled_error. The process exits with whichever status got there first.

Cost

One recursive lock covers both the pool and the registry, so error construction is serialized process-wide. Errors are the exceptional path and correctness there is worth more than throughput, but a program that raises errors in a hot loop will feel it.

The per-thread last-ditch context is a whole akerr_ErrorContext (tens of kilobytes) in thread-local storage, allocated per thread on first use of the library from that thread.

Proving it

tests/err_threads_init.c, tests/err_threads_pool.c and tests/err_threads_registry.c assert the properties directly and run in the normal suite. The run that proves there is no data race underneath them is ThreadSanitizer:

scripts/thread_test.sh

Upgrade notice: custom status codes (1.0.0)

Version 1.0.0 replaces the consumer-sized status-name array with a private registry, and makes status-code ownership explicit and enforced. This is a source and ABI break. The library now carries a version and an soname (libakerror.so.1), so a stale installed library can no longer be silently paired with a newer header — but anything built against a pre-1.0.0 header must be rebuilt.

What was removed:

  • AKERR_MAX_ERR_VALUE — the registry is sparse and accepts any int, so consumers no longer size it. Delete every compile definition and source reference. A stale -DAKERR_MAX_ERR_VALUE=... is now harmless but useless.
  • __AKERR_ERROR_NAMES — the name table is private to the library. Code that touched this data symbol was using an undocumented interface; use akerr_name_for_status().
  • AKERR_STATUS_RANGE_OK and AKERR_STATUS_NAME_OK — the registry functions no longer return an int. Success is a NULL akerr_ErrorContext *, like every other function in the library. See "The registry raises errors" below.

To migrate:

  1. Rebuild libakerror and every dependent library against the new header.
  2. Move custom status codes out of the reserved 0255 band. Use fixed integer constants beginning at AKERR_FIRST_CONSUMER_STATUS (256) rather than offsets from AKERR_LAST_ERRNO_VALUE, so a libc that grows an errno cannot move your codes.
  3. Assign a distinct range to every library that may coexist in one process, and coordinate those ranges at the application or dependency-stack level.
  4. Reserve the complete range with akerr_reserve_status_range() during initialization, and treat the error it returns like any other error: CATCH it, PASS it, or let it propagate out of your init function.
  5. Register names with akerr_register_status_name(), passing the same owner string you reserved with. You can no longer name a status you have not reserved — see "Ownership is enforced" below.

For example:

enum {
    MYLIB_ERR_BASE = AKERR_FIRST_CONSUMER_STATUS,   /* 256 */
    MYLIB_ERR_PARSE = MYLIB_ERR_BASE,
    MYLIB_ERR_STORAGE,
    MYLIB_ERR_LIMIT
};

#define MYLIB_OWNER "mylib"

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

    /* Any collision propagates out of mylib_init() to the caller. */
    PASS(errctx, akerr_reserve_status_range(MYLIB_ERR_BASE,
                                            MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
                                            MYLIB_OWNER));
    PASS(errctx, akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_PARSE,
                                            "Parse Error"));
    PASS(errctx, akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_STORAGE,
                                            "Storage Error"));
    SUCCEED_RETURN(errctx);
}

If your init cannot return an akerr_ErrorContext * — a C API with an int return, say — catch the error and convert it. Close with FINISH_NORETURN rather than FINISH: nothing can propagate out of a function that does not return an error context, and FINISH(errctx, false) in an int-returning function compiles the (dead) propagation branch anyway, which warns.

int mylib_init(void)
{
    PREPARE_ERROR(errctx);
    int rc = MYLIB_INIT_OK;

    ATTEMPT {
        CATCH(errctx, akerr_reserve_status_range(MYLIB_ERR_BASE,
                                                 MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
                                                 MYLIB_OWNER));
    } CLEANUP {
    } PROCESS(errctx) {
    } HANDLE_DEFAULT(errctx) {
        LOG_ERROR_WITH_MESSAGE(errctx, "mylib could not claim its status range");
        rc = MYLIB_INIT_FAILED;   /* another component owns part of the range */
    } FINISH_NORETURN(errctx);

    return rc;
}

You do not need to call akerr_init() first. Every registry entry point calls it for you, so a library that reserves its range before anything else in the process has touched libakerror keeps that reservation. (Before 1.0.0 this was silently destructive: akerr_init() cleared the tables, so a reservation made too early was discarded and the next component to claim the same range was told it was free.)

Ownership is enforced

Reserving a range is no longer advisory bookkeeping. A status may only be named from inside a reservation:

  • akerr_register_status_name(owner, status, name) requires that status fall in a range reserved by owner. Naming another component's status fails with AKERR_STATUS_NAME_FOREIGN, and naming a status nobody reserved fails with AKERR_STATUS_NAME_UNRESERVED.
  • The two-argument akerr_name_for_status(status, name) set path still works, but it cannot identify its caller, so it can only require that some reservation covers the status. Prefer the owned form: it is the one that catches a component writing into a range that is not its own.

Every refusal is reported, because a name that fails to register degrades that code to "Unknown Error" in every later stack trace.

This detects name collisions. It cannot detect two components compiling the same integer into a HANDLE label without ever registering a name, so every co-resident library should still reserve its range.

The registry raises errors

akerr_reserve_status_range() and akerr_register_status_name() return akerr_ErrorContext *, exactly like the rest of the library. They are marked AKERR_NOIGNORE, so discarding the result is a compile-time warning, and an error you catch but do not handle propagates out of your init function instead of leaving you with a range you do not actually own.

ATTEMPT {
    CATCH(errctx, akerr_reserve_status_range(256, 16, MYLIB_OWNER));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_STATUS_RANGE_OVERLAP) {
    /* Another component owns part of it -- the message names which. */
} FINISH(errctx, true);

akerr_reserve_status_range() raises:

Status Meaning
— (returns NULL) Range reserved, or an identical range was already reserved by the same owner
AKERR_STATUS_RANGE_OVERLAP Part of the range is owned by someone else (the message names the owner)
AKERR_STATUS_RANGE_FULL No reservation slots remain
AKERR_STATUS_RANGE_INVALID count < 1, NULL/empty/over-long owner, or the range overflows int

akerr_register_status_name() raises:

Status Meaning
— (returns NULL) Name registered
AKERR_STATUS_NAME_UNRESERVED No reservation contains this status
AKERR_STATUS_NAME_FOREIGN The status is in a range owned by someone else
AKERR_STATUS_NAME_FULL The name registry is full
AKERR_STATUS_NAME_INVALID NULL/empty/over-long owner, or a NULL name

These are ordinary status codes inside the library's reserved band, so they can be matched with HANDLE, grouped with HANDLE_GROUP, and they print with a name and a stack trace when they go unhandled.

akerr_name_for_status(status, name) is the one exception: it returns a name rather than an error context, so it cannot raise. A refused registration through that path is reported through akerr_log_method and reads back as "Unknown Error".

Repeating an identical reservation for the same owner is a no-op. A subset or superset of your own range is not — it raises AKERR_STATUS_RANGE_OVERLAP. Reserve the whole range in one call.

The library holds itself to the same rule at startup: if akerr_init() cannot reserve its own 0255 band or name one of its own codes, it logs the failure and terminates the program. That can only happen in a misconfigured build (a name table too small for the library's own entries), and the alternative is a process whose stack traces silently read "Unknown Error" for built-in codes.

Capacity

Both tables are fixed size, allocated in BSS, and never grow:

Limit Default Build-time override
Status names 3072 usable (4096 slots, 75% load) -DAKERR_STATUS_NAME_SLOTS=<power of two>
Reserved ranges 64 -DAKERR_MAX_RESERVED_STATUS_RANGES=<n>

akerr_init() consumes one name slot per host errno value plus one per AKERR_* code — around 150 on glibc, leaving roughly 2900 for all consumers in the process to share. That figure varies with the host libc, so treat it as approximate rather than a budget to fill.

Both overrides are CMake cache variables and apply PRIVATE to the library target. The tables live entirely inside src/error.c, so raising them changes nothing a consumer can observe — unlike the AKERR_MAX_ERR_VALUE they replaced, where a mismatch between the library and its consumers corrupted memory. Set them when configuring libakerror itself:

cmake -S . -B build -DAKERR_STATUS_NAME_SLOTS=16384

Exhausting either table raises an error to the caller; it is never silent.

Thread safety

As of 2.0.0 the registry is serialized: reservations, registrations and lookups are all safe to call concurrently. See "What thread safety here does and does not mean" at the top of this document for the two things that are still yours to coordinate.