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>
16 KiB
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.
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_ignoredis 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. TheIGNOREmacro 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_READYtherefore 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()andakerr_register_status_name(). Two threads reserving overlapping ranges cannot both succeed: one getsNULL, the other getsAKERR_STATUS_RANGE_OVERLAPnaming 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_joinor 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 README.md for the pattern, and for why the queue has to be bounded.
akerr_log_methodandakerr_handler_unhandled_errorare 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_NORETURNwith unhandled errors at the same instant both print a complete stack trace (the buffer belongs to the context, and each line is a singleakerr_log_methodcall) and both callakerr_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,
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:
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 anyint, 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; useakerr_name_for_status().AKERR_STATUS_RANGE_OKandAKERR_STATUS_NAME_OK— the registry functions no longer return anint. Success is aNULLakerr_ErrorContext *, like every other function in the library. See "The registry raises errors" below.
To migrate:
- Rebuild libakerror and every dependent library against the new header.
- Move custom status codes out of the reserved
0–255band. Use fixed integer constants beginning atAKERR_FIRST_CONSUMER_STATUS(256) rather than offsets fromAKERR_LAST_ERRNO_VALUE, so a libc that grows an errno cannot move your codes. - Assign a distinct range to every library that may coexist in one process, and coordinate those ranges at the application or dependency-stack level.
- Reserve the complete range with
akerr_reserve_status_range()during initialization, and treat the error it returns like any other error:CATCHit,PASSit, or let it propagate out of your init function. - Register names with
akerr_register_status_name(), passing the same owner string you reserved with. You can no longer name a status you have not reserved — see "Ownership is enforced" below.
For example:
enum {
MYLIB_ERR_BASE = AKERR_FIRST_CONSUMER_STATUS, /* 256 */
MYLIB_ERR_PARSE = MYLIB_ERR_BASE,
MYLIB_ERR_STORAGE,
MYLIB_ERR_LIMIT
};
#define MYLIB_OWNER "mylib"
akerr_ErrorContext AKERR_NOIGNORE *mylib_init(void)
{
PREPARE_ERROR(errctx);
/* Any collision propagates out of mylib_init() to the caller. */
PASS(errctx, akerr_reserve_status_range(MYLIB_ERR_BASE,
MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
MYLIB_OWNER));
PASS(errctx, akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_PARSE,
"Parse Error"));
PASS(errctx, akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_STORAGE,
"Storage Error"));
SUCCEED_RETURN(errctx);
}
If your init cannot return an akerr_ErrorContext * — a C API with an int
return, say — catch the error and convert it. Close with FINISH_NORETURN
rather than FINISH: nothing can propagate out of a function that does not
return an error context, and FINISH(errctx, false) in an int-returning
function compiles the (dead) propagation branch anyway, which warns.
int mylib_init(void)
{
PREPARE_ERROR(errctx);
int rc = MYLIB_INIT_OK;
ATTEMPT {
CATCH(errctx, akerr_reserve_status_range(MYLIB_ERR_BASE,
MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
MYLIB_OWNER));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "mylib could not claim its status range");
rc = MYLIB_INIT_FAILED; /* another component owns part of the range */
} FINISH_NORETURN(errctx);
return rc;
}
You do not need to call akerr_init() first. Every registry entry point calls
it for you, so a library that reserves its range before anything else in the
process has touched libakerror keeps that reservation. (Before 1.0.0 this was
silently destructive: akerr_init() cleared the tables, so a reservation made
too early was discarded and the next component to claim the same range was told
it was free.)
Ownership is enforced
Reserving a range is no longer advisory bookkeeping. A status may only be named from inside a reservation:
akerr_register_status_name(owner, status, name)requires thatstatusfall in a range reserved byowner. Naming another component's status fails withAKERR_STATUS_NAME_FOREIGN, and naming a status nobody reserved fails withAKERR_STATUS_NAME_UNRESERVED.- The two-argument
akerr_name_for_status(status, name)set path still works, but it cannot identify its caller, so it can only require that some reservation covers the status. Prefer the owned form: it is the one that catches a component writing into a range that is not its own.
Every refusal is reported, because a name that fails to register degrades that
code to "Unknown Error" in every later stack trace.
This detects name collisions. It cannot detect two components compiling the
same integer into a HANDLE label without ever registering a name, so every
co-resident library should still reserve its range.
The registry raises errors
akerr_reserve_status_range() and akerr_register_status_name() return
akerr_ErrorContext *, exactly like the rest of the library. They are marked
AKERR_NOIGNORE, so discarding the result is a compile-time warning, and an
error you catch but do not handle propagates out of your init function instead
of leaving you with a range you do not actually own.
ATTEMPT {
CATCH(errctx, akerr_reserve_status_range(256, 16, MYLIB_OWNER));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_STATUS_RANGE_OVERLAP) {
/* Another component owns part of it -- the message names which. */
} FINISH(errctx, true);
akerr_reserve_status_range() raises:
| Status | Meaning |
|---|---|
— (returns NULL) |
Range reserved, or an identical range was already reserved by the same owner |
AKERR_STATUS_RANGE_OVERLAP |
Part of the range is owned by someone else (the message names the owner) |
AKERR_STATUS_RANGE_FULL |
No reservation slots remain |
AKERR_STATUS_RANGE_INVALID |
count < 1, NULL/empty/over-long owner, or the range overflows int |
akerr_register_status_name() raises:
| Status | Meaning |
|---|---|
— (returns NULL) |
Name registered |
AKERR_STATUS_NAME_UNRESERVED |
No reservation contains this status |
AKERR_STATUS_NAME_FOREIGN |
The status is in a range owned by someone else |
AKERR_STATUS_NAME_FULL |
The name registry is full |
AKERR_STATUS_NAME_INVALID |
NULL/empty/over-long owner, or a NULL name |
These are ordinary status codes inside the library's reserved band, so they can
be matched with HANDLE, grouped with HANDLE_GROUP, and they print with a
name and a stack trace when they go unhandled.
akerr_name_for_status(status, name) is the one exception: it returns a name
rather than an error context, so it cannot raise. A refused registration through
that path is reported through akerr_log_method and reads back as
"Unknown Error".
Repeating an identical reservation for the same owner is a no-op. A subset or
superset of your own range is not — it raises AKERR_STATUS_RANGE_OVERLAP.
Reserve the whole range in one call.
The library holds itself to the same rule at startup: if akerr_init() cannot
reserve its own 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:
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.