22 Commits

Author SHA1 Message Date
8a026d3006 Include passed tests in the JUnit report summary
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m48s
libakerror CI Build / mutation_test (push) Successful in 7m13s
The reporter warned "No annotations found ... configure 'include_passed' as
'true'" because with annotate_only the summary only listed failures. Set
include_passed: true on both reporter steps so the job summary table lists the
passing tests too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 21:15:13 -04:00
792e646957 Work around Gitea Checks API 404 in the JUnit reporter
Some checks failed
libakerror CI Build / cmake_build (push) Successful in 2m45s
libakerror CI Build / mutation_test (push) Has been cancelled
mikepenz/action-junit-report defaults to creating a check run via the Checks
API, which Gitea does not support -- the call 404s and the publish step fails
(mikepenz/action-junit-report#23). Set annotate_only: true on both reporter
steps to skip check creation, and detailed_summary: true so results still show
up in the job summary (which Gitea's runner does render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 21:10:09 -04:00
43516c7e73 Emit JUnit XML from tests + mutation, consume it in CI
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 3m10s
libakerror CI Build / mutation_test (push) Successful in 6m58s
Produce machine-readable results and surface them in the Gitea pipeline:

- ctest: run with --output-junit to write ctest-junit.xml. The path must be
  absolute ("$(pwd)/...") because --output-junit otherwise resolves relative to
  the --test-dir build directory.
- mutation_test.py: new --junit FILE option writes a JUnit report where each
  mutant is a test case and a surviving mutant is a <failure> (so gaps show up
  as failing tests).
- .gitea/workflows/ci.yaml: both jobs generate their XML and feed it to
  mikepenz/action-junit-report with `if: always()`, so results publish even
  when a gate fails. Mutation publishing is display-only (fail_on_failure:
  false); the --threshold flag remains the gate.
- .gitignore: ignore the generated *-junit.xml artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 20:51:28 -04:00
536a269aad Derive err_maxval's code set from the header, not a hardcoded list
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m41s
libakerror CI Build / mutation_test (push) Successful in 6m52s
The previous err_maxval hardcoded the list of AKERR_* codes, which silently
rots the moment a code is added. Instead, parse the generated akerror.h at
runtime: discover every "#define AKERR_<NAME> (AKERR_LAST_ERRNO_VALUE + N)",
take the highest offset actually defined, and assert AKERR_MAX_ERR_VALUE covers
it. A compile-time cross-check ties the parsed ceiling to the compiled macro so
the test can't pass by reading a stale header.

CMake injects the header path as AKERR_GENERATED_HEADER. Verified: the test
fails (max_err_value >= highest_code) when pointed at a +15 header while
AKERR_BADEXC is +17, and now also strengthens header mutation coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 17:26:32 -04:00
10f7203e8f Fix AKERR_MAX_ERR_VALUE to cover all AKERR_* codes
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m41s
libakerror CI Build / mutation_test (push) Successful in 6m54s
AKERR_MAX_ERR_VALUE was AKERR_LAST_ERRNO_VALUE + 15, but the highest defined
code, AKERR_BADEXC, is + 17 (AKERR_NOT_IMPLEMENTED is + 16). akerr_name_for_status
rejects any status above the max, so those codes could never have a registered
name and the AKERR_BADEXC registration in akerr_init was dead code -- a gap
found by mutation testing. Bump the max to + 17.

- err_maxval: new test asserting the reserved AKERR_* range exceeds the number
  of AKERR_* codes and that every code is individually indexable. Fails against
  the old + 15 value (verified), guarding against regression.
- err_error_names: now also checks AKERR_BADEXC's name, which the fix makes
  reachable.

Mutation score on src/error.c rises 71% -> 74%: the previously-dead BADEXC
registration and the name_for_status upper-bound check are now killable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 17:17:26 -04:00
43f46dca64 Add mutation testing to validate the test suite
Introduce a self-contained mutation testing harness that verifies the unit
tests actually catch bugs: it makes small deliberate breakages to the library
(flip comparisons, delete statements, swap true/false, etc.), rebuilds, and
runs the whole CTest suite against each mutant. Tests that still pass reveal a
gap; tests that fail "kill" the mutant.

- scripts/mutation_test.py: the engine (stdlib only, no LLVM/clang deps).
  Operators ROR/LCR/BCR/AOR/ICR/SDL over src/error.c and the macro header.
  Mutates a scratch copy, never the working tree. Supports --target, --list,
  --max-mutants sampling, --threshold gating, --timeout.
- CMakeLists.txt: 'mutation' custom target (cmake --build build --target mutation).
- .gitea/workflows/ci.yaml: gated mutation job on src/error.c (threshold 65%).
- tests/MUTATION.md: how to run, interpret survivors, and known equivalents.

Close the real gaps the harness found in src/error.c (score 53% -> 71%):
- err_error_names: the AKERR_* codes have their names registered by akerr_init
- err_release_clears: releasing a context wipes it before reuse
- err_pool_exhaust: akerr_next_error returns NULL when the pool is full and
  always hands back the lowest free slot

Also surfaced (documented, not fixed): AKERR_MAX_ERR_VALUE (+15) is below
AKERR_NOT_IMPLEMENTED (+16) and AKERR_BADEXC (+17), so those codes can never
have a name registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 17:03:53 -04:00
e5f761662c Reformat new test files with Stroustrup style
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m38s
Apply emacs CC-mode "stroustrup" style (c-basic-offset 4, indent-tabs-mode t)
to the test files added in the previous commit, matching the existing house
style. Whitespace only; no behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 16:35:32 -04:00
4daa411f3f Expand test coverage for error-handling macros and error pool
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m42s
Add 11 CTest programs and a shared test helper covering gaps left by the
original four tests (which only exercised FAIL/CATCH/HANDLE and CLEANUP):

- err_capture.h: capturing akerr_log_method + NDEBUG-proof AKERR_CHECK so
  tests can assert on message/status/stacktrace content, not just exit codes
- err_success: clean nested return does not break/handle/leak
- err_pool_refcount: 100k raise->catch->handle cycles leak 0 pool slots
- err_handle_default / err_handle_group / err_handle_dispatch: handler routing
- err_pass / err_ignore / err_swallow: PASS, IGNORE, FINISH(e,false)
- err_break_variants: FAIL_*_BREAK and FAIL_*_RETURN
- err_errno: system errno name lookup + "Unknown Error" boundary
- err_custom_handler: override the unhandled-error hook, assert non-fatally

Register tests via a foreach loop in CMakeLists.txt. Ignore build/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 16:18:17 -04:00
4fad0cec59 Add gitea workflow
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m39s
2026-06-27 10:22:26 -04:00
5b1a6c2cc7 Remove spurious duplicate lines from the stacktrace. Only log the DETECT and FAIL points, everything else is noise. 2026-06-27 08:37:05 -04:00
675c60b5e0 Removed AKERR_HEAP, AKERR_REGISTRY, AKERR_BEHAVIOR, they belong to libakgl. Replaced AKERR_BEHAVIOR which was ambiguous with AKERR_BADEXC which is raised when a function falls to call SUCCEED_RETURN(). 2026-06-22 08:15:07 -04:00
93f5e93480 Add an error type for circular references 2026-06-02 17:12:14 -04:00
b5435041f2 VALID() wasn't resetting invalid error contexts back to NULL before atempting to ENSURE_READY() them 2026-05-24 19:41:32 -04:00
235033d633 VALID() wasn't properly handling NULL returns, leading to false positives 2026-05-24 19:14:35 -04:00
7700af06a1 Changes maximum stacktrace string values to better allow for inclusion of values up to PATH_MAX. I don't like it. The values are huge. Need a better more sensible way. 2026-05-24 09:50:21 -04:00
be2dba8416 Fix a bug in the new VALID() method, false is not true 2026-05-21 21:44:52 -04:00
03e9b8a96d Update docs, replace the missing VALID() macro to safeguard against misbehaving functions, add PASS, remove CATCH_AND_RETURN 2026-05-15 19:41:22 -04:00
768a235da4 Fix builds when in a submodule 2026-05-12 21:29:07 -04:00
4a09ca87fd Fix cmake duplicate targets 2026-05-12 16:56:49 -04:00
51b6b23b4c Make the build work from a subdirectory dependency 2026-05-12 16:44:06 -04:00
166a478e6a Add AKERR_EOF, properly call akerr_init_errno 2026-05-12 16:42:33 -04:00
0c29f5d69f Fix handling of CATCH() or FAIL() macros around functions that should return an (akerr_ErrorContext *) but return an invalid pointer (to something not in our exception array) 2026-05-06 12:36:56 -04:00
29 changed files with 1706 additions and 114 deletions

69
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,69 @@
name: libakerror CI Build
run-name: ${{ gitea.actor }} libakerror test
on: [push]
jobs:
cmake_build:
runs-on: ubuntu-latest
steps:
- run: echo "Triggered by ${{ gitea.event_name }} from ${{ gitea.repository }}@${{ gitea.ref }}. Building on ${{ runner.os }}."
- name: Check out repository code
uses: actions/checkout@v4
- name: dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake gcc moreutils
- name: build and install
run: |
mkdir installdir
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=installdir
cmake --build build
cmake --install build
# --output-junit resolves relative to the test dir, so give an absolute
# path to land the report in the workspace root for the reporter below.
- name: test (JUnit)
run: ctest --test-dir build --output-on-failure --output-junit "$(pwd)/ctest-junit.xml"
# annotate_only: true skips creating a check run via the Checks API, which
# Gitea does not support and 404s on (mikepenz/action-junit-report#23).
# Results surface via the job summary instead.
- name: publish test results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'ctest-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'true'
- run: echo "🍏 This job's status is ${{ job.status }}."
mutation_test:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y cmake gcc moreutils python3
# Verify the tests actually catch bugs: break the library many ways and
# confirm the suite fails. Gated on src/error.c (fast, deterministic).
# The threshold keeps headroom below the current score for equivalent
# mutants; see tests/MUTATION.md. Run the full default target locally for
# deeper (slower) coverage including the macro header.
- name: mutation testing
run: |
python3 scripts/mutation_test.py --target src/error.c --junit mutation-junit.xml --threshold 65
# Publish even when the threshold gate fails, so survivors are visible.
# Display-only (fail_on_failure: false); the --threshold above is the gate.
# annotate_only avoids the Checks API 404 on Gitea (see note above).
- name: publish mutation results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'mutation-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'false'
- run: echo "🍏 This job's status is ${{ job.status }}."

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
include/akerror.h
build/
*-junit.xml

View File

@@ -3,57 +3,111 @@ project(akerror LANGUAGES C)
include(GNUInstallDirs) include(GNUInstallDirs)
include(CMakePackageConfigHelpers) include(CMakePackageConfigHelpers)
include(CTest)
set(AKERR_USE_STDLIB 1 CACHE BOOL "Use the C standard library") set(AKERR_USE_STDLIB 1 CACHE BOOL "Use the C standard library")
set(akerror_install_cmakedir "${CMAKE_INSTALL_LIBDIR}/cmake/akerror") set(akerror_install_cmakedir "${CMAKE_INSTALL_LIBDIR}/cmake/akerror")
set(SCRIPT ${CMAKE_SOURCE_DIR}/scripts/generrno.sh) set(SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generrno.sh)
set(INFILE ${CMAKE_SOURCE_DIR}/include/akerror.tmpl.h) set(INFILE ${CMAKE_CURRENT_SOURCE_DIR}/include/akerror.tmpl.h)
set(OUTFILES ${CMAKE_SOURCE_DIR}/src/errno.c ${CMAKE_SOURCE_DIR}/include/akerror.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)
add_custom_command( add_custom_command(
OUTPUT ${OUTFILES} OUTPUT ${GENERATED_ERRNO_C} ${GENERATED_AKERROR_H}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR} COMMAND ${CMAKE_COMMAND} -E make_directory ${GENERATED_DIR}
COMMAND /usr/bin/env bash ${SCRIPT} ${CMAKE_SOURCE_DIR} COMMAND /usr/bin/env bash
${SCRIPT}
${CMAKE_CURRENT_SOURCE_DIR}
${GENERATED_DIR}
DEPENDS ${SCRIPT} ${INFILE} DEPENDS ${SCRIPT} ${INFILE}
VERBATIM VERBATIM
) )
set_source_files_properties(src/errno.c PROPERTIES GENERATED TRUE) add_library(akerror SHARED
src/error.c
${GENERATED_ERRNO_C}
)
add_custom_target(generrno DEPENDS src/errno.c) target_include_directories(akerror PUBLIC
$<BUILD_INTERFACE:${GENERATED_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
add_library(akerror STATIC add_library(akerror::akerror ALIAS akerror)
src/error.c
src/errno.c
)
add_dependencies(akerror generrno)
target_compile_definitions(akerror target_compile_definitions(akerror
PUBLIC AKERR_USE_STDLIB=${AKERR_USE_STDLIB} PUBLIC AKERR_USE_STDLIB=${AKERR_USE_STDLIB}
) )
add_executable(test_err_catch tests/err_catch.c) # Each test is one source file in tests/ built into test_<name> and registered
add_executable(test_err_cleanup tests/err_cleanup.c) # as CTest <name>. Tests expected to abort (unhandled error / contract
add_executable(test_err_trace tests/err_trace.c) # violation) go in AKERR_WILL_FAIL_TESTS; all others must exit 0.
add_executable(test_err_unhandled tests/err_unhandled.c) set(AKERR_TESTS
add_test(NAME err_catch COMMAND test_err_catch) err_catch
add_test(NAME err_cleanup COMMAND test_err_cleanup) err_cleanup
add_test(NAME err_trace COMMAND test_err_trace) err_trace
add_test(NAME err_unhandled COMMAND test_err_unhandled) err_improper_closure
err_success
# Specify include directories for the library's headers (if applicable) err_pool_refcount
target_include_directories(akerror PUBLIC err_handle_default
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> err_handle_group
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}/> 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
) )
target_link_libraries(test_err_catch PRIVATE akerror)
target_link_libraries(test_err_cleanup PRIVATE akerror) set(AKERR_WILL_FAIL_TESTS
target_link_libraries(test_err_trace PRIVATE akerror) err_trace
target_link_libraries(test_err_unhandled PRIVATE akerror) err_improper_closure
)
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)
add_test(NAME ${_test} COMMAND test_${_test})
endforeach()
# err_maxval parses the generated header to check AKERR_MAX_ERR_VALUE covers
# every AKERR_* code, so it needs the path to the header it was built against.
target_compile_definitions(test_err_maxval PRIVATE
AKERR_GENERATED_HEADER="${GENERATED_AKERROR_H}")
set_tests_properties(
${AKERR_WILL_FAIL_TESTS}
PROPERTIES WILL_FAIL TRUE
)
# Mutation testing: break the library in small ways and confirm the test suite
# notices. This is a meta-check on the tests themselves, so it is a manual
# target (it rebuilds and re-runs the whole suite many times), not a CTest test.
# cmake --build build --target mutation
find_package(Python3 COMPONENTS Interpreter)
if(Python3_FOUND)
add_custom_target(mutation
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}") set(main_lib_dest "lib/my_library-${MY_LIBRARY_VERSION}")
install(TARGETS akerror EXPORT akerror DESTINATION "lib/")
install(TARGETS akerror install(TARGETS akerror
EXPORT akerrorTargets EXPORT akerrorTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
@@ -62,13 +116,10 @@ install(TARGETS akerror
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
) )
install(EXPORT akerror FILE akerrorTargets.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/akerror) install(FILES ${GENERATED_AKERROR_H} DESTINATION "include/")
install(FILES "include/akerror.h" DESTINATION "include/")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akerror.pc DESTINATION "lib/pkgconfig/") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akerror.pc DESTINATION "lib/pkgconfig/")
install(EXPORT akerrorTargets
install(EXPORT akerror
FILE akerrorTargets.cmake FILE akerrorTargets.cmake
NAMESPACE akerror:: NAMESPACE akerror::
DESTINATION ${akerror_install_cmakedir} DESTINATION ${akerror_install_cmakedir}

View File

@@ -2,6 +2,8 @@
This library provides a TRY/CATCH style exception handling mechanism for C. This library provides a TRY/CATCH style exception handling mechanism for C.
![build badge](https://source.starfort.tech/andrew/libakerror/actions/workflows/ci.yaml/badge.svg?branch=main)
# Why? # Why?
There is nothing wrong with C as it is. This library does not claim to fix some problem with C. There is nothing wrong with C as it is. This library does not claim to fix some problem with C.
@@ -137,6 +139,14 @@ pkg_check_modules(akerror REQUIRED akerror)
target_link_libraries(YOUR_TARGET PRIVATE akerror::akerror) target_link_libraries(YOUR_TARGET PRIVATE akerror::akerror)
``` ```
Using this project as a submodule with cmake:
```cmake
add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)
target_link_libraries(YOUR_PROJECT PRIVATE akerror::akerror)
```
## (Optional) Configuring the logging function ## (Optional) Configuring the logging function
@@ -222,6 +232,28 @@ ATTEMPT {
When either of these two macros are used, the `ATTEMPT` block is immediately exited, and the `CLEANUP` block begins. When either of these two macros are used, the `ATTEMPT` block is immediately exited, and the `CLEANUP` block begins.
# Passing errors
Sometimes you can't actually do anything about the errors that come out of a given method, but you want that error to be propagated back up the call chain, and to be properly reported. If this is your goal, you can avoid using a `ATTEMPT ... FINISH` block, and simply use the `PASS` macro.
```
PREPARE_ERROR(e);
PASS(e, some_method_that_may_fail());
SUCCEED_RETURN(e);
```
This does the same thing as this, but with less code:
```
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, some_method_that_may_fail());
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
```
# Handling errors # Handling errors
Inside of the `PROCESS { ... }` block, you must handle any errors that occurred during the `ATTEMPT { ... }` block. You do this with `HANDLE`, `HANDLE_GROUP`, and `HANDLE_DEFAULT`. Inside of the `PROCESS { ... }` block, you must handle any errors that occurred during the `ATTEMPT { ... }` block. You do this with `HANDLE`, `HANDLE_GROUP`, and `HANDLE_DEFAULT`.
@@ -310,20 +342,24 @@ FAIL_NONZERO_RETURN(errctx, strcmp("not", "equal"), AKERR_VALUE, "Strings are no
# Uncaught errors # Uncaught errors
## Misbehaving methods
Any function which returns `akerr_ErrorContext *` and completes successfully MUST call `SUCCEED_RETURN(errctx)`. Failure to do this may result in an invalid `akerr_ErrorContext *` being returned, which will cause an `AKERR_BEHAVIOR` error to be triggered from your code.
## Ensuring that all error codes are captured ## Ensuring that all error codes are captured
Any function which returns `akerr_ErrorContext *` should also be marked with `ERROR_NOIGNORE`. Any function which returns `akerr_ErrorContext *` should also be marked with `AKERROR_NOIGNORE`.
```c ```c
akerr_ErrorContext ERROR_NOIGNORE *f(...); akerr_ErrorContext AKERROR_NOIGNORE *f(...);
``` ```
This will cause a compile-time error if the return value of such a function is not used. "Used" here means assigned to a variable - it does not necessarily mean that the value is checked. However assuming that such functions are called inside of `ATTEMPT { ... }` blocks, it is safe to assume that such returns will be caught with `CATCH(...)`; therefore this error is a generally effective safeguard against careless coding where errors are not checked. This will cause a compile-time error if the return value of such a function is not used. "Used" here means assigned to a variable - it does not necessarily mean that the value is checked. However assuming that such functions are called inside of `ATTEMPT { ... }` blocks, it is safe to assume that such returns will be caught with `CATCH(...)`; therefore this error is a generally effective safeguard against careless coding where errors are not checked.
Beware that `ERROR_NOIGNORE` is not a failsafe - it implements the `warn_unused_result` mechanic. By design users may explicitly ignore an error code from a function marked with `warn_unused_result` by explicitly casting the return to `void`. Beware that `AKERROR_NOIGNORE` is not a failsafe - it implements the `warn_unused_result` mechanic. By design users may explicitly ignore an error code from a function marked with `warn_unused_result` by explicitly casting the return to `void`.
```c ```c
#define ERROR_NOIGNORE __attribute__((warn_unused_result)) #define AKERROR_NOIGNORE __attribute__((warn_unused_result))
``` ```
## Stack Traces ## Stack Traces

View File

@@ -6,33 +6,44 @@
#include <stdbool.h> #include <stdbool.h>
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
#include <limits.h>
#endif #endif
#define AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH 1024 // 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
// shut up, it's enormous (PATH_MAX*3).
#define AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH 12384
#define AKERR_MAX_ERROR_NAME_LENGTH 64 #define AKERR_MAX_ERROR_NAME_LENGTH 64
#define AKERR_MAX_ERROR_FNAME_LENGTH 256 #define AKERR_MAX_ERROR_FNAME_LENGTH PATH_MAX
#define AKERR_MAX_ERROR_FUNCTION_LENGTH 128 #define AKERR_MAX_ERROR_FUNCTION_LENGTH 128
#define AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH 2048 #define AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH (AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH + AKERR_MAX_ERROR_NAME_LENGTH + AKERR_MAX_ERROR_FNAME_LENGTH + AKERR_MAX_ERROR_FUNCTION_LENGTH + 16)
#define AKERR_LAST_ERRNO_VALUE AKERR_LAST_ERRNO_VALUE_SED #define AKERR_LAST_ERRNO_VALUE AKERR_LAST_ERRNO_VALUE_SED
#define AKERR_NULLPOINTER (AKERR_LAST_ERRNO_VALUE + 1) #define AKERR_NULLPOINTER (AKERR_LAST_ERRNO_VALUE + 1) /** A pointer had a NULL value where such was not permissible */
#define AKERR_OUTOFBOUNDS (AKERR_LAST_ERRNO_VALUE + 2) #define AKERR_OUTOFBOUNDS (AKERR_LAST_ERRNO_VALUE + 2) /** Attempt to access a datastructure outside of bounds */
#define AKERR_API (AKERR_LAST_ERRNO_VALUE + 3) #define AKERR_API (AKERR_LAST_ERRNO_VALUE + 3) /** An otherwise unspecified API contract has been violated */
#define AKERR_ATTRIBUTE (AKERR_LAST_ERRNO_VALUE + 4) #define AKERR_ATTRIBUTE (AKERR_LAST_ERRNO_VALUE + 4) /** Relates to accessing of attributes on objects */
#define AKERR_TYPE (AKERR_LAST_ERRNO_VALUE + 5) #define AKERR_TYPE (AKERR_LAST_ERRNO_VALUE + 5) /** An object had the incorrect type */
#define AKERR_KEY (AKERR_LAST_ERRNO_VALUE + 6) #define AKERR_KEY (AKERR_LAST_ERRNO_VALUE + 6) /** A key was either invalid for or not present in a map */
#define AKERR_HEAP (AKERR_LAST_ERRNO_VALUE + 7) #define AKERR_INDEX (AKERR_LAST_ERRNO_VALUE + 8) /** An error occurred when attempting to index an indexable datastructure (other than out of bounds) */
#define AKERR_INDEX (AKERR_LAST_ERRNO_VALUE + 8) #define AKERR_FORMAT (AKERR_LAST_ERRNO_VALUE + 9) /** An error occurred in the formatting of an object (usually a string) */
#define AKERR_FORMAT (AKERR_LAST_ERRNO_VALUE + 9) #define AKERR_IO (AKERR_LAST_ERRNO_VALUE + 10) /** An unspecified IO error occurred. */
#define AKERR_IO (AKERR_LAST_ERRNO_VALUE + 10) #define AKERR_VALUE (AKERR_LAST_ERRNO_VALUE + 11) /** A provided value was invalid */
#define AKERR_REGISTRY (AKERR_LAST_ERRNO_VALUE + 11) #define AKERR_RELATIONSHIP (AKERR_LAST_ERRNO_VALUE + 12) /** An error occurred in establishing, maintaining or severing a relationship between two objects */
#define AKERR_VALUE (AKERR_LAST_ERRNO_VALUE + 12) #define AKERR_EOF (AKERR_LAST_ERRNO_VALUE + 13) /** The end of a stream or file has been encountered */
#define AKERR_BEHAVIOR (AKERR_LAST_ERRNO_VALUE + 13) #define AKERR_CIRCULAR_REFERENCE (AKERR_LAST_ERRNO_VALUE + 14) /** Indicates that a circular reference has been found in a linked list */
#define AKERR_RELATIONSHIP (AKERR_LAST_ERRNO_VALUE + 14) #define AKERR_ITERATOR_BREAK (AKERR_LAST_ERRNO_VALUE + 15) /** Used to prematurely end an iteration cycle (such as when searching a graph and the desired node has been found) */
#define AKERR_NOT_IMPLEMENTED (AKERR_LAST_ERRNO_VALUE + 16) /** A method was called that is defined but not currently implemented */
#define AKERR_BADEXC (AKERR_LAST_ERRNO_VALUE + 17) /** The libakerr library was given an akerr_ErrorContext to parse that did not come from AKERR_ARRAY_ERROR (likely an uninitialized pointer) */
#ifndef AKERR_MAX_ERR_VALUE #ifndef AKERR_MAX_ERR_VALUE
#define AKERR_MAX_ERR_VALUE (AKERR_LAST_ERRNO_VALUE + 14) /* Must be >= the highest AKERR_* offset above (AKERR_BADEXC, +17) so every
* library error code is indexable in __AKERR_ERROR_NAMES. Keep in sync when
* adding codes; tests/err_maxval.c guards this invariant. */
#define AKERR_MAX_ERR_VALUE (AKERR_LAST_ERRNO_VALUE + 17)
#elif AKERR_MAX_ERR_VALUE < 256 #elif AKERR_MAX_ERR_VALUE < 256
#error user-defined AKERR_MAX_ERR_VALUE must be >= 256 #error user-defined AKERR_MAX_ERR_VALUE must be >= 256
#endif #endif
@@ -55,7 +66,6 @@ typedef struct
bool reported; bool reported;
char stacktracebuf[AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH]; char stacktracebuf[AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH];
char *stacktracebufptr; char *stacktracebufptr;
int stacktracebufremaining;
} akerr_ErrorContext; } akerr_ErrorContext;
#define AKERR_NOIGNORE __attribute__((warn_unused_result)) #define AKERR_NOIGNORE __attribute__((warn_unused_result))
@@ -68,13 +78,13 @@ extern akerr_ErrorUnhandledErrorHandler akerr_handler_unhandled_error;
extern akerr_ErrorLogFunction akerr_log_method; extern akerr_ErrorLogFunction akerr_log_method;
extern akerr_ErrorContext *__akerr_last_ignored; extern akerr_ErrorContext *__akerr_last_ignored;
akerr_ErrorContext AKERR_NOIGNORE *akerr_user_main(int argc, char **argv);
akerr_ErrorContext AKERR_NOIGNORE *akerr_release_error(akerr_ErrorContext *ptr); akerr_ErrorContext AKERR_NOIGNORE *akerr_release_error(akerr_ErrorContext *ptr);
akerr_ErrorContext AKERR_NOIGNORE *akerr_next_error(); akerr_ErrorContext AKERR_NOIGNORE *akerr_next_error();
char *akerr_name_for_status(int status, char *name); char *akerr_name_for_status(int status, char *name);
void akerr_init(); void akerr_init();
void akerr_default_handler_unhandled_error(akerr_ErrorContext *ptr); void akerr_default_handler_unhandled_error(akerr_ErrorContext *ptr);
void akerr_default_logger(const char *f, ...); void akerr_default_logger(const char *f, ...);
int akerr_valid_error_address(akerr_ErrorContext *ptr);
/* defined in src/errno.c which is built dynamically at build time from system errno definitions */ /* defined in src/errno.c which is built dynamically at build time from system errno definitions */
void akerr_init_errno(void); void akerr_init_errno(void);
@@ -177,10 +187,16 @@ void akerr_init_errno(void);
switch ( 0 ) { \ switch ( 0 ) { \
case 0: \ case 0: \
#define VALID(__err_context, __stmt) \
__stmt; \
if ( akerr_valid_error_address(__err_context) == 0 ) { \
__err_context = NULL; \
FAIL(__err_context, AKERR_BADEXC, "Received (akerr_ErrorContext *) from an invalid memory region. (Did the method finish without calling SUCCEED_RETURN?)"); \
}
#define DETECT(__err_context, __stmt) \ #define DETECT(__err_context, __stmt) \
__stmt; \ VALID(__err_context, __stmt); \
if ( __err_context != NULL ) { \ if ( __err_context != NULL ) { \
__err_context->stacktracebufptr += snprintf(__err_context->stacktracebufptr, AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH, "%s:%s:%d: Detected error %d from array (refcount %d)\n", (char *)__FILE__, (char *)__func__, __LINE__, __err_context->arrayid, __err_context->refcount); \
if ( __err_context->status != 0 ) { \ if ( __err_context->status != 0 ) { \
__err_context->stacktracebufptr += snprintf(__err_context->stacktracebufptr, AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH, "%s:%s:%d\n", (char *)__FILE__, (char *)__func__, __LINE__); \ __err_context->stacktracebufptr += snprintf(__err_context->stacktracebufptr, AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH, "%s:%s:%d\n", (char *)__FILE__, (char *)__func__, __LINE__); \
break; \ break; \
@@ -190,6 +206,13 @@ void akerr_init_errno(void);
#define CATCH(__err_context, __stmt) \ #define CATCH(__err_context, __stmt) \
DETECT(__err_context, __err_context = __stmt); DETECT(__err_context, __err_context = __stmt);
#define PASS(__err_context, __stmt) \
switch ( 0 ) { \
case 0: \
DETECT(__err_context, __err_context = __stmt); \
} \
FINISH_LOGIC(__err_context, true);
#define IGNORE(__stmt) \ #define IGNORE(__stmt) \
__akerr_last_ignored = __stmt; \ __akerr_last_ignored = __stmt; \
if ( __akerr_last_ignored != NULL ) { \ if ( __akerr_last_ignored != NULL ) { \
@@ -222,15 +245,17 @@ void akerr_init_errno(void);
__err_context->stacktracebufptr = (char *)&__err_context->stacktracebuf; \ __err_context->stacktracebufptr = (char *)&__err_context->stacktracebuf; \
__err_context->handled = true; __err_context->handled = true;
#define FINISH(__err_context, __pass_up) \ #define FINISH_LOGIC(__err_context, __pass_up) \
}; \
}; \
if ( __err_context != NULL ) { \ if ( __err_context != NULL ) { \
if ( __err_context->handled == false && __pass_up == true ) { \ if ( __err_context->handled == false && __pass_up == true ) { \
__err_context->stacktracebufptr += snprintf(__err_context->stacktracebufptr, AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH, "%s:%s:%d\n", (char *)__FILE__, (char *)__func__, __LINE__); \
return __err_context; \ return __err_context; \
} \ } \
} \ } \
#define FINISH(__err_context, __pass_up) \
}; \
}; \
FINISH_LOGIC(__err_context, __pass_up) \
RELEASE_ERROR(__err_context); RELEASE_ERROR(__err_context);
#define FINISH_NORETURN(__err_context) \ #define FINISH_NORETURN(__err_context) \
@@ -244,12 +269,4 @@ void akerr_init_errno(void);
} \ } \
RELEASE_ERROR(__err_context); RELEASE_ERROR(__err_context);
#define CATCH_AND_RETURN(__err_context, __stmt) \
ATTEMPT { \
CATCH(__err_context, __stmt); \
} CLEANUP { \
} PROCESS(__err_context) { \
} FINISH(__err_context, true);
#endif // _AKERR_H_ #endif // _AKERR_H_

View File

@@ -1,16 +1,20 @@
#!/bin/bash #!/bin/bash
srcdir=$1 srcdir=$1
rm -f ${srcdir}/src/errno.c outdir=$2
echo "#include <akerror.h>" >> ${srcdir}/src/errno.c
echo "#include <errno.h>" >> ${srcdir}/src/errno.c mkdir -p ${outdir}/src
echo "void akerr_init_errno(void) {" >> ${srcdir}/src/errno.c mkdir -p ${outdir}/include
rm -f ${outdir}/src/errno.c
echo "#include <akerror.h>" >> ${outdir}/src/errno.c
echo "#include <errno.h>" >> ${outdir}/src/errno.c
echo "void akerr_init_errno(void) {" >> ${outdir}/src/errno.c
maxval=$(errno --list | cut -d ' ' -f 2 | sort -g | tail -n 1) maxval=$(errno --list | cut -d ' ' -f 2 | sort -g | tail -n 1)
errno --list | while read LINE; do errno --list | while read LINE; do
define=$(echo "$LINE" | cut -d ' ' -f 1); define=$(echo "$LINE" | cut -d ' ' -f 1);
value=$(echo "$LINE" | cut -d ' ' -f 2); value=$(echo "$LINE" | cut -d ' ' -f 2);
desc=$(echo "$LINE" | cut -d ' ' -f 3-); desc=$(echo "$LINE" | cut -d ' ' -f 3-);
echo " akerr_name_for_status(${define}, \"${desc}\");" >> ${srcdir}/src/errno.c ; echo " akerr_name_for_status(${define}, \"${desc}\");" >> ${outdir}/src/errno.c ;
done; done;
echo "}" >> ${srcdir}/src/errno.c echo "}" >> ${outdir}/src/errno.c
sed "s/#define AKERR_LAST_ERRNO_VALUE .*/#define AKERR_LAST_ERRNO_VALUE ${maxval}/" ${srcdir}/include/akerror.tmpl.h > ${srcdir}/include/akerror.h sed "s/#define AKERR_LAST_ERRNO_VALUE .*/#define AKERR_LAST_ERRNO_VALUE ${maxval}/" ${srcdir}/include/akerror.tmpl.h > ${outdir}/include/akerror.h

436
scripts/mutation_test.py Executable file
View File

@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""
Mutation testing harness for libakerror.
Mutation testing measures how good the test suite is at catching bugs. It works
by making many small, deliberate breakages ("mutants") to the library source --
flipping a comparison, deleting a statement, swapping true/false -- and then
running the whole CTest suite against each one. If the tests fail, the mutant is
"killed" (good: the tests noticed the bug). If the tests still pass, the mutant
"survived" (bad: a real bug of that shape would slip through unnoticed).
The mutation score is killed / (killed + survived). Surviving mutants are printed
with file:line and the exact change so they can be turned into new test cases.
This harness has no third-party dependencies (Python stdlib + the project's
normal cmake/ctest toolchain). It never mutates the real working tree: it copies
the repo to a scratch directory and mutates there.
Usage:
scripts/mutation_test.py [options]
--source-root DIR repo root to copy (default: parent of this script's dir)
--target FILE source file to mutate, relative to root; repeatable.
Default: src/error.c and include/akerror.tmpl.h
--work DIR scratch dir for the mutated copy (default: a temp dir)
--timeout SECONDS per-suite ctest timeout (default: 120)
--threshold PCT exit non-zero if mutation score < PCT (default: 0 = off)
--list only list the mutants that would be run, then exit
--keep keep the scratch working copy on exit (for debugging)
-j N (reserved) currently runs sequentially
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
# --------------------------------------------------------------------------- #
# Mutation operators
#
# Each operator yields zero or more (start, end, replacement) edits for a single
# line of source. The driver applies exactly one edit per mutant so every mutant
# differs from the original by one localized change.
# --------------------------------------------------------------------------- #
# Relational operator replacement: map each operator to the alternatives that
# meaningfully change behaviour (not merely the strict negation).
_REL = {
"==": ["!="],
"!=": ["=="],
"<=": ["<", "=="],
">=": [">", "=="],
"<": ["<=", ">"],
">": [">=", "<"],
}
# Match a relational operator that is NOT part of ->, <<, >>, =>, <=, >=, ==, !=
# unless we intend it. We tokenize the two-char operators first, then single.
_REL_TWO = re.compile(r"(==|!=|<=|>=)")
_REL_ONE = re.compile(r"(?<![-<>=!+])([<>])(?![=<>])")
_LOGICAL = {"&&": "||", "||": "&&"}
_LOG_RE = re.compile(r"(&&|\|\|)")
_BOOL = {"true": "false", "false": "true"}
_BOOL_RE = re.compile(r"\b(true|false)\b")
# Arithmetic / compound-assignment on whitespace-delimited operands only, to
# avoid touching ++, --, ->, unary signs, or pointer/format punctuation.
_ARITH_RE = re.compile(r"(?<=\s)([+\-])(?=\s)")
_ARITH = {"+": "-", "-": "+"}
_COMPOUND_RE = re.compile(r"(\+=|-=)")
_COMPOUND = {"+=": "-=", "-=": "+="}
# Integer literal replacement: 0 <-> 1 (word-bounded, not inside identifiers or
# larger numbers, not a float).
_INT_RE = re.compile(r"(?<![\w.])([01])(?![\w.])")
_INT = {"0": "1", "1": "0"}
def _op_edits(line):
"""Yield (tag, start, end, replacement) for every candidate mutation."""
# Relational (two-char first so we don't split them with the one-char pass)
for m in _REL_TWO.finditer(line):
for alt in _REL[m.group(1)]:
yield ("ROR", m.start(1), m.end(1), alt)
for m in _REL_ONE.finditer(line):
for alt in _REL[m.group(1)]:
yield ("ROR", m.start(1), m.end(1), alt)
for m in _LOG_RE.finditer(line):
yield ("LCR", m.start(1), m.end(1), _LOGICAL[m.group(1)])
for m in _BOOL_RE.finditer(line):
yield ("BCR", m.start(1), m.end(1), _BOOL[m.group(1)])
for m in _COMPOUND_RE.finditer(line):
yield ("AOR", m.start(1), m.end(1), _COMPOUND[m.group(1)])
for m in _ARITH_RE.finditer(line):
yield ("AOR", m.start(1), m.end(1), _ARITH[m.group(1)])
for m in _INT_RE.finditer(line):
yield ("ICR", m.start(1), m.end(1), _INT[m.group(1)])
# Statement-deletion: neutralize a whole statement. We only delete statements
# that are safe to drop without guaranteeing a compile error, so a surviving
# deletion is a genuine test gap rather than compiler noise.
_STMT_DELETABLE = re.compile(
r"""^\s*(
break |
return\b[^;]* |
[A-Za-z_][-\w>().\[\]* ]*\s*=\s*[^;]* | # assignments
[A-Za-z_][\w]*\s*\([^;]*\) # bare function calls
)\s*;\s*(\\?)\s*$""",
re.VERBOSE,
)
# --------------------------------------------------------------------------- #
# Deciding which lines are eligible to mutate
# --------------------------------------------------------------------------- #
# Skip preprocessor control and the block of constant/error-code #defines in the
# template header: mutating buffer sizes or renumbering error codes produces
# equivalent or uninteresting mutants that swamp the signal.
_SKIP_LINE = re.compile(
r"""^\s*(
\#\s*(include|ifn?def|ifdef|if|elif|else|endif|error|pragma|undef) |
\#\s*define\s+AKERR_(MAX|LAST|NULLPOINTER|OUTOFBOUNDS|API|ATTRIBUTE|
TYPE|KEY|INDEX|FORMAT|IO|VALUE|RELATIONSHIP|EOF|CIRCULAR_REFERENCE|
ITERATOR_BREAK|NOT_IMPLEMENTED|BADEXC|NOIGNORE|USE_STDLIB)\b |
\* | // # comment bodies / line comments
)""",
re.VERBOSE,
)
def _is_comment_or_blank(line):
s = line.strip()
return (not s) or s.startswith("//") or s.startswith("/*") or s.startswith("*")
def eligible(line):
if _is_comment_or_blank(line):
return False
if _SKIP_LINE.match(line):
return False
return True
class Mutant:
__slots__ = ("path", "lineno", "op", "before", "after", "col")
def __init__(self, path, lineno, op, before, after, col):
self.path = path
self.lineno = lineno
self.op = op
self.before = before
self.after = after
self.col = col
def describe(self):
return (f"{self.path}:{self.lineno} [{self.op}] "
f"col{self.col}: {self.before.strip()} -> {self.after.strip()}")
def generate_mutants(root, rel_target):
"""Enumerate all mutants for one target file."""
abspath = os.path.join(root, rel_target)
with open(abspath, "r") as fh:
lines = fh.readlines()
mutants = []
for i, line in enumerate(lines, start=1):
if not eligible(line):
continue
# substitution operators
seen = set()
for tag, s, e, repl in _op_edits(line):
key = (s, e, repl)
if key in seen:
continue
seen.add(key)
mutated = line[:s] + repl + line[e:]
if mutated == line:
continue
mutants.append(Mutant(rel_target, i, tag, line, mutated, s))
# statement deletion
m = _STMT_DELETABLE.match(line)
if m:
indent = line[: len(line) - len(line.lstrip())]
cont = "\\" if line.rstrip().endswith("\\") else ""
deleted = f"{indent}/* mutant: deleted */ {cont}\n" if cont else f"{indent};\n"
mutants.append(Mutant(rel_target, i, "SDL", line, deleted, 0))
return mutants
# --------------------------------------------------------------------------- #
# Build / test orchestration against a scratch copy
# --------------------------------------------------------------------------- #
class Runner:
def __init__(self, work, timeout):
self.work = work
self.build = os.path.join(work, "build")
self.timeout = timeout
def _run(self, cmd, timeout=None):
return subprocess.run(
cmd, cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout,
)
def configure(self):
r = self._run(["cmake", "-S", ".", "-B", "build"], timeout=self.timeout)
return r.returncode == 0, r.stdout
def build_and_test(self):
"""Return ('killed-compile' | 'killed-test' | 'killed-timeout' | 'survived')."""
try:
b = self._run(["cmake", "--build", "build"], timeout=self.timeout)
except subprocess.TimeoutExpired:
return "killed-timeout"
if b.returncode != 0:
return "killed-compile"
try:
t = subprocess.run(
["ctest", "--test-dir", "build", "--output-on-failure",
"--stop-on-failure"],
cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=self.timeout,
)
except subprocess.TimeoutExpired:
return "killed-timeout"
return "survived" if t.returncode == 0 else "killed-test"
def _xml_escape(s):
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
def write_junit(path, records, targets):
"""Write a JUnit XML report. One <testcase> per mutant; a surviving mutant
is a <failure> (test-suite gap), a killed mutant is a passing case."""
by_file = {t: [] for t in targets}
for m, result in records:
by_file.setdefault(m.path, []).append((m, result))
total = len(records)
total_fail = sum(1 for _, r in records if r == "survived")
out = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<testsuites name="mutation" tests="{total}" failures="{total_fail}">']
for f, items in by_file.items():
if not items:
continue
fails = sum(1 for _, r in items if r == "survived")
out.append(f' <testsuite name="mutation:{_xml_escape(f)}" '
f'tests="{len(items)}" failures="{fails}">')
for m, result in items:
name = _xml_escape(m.describe())
cls = "mutation." + _xml_escape(m.path)
if result == "survived":
detail = _xml_escape(f"{m.before.strip()} -> {m.after.strip()}")
out.append(f' <testcase name="{name}" classname="{cls}" time="0">')
out.append(f' <failure message="survived mutant '
f'({_xml_escape(m.op)} at {_xml_escape(m.path)}:'
f'{m.lineno})">{detail}</failure>')
out.append(' </testcase>')
else:
out.append(f' <testcase name="{name}" classname="{cls}" '
f'time="0"><system-out>{_xml_escape(result)}'
'</system-out></testcase>')
out.append(' </testsuite>')
out.append('</testsuites>')
with open(path, "w") as fh:
fh.write("\n".join(out) + "\n")
def copy_tree(src, dst):
ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~",
"#*#", "*.iso", "*.png")
shutil.copytree(src, dst, ignore=ignore, symlinks=True)
def read_lines(path):
with open(path) as fh:
return fh.readlines()
def write_lines(path, lines):
with open(path, "w") as fh:
fh.writelines(lines)
def main():
# Line-buffer stdout so progress is visible live under CI / the cmake target.
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
here = os.path.dirname(os.path.abspath(__file__))
default_root = os.path.dirname(here)
ap = argparse.ArgumentParser(description="Mutation testing for libakerror")
ap.add_argument("--source-root", default=default_root)
ap.add_argument("--target", action="append", default=None)
ap.add_argument("--work", default=None)
ap.add_argument("--timeout", type=int, default=120)
ap.add_argument("--threshold", type=float, default=0.0)
ap.add_argument("--junit", default=None,
help="write a JUnit XML report to this path")
ap.add_argument("--max-mutants", type=int, default=0,
help="cap the run at N evenly-sampled mutants (0 = all)")
ap.add_argument("--list", action="store_true")
ap.add_argument("--keep", action="store_true")
ap.add_argument("-j", type=int, default=1)
args = ap.parse_args()
root = os.path.abspath(args.source_root)
targets = args.target or ["src/error.c", "include/akerror.tmpl.h"]
# Enumerate mutants from the pristine sources.
all_mutants = []
for t in targets:
all_mutants.extend(generate_mutants(root, t))
print(f"Generated {len(all_mutants)} mutants across {len(targets)} file(s):")
for t in targets:
n = sum(1 for m in all_mutants if m.path == t)
print(f" {t}: {n}")
# Optional even-strided sampling to bound run time (CI / smoke tests).
if args.max_mutants and len(all_mutants) > args.max_mutants:
step = len(all_mutants) / args.max_mutants
sampled = [all_mutants[int(i * step)] for i in range(args.max_mutants)]
print(f"Sampling {len(sampled)} of {len(all_mutants)} mutants "
f"(--max-mutants {args.max_mutants}).")
all_mutants = sampled
if args.list:
for m in all_mutants:
print(" " + m.describe())
return 0
if not all_mutants:
print("No mutants generated; nothing to do.")
return 0
# Scratch working copy.
work_parent = args.work or tempfile.mkdtemp(prefix="akerr_mut_")
work = os.path.join(work_parent, "src") if args.work else work_parent
if os.path.exists(work):
shutil.rmtree(work)
print(f"\nCopying sources to scratch dir: {work}")
copy_tree(root, work)
runner = Runner(work, args.timeout)
print("Configuring baseline ...")
ok, out = runner.configure()
if not ok:
sys.stderr.write(out.decode(errors="replace"))
sys.stderr.write("\nBaseline configure FAILED; aborting.\n")
return 2
print("Verifying baseline is green (no mutation) ...")
baseline = runner.build_and_test()
if baseline != "survived":
sys.stderr.write(f"Baseline is not green ({baseline}); aborting. "
"Fix the suite before mutation testing.\n")
return 2
print("Baseline OK.\n")
# Group mutants by file so we mutate one file at a time and restore it.
killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0}
survivors = []
records = []
total = len(all_mutants)
# Cache pristine contents per target.
pristine = {t: read_lines(os.path.join(work, t)) for t in targets}
for idx, m in enumerate(all_mutants, start=1):
tgt_abs = os.path.join(work, m.path)
lines = list(pristine[m.path])
lines[m.lineno - 1] = m.after
write_lines(tgt_abs, lines)
try:
result = runner.build_and_test()
finally:
write_lines(tgt_abs, pristine[m.path]) # always restore
records.append((m, result))
if result == "survived":
survivors.append(m)
mark = "SURVIVED"
else:
killed[result] += 1
mark = result.upper()
print(f"[{idx}/{total}] {mark:16} {m.describe()}")
total_killed = sum(killed.values())
score = 100.0 * total_killed / total if total else 100.0
print("\n" + "=" * 72)
print("MUTATION TESTING SUMMARY")
print("=" * 72)
print(f" total mutants : {total}")
print(f" killed (test) : {killed['killed-test']}")
print(f" killed (compile): {killed['killed-compile']}")
print(f" killed (timeout): {killed['killed-timeout']}")
print(f" survived : {len(survivors)}")
print(f" mutation score : {score:.1f}%")
if survivors:
print("\nSurviving mutants (test-suite gaps -- turn these into tests):")
for m in survivors:
print(" " + m.describe())
if args.junit:
junit_path = os.path.abspath(args.junit)
write_junit(junit_path, records, targets)
print(f"\nJUnit report written to: {junit_path}")
if not args.keep and not args.work:
shutil.rmtree(work_parent, ignore_errors=True)
else:
print(f"\nScratch working copy kept at: {work}")
if args.threshold > 0 and score < args.threshold:
print(f"\nFAIL: mutation score {score:.1f}% < threshold {args.threshold:.1f}%")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -14,26 +14,14 @@ char __AKERR_ERROR_NAMES[AKERR_MAX_ERR_VALUE+1][AKERR_MAX_ERROR_NAME_LENGTH];
akerr_ErrorContext AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR]; akerr_ErrorContext AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR];
int main(int argc, char **argv) int akerr_valid_error_address(akerr_ErrorContext *ptr)
{ {
int i = 0; // Is this within the memory region occupied by AKERR_ARRAY_ERROR?
PREPARE_ERROR(e); if ( ptr == NULL ) {
ATTEMPT { return 1;
CATCH(e, akerr_user_main(argc, argv)); }
if ( e == NULL || e->status == 0 ) { return ((ptr >= &AKERR_ARRAY_ERROR[0]) &&
/* User code claims everything went fine, doublecheck the error array before we quit */ (ptr <= &AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR-1]));
for ( i = AKERR_MAX_ARRAY_ERROR - 1; i >= 0; i-- ) {
if ( AKERR_ARRAY_ERROR[i].status != 0 && AKERR_ARRAY_ERROR[i].refcount != 0 ) {
akerr_log_method("%s:%s:%d: Found unhandled and unreported error in the array at index %d\n", __FILE__, (char *)__func__, __LINE__, i);
e = &AKERR_ARRAY_ERROR[i];
}
}
}
} CLEANUP {
} PROCESS(e) {
} FINISH_NORETURN(e);
SUCCEED(e);
return 0;
} }
void akerr_default_logger(const char *fmt, ...) void akerr_default_logger(const char *fmt, ...)
@@ -73,15 +61,16 @@ void akerr_init()
akerr_name_for_status(AKERR_ATTRIBUTE, "Attribute Error"); akerr_name_for_status(AKERR_ATTRIBUTE, "Attribute Error");
akerr_name_for_status(AKERR_TYPE, "Type Error"); akerr_name_for_status(AKERR_TYPE, "Type Error");
akerr_name_for_status(AKERR_KEY, "Key Error"); akerr_name_for_status(AKERR_KEY, "Key Error");
akerr_name_for_status(AKERR_HEAP, "Heap Error");
akerr_name_for_status(AKERR_INDEX, "Index Error"); akerr_name_for_status(AKERR_INDEX, "Index Error");
akerr_name_for_status(AKERR_FORMAT, "Format Error"); akerr_name_for_status(AKERR_FORMAT, "Format Error");
akerr_name_for_status(AKERR_IO, "Input Output Error"); akerr_name_for_status(AKERR_IO, "Input Output Error");
akerr_name_for_status(AKERR_REGISTRY, "Registry Error");
akerr_name_for_status(AKERR_VALUE, "Value Error"); akerr_name_for_status(AKERR_VALUE, "Value Error");
akerr_name_for_status(AKERR_BEHAVIOR, "Behavior Error");
akerr_name_for_status(AKERR_RELATIONSHIP, "Relationship Error"); akerr_name_for_status(AKERR_RELATIONSHIP, "Relationship Error");
akerr_name_for_status(AKERR_CIRCULAR_REFERENCE, "Circular Reference Error");
akerr_name_for_status(AKERR_BADEXC, "Invalid akerr_ErrorContext");
#if (defined(AKERR_USE_STDLIB) && AKERR_USE_STDLIB == 1) || (!defined(AKERR_USE_STDLIB))
akerr_init_errno();
#endif
inited = 1; inited = 1;
} }
} }

