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

View File

@@ -9,6 +9,42 @@
#include <limits.h>
#endif
/*
* Threading.
*
* scripts/generrno.sh stamps this value in at build time from the AKERR_THREADS
* build option, the same way it stamps AKERR_LAST_ERRNO_VALUE. It is generated
* rather than defined by the consumer on purpose: whether the library
* serializes its global state and whether __akerr_last_ignored is a
* thread-local are the same decision, and a consumer that disagreed with the
* library about it would link against a differently shaped symbol.
*
* 1 The error pool and the status registry are mutex protected, and the
* per-thread state below is thread local. Every entry point may be called
* from any thread. See "Thread safety" in README.md for what that does and
* does not cover.
* 0 The library was built -DAKERR_THREADS=none for a single-threaded
* process: no locking, no thread-local storage, and calling it from more
* than one thread is undefined.
*
* Consumers can test it: #if AKERR_THREAD_SAFE.
*/
#define AKERR_THREAD_SAFE AKERR_THREAD_SAFE_SED
#if AKERR_THREAD_SAFE == 1
#if defined(__GNUC__) || defined(__clang__)
#define AKERR_THREAD_LOCAL __thread
#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
#define AKERR_THREAD_LOCAL _Thread_local
#elif defined(_MSC_VER)
#define AKERR_THREAD_LOCAL __declspec(thread)
#else
#error "libakerror was built thread safe, but this compiler has no thread-local storage specifier that akerror.h knows about. Rebuild libakerror with -DAKERR_THREADS=none, or add the spelling here."
#endif
#else
#define AKERR_THREAD_LOCAL
#endif
// FIXME: This is huge now. It used to be 1000 bytes, then I wanted to report errors
// related to filesystem paths, which made it grow beyond PATH_MAX, then I started
// reporting messages including 2 file paths (PATH_MAX * 2), so now to make the compiler warnings
@@ -101,11 +137,29 @@ typedef void (*akerr_ErrorUnhandledErrorHandler)(akerr_ErrorContext *errctx);
typedef void (*akerr_ErrorLogFunction)(const char *f, ...);
extern akerr_ErrorContext AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR];
/*
* Set these before starting threads. They are read on every error and written
* by nothing but your own code, so changing one while other threads are raising
* errors is a data race the library cannot mediate.
*/
extern akerr_ErrorUnhandledErrorHandler akerr_handler_unhandled_error;
extern akerr_ErrorLogFunction akerr_log_method;
extern akerr_ErrorContext *__akerr_last_ignored;
/*
* The error IGNORE() last swallowed, per thread: an ignored error is a fact
* about the thread that ignored it, and one shared slot would have two threads
* overwriting each other's. Thread local only when AKERR_THREAD_SAFE is 1.
*/
extern AKERR_THREAD_LOCAL akerr_ErrorContext *__akerr_last_ignored;
akerr_ErrorContext AKERR_NOIGNORE *akerr_release_error(akerr_ErrorContext *ptr);
/*
* Check a context out of the pool. The returned context already carries one
* reference: finding a free slot and claiming it is a single operation under
* the pool lock, because two threads scanning at once would otherwise be handed
* the same slot. Release it with akerr_release_error() (or let RELEASE_ERROR,
* SUCCEED_RETURN or FINISH do it for you). Returns NULL when every slot is
* checked out.
*/
akerr_ErrorContext AKERR_NOIGNORE *akerr_next_error();
/*
* Look up (name == NULL) or register (name != NULL) the display name for a
@@ -173,6 +227,11 @@ akerr_ErrorContext AKERR_NOIGNORE *__akerr_copy_string(char *destination, int ca
akerr_init(); \
akerr_ErrorContext __attribute__ ((unused)) *__err_context = NULL;
/*
* akerr_next_error() hands back a context that already holds one reference --
* it has to, or a second thread could be given the same slot between the scan
* and the increment. There is nothing to increment here.
*/
#define ENSURE_ERROR_READY(__err_context) \
if ( __err_context == NULL ) { \
__err_context = akerr_next_error(); \
@@ -180,7 +239,6 @@ akerr_ErrorContext AKERR_NOIGNORE *__akerr_copy_string(char *destination, int ca
akerr_log_method("%s:%s:%d: Unable to pull an error context from the array!", __FILE__, (char *)__func__, __LINE__); \
exit(1); \
} \
__err_context->refcount += 1; \
}
/*