Enforce status-code ownership and harden the name registry

Reservations were advisory bookkeeping: any component could name any status,
so the registry only detected declared-range overlap between components that
both opted in. Naming a status now requires a reservation.
akerr_register_status_name() checks that the range belongs to the caller, and
the legacy two-argument akerr_name_for_status() set path, which cannot
identify its caller, requires that some reservation covers the status. Every
refusal is logged and names the real owner, because a name that fails to
register degrades that code to "Unknown Error" in every later stack trace.

Fix a reservation made before the first PREPARE_ERROR being silently
discarded. akerr_init() clears the tables, so whichever component first
triggered it wiped an earlier reservation and the next component to claim the
same range was told it was free, producing exactly the undetected aliasing
the registry exists to prevent. Every registry entry point now calls
akerr_init(), which sets its guard before doing any work so those calls do
not recurse.

Replace the linear-scan name array with an open-addressed hash table, taking
lookup from O(n) to O(1) and raising usable capacity from 512 entries (366
free to consumers after errno registration) to 3072 (~2900 free). Both table
sizes are build-time overridable and applied PRIVATE: they live entirely in
src/error.c, so raising them cannot desynchronize a library from its
consumers the way AKERR_MAX_ERR_VALUE could. Exhausting either table is now
logged and returned to the caller rather than silently dropping the entry.
No dynamic allocation is introduced; both tables remain file-scope arrays,
and the library's undefined-symbol set gains only strcmp and strlen.

Register names for AKERR_EOF, AKERR_ITERATOR_BREAK and AKERR_NOT_IMPLEMENTED,
which had none and rendered as "Unknown Error" in every stack trace carrying
them. err_error_names.c now sweeps the whole AKERR_* offset span so a code
added without a name fails there instead of in production traces.

Add static assertions that the slot count is a power of two and that
AKERR_BADEXC stays inside the library's own 0-255 band, the latter guarding
against a host errno space large enough to push library codes into the range
consumers are told to allocate from.

Set a project version and soname (1.0.0 / libakerror.so.1) so a stale
installed library can no longer be silently paired with newer headers, and so
akerror.pc ships a real Version field instead of an empty one.

Mutation testing surfaced an out-of-bounds probe in the new table that the
suite did not catch: masking with SLOTS rather than SLOTS-1 indexes past the
array, and err_maxval.c asserted only that some names registered before the
table filled, which a collapsed probe sequence still satisfies. It now
requires a substantial entry count and reads every entry back by its own
distinct name.

Tests: 28/28 pass. Coverage 99.4% line / 86.8% branch. Mutation score for
src/error.c 74% -> 77.3%.

Compatibility: source and ABI break. AKERR_MAX_ERR_VALUE and the
__AKERR_ERROR_NAMES data symbol are gone, custom codes must move out of
0-255, and names must be registered against a reserved range. README.md
carries the migration steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:19:25 -04:00
parent 11d21068df
commit f1283e21a3
11 changed files with 864 additions and 109 deletions

180
README.md
View File