117
tests/MUTATION.md Normal file
View File

@@ -0,0 +1,117 @@
# Mutation testing
The unit tests tell us the library works. **Mutation testing tells us the tests
work** — that they would actually fail if the library were broken.
`scripts/mutation_test.py` deliberately breaks the library in small ways
("mutants"), one at a time, and runs the whole CTest suite against each broken
copy:
* if the tests **fail**, the mutant is **killed** — good, the suite caught it;
* if the tests still **pass**, the mutant **survived** — a bug of that shape
would slip through, so it points at a missing test.
The **mutation score** is `killed / (killed + survived)`. A surviving mutant is
a to-do item: write a test that distinguishes the mutant from the original.
## Running
No third-party tools are required — just Python 3 and the normal
cmake/ctest toolchain. The harness never touches your working tree; it copies
the repo to a scratch directory and mutates the copy.
```sh
# Default: mutate src/error.c and include/akerror.tmpl.h
scripts/mutation_test.py
# Faster: just the C source
scripts/mutation_test.py --target src/error.c
# See what would run without building anything
scripts/mutation_test.py --target src/error.c --list
# Gate CI: exit non-zero if the score drops below 90%
scripts/mutation_test.py --threshold 90
```
Via CMake (configures a build first if needed):
```sh
cmake --build build --target mutation
```
Useful flags: `--timeout SECONDS` (per-suite build+test cap; a mutant that
hangs is counted as killed), `--keep` (retain the scratch copy for debugging),
`--work DIR` (use a specific scratch directory), `--junit FILE` (write a JUnit
XML report — surviving mutants appear as failing test cases).
## CI reporting
Both the unit tests and the mutation run emit JUnit XML that CI consumes:
* `ctest --test-dir build --output-junit "$(pwd)/ctest-junit.xml"` — note the
absolute path; `--output-junit` otherwise resolves relative to the test dir.
* `scripts/mutation_test.py --junit mutation-junit.xml`
`.gitea/workflows/ci.yaml` runs both and feeds the XML to
`mikepenz/action-junit-report` (with `if: always()`, so results publish even
when a gate fails). The reporter runs with `annotate_only: true`: Gitea does not
implement the Checks API the action uses to create a check run, so creating one
404s (mikepenz/action-junit-report#23). `annotate_only` skips that call and the
results surface via the job summary (`detailed_summary: true`) instead. The
generated `*-junit.xml` files are git-ignored.
## Mutation operators
Each mutant changes exactly one location by one of:
| Tag | Operator | Example |
|-----|--------------------------------|----------------------------------|
| ROR | relational operator | `==``!=`, `<``<=`, `>=``>` |
| LCR | logical connector | `&&``\|\|` |
| BCR | boolean constant | `true``false` |
| AOR | arithmetic / compound assign | `+``-`, `+=``-=` |
| ICR | integer literal | `0``1`, `1``0` |
| SDL | statement deletion | `err->refcount += 1;`*(removed)* |
Preprocessor control lines, comments, and the block of error-code / buffer-size
`#define`s are skipped: mutating those produces equivalent or uninteresting
mutants that only add noise.
## Interpreting survivors
Not every survivor is a test gap — some mutants are **equivalent** (they don't
change observable behaviour, e.g. resizing an internal scratch buffer). For each
survivor, decide:
1. **Real gap** → add or strengthen a test in `tests/` so the mutant is killed,
then re-run.
2. **Equivalent mutant** → no test can catch it; leave a note. If a specific
line is a persistent source of equivalents, narrow the target with
`--target` or extend the skip rules in `scripts/mutation_test.py`.
Re-run after adding tests and confirm the score went up.
## Current status
`src/error.c` scores ~74% (the CI gate is set to 65% for headroom). The
remaining survivors are dominated by:
* **Equivalent mutants** in `akerr_init`: deleting the `memset`/`NULL` setup of
file-scope statics (`AKERR_ARRAY_ERROR`, `__akerr_last_ditch`,
`__akerr_last_ignored`) changes nothing, because C already zero-initializes
objects with static storage duration. `int oldid = 0;``1` is likewise
dead: it is overwritten before use.
* **Default logger / handler internals** (`vfprintf`, `va_end`, the
`errctx == NULL` branch, `exit(1)`): killing these needs a subprocess-based
test that captures a child's stderr and exit code, rather than the in-process
capturing logger the other tests use.
Findings surfaced by mutation testing:
* **Fixed:** `AKERR_MAX_ERR_VALUE` was `AKERR_LAST_ERRNO_VALUE + 15`, below
`AKERR_NOT_IMPLEMENTED` (+16) and `AKERR_BADEXC` (+17). `akerr_name_for_status`
rejects any status `> AKERR_MAX_ERR_VALUE`, so those codes could never store or
return a name and the `akerr_name_for_status(AKERR_BADEXC, ...)` call in
`akerr_init` was dead code (which is why deleting it survived). The max is now
`+ 17`, and `tests/err_maxval.c` guards the invariant so it can't regress.

View File

@@ -0,0 +1,92 @@
#include "akerror.h"
#include "err_capture.h"
/*
* Cover the conditional break/return failure macros:
* FAIL_ZERO_BREAK / FAIL_NONZERO_BREAK / FAIL_BREAK (inside ATTEMPT)
* FAIL_ZERO_RETURN / FAIL_NONZERO_RETURN / FAIL_RETURN (direct return)
* Each is checked in both its failing and its non-failing (pass-through) state.
*/
akerr_ErrorContext *zero_break(int x)
{
PREPARE_ERROR(e);
ATTEMPT {
FAIL_ZERO_BREAK(e, x, AKERR_VALUE, "x was zero");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *nonzero_break(int x)
{
PREPARE_ERROR(e);
ATTEMPT {
FAIL_NONZERO_BREAK(e, x, AKERR_INDEX, "x was nonzero");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *always_break(void)
{
PREPARE_ERROR(e);
ATTEMPT {
FAIL_BREAK(e, AKERR_IO, "always");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *zero_return(int x)
{
PREPARE_ERROR(e);
FAIL_ZERO_RETURN(e, x, AKERR_KEY, "x was zero");
SUCCEED_RETURN(e);
}
akerr_ErrorContext *nonzero_return(int x)
{
PREPARE_ERROR(e);
FAIL_NONZERO_RETURN(e, x, AKERR_TYPE, "x was nonzero");
SUCCEED_RETURN(e);
}
int main(void)
{
akerr_capture_install();
akerr_init();
akerr_ErrorContext *r;
r = zero_break(0);
AKERR_CHECK(r != NULL && r->status == AKERR_VALUE);
r = akerr_release_error(r);
AKERR_CHECK(zero_break(7) == NULL);
r = nonzero_break(7);
AKERR_CHECK(r != NULL && r->status == AKERR_INDEX);
r = akerr_release_error(r);
AKERR_CHECK(nonzero_break(0) == NULL);
r = always_break();
AKERR_CHECK(r != NULL && r->status == AKERR_IO);
r = akerr_release_error(r);
r = zero_return(0);
AKERR_CHECK(r != NULL && r->status == AKERR_KEY);
r = akerr_release_error(r);
AKERR_CHECK(zero_return(7) == NULL);
r = nonzero_return(7);
AKERR_CHECK(r != NULL && r->status == AKERR_TYPE);
r = akerr_release_error(r);
AKERR_CHECK(nonzero_return(0) == NULL);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_break_variants ok\n");
return 0;
}

84
tests/err_capture.h Normal file
View File

@@ -0,0 +1,84 @@
#ifndef AKERR_TEST_CAPTURE_H
#define AKERR_TEST_CAPTURE_H
/*
* Shared test helpers for libakerror.
*
* Installs a capturing implementation of akerr_log_method so tests can assert
* on the *content* of log/stacktrace output (messages, status codes, error
* names) instead of relying solely on process exit codes.
*
* Also provides AKERR_CHECK(), a NDEBUG-proof assertion that fails the test by
* returning non-zero from main() (unlike assert(), which is compiled out in
* release builds and would silently turn a test into a no-op).
*/
#include "akerror.h"
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#define AKERR_CAPTURE_BUFSZ 65536
static char akerr_capture_buf[AKERR_CAPTURE_BUFSZ];
static size_t akerr_capture_len = 0;
static void __attribute__((unused)) akerr_capture_logger(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(akerr_capture_buf + akerr_capture_len,
AKERR_CAPTURE_BUFSZ - akerr_capture_len, fmt, ap);
va_end(ap);
if ( n > 0 ) {
akerr_capture_len += (size_t)n;
if ( akerr_capture_len >= AKERR_CAPTURE_BUFSZ ) {
akerr_capture_len = AKERR_CAPTURE_BUFSZ - 1;
}
}
}
static void __attribute__((unused)) akerr_capture_reset(void)
{
akerr_capture_len = 0;
akerr_capture_buf[0] = '\0';
}
/*
* Install the capturing logger. akerr_init() only assigns a default logger when
* akerr_log_method is NULL, and it is idempotent, so calling this either before
* or after the first PREPARE_ERROR keeps our logger in place.
*/
static void __attribute__((unused)) akerr_capture_install(void)
{
akerr_capture_reset();
akerr_log_method = &akerr_capture_logger;
}
/* Count array slots currently checked out of the pool (refcount != 0). */
static int __attribute__((unused)) akerr_slots_in_use(void)
{
int n = 0;
for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
if ( AKERR_ARRAY_ERROR[i].refcount != 0 ) {
n++;
}
}
return n;
}
#define AKERR_CHECK(cond) \
do { \
if ( !(cond) ) { \
fprintf(stderr, "CHECK FAILED: %s at %s:%d\n", \
#cond, __FILE__, __LINE__); \
return 1; \
} \
} while ( 0 )
#define AKERR_CHECK_CONTAINS(needle) \
AKERR_CHECK(strstr(akerr_capture_buf, (needle)) != NULL)
#define AKERR_CHECK_NOT_CONTAINS(needle) \
AKERR_CHECK(strstr(akerr_capture_buf, (needle)) == NULL)
#endif // AKERR_TEST_CAPTURE_H

View File

@@ -23,7 +23,7 @@ akerr_ErrorContext *func1(void)
} }
akerr_ErrorContext *akerr_user_main(int argc, char **argv) int main(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
ATTEMPT { ATTEMPT {
@@ -32,6 +32,5 @@ akerr_ErrorContext *akerr_user_main(int argc, char **argv)
} PROCESS(errctx) { } PROCESS(errctx) {
} HANDLE(errctx, AKERR_NULLPOINTER) { } HANDLE(errctx, AKERR_NULLPOINTER) {
akerr_log_method("Caught exception"); akerr_log_method("Caught exception");
} FINISH(errctx, true); } FINISH_NORETURN(errctx);
SUCCEED(errctx);
} }

View File

@@ -25,7 +25,7 @@ akerr_ErrorContext *func1(void)
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
akerr_ErrorContext *akerr_user_main(int argc, char **argv) int main(void)
{ {
x = 12345; x = 12345;
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -35,8 +35,9 @@ akerr_ErrorContext *akerr_user_main(int argc, char **argv)
} PROCESS(errctx) { } PROCESS(errctx) {
} HANDLE(errctx, AKERR_NULLPOINTER) { } HANDLE(errctx, AKERR_NULLPOINTER) {
if ( x == 0 ) { if ( x == 0 ) {
FAIL_RETURN(errctx, AKERR_API, "Cleanup does not work"); fprintf(stderr, "Cleanup works\n");
return 0;
} }
} FINISH(errctx, true); return 1;
SUCCEED(errctx); } FINISH_NORETURN(errctx);
} }

View File

@@ -0,0 +1,45 @@
#include "akerror.h"
#include "err_capture.h"
/*
* The unhandled-error hook (akerr_handler_unhandled_error) is overridable.
* Install a non-fatal handler so an unhandled error can be asserted directly
* (status, invocation) instead of relying on process death / WILL_FAIL.
*/
static int custom_fired = 0;
static int custom_status = 0;
static void my_handler(akerr_ErrorContext *e)
{
custom_fired = 1;
custom_status = (e != NULL) ? e->status : -1;
/* deliberately does NOT exit() */
}
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_TYPE, "unhandled on purpose");
}
int main(void)
{
akerr_capture_install();
akerr_init(); /* sets the default handler... */
akerr_handler_unhandled_error = &my_handler; /* ...which we then override */
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
/* no HANDLE for AKERR_TYPE -> stays unhandled */
} FINISH_NORETURN(e);
AKERR_CHECK(custom_fired == 1);
AKERR_CHECK(custom_status == AKERR_TYPE);
AKERR_CHECK_CONTAINS("Unhandled Error");
fprintf(stderr, "err_custom_handler ok\n");
return 0;
}

