Stop an unhandled error from exiting zero
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>
2026-08-01 07:25:37 -04:00
|
|
|
|
# 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.
|
|
|
|
|
|
|
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
|
|
|
|
# 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.
|
Document and test handing an error context between threads
The thread-safety section filed two different things under "does not cover,
and cannot": sharing a context between threads, and passing one to another
thread. Only the first is unsupported. Transfer already works by
construction -- the reference count is the only field the library reads
across an ownership boundary, and it is only ever touched under the pool
lock, so akerr_release_error() does not care which thread checked the slot
out. The pool is process-global, not thread-local, so a context outlives the
thread that raised it.
Calling that unsupported told readers the worker/collector shape was off the
table, which either cost them the pattern or cost them the stack trace when
they rolled their own struct instead.
Split the bullet: transfer joins the covered list and gets its own section
with the rule, the worked pattern, and the four receiving-side hazards
(PREPARE_ERROR cannot adopt, CATCH assigns over the pointer, FINISH in a
void helper still parses its return, and an unhandled error now terminates
from the collector's thread). Sharing keeps the "cannot" bullet, narrowed to
what it actually is.
err_threads_handoff.c proves it: the existing thread tests all keep every
context on the thread that raised it, so the transfer path was exercised
nowhere. Seven producers hand errors to one collector through a bounded
mutex/condvar queue -- the mutex is the thing under test, since it is what
publishes the unlocked content writes -- and the collector asserts the
context is still a live slot at refcount 1, that message and trace arrive
whole and in each producer's order, that the slot was never recycled in
flight, and that a thread which never called akerr_next_error() can release
it. A second phase reads a context whose raising thread has already exited.
Also document why copying a context by assignment is silently wrong:
stacktracebufptr is self-referential, so the copy's cursor points into the
source's buffer and the first append corrupts a slot the copier no longer
owns. TODO.md records the akerr_copy_error() shape that would fix it and the
trigger for building it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:35:18 -04:00
|
|
|
|
* **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.
|
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
|
|
|
|
|
|
|
|
|
|
Still yours to coordinate:
|
|
|
|
|
|
|
Document and test handing an error context between threads
The thread-safety section filed two different things under "does not cover,
and cannot": sharing a context between threads, and passing one to another
thread. Only the first is unsupported. Transfer already works by
construction -- the reference count is the only field the library reads
across an ownership boundary, and it is only ever touched under the pool
lock, so akerr_release_error() does not care which thread checked the slot
out. The pool is process-global, not thread-local, so a context outlives the
thread that raised it.
Calling that unsupported told readers the worker/collector shape was off the
table, which either cost them the pattern or cost them the stack trace when
they rolled their own struct instead.
Split the bullet: transfer joins the covered list and gets its own section
with the rule, the worked pattern, and the four receiving-side hazards
(PREPARE_ERROR cannot adopt, CATCH assigns over the pointer, FINISH in a
void helper still parses its return, and an unhandled error now terminates
from the collector's thread). Sharing keeps the "cannot" bullet, narrowed to
what it actually is.
err_threads_handoff.c proves it: the existing thread tests all keep every
context on the thread that raised it, so the transfer path was exercised
nowhere. Seven producers hand errors to one collector through a bounded
mutex/condvar queue -- the mutex is the thing under test, since it is what
publishes the unlocked content writes -- and the collector asserts the
context is still a live slot at refcount 1, that message and trace arrive
whole and in each producer's order, that the slot was never recycled in
flight, and that a thread which never called akerr_next_error() can release
it. A second phase reads a context whose raising thread has already exited.
Also document why copying a context by assignment is silently wrong:
stacktracebufptr is self-referential, so the copy's cursor points into the
source's buffer and the first append corrupts a slot the copier no longer
owns. TODO.md records the akerr_copy_error() shape that would fix it and the
trigger for building it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:35:18 -04:00
|
|
|
|
* **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 README.md for the pattern, and for why the queue has to be bounded.
|
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
|
|
|
|
* **`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
|
|
|
|
|
|
|
Document and test handing an error context between threads
The thread-safety section filed two different things under "does not cover,
and cannot": sharing a context between threads, and passing one to another
thread. Only the first is unsupported. Transfer already works by
construction -- the reference count is the only field the library reads
across an ownership boundary, and it is only ever touched under the pool
lock, so akerr_release_error() does not care which thread checked the slot
out. The pool is process-global, not thread-local, so a context outlives the
thread that raised it.
Calling that unsupported told readers the worker/collector shape was off the
table, which either cost them the pattern or cost them the stack trace when
they rolled their own struct instead.
Split the bullet: transfer joins the covered list and gets its own section
with the rule, the worked pattern, and the four receiving-side hazards
(PREPARE_ERROR cannot adopt, CATCH assigns over the pointer, FINISH in a
void helper still parses its return, and an unhandled error now terminates
from the collector's thread). Sharing keeps the "cannot" bullet, narrowed to
what it actually is.
err_threads_handoff.c proves it: the existing thread tests all keep every
context on the thread that raised it, so the transfer path was exercised
nowhere. Seven producers hand errors to one collector through a bounded
mutex/condvar queue -- the mutex is the thing under test, since it is what
publishes the unlocked content writes -- and the collector asserts the
context is still a live slot at refcount 1, that message and trace arrive
whole and in each producer's order, that the slot was never recycled in
flight, and that a thread which never called akerr_next_error() can release
it. A second phase reads a context whose raising thread has already exited.
Also document why copying a context by assignment is silently wrong:
stacktracebufptr is self-referential, so the copy's cursor points into the
source's buffer and the first append corrupts a slot the copier no longer
owns. TODO.md records the akerr_copy_error() shape that would fix it and the
trigger for building it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:35:18 -04:00
|
|
|
|
`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
|
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
|
|
|
|
ThreadSanitizer:
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
scripts/thread_test.sh
|
|
|
|
|
|
```
|
|
|
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
|
|
|
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
|
|
|
|
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.
|