Files
libakerror/UPGRADING.md
Tachikoma 983ecf31c9
All checks were successful
libakerror CI Build / cmake_build_freestanding (push) Successful in 2m51s
libakerror CI Build / coverage (push) Successful in 2m51s
libakerror CI Build / cmake_build (push) Successful in 2m55s
libakerror CI Build / mutation_test (push) Successful in 47m2s
Fix the AKERR_USE_STDLIB=OFF build (issue #12)
The freestanding build (-DAKERR_USE_STDLIB=OFF) did not compile at all:
bool, PATH_MAX and NULL were used unconditionally in the public header
but only included under the stdlib branch, and the CMake option was
pasted straight into a preprocessor definition, so a non-numeric cache
spelling (-DAKERR_USE_STDLIB=ON) silently evaluated to 0.

- Normalize AKERR_USE_STDLIB to a plain 1/0 in CMake before stamping it,
  and use a consistent '#if AKERR_USE_STDLIB' everywhere it is tested.
- Include <stdbool.h>/<stddef.h> unconditionally (freestanding-safe);
  keep <stdlib.h>/<string.h>/<stdio.h> behind AKERR_USE_STDLIB; move
  <limits.h> out of the public header into src/error.c, its only user.
- Define the freestanding runtime contract: AKERR_RUNTIME_HEADER must
  name a header providing exit, memset, snprintf, strcmp, strlen and
  strncpy when AKERR_USE_STDLIB is OFF, or the header #errors naming
  them. Add cmake/akerr_default_runtime.h, a libc-backed convenience
  default so this repo's own OFF build and tests work out of the box.
- Route ENSURE_ERROR_READY's pool-exhaustion path through akerr_exit()
  instead of a direct exit(1). Deliberate behavior change: that exit
  code moves from 1 to AKERR_EXIT_STATUS_UNREPRESENTABLE (125), the same
  sentinel every other unrepresentable status already uses. Documented
  in docs/building.md and UPGRADING.md. Not an ABI break.
- Retire PATH_MAX: AKERR_MAX_ERROR_FNAME_LENGTH is now stamped by
  scripts/generrno.sh from a new AKERR_MAX_ERROR_FNAME_LENGTH cache
  variable, defaulting to 4096 (PATH_MAX on Linux/glibc) so
  sizeof(akerr_ErrorContext) and the soname are unchanged. Rewrite the
  now-stale PATH_MAX justification in src/lock.h's feature-test-macro
  comment.
- Fail the configure with a FATAL_ERROR, not a warning, when
  AKERR_USE_STDLIB=OFF and AKERR_THREADS would resolve to pthread,
  naming -DAKERR_THREADS=none as the fix.
- Keep the generated errno table (errno.c) out of the OFF build; skip
  the 'errno --list' shellout in scripts/generrno.sh under OFF and
  stamp AKERR_LAST_ERRNO_VALUE from a new fallback cache variable
  (default 133, Linux's EHWPOISON) instead.
- Update docs/building.md: drop the known-defect paragraph, fix
  sprintf -> snprintf, add size_t, drop PATH_MAX, and document the new
  options and the exit-code change.
- Guard tests/err_errno.c's registered-name assertion behind
  AKERR_USE_STDLIB: akerr_init_errno() is not called when it is OFF.
- Add a CI job that configures/builds/tests AKERR_USE_STDLIB=OFF with
  AKERR_THREADS=none, plus a compile-only -nostdinc -ffreestanding
  check of tests/freestanding_fixture.c against the generated header.

Bump the project version to 2.0.2 (no ABI break: soname and struct
layout are unchanged).

Verified locally: OFF+none configures and builds clean with all tests
passing; ON and the plain default build both build and pass their full
test suites (37/37); OFF with the default/auto thread backend fails
configure with a message naming -DAKERR_THREADS=none; sizeof(akerr_ErrorContext)
is unchanged (37296 bytes, fname/function still 4096 each) versus the
pre-change tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 10:30:40 -04:00

398 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bug fix: the AKERR_USE_STDLIB=OFF build, and a pool-exhaustion exit code (2.0.2)
`-DAKERR_USE_STDLIB=OFF` did not compile at all ([issue #12](https://source.starfort.tech/andrew/libakerror/issues/12)):
`bool`, `PATH_MAX` and `NULL` were used unconditionally in the public header
but only included under the stdlib branch, and the CMake option itself was
pasted straight into a preprocessor definition, so a boolean spelling like
`ON` silently evaluated to 0 in `#if AKERR_USE_STDLIB == 1`. Both are fixed:
`<stdbool.h>` and `<stddef.h>` are now included unconditionally (they are
freestanding-safe), and the CMake option is normalized to a plain `1`/`0`
before being stamped into the header.
`AKERR_USE_STDLIB=OFF` now has a mandatory, explicit contract:
`AKERR_RUNTIME_HEADER` must name a header providing `exit`, `memset`,
`snprintf`, `strcmp`, `strlen` and `strncpy` — the header `#error`s at compile
time naming those six symbols if it is unset. `AKERR_THREADS` must resolve to
`none`; the configure now fails outright (not a warning) if it would resolve
to `pthread`, since the pthread backend calls into libc. See
[docs/building.md](docs/building.md#dependencies).
`PATH_MAX`, used to size the `fname`/`function` fields of `akerr_ErrorContext`,
is retired in favor of a new `AKERR_MAX_ERROR_FNAME_LENGTH` build option. Its
default (4096) matches `PATH_MAX` on Linux/glibc, so `sizeof(akerr_ErrorContext)`
and the soname are unchanged unless you deliberately override it — **no ABI
break**.
**Behavior change, not an ABI break:** `ENSURE_ERROR_READY`'s pool-exhaustion
path — reached when every slot in `AKERR_ARRAY_ERROR` is checked out and
something still tries to raise — used to call `exit(1)` directly. It now calls
`akerr_exit(AKERR_EXIT_STATUS_UNREPRESENTABLE)`, the same terminal path every
other exit out of the library's status space goes through, so **the exit code
for that case changes from 1 to 125**. If you were checking `$?` for exactly
`1` to detect pool exhaustion specifically, check for 125 instead (and note
125 is shared with every other status an exit code cannot carry — see the note
on `AKERR_EXIT_STATUS_UNREPRESENTABLE` in the header).
# Bug fix: unhandled-error exit status (2.0.1)
An unhandled error could kill the process and still report success.
`akerr_default_handler_unhandled_error()` ended in `exit(errctx->status)`, and a
process exit status is one byte wide — the kernel keeps the low 8 bits of the
argument and discards the rest. Consumer statuses start at
`AKERR_FIRST_CONSUMER_STATUS` (256), so **the first status any consumer can
reserve exited 0**, and a shell or supervisor watching `$?` saw a clean run.
Status 300 exited 44, which is some unrelated error's code. No status a consumer
owns could ever come out of `$?` intact, and there is no wider `exit()` to reach
for: `_exit()`, `_Exit()`, `quick_exit()` and the raw `exit_group` syscall all
truncate identically, and even `waitid()`, whose `si_status` is a full `int`,
reports the truncated value.
The mapping now lives in one place, `akerr_exit()`, which the default handler
calls:
```
status exit code
0 0 (success)
1 .. 255 the status
negative, or > 255 AKERR_EXIT_STATUS_UNREPRESENTABLE (125)
```
Statuses 0 through 255 are unchanged, which covers every `errno` and every
`AKERR_*` code. Only the values that were already being delivered wrong behave
differently, and they now exit 125 instead of a truncated byte.
**What you should change.** Call `akerr_exit()` instead of `exit()` anywhere you
leave the process on an akerr status — your own unhandled-error handler, a
top-level `HANDLE` block, an init routine that cannot continue — so one mapping
covers every exit. It is declared `AKERR_NORETURN`. If you were reading a
consumer status out of `$?`, you were never getting it: read the stack trace,
which carries the status at full width along with its registered name, or
install a handler that maps your own statuses into a byte. See
[docs/exit-status.md](docs/exit-status.md).
No ABI break. The soname stays `libakerror.so.2` and nothing you already call
changed shape. `akerr_exit()` is a new exported symbol, so a consumer that
starts calling it needs 2.0.1 or later at link time.
# 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.
* **Handing a context to another thread, and releasing it there.** 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. Contexts live in process-global storage, not
thread-local, so one outlives the thread that raised it. What is still yours is
the handoff *itself*: it has to carry a happens-before edge, which any mutex,
condvar, `pthread_join` or acquire/release atomic gives you.
Still yours to coordinate:
* **Two threads in one context at once.** Ownership moves; it does not fork.
Hand a context over and stop touching it — the content is written with no
lock, so the handoff is what publishes it. See "Handing an error to another
thread" in docs/thread-safety.md for the pattern, and for why the queue has
to be bounded.
* **`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.
Releasing the last reference to a context remains serialized under that same
pool lock, but it now resets only the handled/status/reported state, the string
heads, and the stack-trace cursor. It no longer wipes the whole context buffer,
so release is a fixed handful of stores rather than a tens-of-kilobytes write.
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`,
`tests/err_threads_registry.c` and `tests/err_threads_handoff.c` assert the
properties directly and run in the normal suite. The run that proves there is no data race underneath them is
ThreadSanitizer:
```sh
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 `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, 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:
```c
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.
```c
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.
```c
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 `0``255` 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:
```sh
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.