47
tests/err_errno.c Normal file
View File

@@ -0,0 +1,47 @@
#include "akerror.h"
#include "err_capture.h"
#include <errno.h>
/*
* The library imports system errno codes and their descriptions at build time
* (scripts/generrno.sh -> akerr_init_errno). Verify:
* - a system errno (EACCES) has a registered, non-empty name;
* - an out-of-range status returns the "Unknown Error" sentinel;
* - a system errno can be raised, propagated and handled like any AKERR_* code.
*/
static int handled = 0;
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, EACCES, "permission denied");
}
int main(void)
{
akerr_capture_install();
akerr_init();
char *nm = akerr_name_for_status(EACCES, NULL);
AKERR_CHECK(nm != NULL);
AKERR_CHECK(nm[0] != '\0');
AKERR_CHECK(strcmp(nm, "Unknown Error") != 0);
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_MAX_ERR_VALUE + 5000, NULL),
"Unknown Error") == 0);
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, EACCES) {
handled = 1;
} FINISH_NORETURN(e);
AKERR_CHECK(handled == 1);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_errno ok (EACCES name: \"%s\")\n", nm);
return 0;
}

46
tests/err_error_names.c Normal file
View File

@@ -0,0 +1,46 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* akerr_init() registers a human-readable name for each library error code.
* Verify the names are actually installed (mutation testing showed the
* registration calls could be deleted without any test noticing).
*
* Note: AKERR_EOF, AKERR_ITERATOR_BREAK and AKERR_NOT_IMPLEMENTED are omitted --
* they are valid codes but akerr_init does not register a display name for them,
* so akerr_name_for_status returns an empty string rather than a known name.
*/
static const struct {
int code;
const char *name;
} expected[] = {
{ AKERR_NULLPOINTER, "Null Pointer Error" },
{ AKERR_OUTOFBOUNDS, "Out Of Bounds Error" },
{ AKERR_API, "API Error" },
{ AKERR_ATTRIBUTE, "Attribute Error" },
{ AKERR_TYPE, "Type Error" },
{ AKERR_KEY, "Key Error" },
{ AKERR_INDEX, "Index Error" },
{ AKERR_FORMAT, "Format Error" },
{ AKERR_IO, "Input Output Error" },
{ AKERR_VALUE, "Value Error" },
{ AKERR_RELATIONSHIP, "Relationship Error" },
{ AKERR_CIRCULAR_REFERENCE, "Circular Reference Error" },
{ AKERR_BADEXC, "Invalid akerr_ErrorContext" },
};
int main(void)
{
akerr_init();
for ( unsigned i = 0; i < sizeof(expected) / sizeof(expected[0]); i++ ) {
char *nm = akerr_name_for_status(expected[i].code, NULL);
AKERR_CHECK(nm != NULL);
AKERR_CHECK(strcmp(nm, expected[i].name) == 0);
}
fprintf(stderr, "err_error_names ok\n");
return 0;
}

