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>
This commit is contained in:
2026-07-31 08:31:22 -04:00
parent 5ff87908e7
commit be24f80022
19 changed files with 1463 additions and 140 deletions

124
tests/err_threads_init.c Normal file
View File

@@ -0,0 +1,124 @@
#include "akerror.h"
#include "err_capture.h"
#include "err_threads.h"
#include <errno.h>
#include <string.h>
/*
* akerr_init() runs exactly once, no matter how many threads reach the library
* at the same instant.
*
* This is the hardest of the three to get right, because initialization is
* re-entrant: akerr_init() reserves the library's own status band and names its
* own codes, and every one of those calls goes through a public entry point
* that calls akerr_init() again. The guard against that recursion has to be
* per-thread, or a second thread arriving mid-initialization would see the flag
* the first thread raised on its way in and walk tables that are still being
* built.
*
* Nothing in main() touches the library before the threads start, so the race
* is real: whichever thread wins does the initializing, and the rest must block
* until it is finished rather than proceed on half-built tables.
*
* What proves it ran once rather than several times: each thread reserves its
* own status range as its first act. A second pass through akerr_init() would
* memset the range table, so a reservation made by a thread that raced ahead
* would silently vanish -- exactly the failure the pre-1.0.0 library had. After
* the join, every thread's reservation must still be there, still attributed to
* that thread.
*/
static int thread_range_base(int id)
{
return 400000 + (id * 16);
}
static void *init_racer(void *raw)
{
akerr_ThreadArg *arg = raw;
akerr_ErrorContext *e;
char owner[32];
char name[32];
int base = thread_range_base(arg->id);
snprintf(owner, sizeof(owner), "init-thread-%d", arg->id);
snprintf(name, sizeof(name), "Thread %d Error", arg->id);
pthread_barrier_wait(arg->barrier);
/* First touch of the library from this thread, and for one of them the
* first touch in the process. */
e = akerr_reserve_status_range(base, 16, owner);
AKERR_TCHECK(arg, e == NULL);
RELEASE_ERROR(e);
e = akerr_register_status_name(owner, base, name);
AKERR_TCHECK(arg, e == NULL);
RELEASE_ERROR(e);
/* The library's own band was reserved by whichever thread initialized, and
* every thread must see it as taken -- including the one that did it. A
* second initialization would have wiped the reservation and let this
* through. */
e = akerr_reserve_status_range(0, AKERR_RESERVED_STATUS_COUNT, owner);
AKERR_TCHECK(arg, e != NULL);
if ( e != NULL ) {
AKERR_TCHECK(arg, e->status == AKERR_STATUS_RANGE_OVERLAP);
AKERR_TCHECK(arg, strstr(e->message, AKERR_LIBRARY_OWNER) != NULL);
}
RELEASE_ERROR(e);
/* The name tables are complete as seen from every thread: the library's own
* codes, the generated errno names, and this thread's own registration. */
AKERR_TCHECK(arg, strcmp(akerr_name_for_status(AKERR_VALUE, NULL),
"Value Error") == 0);
AKERR_TCHECK(arg, strcmp(akerr_name_for_status(AKERR_NULLPOINTER, NULL),
"Null Pointer Error") == 0);
AKERR_TCHECK(arg, strcmp(akerr_name_for_status(EACCES, NULL),
"Unknown Error") != 0);
AKERR_TCHECK(arg, strcmp(akerr_name_for_status(base, NULL), name) == 0);
/* And an error raised from this thread renders with a name, which is the
* whole point of the tables being complete. */
PREPARE_ERROR(errctx);
ATTEMPT {
FAIL_BREAK(errctx, AKERR_TYPE, "raised during init race by thread %d",
arg->id);
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_TYPE) {
AKERR_TCHECK(arg, strstr(errctx->stacktracebuf, "Type Error") != NULL);
} FINISH_NORETURN(errctx);
return NULL;
}
int main(void)
{
/* Installed before anything initializes: akerr_init() only supplies the
* default logger when this is still NULL. */
akerr_log_method = &akerr_thread_logger;
int failures = akerr_run_threads(&init_racer);
AKERR_CHECK(failures == 0);
/* Every thread's reservation survived the race, under its own owner. */
for ( int id = 1; id <= AKERR_TEST_THREADS; id++ ) {
char owner[32];
char name[32];
int base = thread_range_base(id);
snprintf(owner, sizeof(owner), "init-thread-%d", id);
snprintf(name, sizeof(name), "Thread %d Error", id);
AKERR_CHECK(strcmp(akerr_name_for_status(base, NULL), name) == 0);
AKERR_CHECK_RAISES(akerr_reserve_status_range(base, 16, "verifier"),
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_MESSAGE_CONTAINS(owner);
}
/* Nothing leaked a pool slot on the way through. */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_threads_init ok (%d threads raced initialization)\n",
AKERR_TEST_THREADS);
return 0;
}