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

@@ -1,9 +1,12 @@
cmake_minimum_required(VERSION 3.10)
# The status-code registry replaced the consumer-sized __AKERR_ERROR_NAMES array
# with private storage, which is a source and ABI break for anything built
# against an earlier header -- hence 1.0.0 and a SOVERSION, so a stale installed
# libakerror.so can no longer be silently paired with new headers.
project(akerror VERSION 1.0.0 LANGUAGES C)
# 1.0.0 replaced the consumer-sized __AKERR_ERROR_NAMES array with private
# storage. 2.0.0 makes the library thread safe, which is a second ABI break in
# the same places: __akerr_last_ignored became thread-local storage, and
# ENSURE_ERROR_READY no longer takes the pool reference that akerr_next_error()
# now takes for it. Consumer code compiled against a 1.x header would
# double-count every reference. Hence the major bump and the SOVERSION, so a
# stale installed libakerror.so cannot be silently paired with new headers.
project(akerror VERSION 2.0.0 LANGUAGES C)
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
@@ -11,6 +14,39 @@ include(CTest)
set(AKERR_USE_STDLIB 1 CACHE BOOL "Use the C standard library")
set(AKERR_COVERAGE 0 CACHE BOOL "Instrument the build with gcov coverage counters")
set(AKERR_SANITIZE "" CACHE STRING
"Sanitizers to build the library and tests with, e.g. thread or address,undefined")
# Threading backend for the library's global state (the error pool and the
# status registry). "auto" takes POSIX threads when they exist and fails the
# configure when they do not: a build that silently fell back to no locking
# would produce a library that reports itself thread safe and is not. Say
# -DAKERR_THREADS=none to mean it on purpose.
set(AKERR_THREADS "auto" CACHE STRING "Threading backend: auto, pthread, or none")
set_property(CACHE AKERR_THREADS PROPERTY STRINGS auto pthread none)
if(AKERR_THREADS STREQUAL "auto" OR AKERR_THREADS STREQUAL "pthread")
set(THREADS_PREFER_PTHREAD_FLAG ON)
find_package(Threads)
if(CMAKE_USE_PTHREADS_INIT)
set(AKERR_THREAD_SAFE 1)
elseif(AKERR_THREADS STREQUAL "pthread")
message(FATAL_ERROR
"-DAKERR_THREADS=pthread was requested but no POSIX thread library "
"was found.")
else()
message(FATAL_ERROR
"No POSIX thread library was found. libakerror serializes its "
"global state with a recursive pthread mutex; without one it "
"cannot be thread safe. Configure with -DAKERR_THREADS=none to "
"build a deliberately single-threaded library instead.")
endif()
elseif(AKERR_THREADS STREQUAL "none")
set(AKERR_THREAD_SAFE 0)
else()
message(FATAL_ERROR
"AKERR_THREADS must be auto, pthread or none, not '${AKERR_THREADS}'")
endif()
# Size of the private status-name hash table. Must be a power of two; usable
# capacity is 75% of it (src/error.c asserts both). The host's errno list
@@ -59,6 +95,25 @@ function(akerr_instrument_for_coverage _target)
endif()
endfunction()
# Sanitizers. Unlike coverage these go on the tests as well as the library:
# ThreadSanitizer only sees a race if every thread that touches the memory was
# compiled with it, and the threads live in the test programs.
# cmake -S . -B build/tsan -DAKERR_SANITIZE=thread
if(AKERR_SANITIZE AND NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
message(FATAL_ERROR
"AKERR_SANITIZE requires GCC or Clang, not ${CMAKE_C_COMPILER_ID}")
endif()
function(akerr_instrument_for_sanitizers _target)
if(AKERR_SANITIZE)
target_compile_options(${_target} PRIVATE
-fsanitize=${AKERR_SANITIZE}
-fno-omit-frame-pointer -g -O1)
set_property(TARGET ${_target} APPEND_STRING
PROPERTY LINK_FLAGS " -fsanitize=${AKERR_SANITIZE}")
endif()
endfunction()
set(SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generrno.sh)
set(INFILE ${CMAKE_CURRENT_SOURCE_DIR}/include/akerror.tmpl.h)
@@ -67,6 +122,14 @@ set(GENERATED_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated)
set(GENERATED_ERRNO_C ${GENERATED_DIR}/src/errno.c)
set(GENERATED_AKERROR_H ${GENERATED_DIR}/include/akerror.h)
# The threading decision is stamped into the generated header, so the header has
# to be regenerated when it changes. Makefile generators compare timestamps
# rather than command lines, so carry the value through a file: configure_file
# rewrites it only when the content differs, which is exactly the trigger we
# want and no trigger at all on an unchanged reconfigure.
set(GENERATED_THREAD_STAMP ${CMAKE_CURRENT_BINARY_DIR}/akerr_thread_safe.stamp)
configure_file(cmake/thread_safe.stamp.in ${GENERATED_THREAD_STAMP} @ONLY)
add_custom_command(
OUTPUT ${GENERATED_ERRNO_C} ${GENERATED_AKERROR_H}
COMMAND ${CMAKE_COMMAND} -E make_directory ${GENERATED_DIR}
@@ -74,7 +137,8 @@ add_custom_command(
${SCRIPT}
${CMAKE_CURRENT_SOURCE_DIR}
${GENERATED_DIR}
DEPENDS ${SCRIPT} ${INFILE}
${AKERR_THREAD_SAFE}
DEPENDS ${SCRIPT} ${INFILE} ${GENERATED_THREAD_STAMP}
VERBATIM
)
@@ -91,18 +155,34 @@ target_include_directories(akerror PUBLIC
find_package(PkgConfig REQUIRED)
add_library(akerror::akerror ALIAS akerror)
# The threading backend is PRIVATE: src/lock.h is not installed, so which
# primitive the library locks with is invisible to a consumer. What a consumer
# does see -- whether the library locks at all -- travels in the generated
# header instead, where it cannot disagree with this build.
if(AKERR_THREAD_SAFE)
set(AKERR_THREADS_DEFINE AKERR_THREADS_PTHREAD=1)
else()
set(AKERR_THREADS_DEFINE AKERR_THREADS_NONE=1)
endif()
target_compile_definitions(akerror
PUBLIC AKERR_USE_STDLIB=${AKERR_USE_STDLIB}
PRIVATE AKERR_STATUS_NAME_SLOTS=${AKERR_STATUS_NAME_SLOTS}
PRIVATE AKERR_MAX_RESERVED_STATUS_RANGES=${AKERR_MAX_RESERVED_STATUS_RANGES}
PRIVATE ${AKERR_THREADS_DEFINE}
)
if(AKERR_THREAD_SAFE)
target_link_libraries(akerror PRIVATE Threads::Threads)
endif()
set_target_properties(akerror PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
)
akerr_instrument_for_coverage(akerror)
akerr_instrument_for_sanitizers(akerror)
# Each test is one source file in tests/ built into test_<name> and registered
# as CTest <name>. Tests expected to abort (unhandled error / contract
@@ -141,6 +221,18 @@ set(AKERR_TESTS
err_release_refcount
)
# These drive the library from many threads at once. They are worth running on
# their own -- they assert exclusive ownership of pool slots and of reserved
# ranges, which is checkable without a sanitizer -- but the run that proves the
# absence of a race is the one under -DAKERR_SANITIZE=thread.
if(AKERR_THREAD_SAFE)
list(APPEND AKERR_TESTS
err_threads_init
err_threads_pool
err_threads_registry
)
endif()
set(AKERR_WILL_FAIL_TESTS
err_trace
err_improper_closure
@@ -151,7 +243,19 @@ foreach(_test IN LISTS AKERR_TESTS)
add_executable(test_${_test} tests/${_test}.c)
target_include_directories(test_${_test} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests)
target_link_libraries(test_${_test} PRIVATE akerror)
if(AKERR_THREAD_SAFE)
target_link_libraries(test_${_test} PRIVATE Threads::Threads)
endif()
akerr_instrument_for_sanitizers(test_${_test})
add_test(NAME ${_test} COMMAND test_${_test})
# A sanitizer report is a test failure. Without halt_on_error the runtime
# prints and continues, which leaves a race to be noticed in the log by
# somebody reading it -- and under a race storm the reporting itself is slow
# enough to look like a hang.
if(AKERR_SANITIZE)
set_tests_properties(${_test} PROPERTIES ENVIRONMENT
"TSAN_OPTIONS=halt_on_error=1;ASAN_OPTIONS=halt_on_error=1;UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1")
endif()
endforeach()
set_tests_properties(