View File

@@ -0,0 +1,38 @@
#include "akerror.h"
#include "err_capture.h"
/*
* An error whose status has no matching HANDLE block must fall through to
* HANDLE_DEFAULT, and the non-matching HANDLE body must not run.
*/
static int specific_fired = 0;
static int default_fired = 0;
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_TYPE, "wrong type");
}
int main(void)
{
akerr_capture_install();
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_KEY) {
specific_fired = 1; /* must NOT run: error is AKERR_TYPE */
} HANDLE_DEFAULT(e) {
default_fired = 1;
} FINISH_NORETURN(e);
AKERR_CHECK(specific_fired == 0);
AKERR_CHECK(default_fired == 1);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_handle_default ok\n");
return 0;
}

View File

@@ -0,0 +1,42 @@
#include "akerror.h"
#include "err_capture.h"
/*
* With several distinct HANDLE blocks, only the one matching the raised status
* may run. Raise the middle code and confirm exact dispatch.
*/
static int a_fired = 0;
static int b_fired = 0;
static int c_fired = 0;
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_TYPE, "the middle one");
}
int main(void)
{
akerr_capture_install();
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_KEY) {
a_fired = 1;
} HANDLE(e, AKERR_TYPE) {
b_fired = 1;
} HANDLE(e, AKERR_IO) {
c_fired = 1;
} FINISH_NORETURN(e);
AKERR_CHECK(a_fired == 0);
AKERR_CHECK(b_fired == 1);
AKERR_CHECK(c_fired == 0);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_handle_dispatch ok\n");
return 0;
}

