An unhandled error could kill the process and still report success. The default handler ended in exit(errctx->status), and an 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 saw a clean run. Status 300 exited 44, an unrelated error's code. 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 truncation happened before the parent looked. akerr_exit() now owns that mapping and the default handler calls it: 0 exits 0, 1 through 255 exit the status, and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125) rather than a low byte that is either a lie or a claim of success. Only values that were already being delivered wrong behave differently. Call it instead of exit() anywhere you leave the process on a status; it is declared AKERR_NORETURN. akerr_exit(0) exits 0, because 0 is this library's success status. That is not a hole in the rule: PROCESS opens with case 0, which marks a zero status handled, so a successful context never reaches FINISH_NORETURN's call to the handler at all. tests/err_exit_status.c drives one table through akerr_exit() and through the default handler in forked children and requires identical exit codes, so the handler cannot grow a mapping of its own. With the clamp removed it fails with "akerr_exit(256) exited 0, want 125". The full-width status was already reaching the log and still does, which the same test asserts against the captured stack trace. 2.0.1. No ABI break: the soname stays libakerror.so.2 and nothing that already existed changed shape. akerr_exit() is a new exported symbol, so a consumer that starts calling it needs 2.0.1 at link time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
348 lines
16 KiB
Markdown
348 lines
16 KiB
Markdown
# 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 "Exit status" in
|
||
[README.md](README.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.
|
||
|
||
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:
|
||
|
||
```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.
|