Files
libakerror/UPGRADING.md
Andrew Kesterson 64da04e83b
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m47s
libakerror CI Build / coverage (push) Successful in 2m46s
libakerror CI Build / mutation_test (push) Successful in 14m56s
Raise errors from the status registry instead of returning codes
akerr_reserve_status_range() and akerr_register_status_name() returned
private int enumerations, which was the one place in the library where a
failure was not an akerr_ErrorContext *. They now return one like
everything else: NULL on success, and on refusal an error whose status is
a real code in the library's reserved band, so it can be CATCH-ed,
HANDLE-d, PASS-ed, or left to propagate into a stack trace. Both are
marked AKERR_NOIGNORE, so discarding the result warns at compile time.

AKERR_STATUS_RANGE_OK and AKERR_STATUS_NAME_OK are gone; the remaining
seven codes move into the AKERR_* offset span and get registered names.
AKERR_LAST_LIBRARY_STATUS replaces AKERR_BADEXC as the top of that span
in the reserved-band static assert and the exhaustiveness sweep.

The refusal detail that used to go straight to akerr_log_method now
travels in the error message, so a caller that handles the error decides
whether it is reported. The two-argument akerr_name_for_status() set path
is the exception: it returns a name and cannot raise, so it logs and
releases. akerr_init() likewise has no caller to raise into, so failing
to reserve its own band or name its own codes is logged and fatal --
that can only happen on a misconfigured build, and continuing would
degrade every later stack trace to "Unknown Error".

Move the 1.0.0 upgrade notice out of README.md into UPGRADING.md and
rewrite its return-code tables in terms of the statuses now raised.

Tests: ctest 29/29, coverage 97.5% line / 64.5% branch, mutation 77.5%
(was 77.6%; the new survivors are the fatal init path, which needs a
library built with an undersized name table -- TODO item 7).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:58:47 -04:00

9.0 KiB
Raw Blame History

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

The registry is process-global mutable state with no locking. Reserve ranges and register names during single-threaded initialization, before spawning threads. Lookups (akerr_name_for_status(status, NULL)) are safe to call concurrently once registration has finished.