56
tests/err_handle_group.c Normal file
View File

@@ -0,0 +1,56 @@
#include "akerror.h"
#include "err_capture.h"
/*
* HANDLE_GROUP lets several status codes share one handler body via
* case-fallthrough. The first member of the group uses HANDLE (which emits the
* leading break that terminates the previous case); each additional member
* uses HANDLE_GROUP; the shared body follows the last member. Verify that two
* different status codes both reach the shared body.
*/
static int group_fired = 0;
static int other_fired = 0;
akerr_ErrorContext *boom(int status)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, status, "boom %d", status);
}
akerr_ErrorContext *run(int status)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom(status));
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_KEY)
HANDLE_GROUP(e, AKERR_INDEX) {
group_fired++;
} HANDLE(e, AKERR_IO) {
other_fired++;
} FINISH(e, false);
return e;
}
int main(void)
{
akerr_capture_install();
run(AKERR_KEY);
AKERR_CHECK(group_fired == 1);
AKERR_CHECK(other_fired == 0);
run(AKERR_INDEX);
AKERR_CHECK(group_fired == 2);
AKERR_CHECK(other_fired == 0);
run(AKERR_IO);
AKERR_CHECK(group_fired == 2);
AKERR_CHECK(other_fired == 1);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_handle_group ok\n");
return 0;
}