@@ -4,49 +4,154 @@ This library provides a TRY/CATCH style exception handling mechanism for C.
![build badge](https://source.starfort.tech/andrew/libakerror/actions/workflows/ci.yaml/badge.svg?branch=main)
## Upgrade notice: custom status codes
## Upgrade notice: custom status codes (1.0.0)
This version changes how custom status-code names and ownership are managed. Applications and libraries upgrading from an earlier version should review every custom error-code definition and initialization path.
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.
To comply with the new status-code rules:
What was removed:
1. Rebuild libakerror and all dependent libraries against the new header.
2. Remove `AKERR_MAX_ERR_VALUE` compile definitions and source references. The status-name registry is now sparse and accepts any `int`; consumers no longer configure its size.
3. Move custom status codes out of the reserved `0` through `255` range. Use fixed integer constants beginning at `256`, rather than offsets from `AKERR_LAST_ERRNO_VALUE`, so libc changes cannot move your codes.
4. Assign a distinct range to every library that may coexist in one process. Coordinate these ranges at the application or dependency-stack level.
5. After `akerr_init()` and before raising or naming custom errors, reserve the complete range with `akerr_reserve_status_range()`. Treat every result other than `AKERR_STATUS_RANGE_OK` as an initialization failure.
6. Register human-readable names with `akerr_name_for_status()` as before. Every co-resident library must reserve its range for collision detection to be effective.
* `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()`.
To migrate:
1. Rebuild libakerror and every dependent library against the new header.
2. Move custom status codes out of the reserved `0``255` 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. Treat any result other than `AKERR_STATUS_RANGE_OK` as an
initialization failure.
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:
```c
enum {
MYLIB_ERR_BASE = 256,
MYLIB_ERR_BASE = AKERR_FIRST_CONSUMER_STATUS, /* 256 */
MYLIB_ERR_PARSE = MYLIB_ERR_BASE,
MYLIB_ERR_STORAGE,
MYLIB_ERR_LIMIT
};
#define MYLIB_OWNER "mylib"
int mylib_init(void)
{
int result;
akerr_init();
result = akerr_reserve_status_range(
MYLIB_ERR_BASE,
MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
"mylib");
if (result != AKERR_STATUS_RANGE_OK) {
return result;
if (akerr_reserve_status_range(MYLIB_ERR_BASE,
MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
MYLIB_OWNER) != AKERR_STATUS_RANGE_OK) {
return MYLIB_INIT_FAILED; /* another component owns part of the range */
}
akerr_name_for_status(MYLIB_ERR_PARSE, "Parse Error");
akerr_name_for_status(MYLIB_ERR_STORAGE, "Storage Error");
return AKERR_STATUS_RANGE_OK;
akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_PARSE, "Parse Error");
akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_STORAGE, "Storage Error");
return MYLIB_INIT_OK;
}
```
Repeating the exact same reservation for the same owner is safe. Overlapping ranges, invalid ranges, and exhausted reservation capacity are rejected. Code that directly referenced the former `__AKERR_ERROR_NAMES` data symbol used an undocumented interface and must migrate to `akerr_name_for_status()`.
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 through `akerr_log_method` and names the real owner,
because a name that fails to register degrades that code to `"Unknown Error"` in
every later stack trace. Registration failures are not fatal by themselves —
check the return value during initialization if you want them to be.
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.
### Return codes
`akerr_reserve_status_range()`:
| Code | Meaning |
| --- | --- |
| `AKERR_STATUS_RANGE_OK` (0) | 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 (logged, with 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()`:
| Code | Meaning |
| --- | --- |
| `AKERR_STATUS_NAME_OK` (0) | 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 |
Repeating an identical reservation for the same owner is a no-op. A *subset* or
*superset* of your own range is not — it is reported as an overlap. Reserve the
whole range in one call.
### 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:
```sh
cmake -S . -B build -DAKERR_STATUS_NAME_SLOTS=16384
```
Exhausting either table is reported through `akerr_log_method` and returned 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.
# Why?
@@ -108,24 +213,37 @@ Any function which uses the `PREPARE_ERROR` macro should have a return type of `
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; consumers should allocate codes starting at 256. Status names are stored sparsely, so there is no maximum status value and no compiler definition is needed.
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.
Libraries that may coexist in one process should reserve their ranges during initialization and treat any nonzero result as a startup error:
Every library that may coexist in one process must reserve its range during
initialization and treat any nonzero result as a startup error:
```c
if (akerr_reserve_status_range(256, 16, "my-library") != AKERR_STATUS_RANGE_OK) {
#define MYLIB_OWNER "my-library"
if (akerr_reserve_status_range(256, 16, MYLIB_OWNER) != AKERR_STATUS_RANGE_OK) {
/* Refuse to initialize: another component owns part of the range. */
}
```
Reservations are process-local, fixed-capacity, and idempotent when the same owner repeats the exact same range. They detect declared range collisions while preserving compile-time integer constants for `HANDLE`. Call `akerr_init()` before reserving ranges. Existing custom codes below 256 should migrate into a declared range at or above 256.
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.
Define a human-friendly name for the error with `akerr_name_for_status` during initialization before the error may be used:
Then name each code, quoting the owner you reserved with:
```c
akerr_name_for_status(256, "Some Error Code Description")
akerr_register_status_name(MYLIB_OWNER, 256, "Some Error Code Description");
```
Naming a status outside your reservation is refused and logged. See
[the upgrade notice](#upgrade-notice-custom-status-codes-100) for the return
codes, the capacity limits and how to raise them, and thread-safety rules.
# Installation
@@ -149,10 +267,14 @@ This library depends upon `stdlib`. If you don't want to link against stdlib, yo
- `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: