Files
libakerror/tests/err_threads_pool.c
Andrew Kesterson be24f80022 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

139 lines
4.7 KiB
C

#include "akerror.h"
#include "err_capture.h"
#include "err_threads.h"
#include <string.h>
/*
* The error pool under contention.
*
* AKERR_ARRAY_ERROR is a fixed 128-slot array shared by every thread in the
* process, and a slot is checked out by finding refcount == 0 and taking a
* reference. Those two steps have to be one operation: a scan that returned an
* unclaimed slot would hand the same one to every thread that scanned before
* the first of them incremented the count, and each would then format its own
* error into the same buffers. That failure is invisible to a single-threaded
* test and produces a garbled message rather than a crash, so this test asserts
* exclusivity directly.
*
* akerr_slot_owner[] (see err_threads.h) is the test's own record of who holds
* which slot, kept with atomics. Every check-out claims its slot and every
* release drops the claim; a slot handed to two threads at once is caught by
* the claim failing, whether or not the resulting message is garbled.
*
* Each thread also asserts that the error it raised is the error it handles,
* message and all. That is the same property from the other end: a context
* cannot be exclusively ours if another thread's text shows up in it.
*/
#define ITERATIONS 2000
/* Raise an error and take ownership of whatever slot it came from. */
static akerr_ErrorContext AKERR_NOIGNORE *boom(akerr_ThreadArg *arg)
{
PREPARE_ERROR(e);
FAIL(e, AKERR_VALUE, "raised by thread %d", arg->id);
AKERR_TCHECK(arg, akerr_slot_claim(e->arrayid, arg->id) == 0);
return e;
}
static akerr_ErrorContext AKERR_NOIGNORE *ignorable(akerr_ThreadArg *arg)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_IO, "ignored by thread %d", arg->id);
}
/* One raise -> catch -> handle cycle, exclusively owned from end to end. */
static void one_cycle(akerr_ThreadArg *arg)
{
char expected[64];
PREPARE_ERROR(e);
snprintf(expected, sizeof(expected), "raised by thread %d", arg->id);
ATTEMPT {
CATCH(e, boom(arg));
} CLEANUP {
} PROCESS(e) {
/* case 0: the error we just raised came back clean, which can only
* mean another thread wrote over this context. */
int error_was_lost = 1;
AKERR_TCHECK(arg, error_was_lost == 0);
} HANDLE(e, AKERR_VALUE) {
AKERR_TCHECK(arg, akerr_slot_holder(e->arrayid) == arg->id);
AKERR_TCHECK(arg, strcmp(e->message, expected) == 0);
AKERR_TCHECK(arg, strstr(e->stacktracebuf, expected) != NULL);
/* Give the slot up before FINISH releases the context: the other order
* hands it back to the pool while this thread still claims it. */
akerr_slot_drop(e->arrayid);
} FINISH_NORETURN(e);
}
/* The same property against the raw pool API, with no macros in between. */
static void one_checkout(akerr_ThreadArg *arg)
{
akerr_ErrorContext *e = akerr_next_error();
AKERR_TCHECK(arg, e != NULL);
if ( e == NULL ) {
return;
}
/* The context arrives already holding its reference. */
AKERR_TCHECK(arg, e->refcount == 1);
AKERR_TCHECK(arg, akerr_slot_claim(e->arrayid, arg->id) == 0);
AKERR_TCHECK(arg, akerr_slot_holder(e->arrayid) == arg->id);
akerr_slot_drop(e->arrayid);
RELEASE_ERROR(e);
AKERR_TCHECK(arg, e == NULL);
}
static void *pool_body(void *raw)
{
akerr_ThreadArg *arg = raw;
char expected[64];
snprintf(expected, sizeof(expected), "ignored by thread %d", arg->id);
pthread_barrier_wait(arg->barrier);
for ( int i = 0; i < ITERATIONS; i++ ) {
one_cycle(arg);
one_checkout(arg);
}
/* An ignored error is a fact about the thread that ignored it: each thread
* must see its own, not the last one any thread swallowed. */
IGNORE(ignorable(arg));
AKERR_TCHECK(arg, __akerr_last_ignored != NULL);
if ( __akerr_last_ignored != NULL ) {
AKERR_TCHECK(arg, __akerr_last_ignored->status == AKERR_IO);
AKERR_TCHECK(arg, strcmp(__akerr_last_ignored->message, expected) == 0);
}
/* IGNORE keeps the reference by design; hand it back so the pool is empty
* at the end of the test. */
RELEASE_ERROR(__akerr_last_ignored);
return NULL;
}
int main(void)
{
akerr_log_method = &akerr_thread_logger;
akerr_init();
AKERR_CHECK(akerr_slots_in_use() == 0);
int failures = akerr_run_threads(&pool_body);
AKERR_CHECK(failures == 0);
/* Every context went back to the pool, and every claim was dropped. */
AKERR_CHECK(akerr_slots_in_use() == 0);
for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
AKERR_CHECK(akerr_slot_holder(i) == 0);
}
/* Each thread's IGNORE reported through the log method. */
AKERR_CHECK(akerr_thread_logs() >= AKERR_TEST_THREADS);
fprintf(stderr, "err_threads_pool ok (%d threads x %d cycles)\n",
AKERR_TEST_THREADS, ITERATIONS);
return 0;
}