34
tests/err_ignore.c Normal file
View File

@@ -0,0 +1,34 @@
#include "akerror.h"
#include "err_capture.h"
/*
* IGNORE deliberately swallows an error: it records the context in
* __akerr_last_ignored, logs it with an "IGNORED ERROR" marker, and lets
* execution continue.
*/
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_VALUE, "this error is ignored on purpose");
}
int main(void)
{
akerr_capture_install();
int reached_after_ignore = 0;
PREPARE_ERROR(e);
(void)e;
IGNORE(boom());
reached_after_ignore = 1;
AKERR_CHECK(__akerr_last_ignored != NULL);
AKERR_CHECK(__akerr_last_ignored->status == AKERR_VALUE);
AKERR_CHECK(reached_after_ignore == 1);
AKERR_CHECK_CONTAINS("IGNORED ERROR");
AKERR_CHECK_CONTAINS("this error is ignored on purpose");
fprintf(stderr, "err_ignore ok\n");
return 0;
}

View File

@@ -0,0 +1,22 @@
#include "akerror.h"
#include <stdio.h>
akerr_ErrorContext AKERR_NOIGNORE *improper_closure(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
fprintf(stderr, "Improperly returning from improper_closure\n");
}
int main(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, improper_closure());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}

92
tests/err_maxval.c Normal file
View File

