Files
libakerror/tests/err_threads_registry.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

138 lines
5.0 KiB
C

#include "akerror.h"
#include "err_capture.h"
#include "err_threads.h"
#include <string.h>
/*
* The status registry under contention.
*
* Two properties, and they fail differently:
*
* - A reservation is a decision, not a write. The overlap scan and the entry
* that follows it have to be one operation, or two threads claiming the
* same range both find the table clear and both believe they own it. That
* is silent: neither gets an error, and the collision surfaces much later
* as one component's status rendering under another's name. The contested
* range below is claimed by every thread at once and exactly one may win.
*
* - The name table is an open-addressed hash table with linear probing. A
* concurrent insert that another thread's probe walks through -- an entry
* half claimed, a count incremented before the slot was marked used -- loses
* names or writes outside the table. So every thread registers a block of
* names and reads each one back while the others are still writing, and
* interleaves lookups of a name nobody is touching.
*
* Ownership enforcement has to hold under contention too: after every thread
* has reserved, each one tries to name a status inside its neighbour's range
* and must be refused. The barrier before that is what makes the expected
* refusal exactly AKERR_STATUS_NAME_FOREIGN rather than sometimes
* AKERR_STATUS_NAME_UNRESERVED, which is a real distinction and not just test
* tidiness: FOREIGN means the registry knew who owned it.
*/
#define NAMES_PER_THREAD 64
#define CONTESTED_FIRST 900000
#define CONTESTED_COUNT 64
static int contested_winners;
static int thread_range_base(int id)
{
return 500000 + (id * 1000);
}
static void *registry_body(void *raw)
{
akerr_ThreadArg *arg = raw;
akerr_ErrorContext *e;
char owner[32];
int base = thread_range_base(arg->id);
int victim = thread_range_base((arg->id % AKERR_TEST_THREADS) + 1);
snprintf(owner, sizeof(owner), "registry-%d", arg->id);
pthread_barrier_wait(arg->barrier);
/* One range, every thread, distinct owners. Exactly one may come back
* successful; the rest must be told who won. */
e = akerr_reserve_status_range(CONTESTED_FIRST, CONTESTED_COUNT, owner);
if ( e == NULL ) {
__atomic_fetch_add(&contested_winners, 1, __ATOMIC_RELAXED);
} else {
AKERR_TCHECK(arg, e->status == AKERR_STATUS_RANGE_OVERLAP);
AKERR_TCHECK(arg, strstr(e->message, "registry-") != NULL);
RELEASE_ERROR(e);
}
/* This thread's own range, which nobody contests. */
e = akerr_reserve_status_range(base, NAMES_PER_THREAD, owner);
AKERR_TCHECK(arg, e == NULL);
RELEASE_ERROR(e);
for ( int i = 0; i < NAMES_PER_THREAD; i++ ) {
char name[48];
snprintf(name, sizeof(name), "registry-%d name %d", arg->id, i);
e = akerr_register_status_name(owner, base + i, name);
AKERR_TCHECK(arg, e == NULL);
RELEASE_ERROR(e);
/* Read it back while the other threads are still inserting. */
AKERR_TCHECK(arg, strcmp(akerr_name_for_status(base + i, NULL), name) == 0);
/* And an entry nobody is touching: a probe sequence that a concurrent
* insert walked off loses names that were already there. */
AKERR_TCHECK(arg, strcmp(akerr_name_for_status(AKERR_VALUE, NULL),
"Value Error") == 0);
}
/* Everyone has reserved by the time anyone tries to trespass. */
pthread_barrier_wait(arg->barrier);
e = akerr_register_status_name(owner, victim, "Hijack");
AKERR_TCHECK(arg, e != NULL);
if ( e != NULL ) {
AKERR_TCHECK(arg, e->status == AKERR_STATUS_NAME_FOREIGN);
}
RELEASE_ERROR(e);
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(&registry_body);
AKERR_CHECK(failures == 0);
/* The contested range went to exactly one owner. */
AKERR_CHECK(contested_winners == 1);
/* Every name every thread registered is present and correct: nothing was
* dropped, overwritten, or attributed to the wrong thread. */
for ( int id = 1; id <= AKERR_TEST_THREADS; id++ ) {
int base = thread_range_base(id);
for ( int i = 0; i < NAMES_PER_THREAD; i++ ) {
char expected[48];
snprintf(expected, sizeof(expected), "registry-%d name %d", id, i);
AKERR_CHECK(strcmp(akerr_name_for_status(base + i, NULL), expected) == 0);
}
/* The trespass attempt did not leave a name behind. */
AKERR_CHECK(strcmp(akerr_name_for_status(base, NULL), "Hijack") != 0);
}
/* The library's own entries survived every one of those inserts. */
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_VALUE, NULL), "Value Error") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_STATUS_NAME_FOREIGN, NULL),
"Foreign Status Name") == 0);
/* Thousands of refusals and registrations later, the pool is empty. */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_threads_registry ok (%d threads x %d names)\n",
AKERR_TEST_THREADS, NAMES_PER_THREAD);
return 0;
}