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>
343 lines
13 KiB
CMake
343 lines
13 KiB
CMake
cmake_minimum_required(VERSION 3.10)
|
|
# 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)
|
|
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
|
|
# consumes part of that at akerr_init() time, so the remainder is what all
|
|
# consumer libraries in the process share. These are applied PRIVATE on purpose:
|
|
# the table lives entirely in src/error.c, so raising them never changes
|
|
# anything a consumer can see. That is what makes them safe to tune, unlike the
|
|
# AKERR_MAX_ERR_VALUE they replaced.
|
|
set(AKERR_STATUS_NAME_SLOTS 4096 CACHE STRING
|
|
"Slots in the status-name table (power of two; 75% usable)")
|
|
set(AKERR_MAX_RESERVED_STATUS_RANGES 64 CACHE STRING
|
|
"Maximum number of status ranges that may be reserved")
|
|
set(akerror_install_cmakedir "${CMAKE_INSTALL_LIBDIR}/cmake/akerror")
|
|
|
|
# Coverage instrumentation. Applied per target (not globally) so it never leaks
|
|
# into the exported/installed target interface. Only the library is
|
|
# instrumented: the tests are the thing doing the covering, and the public
|
|
# header's macros cannot be measured this way at all -- GCC attributes an
|
|
# expanded macro to its call site, so header logic shows up as test-file lines.
|
|
# Coverage of those macros is what mutation testing (--target
|
|
# include/akerror.tmpl.h) is for.
|
|
if(AKERR_COVERAGE)
|
|
if(NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
|
message(FATAL_ERROR
|
|
"AKERR_COVERAGE requires GCC or Clang, not ${CMAKE_C_COMPILER_ID}")
|
|
endif()
|
|
# -O0 keeps line counts attributable; no inlining or code motion.
|
|
set(AKERR_COVERAGE_FLAGS --coverage -O0 -g)
|
|
if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
|
|
# Record absolute source paths so gcov resolves sources built from the
|
|
# generated directory (GCC 8+; harmless to check).
|
|
include(CheckCCompilerFlag)
|
|
check_c_compiler_flag(-fprofile-abs-path AKERR_HAVE_PROFILE_ABS_PATH)
|
|
if(AKERR_HAVE_PROFILE_ABS_PATH)
|
|
list(APPEND AKERR_COVERAGE_FLAGS -fprofile-abs-path)
|
|
endif()
|
|
endif()
|
|
endif()
|
|
|
|
# Add coverage compile/link flags to one target, if coverage is enabled.
|
|
function(akerr_instrument_for_coverage _target)
|
|
if(AKERR_COVERAGE)
|
|
target_compile_options(${_target} PRIVATE ${AKERR_COVERAGE_FLAGS})
|
|
set_property(TARGET ${_target} APPEND_STRING
|
|
PROPERTY LINK_FLAGS " --coverage")
|
|
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)
|
|
|
|
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}
|
|
COMMAND /usr/bin/env bash
|
|
${SCRIPT}
|
|
${CMAKE_CURRENT_SOURCE_DIR}
|
|
${GENERATED_DIR}
|
|
${AKERR_THREAD_SAFE}
|
|
DEPENDS ${SCRIPT} ${INFILE} ${GENERATED_THREAD_STAMP}
|
|
VERBATIM
|
|
)
|
|
|
|
add_library(akerror SHARED
|
|
src/error.c
|
|
${GENERATED_ERRNO_C}
|
|
)
|
|
|
|
target_include_directories(akerror PUBLIC
|
|
$<BUILD_INTERFACE:${GENERATED_DIR}/include>
|
|
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
|
)
|
|
|
|
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
|
|
# violation) go in AKERR_WILL_FAIL_TESTS; all others must exit 0.
|
|
set(AKERR_TESTS
|
|
err_catch
|
|
err_cleanup
|
|
err_trace
|
|
err_improper_closure
|
|
err_success
|
|
err_pool_refcount
|
|
err_handle_default
|
|
err_handle_group
|
|
err_handle_dispatch
|
|
err_pass
|
|
err_ignore
|
|
err_swallow
|
|
err_errno
|
|
err_break_variants
|
|
err_custom_handler
|
|
err_error_names
|
|
err_release_clears
|
|
err_pool_exhaust
|
|
err_maxval
|
|
err_name_ownership
|
|
err_registry_init_order
|
|
err_status_exception
|
|
err_copy_string
|
|
err_library_status_fatal
|
|
err_refcount_double_fail
|
|
err_stacktrace_bounds
|
|
err_name_bounds
|
|
err_format_string
|
|
err_unhandled_null
|
|
err_release_null
|
|
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
|
|
err_library_status_fatal
|
|
)
|
|
|
|
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(
|
|
${AKERR_WILL_FAIL_TESTS}
|
|
PROPERTIES WILL_FAIL TRUE
|
|
)
|
|
|
|
# Coverage and mutation testing are meta-checks on the test suite itself, and
|
|
# both rebuild and re-run the whole suite, so they are manual targets rather
|
|
# than CTest tests.
|
|
find_package(Python3 COMPONENTS Interpreter)
|
|
if(Python3_FOUND)
|
|
# Code coverage: which library lines/branches the CTest suite reaches.
|
|
# cmake --build build --target coverage
|
|
# The script configures and drives its own instrumented build tree (under
|
|
# ${CMAKE_BINARY_DIR}/coverage) so this build's binaries and its coverage
|
|
# counters can never be stale or half-instrumented. Reports via gcov.
|
|
add_custom_target(coverage
|
|
COMMAND ${Python3_EXECUTABLE}
|
|
${CMAKE_CURRENT_SOURCE_DIR}/scripts/coverage.py
|
|
--source-root ${CMAKE_CURRENT_SOURCE_DIR}
|
|
--build-dir ${CMAKE_CURRENT_BINARY_DIR}/coverage
|
|
--cmake ${CMAKE_COMMAND}
|
|
--ctest ${CMAKE_CTEST_COMMAND}
|
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
|
USES_TERMINAL
|
|
COMMENT "Running the test suite instrumented for coverage"
|
|
)
|
|
|
|
# Mutation testing: break the library in small ways and confirm the test
|
|
# suite notices.
|
|
# cmake --build build --target mutation
|
|
# When embedded in another project, use a namespaced target to avoid
|
|
# collisions with mutation targets provided by sibling dependencies.
|
|
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
|
set(AKERR_MUTATION_TARGET mutation)
|
|
else()
|
|
set(AKERR_MUTATION_TARGET akerror_mutation)
|
|
endif()
|
|
add_custom_target(${AKERR_MUTATION_TARGET}
|
|
COMMAND ${Python3_EXECUTABLE}
|
|
${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
|
|
--source-root ${CMAKE_CURRENT_SOURCE_DIR}
|
|
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
|
USES_TERMINAL
|
|
COMMENT "Running mutation tests (breaks the library, expects tests to fail)"
|
|
)
|
|
endif()
|
|
|
|
set(main_lib_dest "lib/my_library-${MY_LIBRARY_VERSION}")
|
|
install(TARGETS akerror
|
|
EXPORT akerrorTargets
|
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
|
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
|
|
)
|
|
|
|
install(FILES ${GENERATED_AKERROR_H} DESTINATION "include/")
|
|
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akerror.pc DESTINATION "lib/pkgconfig/")
|
|
|
|
install(EXPORT akerrorTargets
|
|
FILE akerrorTargets.cmake
|
|
NAMESPACE akerror::
|
|
DESTINATION ${akerror_install_cmakedir}
|
|
)
|
|
|
|
configure_package_config_file(
|
|
cmake/akerror.cmake.in
|
|
"${CMAKE_CURRENT_BINARY_DIR}/akerrorConfig.cmake"
|
|
INSTALL_DESTINATION ${akerror_install_cmakedir}
|
|
)
|
|
|
|
install(FILES
|
|
"${CMAKE_CURRENT_BINARY_DIR}/akerrorConfig.cmake"
|
|
DESTINATION ${akerror_install_cmakedir}
|
|
)
|
|
|
|
# pkgconfig
|
|
set(prefix ${CMAKE_INSTALL_PREFIX})
|
|
set(exec_prefix "\${prefix}")
|
|
set(libdir "\${exec_prefix}/lib")
|
|
set(includedir "\${prefix}/include")
|
|
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/akerror.pc.in ${CMAKE_CURRENT_BINARY_DIR}/akerror.pc @ONLY)
|