@@ -0,0 +1,92 @@
#include "akerror.h"
#include "err_capture.h"
#include <regex.h>
/*
* AKERR_MAX_ERR_VALUE sizes the __AKERR_ERROR_NAMES table and is the upper bound
* akerr_name_for_status() accepts. If it is smaller than the highest AKERR_*
* code, those codes silently lose their names (this was a real bug: the max was
* +15 while AKERR_BADEXC is +17).
*
* Rather than hardcode the list of codes (which rots the moment someone adds a
* code), this test parses the generated akerror.h, discovers every
* #define AKERR_<NAME> (AKERR_LAST_ERRNO_VALUE + <N>)
* finds the highest offset actually defined, and verifies AKERR_MAX_ERR_VALUE
* covers it. The path to the header the library was built from is injected by
* CMake as AKERR_GENERATED_HEADER.
*/
#ifndef AKERR_GENERATED_HEADER
#error "AKERR_GENERATED_HEADER (path to generated akerror.h) must be defined"
#endif
int main(void)
{
FILE *fh = fopen(AKERR_GENERATED_HEADER, "r");
AKERR_CHECK(fh != NULL);
/* #define AKERR_NAME (AKERR_LAST_ERRNO_VALUE + N) */
regex_t re;
const char *pattern =
"^[[:space:]]*#define[[:space:]]+(AKERR_[A-Za-z0-9_]+)[[:space:]]+"
"\\(AKERR_LAST_ERRNO_VALUE[[:space:]]*\\+[[:space:]]*([0-9]+)\\)";
AKERR_CHECK(regcomp(&re, pattern, REG_EXTENDED) == 0);
int highest_code = -1; /* highest offset among real error codes */
char highest_name[64] = "";
int max_err_value = -1; /* offset parsed from AKERR_MAX_ERR_VALUE */
int code_count = 0;
char line[4096];
regmatch_t m[3];
while ( fgets(line, sizeof(line), fh) != NULL ) {
if ( regexec(&re, line, 3, m, 0) != 0 ) {
continue;
}
char name[64];
int nlen = (int)(m[1].rm_eo - m[1].rm_so);
if ( nlen >= (int)sizeof(name) ) {
nlen = (int)sizeof(name) - 1;
}
memcpy(name, line + m[1].rm_so, nlen);
name[nlen] = '\0';
char num[16];
int vlen = (int)(m[2].rm_eo - m[2].rm_so);
if ( vlen >= (int)sizeof(num) ) {
vlen = (int)sizeof(num) - 1;
}
memcpy(num, line + m[2].rm_so, vlen);
num[vlen] = '\0';
int offset = atoi(num);
if ( strcmp(name, "AKERR_MAX_ERR_VALUE") == 0 ) {
max_err_value = offset;
} else {
code_count++;
if ( offset > highest_code ) {
highest_code = offset;
snprintf(highest_name, sizeof(highest_name), "%s", name);
}
}
}
regfree(&re);
fclose(fh);
/* We must have actually parsed both the codes and the ceiling. */
AKERR_CHECK(code_count > 0);
AKERR_CHECK(highest_code > 0);
AKERR_CHECK(max_err_value > 0);
/* Guard against parsing a different header than the one compiled in. */
AKERR_CHECK(max_err_value == (AKERR_MAX_ERR_VALUE - AKERR_LAST_ERRNO_VALUE));
/* The actual invariant: every defined code is indexable in the names table. */
AKERR_CHECK(max_err_value >= highest_code);
fprintf(stderr,
"err_maxval ok (%d codes parsed, highest %s at +%d, max +%d)\n",
code_count, highest_name, highest_code, max_err_value);
return 0;
}

46
tests/err_pass.c Normal file
View File

@@ -0,0 +1,46 @@
#include "akerror.h"
#include "err_capture.h"
/*
* PASS bubbles an error up to the caller without a local ATTEMPT/PROCESS block:
* if the wrapped call fails, PASS returns the context from the current function.
* Verify the error reaches main and that the code after PASS is skipped on
* failure.
*/
static int reached_after_pass = 0;
akerr_ErrorContext *inner(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_IO, "inner failed");
}
akerr_ErrorContext *outer(void)
{
PREPARE_ERROR(e);
PASS(e, inner());
reached_after_pass = 1; /* must NOT run: PASS returned already */
SUCCEED_RETURN(e);
}
int main(void)
{
akerr_capture_install();
int handled = 0;
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, outer());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_IO) {
handled = 1;
} FINISH_NORETURN(e);
AKERR_CHECK(handled == 1);
AKERR_CHECK(reached_after_pass == 0);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_pass ok\n");
return 0;
}

39
tests/err_pool_exhaust.c Normal file
View File

@@ -0,0 +1,39 @@
#include "akerror.h"
#include "err_capture.h"
/*
* The error pool is a fixed array of AKERR_MAX_ARRAY_ERROR slots. When every
* slot is checked out, akerr_next_error() must return NULL rather than run off
* the end of the array; and it must always hand back the lowest free slot.
* Mutation testing showed both the terminating "return NULL" and the scan
* bounds could be broken without any test noticing.
*/
int main(void)
{
akerr_init();
akerr_ErrorContext *slots[AKERR_MAX_ARRAY_ERROR];
/* Check out every slot. */
for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
slots[i] = akerr_next_error();
AKERR_CHECK(slots[i] != NULL);
slots[i]->refcount = 1;
}
/* Pool is fully exhausted: the next request must fail cleanly. */
AKERR_CHECK(akerr_next_error() == NULL);
/* Free exactly the first slot; the scan must find and return it. */
slots[0]->refcount = 0;
AKERR_CHECK(akerr_next_error() == slots[0]);
/* Tidy up. */
for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
slots[i]->refcount = 0;
}
fprintf(stderr, "err_pool_exhaust ok\n");
return 0;
}

55
tests/err_pool_refcount.c Normal file
View File

@@ -0,0 +1,55 @@
#include "akerror.h"
#include "err_capture.h"
/*
* Pool-hygiene regression test. The error pool is a fixed 128-slot static
* array; a single leaked reference per error would silently exhaust it. Run a
* large number of full raise -> catch -> handle cycles and confirm every slot
* is returned to the pool (refcount 0) and the pool can still hand out
* contexts afterwards.
*/
#define ITERATIONS 100000
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
ATTEMPT {
FAIL(e, AKERR_VALUE, "boom %d", 1);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/* One full raise -> catch -> handle cycle. Returns NULL (context released). */
akerr_ErrorContext *one_cycle(void)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_VALUE) {
} FINISH(e, false);
return e;
}
int main(void)
{
akerr_capture_install();
akerr_init();
AKERR_CHECK(akerr_slots_in_use() == 0);
for ( int iter = 0; iter < ITERATIONS; iter++ ) {
(void)one_cycle();
}
AKERR_CHECK(akerr_slots_in_use() == 0);
akerr_ErrorContext *probe = akerr_next_error();
AKERR_CHECK(probe != NULL);
fprintf(stderr, "err_pool_refcount ok (%d cycles, 0 leaked)\n", ITERATIONS);
return 0;
}

View File

@@ -0,0 +1,44 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* Releasing an error context back to the pool must wipe it, so the next caller
* that checks it out never sees stale status/message/stacktrace from a previous
* error. Mutation testing showed the clearing memset in akerr_release_error
* could be deleted without any test noticing.
*/
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_VALUE, "stale dirty message that must not survive");
}
int main(void)
{
akerr_capture_install();
akerr_init();
/* Raise and fully handle an error; FINISH_NORETURN releases it to the pool. */
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_VALUE) {
} FINISH_NORETURN(e);
AKERR_CHECK(e == NULL);
/* The next context handed out is the slot we just released: it must be clean. */
akerr_ErrorContext *slot = akerr_next_error();
AKERR_CHECK(slot != NULL);
AKERR_CHECK(slot->status == 0);
AKERR_CHECK(slot->message[0] == '\0');
AKERR_CHECK(slot->stacktracebuf[0] == '\0');
AKERR_CHECK(strstr(slot->message, "stale dirty message") == NULL);
fprintf(stderr, "err_release_clears ok\n");
return 0;
}

44
tests/err_success.c Normal file
View File

@@ -0,0 +1,44 @@
#include "akerror.h"
#include "err_capture.h"
/*
* The success path: a nested call that returns cleanly (NULL) must not break
* out of the caller's ATTEMPT block, must not enter any handler, and must not
* consume a slot from the error pool.
*/
static int default_fired = 0;
akerr_ErrorContext *ok_func(void)
{
PREPARE_ERROR(e);
ATTEMPT {
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
int main(void)
{
akerr_capture_install();
akerr_init();
int reached_after_catch = 0;
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, ok_func());
reached_after_catch = 1; /* must run: no break on success */
} CLEANUP {
} PROCESS(e) {
} HANDLE_DEFAULT(e) {
default_fired = 1; /* must NOT run */
} FINISH_NORETURN(e);
AKERR_CHECK(reached_after_catch == 1);
AKERR_CHECK(default_fired == 0);
AKERR_CHECK(e == NULL);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_success ok\n");
return 0;
}

45
tests/err_swallow.c Normal file
View File

@@ -0,0 +1,45 @@
#include "akerror.h"
#include "err_capture.h"
/*
* FINISH(ctx, false) closes an ATTEMPT block without propagating: an unhandled
* error is dropped (not returned, not passed to the unhandled handler) and the
* context is released back to the pool. Because we use FINISH (not
* FINISH_NORETURN) the process must continue normally and exit 0.
*/
static int wrong_handler_fired = 0;
akerr_ErrorContext *boom(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_VALUE, "unhandled but swallowed");
}
/* FINISH(e, false) belongs in a context-returning function. On the swallow
* path e is released to NULL, so this returns NULL to its caller. */
akerr_ErrorContext *swallow_it(void)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, boom());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_KEY) {
wrong_handler_fired = 1; /* does not match AKERR_VALUE */
} FINISH(e, false);
return e; /* NULL: released even though unhandled */
}
int main(void)
{
akerr_capture_install();
akerr_ErrorContext *res = swallow_it();
AKERR_CHECK(wrong_handler_fired == 0);
AKERR_CHECK(res == NULL); /* released even though unhandled */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_swallow ok\n");
return 0;
}

View File

@@ -23,13 +23,12 @@ akerr_ErrorContext *func1(void)
} }
akerr_ErrorContext *akerr_user_main(int argc, char **argv) int main(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
ATTEMPT { ATTEMPT {
CATCH(errctx, func1()); CATCH(errctx, func1());
} CLEANUP { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH(errctx, true); } FINISH_NORETURN(errctx);
SUCCEED(errctx);
} }