14 Commits

Author SHA1 Message Date
5ff87908e7 Use the library's own error idioms inside the library
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m48s
libakerror CI Build / coverage (push) Successful in 2m46s
libakerror CI Build / mutation_test (push) Successful in 16m9s
Four things in src/error.c did by hand what the macros already do, or
skipped checks the library would have caught for a consumer.

akerr_copy_string() returned void and validated only its capacity, while
writing through a caller-supplied pointer for a caller-supplied length.
It is now __akerr_copy_string() and raises: AKERR_NULLPOINTER for a NULL
destination or source, AKERR_VALUE for a capacity with no room for a
terminator. Both call sites PASS it, and the owner copy in
akerr_reserve_status_range() now gates the commit, so a failed copy
cannot leave a range claimed under an empty owner. It is exported under
the internal prefix rather than static so tests/err_copy_string.c can
drive those guards; nothing else can reach them.

__akerr_name_library_status() and the band reservation in akerr_init()
hand-rolled the log/handler/release sequence. Both now use
ATTEMPT/CATCH/PROCESS/FINISH_NORETURN. PASS does not fit: both sites are
void and have no caller to propagate to, so the terminal form of the same
idiom is the right one -- an unhandled failure prints its stack trace and
goes to akerr_handler_unhandled_error, which terminates, exactly as
before but without the bespoke plumbing. The legacy set path in
akerr_name_for_status() had the same shape and now handles its refusal
with HANDLE_DEFAULT, converting it to the "Unknown Error" sentinel.

Every remaining `if (x) { FAIL_RETURN }` in the registry is now
FAIL_ZERO_RETURN or FAIL_NONZERO_RETURN, and akerr_register_status_name()
checks both owner and name before passing either down --
akerr_store_status_name() reads a NULL owner as "caller did not identify
itself" for the legacy path, so a NULL arriving through the owned entry
point would have skipped the ownership check entirely.

New tests: err_copy_string (the guards above), err_library_status_fatal
(WILL_FAIL -- proves a refused library-status registration terminates).

Tests: ctest 31/31, mutation 80.7% (was 77.5%), line coverage 98.9%.
Branch coverage on src/error.c drops 64.5% -> 50.4%, just over its gate:
each FAIL_* site carries ~6 branch outcomes of error-construction
machinery that only run when that failure fires, and each PASS around a
call that cannot fail carries ~25, so added validation lowers the ratio
by construction. Recorded in TODO.md item 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:53:33 -04:00
64da04e83b Raise errors from the status registry instead of returning codes
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m47s
libakerror CI Build / coverage (push) Successful in 2m46s
libakerror CI Build / mutation_test (push) Successful in 14m56s
akerr_reserve_status_range() and akerr_register_status_name() returned
private int enumerations, which was the one place in the library where a
failure was not an akerr_ErrorContext *. They now return one like
everything else: NULL on success, and on refusal an error whose status is
a real code in the library's reserved band, so it can be CATCH-ed,
HANDLE-d, PASS-ed, or left to propagate into a stack trace. Both are
marked AKERR_NOIGNORE, so discarding the result warns at compile time.

AKERR_STATUS_RANGE_OK and AKERR_STATUS_NAME_OK are gone; the remaining
seven codes move into the AKERR_* offset span and get registered names.
AKERR_LAST_LIBRARY_STATUS replaces AKERR_BADEXC as the top of that span
in the reserved-band static assert and the exhaustiveness sweep.

The refusal detail that used to go straight to akerr_log_method now
travels in the error message, so a caller that handles the error decides
whether it is reported. The two-argument akerr_name_for_status() set path
is the exception: it returns a name and cannot raise, so it logs and
releases. akerr_init() likewise has no caller to raise into, so failing
to reserve its own band or name its own codes is logged and fatal --
that can only happen on a misconfigured build, and continuing would
degrade every later stack trace to "Unknown Error".

Move the 1.0.0 upgrade notice out of README.md into UPGRADING.md and
rewrite its return-code tables in terms of the statuses now raised.

Tests: ctest 29/29, coverage 97.5% line / 64.5% branch, mutation 77.5%
(was 77.6%; the new survivors are the fatal init path, which needs a
library built with an undersized name table -- TODO item 7).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:58:47 -04:00
ba2430bfa1 Reduce TODO.md to outstanding work
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m46s
libakerror CI Build / coverage (push) Successful in 2m45s
libakerror CI Build / mutation_test (push) Successful in 15m10s
The status-code ownership notes described work that has landed. That record
belongs in the commit that made the change and in the comments around the code
it constrains, not in a file whose purpose is naming what is left.

Keeps the six open items and the two unrelated pre-existing issues, and
promotes the missing sanitizer run to the top: mutation testing found an
out-of-bounds probe whose failure mode was a silent BSS write, which no
assertion-based test was positioned to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:19:53 -04:00
f1283e21a3 Enforce status-code ownership and harden the name registry
Reservations were advisory bookkeeping: any component could name any status,
so the registry only detected declared-range overlap between components that
both opted in. Naming a status now requires a reservation.
akerr_register_status_name() checks that the range belongs to the caller, and
the legacy two-argument akerr_name_for_status() set path, which cannot
identify its caller, requires that some reservation covers the status. Every
refusal is logged and names the real owner, because a name that fails to
register degrades that code to "Unknown Error" in every later stack trace.

Fix a reservation made before the first PREPARE_ERROR being silently
discarded. akerr_init() clears the tables, so whichever component first
triggered it wiped an earlier reservation and the next component to claim the
same range was told it was free, producing exactly the undetected aliasing
the registry exists to prevent. Every registry entry point now calls
akerr_init(), which sets its guard before doing any work so those calls do
not recurse.

Replace the linear-scan name array with an open-addressed hash table, taking
lookup from O(n) to O(1) and raising usable capacity from 512 entries (366
free to consumers after errno registration) to 3072 (~2900 free). Both table
sizes are build-time overridable and applied PRIVATE: they live entirely in
src/error.c, so raising them cannot desynchronize a library from its
consumers the way AKERR_MAX_ERR_VALUE could. Exhausting either table is now
logged and returned to the caller rather than silently dropping the entry.
No dynamic allocation is introduced; both tables remain file-scope arrays,
and the library's undefined-symbol set gains only strcmp and strlen.

Register names for AKERR_EOF, AKERR_ITERATOR_BREAK and AKERR_NOT_IMPLEMENTED,
which had none and rendered as "Unknown Error" in every stack trace carrying
them. err_error_names.c now sweeps the whole AKERR_* offset span so a code
added without a name fails there instead of in production traces.

Add static assertions that the slot count is a power of two and that
AKERR_BADEXC stays inside the library's own 0-255 band, the latter guarding
against a host errno space large enough to push library codes into the range
consumers are told to allocate from.

Set a project version and soname (1.0.0 / libakerror.so.1) so a stale
installed library can no longer be silently paired with newer headers, and so
akerror.pc ships a real Version field instead of an empty one.

Mutation testing surfaced an out-of-bounds probe in the new table that the
suite did not catch: masking with SLOTS rather than SLOTS-1 indexes past the
array, and err_maxval.c asserted only that some names registered before the
table filled, which a collapsed probe sequence still satisfies. It now
requires a substantial entry count and reads every entry back by its own
distinct name.

Tests: 28/28 pass. Coverage 99.4% line / 86.8% branch. Mutation score for
src/error.c 74% -> 77.3%.

Compatibility: source and ABI break. AKERR_MAX_ERR_VALUE and the
__AKERR_ERROR_NAMES data symbol are gone, custom codes must move out of
0-255, and names must be registered against a reserved range. README.md
carries the migration steps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:19:25 -04:00
11d21068df Add collision-safe status code registry
Replace the consumer-sized status-name array with private sparse storage and accept arbitrary integer status values. Add explicit range reservations with overlap diagnostics, reserve the library's 0-255 compatibility band, and harden pointer and string boundary handling.

Update regression coverage and document the required migration for custom status-code consumers.
2026-07-30 13:53:52 -04:00
0bb3a4d52c TODO.md
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m45s
libakerror CI Build / coverage (push) Successful in 2m45s
libakerror CI Build / mutation_test (push) Successful in 8m1s
2026-07-30 13:22:50 -04:00
539293cc1c Expand error release and handler test coverage
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m46s
libakerror CI Build / coverage (push) Successful in 2m45s
libakerror CI Build / mutation_test (push) Successful in 8m0s
2026-07-30 02:05:31 -04:00
9f0034a56e Add gcov code coverage to the test suite
scripts/coverage.py configures an instrumented build tree
(-DAKERR_COVERAGE=ON), runs the CTest suite in it, and reports merged
gcov line/branch/function coverage per library source. Like the mutation
harness it has no third-party dependencies and supports --threshold and
--junit; thresholds gate each file as well as the total so the generated
status-name table cannot mask a regression in src/error.c.

Only the library is instrumented. The public header's macros cannot be
measured this way -- GCC attributes an expanded macro to its call site,
so header logic would report as lines of the test that used it -- which
is what mutation testing against include/akerror.tmpl.h is for.

Coverage flags are applied per target rather than globally, so they do
not leak into the exported/installed target interface.

Current numbers for src/error.c are 94.0% line and 59.5% branch; the CI
gate is set to 90/50 to keep headroom, matching the convention used for
the mutation score threshold.

Tests run: ctest (23/23), cmake --build build --target coverage,
threshold gate verified failing at --threshold 99, cmake --install
checked for flag leakage, out-of-tree --build-dir checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:05:30 -04:00
0c0d81249f Avoid mutation target collisions when embedded
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m44s
libakerror CI Build / mutation_test (push) Successful in 7m38s
2026-07-29 18:01:58 -04:00
426efbb2d4 Add CLAUDE.md
Some checks failed
libakerror CI Build / cmake_build (push) Successful in 2m44s
libakerror CI Build / mutation_test (push) Has been cancelled
2026-07-29 17:42:56 -04:00
4ae1decde2 Expand error status test coverage
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m44s
libakerror CI Build / mutation_test (push) Successful in 7m46s
- Added explicit status validation across error handling tests.
- Added lifecycle and slot-leak checks to older tests.
- Improved unhandled-error propagation coverage.
- Added repository guidance and a test runner script.
- Verified all 23 CTest tests pass.

Co-Authored by Codex GPT 5.4
2026-07-29 17:12:52 -04:00
4212ff0b28 Fix format-string use of __FILE__/__func__ and name_for_status lower bound
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m48s
libakerror CI Build / mutation_test (push) Successful in 7m40s
Two hardening fixes flagged by the earlier review:

1. FAIL passed __FILE__ and __func__ directly as the snprintf format string.
   __FILE__ expands to a string literal that could contain a '%' (a build path
   under a directory with a percent sign), and __func__ is not a literal at all;
   either way snprintf would read nonexistent varargs. Pass them as "%s"
   arguments instead.

2. akerr_name_for_status guarded the upper bound but not the lower one, so a
   negative status indexed __AKERR_ERROR_NAMES[negative] -- an out-of-bounds
   read, or an out-of-bounds write when a name was supplied. Reject status < 0.

Regression tests err_format_string (uses #line to put a conversion specifier in
__FILE__) and err_name_bounds fail against the old code (verified) and pass now.
Full suite: 23/23, no warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 10:52:29 -04:00
de13b290d4 Fix refcount leak and stack-trace buffer overflow
Two memory-safety bugs in the macro core:

1. Refcount leak. ENSURE_ERROR_READY incremented refcount on every FAIL/SUCCEED
   rather than only when it acquired a fresh context from the pool. A function
   that FAILed a context more than once and then propagated arrived at its
   caller with refcount 2; the caller released once, leaking the slot. After
   AKERR_MAX_ARRAY_ERROR leaks the pool is exhausted and the library exit(1)s.
   Move the increment inside the acquisition branch.

2. Stack-trace overflow. Each appended frame passed the full buffer length to
   snprintf instead of the space remaining, and advanced the cursor by
   snprintf's would-be return value, so a trace that filled the buffer wrote
   past the end of stacktracebuf and ran the cursor out of bounds. Add
   AKERR_STACKTRACE_APPEND, which bounds the write to the remaining space and
   clamps the cursor advance.

Regression tests err_refcount_double_fail and err_stacktrace_bounds fail against
the old code (verified) and pass now. Full suite: 21/21, no warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 09:32:55 -04:00
3e24356f07 Document that CATCH/FAIL_*_BREAK must not be used inside a loop
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m47s
libakerror CI Build / mutation_test (push) Successful in 6m59s
These macros leave the ATTEMPT block with a C break, which only escapes the
innermost loop/switch. Nesting them in a loop inside an ATTEMPT lets the rest of
the block run with an error already pending. Document the correct patterns:
iterate with PASS / FAIL_*_RETURN (which return, not break), or move the loop
into a helper returning akerr_ErrorContext * and CATCH the single call. Also
note that merely extracting the loop into a function does not fix it if the
helper still wraps the loop in ATTEMPT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 08:49:29 -04:00
40 changed files with 2573 additions and 147 deletions

View File

@@ -37,6 +37,36 @@ jobs:
fail_on_failure: 'true' fail_on_failure: 'true'
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."
coverage:
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
# Run the suite against a gcov-instrumented build and gate on coverage of
# the library sources. Thresholds keep headroom below the current numbers
# (src/error.c: ~94% line, ~60% branch) and apply per file as well as to
# the total, so the generated status-name table cannot mask a regression.
- name: coverage
run: |
python3 scripts/coverage.py --junit coverage-junit.xml --threshold 90 --branch-threshold 50
# Publish even when the threshold gate fails, so gaps 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 coverage results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'coverage-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'false'
- run: echo "🍏 This job's status is ${{ job.status }}."
mutation_test: mutation_test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

81
AGENTS.md Normal file
View File

@@ -0,0 +1,81 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a small C library built with CMake. Core implementation lives in
`src/error.c`. The public header is generated at build time from
`include/akerror.tmpl.h` by `scripts/generrno.sh`, which also generates
`src/errno.c` under the build directory. CMake package and pkg-config templates
are in `cmake/` and `akerror.pc.in`. Tests are one-file C programs in `tests/`;
shared test helpers live beside them, such as `tests/err_capture.h`.
## Build, Test, and Development Commands
Use an out-of-tree build:
```sh
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
`cmake -S . -B build` configures the build and generates `akerror.h`.
`cmake --build build` compiles `libakerror` and test executables. `ctest`
runs the registered unit tests. To emit CI-style results, use:
```sh
ctest --test-dir build --output-on-failure --output-junit "$(pwd)/ctest-junit.xml"
```
Mutation testing is available through:
```sh
cmake --build build --target mutation
scripts/mutation_test.py --target src/error.c --threshold 65
```
Code coverage is available through:
```sh
cmake --build build --target coverage
scripts/coverage.py --threshold 90 --branch-threshold 50
```
`scripts/coverage.py` configures its own instrumented build tree (default
`build/coverage`, `-DAKERR_COVERAGE=ON`), runs the CTest suite there, and
reports gcov line/branch coverage per library source. Thresholds gate each
file as well as the total. Coverage measures `src/error.c` and the generated
`src/errno.c` only; the public header's macros expand at their call sites, so
mutation testing is what checks those.
## Coding Style & Naming Conventions
Use C99-compatible C and follow the surrounding style. Functions and types use
the `akerr_` prefix; macros and constants use `AKERR_` or all-caps macro names
such as `PREPARE_ERROR`. Keep generated-code changes in templates or generator
scripts, not in build outputs. Preserve concise comments for invariants,
macro constraints, and non-obvious error lifecycle behavior.
## Testing Guidelines
Add tests as `tests/err_<behavior>.c`. Register each new test in the
`AKERR_TESTS` list in `CMakeLists.txt`; tests that intentionally abort must
also be listed in `AKERR_WILL_FAIL_TESTS`. Prefer focused executable tests that
return zero on success and use existing helpers such as `AKERR_CHECK`. Run the
CTest suite before submitting changes, and run mutation testing and coverage
when changing core control-flow, reference counting, stack-trace, or handler
behavior.
## Commit & Pull Request Guidelines
Recent commits use short, imperative, sentence-case subjects, for example
`Fix refcount leak and stack-trace buffer overflow`. Keep commits focused and
describe the observable behavior changed. Pull requests should include a brief
summary, tests run, and any compatibility impact for public macros, generated
headers, installation paths, or CMake/pkg-config consumers.
## Agent-Specific Instructions
Do not overwrite uncommitted user changes. Avoid editing generated files in
`build/`; update `include/akerror.tmpl.h`, `src/error.c`, CMake files, tests,
or scripts instead.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
See @AGENTS.md

View File

@@ -1,13 +1,64 @@
cmake_minimum_required(VERSION 3.10) cmake_minimum_required(VERSION 3.10)
project(akerror LANGUAGES C) # The status-code registry replaced the consumer-sized __AKERR_ERROR_NAMES array
# with private storage, which is a source and ABI break for anything built
# against an earlier header -- hence 1.0.0 and a SOVERSION, so a stale installed
# libakerror.so can no longer be silently paired with new headers.
project(akerror VERSION 1.0.0 LANGUAGES C)
include(GNUInstallDirs) include(GNUInstallDirs)
include(CMakePackageConfigHelpers) include(CMakePackageConfigHelpers)
include(CTest) 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(AKERR_COVERAGE 0 CACHE BOOL "Instrument the build with gcov coverage counters")
# 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") 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()
set(SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generrno.sh) set(SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/scripts/generrno.sh)
set(INFILE ${CMAKE_CURRENT_SOURCE_DIR}/include/akerror.tmpl.h) set(INFILE ${CMAKE_CURRENT_SOURCE_DIR}/include/akerror.tmpl.h)
@@ -42,8 +93,17 @@ add_library(akerror::akerror ALIAS akerror)
target_compile_definitions(akerror target_compile_definitions(akerror
PUBLIC AKERR_USE_STDLIB=${AKERR_USE_STDLIB} 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}
) )
set_target_properties(akerror PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
)
akerr_instrument_for_coverage(akerror)
# Each test is one source file in tests/ built into test_<name> and registered # Each test is one source file in tests/ built into test_<name> and registered
# as CTest <name>. Tests expected to abort (unhandled error / contract # as CTest <name>. Tests expected to abort (unhandled error / contract
# violation) go in AKERR_WILL_FAIL_TESTS; all others must exit 0. # violation) go in AKERR_WILL_FAIL_TESTS; all others must exit 0.
@@ -67,11 +127,24 @@ set(AKERR_TESTS
err_release_clears err_release_clears
err_pool_exhaust err_pool_exhaust
err_maxval 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
) )
set(AKERR_WILL_FAIL_TESTS set(AKERR_WILL_FAIL_TESTS
err_trace err_trace
err_improper_closure err_improper_closure
err_library_status_fatal
) )
foreach(_test IN LISTS AKERR_TESTS) foreach(_test IN LISTS AKERR_TESTS)
@@ -81,23 +154,44 @@ foreach(_test IN LISTS AKERR_TESTS)
add_test(NAME ${_test} COMMAND test_${_test}) add_test(NAME ${_test} COMMAND test_${_test})
endforeach() 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( set_tests_properties(
${AKERR_WILL_FAIL_TESTS} ${AKERR_WILL_FAIL_TESTS}
PROPERTIES WILL_FAIL TRUE PROPERTIES WILL_FAIL TRUE
) )
# Mutation testing: break the library in small ways and confirm the test suite # Coverage and mutation testing are meta-checks on the test suite itself, and
# notices. This is a meta-check on the tests themselves, so it is a manual # both rebuild and re-run the whole suite, so they are manual targets rather
# target (it rebuilds and re-runs the whole suite many times), not a CTest test. # than CTest tests.
# cmake --build build --target mutation
find_package(Python3 COMPONENTS Interpreter) find_package(Python3 COMPONENTS Interpreter)
if(Python3_FOUND) if(Python3_FOUND)
add_custom_target(mutation # 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} COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py ${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
--source-root ${CMAKE_CURRENT_SOURCE_DIR} --source-root ${CMAKE_CURRENT_SOURCE_DIR}

View File

@@ -4,6 +4,14 @@ 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) ![build badge](https://source.starfort.tech/andrew/libakerror/actions/workflows/ci.yaml/badge.svg?branch=main)
## Upgrading from a pre-1.0.0 release
1.0.0 replaced the consumer-sized status-name array with a private, ownership-
enforced registry. That is a source and ABI break: see
[UPGRADING.md](UPGRADING.md) for what was removed, how to migrate, the capacity
limits and how to raise them, and the thread-safety rules.
# 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.
@@ -64,14 +72,47 @@ Any function which uses the `PREPARE_ERROR` macro should have a return type of `
The library uses integer values to specify error codes inside of its context. These integer return codes are defined in `akerror.h` in the form of `AKERR_xxxxx` where `xxxxx` is the name of the error code in question. See `akerror.h` for a list of defined errors and their descriptions. The library uses integer values to specify error codes inside of its context. These integer return codes are defined in `akerror.h` in the form of `AKERR_xxxxx` where `xxxxx` is the name of the error code in question. See `akerror.h` for a list of defined errors and their descriptions.
You can define additional error types by defining additional `AKERR_xxxxx` values. Error values up to 255 are reserved by the library (`AKERR_xxxxx` begins where `errno` stops), so please begin your error values at 256. When you add additional error codes, you need to define `-DAKERR_MAX_ERR_VALUE=n` to the compiler, where `n` is the maximum error code you have defined. If you define custom error codes, `AKERR_MAX_ERR_VALUE` must be >= 256 or the compiler will throw an error. You can define additional error types as integer constants. Values 0 through 255
are reserved by libakerror (the host's errno values plus the `AKERR_*` codes);
consumers allocate codes starting at `AKERR_FIRST_CONSUMER_STATUS` (256). Status
names are stored sparsely, so any `int` is a legal status and no compile
definition is needed to use large values.
Define a human-friendly name for the error with the `akerr_name_for_status` method somewhere in your code's initialization before the error may be used: Every library that may coexist in one process must reserve its range during
initialization. `akerr_reserve_status_range()` and
`akerr_register_status_name()` report failure the way everything else in this
library does — they return `akerr_ErrorContext *`, and they are marked
`AKERR_NOIGNORE` — so a collision is an exception you can `CATCH`, `HANDLE`, or
`PASS` up out of your initialization:
```c ```c
akerr_name_for_status(129, "Some Error Code Description") #define MYLIB_OWNER "my-library"
akerr_ErrorContext AKERR_NOIGNORE *mylib_init(void)
{
PREPARE_ERROR(errctx);
/* Another component owning part of the range propagates to our caller. */
PASS(errctx, akerr_reserve_status_range(256, 16, MYLIB_OWNER));
/* Then name each code, quoting the owner you reserved with. */
PASS(errctx, akerr_register_status_name(MYLIB_OWNER, 256,
"Some Error Code Description"));
SUCCEED_RETURN(errctx);
}
``` ```
Reservations are process-local, fixed-capacity, and idempotent when the same
owner repeats the exact same range. They preserve compile-time integer constants
so `HANDLE` still works, since `case` labels require them.
Naming a status outside your reservation raises `AKERR_STATUS_NAME_FOREIGN`, and
naming one nobody reserved raises `AKERR_STATUS_NAME_UNRESERVED`; a colliding
reservation raises `AKERR_STATUS_RANGE_OVERLAP`, with a message naming the real
owner. See [UPGRADING.md](UPGRADING.md) for the full list of statuses these two
functions raise, the capacity limits and how to raise them, and thread-safety
rules.
# Installation # Installation
@@ -95,10 +136,14 @@ This library depends upon `stdlib`. If you don't want to link against stdlib, yo
- `memset` function - `memset` function
- `strncpy` function - `strncpy` function
- `strlen` function
- `strcmp` function
- `sprintf` function - `sprintf` function
- `exit` function - `exit` function
- `bool` type - `bool` type
- `NULL` type - `NULL` type
- `INT_MAX` constant
- `PATH_MAX` constant
... then you can compile it thusly: ... then you can compile it thusly:
@@ -210,6 +255,8 @@ ATTEMPT {
This will assign the return value of the function in question to the akerr_ErrorContext previously prepared in the current scope. If the function returns an akerr_ErrorContext that indicates any type of error, the `ATTEMPT` block is immediately exited, and the `CLEANUP` block begins. This will assign the return value of the function in question to the akerr_ErrorContext previously prepared in the current scope. If the function returns an akerr_ErrorContext that indicates any type of error, the `ATTEMPT` block is immediately exited, and the `CLEANUP` block begins.
(One caveat: because this exit is implemented with a C `break`, `CATCH` must not be used inside a loop within the `ATTEMPT` block — see the section "Important: do not use CATCH or FAIL_*_BREAK inside a loop" below.)
## Setting errors from functions or expressions returning integer ## Setting errors from functions or expressions returning integer
For functions that return integer, such as logical comparisons or most standard library functions, use the `FAIL_ZERO_BREAK` and `FAIL_NONZERO_BREAK` macros. These macros allow you to capture an integer return code from an expression or function and set an error code in the current context based off that return. For functions that return integer, such as logical comparisons or most standard library functions, use the `FAIL_ZERO_BREAK` and `FAIL_NONZERO_BREAK` macros. These macros allow you to capture an integer return code from an expression or function and set an error code in the current context based off that return.
@@ -232,6 +279,49 @@ 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.
## Important: do not use CATCH or FAIL_*_BREAK inside a loop
`CATCH`, `FAIL_ZERO_BREAK`, `FAIL_NONZERO_BREAK`, and `FAIL_BREAK` leave the `ATTEMPT` block by executing a C `break` statement. In C, `break` only exits the *innermost* enclosing `for`, `while`, `do`, or `switch`. Therefore **these macros must not be used inside a loop (or a nested `switch`) that is itself inside an `ATTEMPT` block.** If you do, the `break` escapes only the loop — not the `ATTEMPT` — and the rest of the `ATTEMPT` body then runs with an error already pending:
```c
ATTEMPT {
for ( int i = 0; i < n; i++ ) {
CATCH(errctx, process(items[i])); // WRONG: break exits the for loop, not the ATTEMPT
}
// ... this code still executes, with errctx already in an error state ...
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
```
Note that moving the loop into a helper function does **not** fix this on its own — if the helper still wraps the loop in an `ATTEMPT` and uses `CATCH`/`FAIL_*_BREAK` inside it, it has the exact same problem. The fix is to iterate with `return`-based macros, which are unaffected by loop nesting. Use one of the two patterns below.
**Pattern 1 — use `PASS` (or a `FAIL_*_RETURN` macro) inside the loop.** These exit the *enclosing function* with a `return` rather than a `break`, so loop nesting is irrelevant. Use this when the loop should stop and propagate on the first error:
```c
akerr_ErrorContext AKERR_NOIGNORE *process_all(Item *items, int n)
{
PREPARE_ERROR(errctx);
for ( int i = 0; i < n; i++ ) {
PASS(errctx, process(items[i])); // returns from process_all on the first error
}
SUCCEED_RETURN(errctx);
}
```
**Pattern 2 — move the loop into a helper and `CATCH` the single call.** When you need a `CLEANUP` block or want to `HANDLE` the error locally, put the loop in its own `akerr_ErrorContext *`-returning function (written per Pattern 1) and `CATCH` that one call. The `CATCH` is then not inside a loop, so its `break` scopes to the `ATTEMPT` correctly:
```c
ATTEMPT {
CATCH(errctx, process_all(items, n)); // a single CATCH, not looped
} CLEANUP {
// ... always runs ...
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_VALUE) {
// ... handle a failure from any iteration ...
} FINISH(errctx, true);
```
# Passing errors # 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. 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.

93
TODO.md Normal file
View File

@@ -0,0 +1,93 @@
# TODO
Working notes for `libakerror`. Outstanding items only.
## 1. The test suite has no sanitizer run
Mutation testing caught an out-of-bounds probe in the status-name hash table
that the suite could not: the failure mode was a write into adjacent BSS, which
does not crash, so every test still passed. Sharpening one test closed that
instance, but ASan would have caught the whole class directly and independently
of how sharp the assertions are.
Add a `-fsanitize=address,undefined` build to `.gitea/workflows/ci.yaml`, or a
CMake option alongside `AKERR_COVERAGE`. This is the highest-value item here: it
covers the whole library, not just the registry, and the library's fixed pools
and manual buffer arithmetic are exactly what it is good at.
## 2. `HANDLE`-level status aliasing is still undetectable
Two components can compile the same integer into a `case` label without ever
reserving a range or registering a name, and nothing sees it. Ownership
enforcement covers *naming*, which is the part the library mediates; the `case`
label never reaches it.
Closing this needs the `if`/`else if` handler ladder — rewriting
`PROCESS`/`HANDLE`/`HANDLE_GROUP`/`HANDLE_DEFAULT`/`FINISH` so status matching
is not restricted to integer constant expressions. That would also allow
matching on ranges or predicates, and would let a handler resolve a code through
its owner. It touches the most load-bearing code in the library and every
consumer's error handling at once, so it wants its own change.
Note it would *not* by itself fix the "don't use `CATCH` or `FAIL_*_BREAK`
inside a loop" hazard: that comes from exiting via `break`, not from `switch`.
## 3. No registry introspection
There is no way to ask who owns a status, or to enumerate reservations. The
"coordinate ranges at the dependency-stack level" advice in UPGRADING.md
therefore has no tooling behind it.
A read-only accessor plus a dump through `akerr_log_method` would let a startup
self-check or a CI job print the whole map for a linked stack. Cheap, additive,
and the natural next step for multi-component adoption.
## 4. No way to release a reservation
A plugin host that `dlopen`s many distinct plugins over a process lifetime
accumulates ranges until the table fills. Reloading the *same* plugin is fine —
an identical repeat by the same owner is idempotent.
## 5. The registry is not thread safe
Global mutable state, no locking, and `akerr_status_name_count++` is not atomic.
Currently documented as an initialization-time-only API rather than enforced. If
components start initializing on separate threads this needs either a lock or a
documented once-per-process init barrier.
## 6. Deprecate the two-argument name-registration path
`akerr_name_for_status(status, name)` cannot identify its caller, so it can only
check that *some* reservation covers the status, not that the caller owns it. It
exists for migration. Once consumers have moved to
`akerr_register_status_name()`, make the set path a no-op or remove it and leave
`akerr_name_for_status()` as pure lookup.
## 7. `akerr_init()`'s own reservation failure is untested
`tests/err_library_status_fatal.c` covers the terminal path in
`__akerr_name_library_status()` by naming a status the library does not own. The
band reservation in `akerr_init()` has no such handle: it can only fail in a
build whose tables are too small for the library's own entries, and both sizes
are `PRIVATE` to the library target, so a test executable cannot set them.
Closing it means a second library target built with tiny tables plus a
`WILL_FAIL` test linked against it — worth doing when the CMake gains a
sanitizer variant (item 1), since that adds the same machinery.
Related: branch coverage on `src/error.c` now sits just above its 50% gate.
Every `FAIL_*` site carries about six branch outcomes of error-construction
machinery (`ENSURE_ERROR_READY`, `AKERR_STACKTRACE_APPEND`) that only run when
that specific failure fires, and every `PASS` site around a call that cannot
fail carries about twenty-five. Validating more inputs therefore lowers the
ratio by construction. Before adding defensive checks, expect to add a test that
drives them, as `tests/err_copy_string.c` does.
## Unrelated pre-existing issues
- The `AKERR_USE_STDLIB=OFF` build does not compile at all: `bool`, `PATH_MAX`
and `NULL` are used unconditionally but only included under the stdlib branch.
The README's dependency list states what a replacement must provide, but the
header still needs its includes untangled for that configuration to work.
- `CMakeLists.txt` sets `main_lib_dest` from `MY_LIBRARY_VERSION`, which is never
defined and never read. Dead line.

208
UPGRADING.md Normal file
View File

@@ -0,0 +1,208 @@
# Upgrade notice: custom status codes (1.0.0)
Version 1.0.0 replaces the consumer-sized status-name array with a private
registry, and makes status-code ownership explicit and enforced. This is a
source and ABI break. The library now carries a version and an soname
(`libakerror.so.1`), so a stale installed library can no longer be silently
paired with a newer header — but anything built against a pre-1.0.0 header must
be rebuilt.
What was removed:
* `AKERR_MAX_ERR_VALUE` — the registry is sparse and accepts any `int`, so
consumers no longer size it. Delete every compile definition and source
reference. A stale `-DAKERR_MAX_ERR_VALUE=...` is now harmless but useless.
* `__AKERR_ERROR_NAMES` — the name table is private to the library. Code that
touched this data symbol was using an undocumented interface; use
`akerr_name_for_status()`.
* `AKERR_STATUS_RANGE_OK` and `AKERR_STATUS_NAME_OK` — the registry functions no
longer return an `int`. Success is a `NULL` `akerr_ErrorContext *`, like every
other function in the library. See "The registry raises errors" below.
To migrate:
1. Rebuild libakerror and every dependent library against the new header.
2. Move custom status codes out of the reserved `0``255` band. Use fixed
integer constants beginning at `AKERR_FIRST_CONSUMER_STATUS` (256) rather
than offsets from `AKERR_LAST_ERRNO_VALUE`, so a libc that grows an errno
cannot move your codes.
3. Assign a distinct range to every library that may coexist in one process, and
coordinate those ranges at the application or dependency-stack level.
4. Reserve the complete range with `akerr_reserve_status_range()` during
initialization, and treat the error it returns like any other error: `CATCH`
it, `PASS` it, or let it propagate out of your init function.
5. Register names with `akerr_register_status_name()`, passing the same owner
string you reserved with. **You can no longer name a status you have not
reserved** — see "Ownership is enforced" below.
For example:
```c
enum {
MYLIB_ERR_BASE = AKERR_FIRST_CONSUMER_STATUS, /* 256 */
MYLIB_ERR_PARSE = MYLIB_ERR_BASE,
MYLIB_ERR_STORAGE,
MYLIB_ERR_LIMIT
};
#define MYLIB_OWNER "mylib"
akerr_ErrorContext AKERR_NOIGNORE *mylib_init(void)
{
PREPARE_ERROR(errctx);
/* Any collision propagates out of mylib_init() to the caller. */
PASS(errctx, akerr_reserve_status_range(MYLIB_ERR_BASE,
MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
MYLIB_OWNER));
PASS(errctx, akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_PARSE,
"Parse Error"));
PASS(errctx, akerr_register_status_name(MYLIB_OWNER, MYLIB_ERR_STORAGE,
"Storage Error"));
SUCCEED_RETURN(errctx);
}
```
If your `init` cannot return an `akerr_ErrorContext *` — a C API with an `int`
return, say — catch the error and convert it. Close with `FINISH_NORETURN`
rather than `FINISH`: nothing can propagate out of a function that does not
return an error context, and `FINISH(errctx, false)` in an `int`-returning
function compiles the (dead) propagation branch anyway, which warns.
```c
int mylib_init(void)
{
PREPARE_ERROR(errctx);
int rc = MYLIB_INIT_OK;
ATTEMPT {
CATCH(errctx, akerr_reserve_status_range(MYLIB_ERR_BASE,
MYLIB_ERR_LIMIT - MYLIB_ERR_BASE,
MYLIB_OWNER));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "mylib could not claim its status range");
rc = MYLIB_INIT_FAILED; /* another component owns part of the range */
} FINISH_NORETURN(errctx);
return rc;
}
```
You do not need to call `akerr_init()` first. Every registry entry point calls
it for you, so a library that reserves its range before anything else in the
process has touched libakerror keeps that reservation. (Before 1.0.0 this was
silently destructive: `akerr_init()` cleared the tables, so a reservation made
too early was discarded and the next component to claim the same range was told
it was free.)
## Ownership is enforced
Reserving a range is no longer advisory bookkeeping. A status may only be named
from inside a reservation:
* `akerr_register_status_name(owner, status, name)` requires that `status` fall
in a range reserved by `owner`. Naming another component's status fails with
`AKERR_STATUS_NAME_FOREIGN`, and naming a status nobody reserved fails with
`AKERR_STATUS_NAME_UNRESERVED`.
* The two-argument `akerr_name_for_status(status, name)` set path still works,
but it cannot identify its caller, so it can only require that *some*
reservation covers the status. Prefer the owned form: it is the one that
catches a component writing into a range that is not its own.
Every refusal is reported, because a name that fails to register degrades that
code to `"Unknown Error"` in every later stack trace.
This detects *name* collisions. It cannot detect two components compiling the
same integer into a `HANDLE` label without ever registering a name, so every
co-resident library should still reserve its range.
## The registry raises errors
`akerr_reserve_status_range()` and `akerr_register_status_name()` return
`akerr_ErrorContext *`, exactly like the rest of the library. They are marked
`AKERR_NOIGNORE`, so discarding the result is a compile-time warning, and an
error you catch but do not handle propagates out of your init function instead
of leaving you with a range you do not actually own.
```c
ATTEMPT {
CATCH(errctx, akerr_reserve_status_range(256, 16, MYLIB_OWNER));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_STATUS_RANGE_OVERLAP) {
/* Another component owns part of it -- the message names which. */
} FINISH(errctx, true);
```
`akerr_reserve_status_range()` raises:
| Status | Meaning |
| --- | --- |
| — (returns `NULL`) | Range reserved, or an identical range was already reserved by the same owner |
| `AKERR_STATUS_RANGE_OVERLAP` | Part of the range is owned by someone else (the message names the owner) |
| `AKERR_STATUS_RANGE_FULL` | No reservation slots remain |
| `AKERR_STATUS_RANGE_INVALID` | `count < 1`, NULL/empty/over-long owner, or the range overflows `int` |
`akerr_register_status_name()` raises:
| Status | Meaning |
| --- | --- |
| — (returns `NULL`) | Name registered |
| `AKERR_STATUS_NAME_UNRESERVED` | No reservation contains this status |
| `AKERR_STATUS_NAME_FOREIGN` | The status is in a range owned by someone else |
| `AKERR_STATUS_NAME_FULL` | The name registry is full |
| `AKERR_STATUS_NAME_INVALID` | NULL/empty/over-long owner, or a NULL name |
These are ordinary status codes inside the library's reserved band, so they can
be matched with `HANDLE`, grouped with `HANDLE_GROUP`, and they print with a
name and a stack trace when they go unhandled.
`akerr_name_for_status(status, name)` is the one exception: it returns a name
rather than an error context, so it cannot raise. A refused registration through
that path is reported through `akerr_log_method` and reads back as
`"Unknown Error"`.
Repeating an identical reservation for the same owner is a no-op. A *subset* or
*superset* of your own range is not — it raises `AKERR_STATUS_RANGE_OVERLAP`.
Reserve the whole range in one call.
The library holds itself to the same rule at startup: if `akerr_init()` cannot
reserve its own `0``255` band or name one of its own codes, it logs the failure
and terminates the program. That can only happen in a misconfigured build (a
name table too small for the library's own entries), and the alternative is a
process whose stack traces silently read `"Unknown Error"` for built-in codes.
## Capacity
Both tables are fixed size, allocated in BSS, and never grow:
| Limit | Default | Build-time override |
| --- | --- | --- |
| Status names | 3072 usable (4096 slots, 75% load) | `-DAKERR_STATUS_NAME_SLOTS=<power of two>` |
| Reserved ranges | 64 | `-DAKERR_MAX_RESERVED_STATUS_RANGES=<n>` |
`akerr_init()` consumes one name slot per host errno value plus one per
`AKERR_*` code — around 150 on glibc, leaving roughly 2900 for all consumers in
the process to share. That figure varies with the host libc, so treat it as
approximate rather than a budget to fill.
Both overrides are CMake cache variables and apply `PRIVATE` to the library
target. The tables live entirely inside `src/error.c`, so raising them changes
nothing a consumer can observe — unlike the `AKERR_MAX_ERR_VALUE` they replaced,
where a mismatch between the library and its consumers corrupted memory. Set
them when configuring libakerror itself:
```sh
cmake -S . -B build -DAKERR_STATUS_NAME_SLOTS=16384
```
Exhausting either table raises an error to the caller; it is never silent.
## Thread safety
The registry is process-global mutable state with no locking. Reserve ranges and
register names during single-threaded initialization, before spawning threads.
Lookups (`akerr_name_for_status(status, NULL)`) are safe to call concurrently
once registration has finished.

View File

@@ -39,16 +39,43 @@
#define AKERR_NOT_IMPLEMENTED (AKERR_LAST_ERRNO_VALUE + 16) /** A method was called that is defined but not currently implemented */ #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) */ #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 /*
/* Must be >= the highest AKERR_* offset above (AKERR_BADEXC, +17) so every * Registry failures. These are ordinary status codes, not a private return
* library error code is indexable in __AKERR_ERROR_NAMES. Keep in sync when * enumeration: akerr_reserve_status_range() and akerr_register_status_name()
* adding codes; tests/err_maxval.c guards this invariant. */ * return akerr_ErrorContext * like everything else in this library, so a refused
#define AKERR_MAX_ERR_VALUE (AKERR_LAST_ERRNO_VALUE + 17) * reservation can be CATCH-ed, HANDLE-d, PASS-ed, or left unhandled to produce a
#elif AKERR_MAX_ERR_VALUE < 256 * stack trace and stop the program. Success returns NULL.
#error user-defined AKERR_MAX_ERR_VALUE must be >= 256 */
#endif #define AKERR_STATUS_RANGE_OVERLAP (AKERR_LAST_ERRNO_VALUE + 18) /** Some part of the range is already owned by someone else */
#define AKERR_STATUS_RANGE_FULL (AKERR_LAST_ERRNO_VALUE + 19) /** No reservation slots remain (see AKERR_MAX_RESERVED_STATUS_RANGES) */
#define AKERR_STATUS_RANGE_INVALID (AKERR_LAST_ERRNO_VALUE + 20) /** Bad count, bad owner string, or the range overflows int */
#define AKERR_STATUS_NAME_UNRESERVED (AKERR_LAST_ERRNO_VALUE + 21) /** No owner has reserved a range containing this status */
#define AKERR_STATUS_NAME_FOREIGN (AKERR_LAST_ERRNO_VALUE + 22) /** The status lies in a range reserved by a different owner */
#define AKERR_STATUS_NAME_FULL (AKERR_LAST_ERRNO_VALUE + 23) /** The name registry is full (raise AKERR_STATUS_NAME_SLOTS) */
#define AKERR_STATUS_NAME_INVALID (AKERR_LAST_ERRNO_VALUE + 24) /** NULL/empty/over-long owner, or a NULL name */
extern char __AKERR_ERROR_NAMES[AKERR_MAX_ERR_VALUE+1][AKERR_MAX_ERROR_NAME_LENGTH]; /* The last status the library defines for itself. Everything from
* AKERR_LAST_ERRNO_VALUE + 1 through here must have a registered name. */
#define AKERR_LAST_LIBRARY_STATUS AKERR_STATUS_NAME_INVALID
/*
* Status values 0 through 255 are reserved by libakerror at akerr_init() time:
* the host's errno values plus the AKERR_* codes above. Consumers allocate from
* 256 upwards. Reserving any part of this band fails with
* AKERR_STATUS_RANGE_OVERLAP naming AKERR_LIBRARY_OWNER.
*/
#define AKERR_LIBRARY_OWNER "libakerror"
#define AKERR_RESERVED_STATUS_COUNT 256
#define AKERR_FIRST_CONSUMER_STATUS AKERR_RESERVED_STATUS_COUNT
/*
* The library reserves status values 0 through 255 for itself (see akerr_init),
* which must contain every AKERR_* code above. AKERR_LAST_ERRNO_VALUE is
* derived from the host's errno list at build time, so on a platform with an
* unusually large errno space these codes could escape the band and collide
* with consumer codes allocated at 256. Fail the build instead.
*/
typedef char akerr_assert_codes_within_reserved_band[(AKERR_LAST_LIBRARY_STATUS < 256) ? 1 : -1];
#define AKERR_MAX_ARRAY_ERROR 128 #define AKERR_MAX_ARRAY_ERROR 128
@@ -80,13 +107,56 @@ extern akerr_ErrorContext *__akerr_last_ignored;
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();
/*
* Look up (name == NULL) or register (name != NULL) the display name for a
* status. Registration succeeds only if some owner has reserved a range
* containing `status`; prefer akerr_register_status_name(), which also checks
* that the range belongs to you and raises an error saying why a registration
* was refused. This entry point cannot return an error context, so a refusal
* here is reported through akerr_log_method and reads back as "Unknown Error".
* Never returns NULL -- an unregistered status reads back as "Unknown Error".
*/
char *akerr_name_for_status(int status, char *name); char *akerr_name_for_status(int status, char *name);
/*
* Register a display name for a status you own. `owner` must match the owner
* string passed to akerr_reserve_status_range() for the range containing
* `status`. Returns NULL on success, or an error context whose status is one of
* the AKERR_STATUS_NAME_* codes above -- CATCH it, HANDLE it, or let it
* propagate.
*/
akerr_ErrorContext AKERR_NOIGNORE *akerr_register_status_name(const char *owner, int status, const char *name);
/*
* Claim `count` status values starting at `first_status` for `owner`. Repeating
* an identical reservation for the same owner is a no-op; any other collision is
* refused. Returns NULL on success, or an error context whose status is one of
* the AKERR_STATUS_RANGE_* codes above. Treat any error as an initialization
* failure: either handle it or let it propagate out of your init function.
*/
akerr_ErrorContext AKERR_NOIGNORE *akerr_reserve_status_range(int first_status, int count, const char *owner);
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); 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);
/*
* Internal. Names a status in the library's own reserved band on behalf of
* akerr_init() and the generated errno table, which have no caller to raise
* into: a failure here is logged and terminates the program. Not part of the
* consumer API -- use akerr_register_status_name(), which raises instead.
*/
void __akerr_name_library_status(int status, const char *name);
/*
* Internal. Bounded string copy into a fixed buffer, always NUL-terminated.
* Raises AKERR_NULLPOINTER for a NULL destination or source and AKERR_VALUE for
* a capacity that leaves no room for a terminator. Exported so the library's
* own tests can drive those guards; not part of the consumer API.
*/
akerr_ErrorContext AKERR_NOIGNORE *__akerr_copy_string(char *destination, int capacity,
const char *source);
#define LOG_ERROR_WITH_MESSAGE(__err_context, __err_message) \ #define LOG_ERROR_WITH_MESSAGE(__err_context, __err_message) \
akerr_log_method("%s%s:%s:%d: %s %d (%s): %s", (char *)&__err_context->stacktracebuf, (char *)__FILE__, (char *)__func__, __LINE__, __err_message, __err_context->status, akerr_name_for_status(__err_context->status, NULL), __err_context->message); \ akerr_log_method("%s%s:%s:%d: %s %d (%s): %s", (char *)&__err_context->stacktracebuf, (char *)__FILE__, (char *)__func__, __LINE__, __err_message, __err_context->status, akerr_name_for_status(__err_context->status, NULL), __err_context->message); \
@@ -110,8 +180,30 @@ void akerr_init_errno(void);
akerr_log_method("%s:%s:%d: Unable to pull an error context from the array!", __FILE__, (char *)__func__, __LINE__); \ akerr_log_method("%s:%s:%d: Unable to pull an error context from the array!", __FILE__, (char *)__func__, __LINE__); \
exit(1); \ exit(1); \
} \ } \
} \ __err_context->refcount += 1; \
__err_context->refcount += 1; }
/*
* Append a formatted line to the error's stack-trace buffer, bounded by the
* space that remains so a deep propagation chain cannot write past the end of
* stacktracebuf. snprintf reports the length it *would* have written, which on
* truncation exceeds what it actually wrote, so the cursor advance is clamped
* to the remaining space.
*/
#define AKERR_STACKTRACE_APPEND(__err_context, ...) \
do { \
char *__akerr_stb = (char *)__err_context->stacktracebuf; \
size_t __akerr_used = (size_t)(__err_context->stacktracebufptr - __akerr_stb); \
if ( __akerr_used < AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH ) { \
size_t __akerr_rem = AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH - __akerr_used; \
int __akerr_n = snprintf(__err_context->stacktracebufptr, __akerr_rem, __VA_ARGS__); \
if ( __akerr_n < 0 ) { \
__akerr_n = 0; \
} \
__err_context->stacktracebufptr += ((size_t)__akerr_n < __akerr_rem) \
? (size_t)__akerr_n : (__akerr_rem - 1); \
} \
} while ( 0 )
/* /*
* Failure and success methods for functions that return akerr_ErrorContext * * Failure and success methods for functions that return akerr_ErrorContext *
@@ -168,11 +260,11 @@ void akerr_init_errno(void);
#define FAIL(__err_context, __err, __message, ...) \ #define FAIL(__err_context, __err, __message, ...) \
ENSURE_ERROR_READY(__err_context); \ ENSURE_ERROR_READY(__err_context); \
__err_context->status = __err; \ __err_context->status = __err; \
snprintf((char *)__err_context->fname, AKERR_MAX_ERROR_FNAME_LENGTH, __FILE__); \ snprintf((char *)__err_context->fname, AKERR_MAX_ERROR_FNAME_LENGTH, "%s", __FILE__); \
snprintf((char *)__err_context->function, AKERR_MAX_ERROR_FUNCTION_LENGTH, __func__); \ snprintf((char *)__err_context->function, AKERR_MAX_ERROR_FUNCTION_LENGTH, "%s", __func__); \
__err_context->lineno = __LINE__; \ __err_context->lineno = __LINE__; \
snprintf((char *)__err_context->message, AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH, __message, ## __VA_ARGS__); \ snprintf((char *)__err_context->message, AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH, __message, ## __VA_ARGS__); \
__err_context->stacktracebufptr += snprintf(__err_context->stacktracebufptr, AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH, "%s:%s:%d: %d (%s) : %s\n", (char *)__err_context->fname, (char *)__err_context->function, __err_context->lineno, __err_context->status, akerr_name_for_status(__err_context->status, NULL), (__err_context->message == NULL ? "" : __err_context->message)); AKERR_STACKTRACE_APPEND(__err_context, "%s:%s:%d: %d (%s) : %s\n", (char *)__err_context->fname, (char *)__err_context->function, __err_context->lineno, __err_context->status, akerr_name_for_status(__err_context->status, NULL), (__err_context->message == NULL ? "" : __err_context->message));
#define SUCCEED(__err_context) \ #define SUCCEED(__err_context) \
@@ -198,7 +290,7 @@ void akerr_init_errno(void);
VALID(__err_context, __stmt); \ VALID(__err_context, __stmt); \
if ( __err_context != NULL ) { \ if ( __err_context != NULL ) { \
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__); \ AKERR_STACKTRACE_APPEND(__err_context, "%s:%s:%d\n", (char *)__FILE__, (char *)__func__, __LINE__); \
break; \ break; \
} \ } \
} }

407
scripts/coverage.py Executable file
View File

@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
Code coverage harness for libakerror.
Coverage measures which parts of the library the CTest suite actually executes.
It is the complement to mutation testing (scripts/mutation_test.py): coverage
finds code the tests never reach, mutation testing finds code the tests reach
but do not really check.
What is measured is the library's own translation units: src/error.c and the
generated src/errno.c. The macros in the public header are deliberately not
measured -- GCC attributes an expanded macro to its call site, so header logic
would be reported as lines of the test that used it. Use mutation testing
(--target include/akerror.tmpl.h) to check how well those macros are tested.
This harness has no third-party dependencies -- just gcov, which ships with the
compiler, plus the project's normal cmake/ctest toolchain. By default it
configures its own instrumented build directory so the ordinary build tree is
left alone and coverage counters can never be stale.
Usage:
scripts/coverage.py [options]
--source-root DIR repo root (default: parent of this script's dir)
--build-dir DIR instrumented build dir (default: <root>/build/coverage)
--no-configure reuse the build dir as-is (do not cmake/build)
--no-run report existing counters (do not reset and run ctest)
--threshold PCT exit non-zero if line coverage < PCT (default: 0 = off)
--branch-threshold P exit non-zero if branch coverage < P (default: 0 = off)
--junit PATH write a JUnit XML report to this path
--max-uncovered N uncovered lines to list per file (default: 40, 0 = all)
--exclude SUBSTR skip reported paths containing SUBSTR; repeatable
--gcov PROG gcov program (default: $GCOV or "gcov")
"""
import argparse
import collections
import json
import os
import subprocess
import sys
# --------------------------------------------------------------------------- #
# Running the instrumented suite
# --------------------------------------------------------------------------- #
def configure_and_build(cmake, root, build, jobs):
"""Configure an instrumented build dir and build everything in it."""
cmds = [
[cmake, "-S", root, "-B", build, "-DAKERR_COVERAGE=ON"],
[cmake, "--build", build] + (["--parallel", str(jobs)] if jobs else []),
]
for cmd in cmds:
print("+ " + " ".join(cmd))
if subprocess.call(cmd) != 0:
return False
return True
def reset_counters(build):
"""Delete accumulated .gcda files so each run reports one suite run."""
removed = 0
for dirpath, _dirs, files in os.walk(build):
for name in files:
if name.endswith(".gcda"):
os.unlink(os.path.join(dirpath, name))
removed += 1
if removed:
print(f"Reset {removed} coverage data file(s).")
def run_ctest(build, ctest):
"""Run the suite. Returns True if every test passed.
Coverage counters are flushed at exit(), and the tests that are expected to
fail exit via the library's unhandled-error handler (exit(), not abort()),
so WILL_FAIL tests still contribute their counters.
"""
cmd = [ctest, "--output-on-failure"]
print("+ " + " ".join(cmd) + f" (in {build})")
return subprocess.call(cmd, cwd=build) == 0
# --------------------------------------------------------------------------- #
# Collecting gcov data
# --------------------------------------------------------------------------- #
class FileCov:
"""Merged coverage for one source file, across every object that built it.
A file compiled into more than one object -- or a header included by several
translation units -- is reported once, with counts summed. Branches are
merged by (line, index within line), so differing expansions of the same
line contribute the union of their branches.
"""
def __init__(self):
self.lines = collections.Counter() # lineno -> execution count
self.branches = collections.Counter() # (lineno, idx) -> taken count
self.funcs = collections.Counter() # (name, start_line) -> count
def merge(self, entry):
for ln in entry.get("lines", []):
no = ln["line_number"]
self.lines[no] += ln.get("count", 0)
for idx, br in enumerate(ln.get("branches", [])):
if br.get("throw"):
continue
self.branches[(no, idx)] += br.get("count", 0)
for fn in entry.get("functions", []):
key = (fn.get("name", "?"), fn.get("start_line", 0))
self.funcs[key] += fn.get("execution_count", 0)
@staticmethod
def _ratio(counter):
total = len(counter)
hit = sum(1 for v in counter.values() if v > 0)
return hit, total
def line_stats(self):
return self._ratio(self.lines)
def branch_stats(self):
return self._ratio(self.branches)
def func_stats(self):
return self._ratio(self.funcs)
def uncovered_lines(self):
return sorted(no for no, count in self.lines.items() if count == 0)
def find_notes(build):
"""Every .gcno in the build tree: one per instrumented translation unit."""
notes = []
for dirpath, _dirs, files in os.walk(build):
for name in files:
if name.endswith(".gcno"):
notes.append(os.path.join(dirpath, name))
return sorted(notes)
def gcov_json(gcov, note, cwd):
"""Run gcov on one .gcno and return its parsed JSON, or None on failure.
--stdout keeps gcov from littering .gcov files in the build tree. A .gcno
with no matching .gcda still reports, with all counts zero, which is the
correct answer for a translation unit no test executed.
"""
cmd = [gcov, "--branch-probabilities", "--json-format", "--stdout", note]
proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
out, err = proc.communicate()
if proc.returncode != 0:
sys.stderr.write(f"warning: {' '.join(cmd)} failed:\n"
f"{err.decode(errors='replace')}")
return None
try:
return json.loads(out.decode(errors="replace"))
except ValueError as exc:
sys.stderr.write(f"warning: unparseable gcov output for {note}: {exc}\n")
return None
def display_path(path, build, root):
"""Label a source: build-relative for generated files, else repo-relative.
Returns None for anything outside both trees (system headers, toolchain
internals). Generated sources are labelled relative to the build dir so the
report and its thresholds do not change with the build dir's location.
"""
for base in (build, root):
if path.startswith(base + os.sep):
return os.path.relpath(path, base)
return None
def collect(gcov, build, root, excludes):
"""Merge gcov data for every instrumented unit into {display path: FileCov}."""
covs = {}
for note in find_notes(build):
data = gcov_json(gcov, note, build)
if data is None:
continue
# Paths in the report are relative to the directory the unit was
# compiled in, which gcov records in the notes file.
compile_dir = data.get("current_working_directory") or build
for entry in data.get("files", []):
path = entry.get("file", "")
if not os.path.isabs(path):
path = os.path.join(compile_dir, path)
display = display_path(os.path.realpath(path), build, root)
if display is None:
continue # system headers, toolchain internals
if any(x in display for x in excludes):
continue
covs.setdefault(display, FileCov()).merge(entry)
return covs
# --------------------------------------------------------------------------- #
# Reporting
# --------------------------------------------------------------------------- #
def pct(hit, total):
return 100.0 * hit / total if total else 100.0
def fmt_ratio(hit, total):
if not total:
return f"{'-':>9} -"
return f"{hit:>4}/{total:<4} {pct(hit, total):5.1f}%"
def compress(numbers):
"""[1,2,3,7,9,10] -> '1-3, 7, 9-10' for readable uncovered-line lists."""
out, start, prev = [], None, None
for n in numbers:
if start is None:
start = prev = n
elif n == prev + 1:
prev = n
else:
out.append(f"{start}" if start == prev else f"{start}-{prev}")
start = prev = n
if start is not None:
out.append(f"{start}" if start == prev else f"{start}-{prev}")
return ", ".join(out)
def report(covs, max_uncovered):
"""Print the per-file table and uncovered detail; return overall percentages."""
width = max([len(p) for p in covs] + [len("TOTAL")])
print("\n" + "=" * 72)
print("CODE COVERAGE SUMMARY")
print("=" * 72)
print(f" {'FILE':<{width}} {'LINES':^15} {'BRANCHES':^15} FUNCS")
totals = [0, 0, 0, 0, 0, 0] # lines hit/total, branches hit/total, funcs
for path in sorted(covs):
cov = covs[path]
lh, lt = cov.line_stats()
bh, bt = cov.branch_stats()
fh, ft = cov.func_stats()
for i, v in enumerate((lh, lt, bh, bt, fh, ft)):
totals[i] += v
print(f" {path:<{width}} {fmt_ratio(lh, lt)} {fmt_ratio(bh, bt)} "
f"{fh}/{ft}")
lh, lt, bh, bt, fh, ft = totals
print(f" {'-' * width} {'-' * 15} {'-' * 15} -----")
print(f" {'TOTAL':<{width}} {fmt_ratio(lh, lt)} {fmt_ratio(bh, bt)} "
f"{fh}/{ft}")
for path in sorted(covs):
missing = covs[path].uncovered_lines()
if not missing:
continue
shown = missing if not max_uncovered else missing[:max_uncovered]
more = "" if len(shown) == len(missing) else \
f" ... (+{len(missing) - len(shown)} more)"
print(f"\n uncovered in {path} ({len(missing)} line(s)):")
print(f" {compress(shown)}{more}")
return pct(lh, lt), pct(bh, bt)
def _xml_escape(text):
return (str(text).replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
def write_junit(path, covs, line_threshold, branch_threshold):
"""One <testcase> per file per metric; below-threshold is a <failure>."""
cases = []
for f in sorted(covs):
cov = covs[f]
cases.append((f, "lines", cov.line_stats(), line_threshold))
cases.append((f, "branches", cov.branch_stats(), branch_threshold))
for metric, thr, stats in (("lines", line_threshold,
[c.line_stats() for c in covs.values()]),
("branches", branch_threshold,
[c.branch_stats() for c in covs.values()])):
cases.append(("TOTAL", metric,
(sum(h for h, _t in stats), sum(t for _h, t in stats)),
thr))
fails = sum(1 for _f, _m, (h, t), thr in cases
if t and thr > 0 and pct(h, t) < thr)
out = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<testsuites name="coverage" tests="{len(cases)}" '
f'failures="{fails}">',
f' <testsuite name="coverage" tests="{len(cases)}" '
f'failures="{fails}">']
for f, metric, (hit, total), thr in cases:
name = _xml_escape(f"{f} {metric}")
detail = _xml_escape(f"{hit}/{total} ({pct(hit, total):.1f}%)"
if total else "no data")
out.append(f' <testcase name="{name}" '
f'classname="coverage.{_xml_escape(metric)}" time="0">')
if total and thr > 0 and pct(hit, total) < thr:
out.append(f' <failure message="{metric} coverage '
f'{pct(hit, total):.1f}% &lt; threshold {thr:.1f}%">'
f'{detail}</failure>')
else:
out.append(f' <system-out>{detail}</system-out>')
out.append(' </testcase>')
out.append(' </testsuite>')
out.append('</testsuites>')
with open(path, "w") as fh:
fh.write("\n".join(out) + "\n")
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="Code coverage for libakerror")
ap.add_argument("--source-root", default=default_root)
ap.add_argument("--build-dir", default=None)
ap.add_argument("--configure", dest="configure", action="store_true",
default=True)
ap.add_argument("--no-configure", dest="configure", action="store_false")
ap.add_argument("--run", dest="run", action="store_true", default=True)
ap.add_argument("--no-run", dest="run", action="store_false")
ap.add_argument("--threshold", type=float, default=0.0,
help="fail if line coverage is below this percentage")
ap.add_argument("--branch-threshold", type=float, default=0.0,
help="fail if branch coverage is below this percentage")
ap.add_argument("--junit", default=None,
help="write a JUnit XML report to this path")
ap.add_argument("--max-uncovered", type=int, default=40)
ap.add_argument("--exclude", action="append", default=None,
help="skip reported paths containing this substring")
ap.add_argument("--gcov", default=os.environ.get("GCOV", "gcov"))
ap.add_argument("--cmake", default=os.environ.get("CMAKE", "cmake"))
ap.add_argument("--ctest", default=os.environ.get("CTEST", "ctest"))
ap.add_argument("-j", "--jobs", type=int, default=0)
args = ap.parse_args()
root = os.path.realpath(args.source_root)
build = os.path.realpath(args.build_dir or os.path.join(root, "build",
"coverage"))
# The tests exercise the library; their own source is not what we measure.
excludes = args.exclude if args.exclude is not None else ["tests/"]
if args.configure:
if not configure_and_build(args.cmake, root, build, args.jobs):
sys.stderr.write("\nInstrumented build FAILED; aborting.\n")
return 2
elif not os.path.isdir(build):
sys.stderr.write(f"No build dir at {build} (drop --no-configure).\n")
return 2
tests_ok = True
if args.run:
reset_counters(build)
tests_ok = run_ctest(build, args.ctest)
if not tests_ok:
sys.stderr.write("\nwarning: some tests FAILED; coverage below is "
"still reported, but the run is not green.\n")
covs = collect(args.gcov, build, root, excludes)
if not covs:
sys.stderr.write("No coverage data found. Was the build instrumented "
"(-DAKERR_COVERAGE=ON) and the suite run?\n")
return 2
line_pct, branch_pct = report(covs, args.max_uncovered)
if args.junit:
junit_path = os.path.abspath(args.junit)
write_junit(junit_path, covs, args.threshold, args.branch_threshold)
print(f"\nJUnit report written to: {junit_path}")
rc = 0
if not tests_ok:
print("\nFAIL: the CTest suite did not pass.")
rc = 1
# Thresholds gate every file as well as the total: a small, well-covered
# file (the generated status-name table) must not mask a regression in a
# bigger one. Files with no branches at all are not branch-gated.
checks = [("total", "line", line_pct, args.threshold),
("total", "branch", branch_pct, args.branch_threshold)]
for path in sorted(covs):
lh, lt = covs[path].line_stats()
bh, bt = covs[path].branch_stats()
checks.append((path, "line", pct(lh, lt), args.threshold))
if bt:
checks.append((path, "branch", pct(bh, bt), args.branch_threshold))
for path, metric, value, threshold in checks:
if threshold > 0 and value < threshold:
print(f"\nFAIL: {path} {metric} coverage {value:.1f}% < threshold "
f"{threshold:.1f}%")
rc = 1
return rc
if __name__ == "__main__":
sys.exit(main())

View File

@@ -8,13 +8,24 @@ mkdir -p ${outdir}/include
rm -f ${outdir}/src/errno.c rm -f ${outdir}/src/errno.c
echo "#include <akerror.h>" >> ${outdir}/src/errno.c echo "#include <akerror.h>" >> ${outdir}/src/errno.c
echo "#include <errno.h>" >> ${outdir}/src/errno.c echo "#include <errno.h>" >> ${outdir}/src/errno.c
cat >> ${outdir}/src/errno.c <<'EOF'
/*
* These names belong to the library's own reserved band, and this runs from
* akerr_init(), which has no caller to raise into -- so it goes through
* __akerr_name_library_status(), which reports a refusal and terminates rather
* than leaving every later stack trace to print "Unknown Error" for an errno.
* Keeping the branch in src/error.c also keeps this generated file free of
* control flow no test can reach.
*/
EOF
echo "void akerr_init_errno(void) {" >> ${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}\");" >> ${outdir}/src/errno.c ; echo " __akerr_name_library_status(${define}, \"${desc}\");" >> ${outdir}/src/errno.c ;
done; done;
echo "}" >> ${outdir}/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 > ${outdir}/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

View File

@@ -10,18 +10,106 @@ akerr_ErrorContext *__akerr_last_ignored;
akerr_ErrorUnhandledErrorHandler akerr_handler_unhandled_error; akerr_ErrorUnhandledErrorHandler akerr_handler_unhandled_error;
akerr_ErrorLogFunction akerr_log_method = NULL; akerr_ErrorLogFunction akerr_log_method = NULL;
char __AKERR_ERROR_NAMES[AKERR_MAX_ERR_VALUE+1][AKERR_MAX_ERROR_NAME_LENGTH]; /*
* Status-name registry.
*
* Storage is an open-addressed hash table keyed by status value. Both sizes are
* private to this translation unit -- they are deliberately NOT in the public
* header, because a consumer-visible table bound is exactly the ABI hazard this
* registry replaced. Overriding them changes only this file, so a library and
* its consumers can never disagree about the layout.
*
* The table is never resized or rehashed, so a pointer handed out by
* akerr_name_for_status() stays valid for the life of the process. Entries are
* never removed, so probing needs no tombstones.
*/
#ifndef AKERR_STATUS_NAME_SLOTS
#define AKERR_STATUS_NAME_SLOTS 4096
#endif
#ifndef AKERR_MAX_RESERVED_STATUS_RANGES
#define AKERR_MAX_RESERVED_STATUS_RANGES 64
#endif
/* Probing masks with SLOTS-1, so the slot count must be a power of two. This is
* the C99-portable spelling of a static assertion (a negative array bound). */
typedef char akerr_assert_name_slots_pow2[
(AKERR_STATUS_NAME_SLOTS > 0 &&
(AKERR_STATUS_NAME_SLOTS & (AKERR_STATUS_NAME_SLOTS - 1)) == 0) ? 1 : -1];
/* Cap occupancy at 75% so linear probing always meets an empty slot. */
#define AKERR_MAX_REGISTERED_STATUS_NAMES \
(AKERR_STATUS_NAME_SLOTS - (AKERR_STATUS_NAME_SLOTS / 4))
#define AKERR_MAX_STATUS_RANGE_OWNER_LENGTH 64
typedef struct
{
int status;
int used;
char name[AKERR_MAX_ERROR_NAME_LENGTH];
} akerr_StatusName;
typedef struct
{
int first;
int last;
char owner[AKERR_MAX_STATUS_RANGE_OWNER_LENGTH];
} akerr_StatusRange;
static akerr_StatusName akerr_status_names[AKERR_STATUS_NAME_SLOTS];
static int akerr_status_name_count;
static akerr_StatusRange akerr_status_ranges[AKERR_MAX_RESERVED_STATUS_RANGES];
static int akerr_status_range_count;
akerr_ErrorContext AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR]; akerr_ErrorContext AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR];
/*
* Bounded copy into a fixed buffer. Every argument is checked: this writes
* through a caller-supplied pointer for a caller-supplied length, so a NULL or
* a non-positive capacity here is a memory error waiting to happen, not
* something to absorb and return from quietly.
*
* Exported under the internal __akerr_ prefix rather than kept static so that
* tests/err_copy_string.c can reach these guards. Both in-library callers
* validate their arguments first, so nothing else can drive them.
*/
akerr_ErrorContext *__akerr_copy_string(char *destination, int capacity,
const char *source)
{
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx, (destination == NULL || source == NULL),
AKERR_NULLPOINTER,
"__akerr_copy_string got a NULL %s",
destination == NULL ? "destination buffer" : "source string");
FAIL_NONZERO_RETURN(errctx, (capacity <= 0), AKERR_VALUE,
"__akerr_copy_string got a capacity of %d; a buffer must "
"have room for at least a terminator", capacity);
strncpy(destination, source, (size_t)capacity - 1);
destination[capacity - 1] = '\0';
SUCCEED_RETURN(errctx);
}
/*
* Compare against each element address rather than testing the address range.
* A range test also accepts pointers into the *interior* of an element, which
* would then be treated as the head of an akerr_ErrorContext and written
* through. Keep this an element-wise scan; it is not a missed optimization.
*/
int akerr_valid_error_address(akerr_ErrorContext *ptr) int akerr_valid_error_address(akerr_ErrorContext *ptr)
{ {
// Is this within the memory region occupied by AKERR_ARRAY_ERROR? // Is this within the memory region occupied by AKERR_ARRAY_ERROR?
if ( ptr == NULL ) { if ( ptr == NULL ) {
return 1; return 1;
} }
return ((ptr >= &AKERR_ARRAY_ERROR[0]) && for ( int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
(ptr <= &AKERR_ARRAY_ERROR[AKERR_MAX_ARRAY_ERROR-1])); if ( ptr == &AKERR_ARRAY_ERROR[i] ) {
return 1;
}
}
return 0;
} }
void akerr_default_logger(const char *fmt, ...) void akerr_default_logger(const char *fmt, ...)
@@ -37,10 +125,48 @@ void akerr_default_logger(const char *fmt, ...)
#endif #endif
} }
/*
* The library naming its own codes.
*
* akerr_init() returns void and runs before any consumer frame exists, so there
* is nothing to PASS an error to: this *is* the top of the stack. FINISH_NORETURN
* is the library's idiom for that position -- the same one main() uses -- so an
* unhandled failure prints its stack trace and goes to
* akerr_handler_unhandled_error, which terminates.
*
* That is fatal on purpose. The library can only fail to name its own status
* codes if the build is misconfigured -- a name table too small to hold even
* the library's own entries, or a reservation that did not take -- and the
* consequence of continuing is every later stack trace in the process printing
* "Unknown Error" for a code the library defines. That is a startup defect, and
* it is far cheaper to see it at init than to debug it from a degraded trace.
*
* The generated errno table calls this rather than registering names directly,
* so that all of this control flow lives here in one place.
*/
void __akerr_name_library_status(int status, const char *name)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akerr_register_status_name(AKERR_LIBRARY_OWNER, status, name));
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}
/*
* Idempotent. `inited` is set before any work so that the registry calls below
* -- and the public registry entry points, which all call akerr_init() so that
* a consumer reserving its range before anything else touches the library
* cannot have that reservation wiped by a later first-use of the pool -- see
* themselves as already initialized instead of recursing.
*/
void akerr_init() void akerr_init()
{ {
static int inited = 0; static int inited = 0;
if ( inited == 0 ) { if ( inited == 0 ) {
inited = 1;
for (int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) { for (int i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) {
memset((void *)&AKERR_ARRAY_ERROR[i], 0x00, sizeof(akerr_ErrorContext)); memset((void *)&AKERR_ARRAY_ERROR[i], 0x00, sizeof(akerr_ErrorContext));
AKERR_ARRAY_ERROR[i].arrayid = i; AKERR_ARRAY_ERROR[i].arrayid = i;
@@ -53,25 +179,53 @@ void akerr_init()
akerr_log_method = &akerr_default_logger; akerr_log_method = &akerr_default_logger;
} }
akerr_handler_unhandled_error = &akerr_default_handler_unhandled_error; akerr_handler_unhandled_error = &akerr_default_handler_unhandled_error;
memset((void *)&__AKERR_ERROR_NAMES[0], 0x00, ((AKERR_MAX_ERR_VALUE+1) * AKERR_MAX_ERROR_NAME_LENGTH)); memset((void *)&akerr_status_names[0], 0x00, sizeof(akerr_status_names));
memset((void *)&akerr_status_ranges[0], 0x00, sizeof(akerr_status_ranges));
akerr_status_name_count = 0;
akerr_status_range_count = 0;
akerr_name_for_status(AKERR_NULLPOINTER, "Null Pointer Error"); /* errno and AKERR_* values are the library-owned compatibility band.
akerr_name_for_status(AKERR_OUTOFBOUNDS, "Out Of Bounds Error"); * This must precede every registration below: naming a status is only
akerr_name_for_status(AKERR_API, "API Error"); * permitted inside a reserved range. Terminal for the same reason as
akerr_name_for_status(AKERR_ATTRIBUTE, "Attribute Error"); * __akerr_name_library_status(), and handled the same way: without this
akerr_name_for_status(AKERR_TYPE, "Type Error"); * band the library owns nothing, so none of the names below could
akerr_name_for_status(AKERR_KEY, "Key Error"); * register either. */
akerr_name_for_status(AKERR_INDEX, "Index Error"); PREPARE_ERROR(errctx);
akerr_name_for_status(AKERR_FORMAT, "Format Error"); ATTEMPT {
akerr_name_for_status(AKERR_IO, "Input Output Error"); CATCH(errctx, akerr_reserve_status_range(0, AKERR_RESERVED_STATUS_COUNT,
akerr_name_for_status(AKERR_VALUE, "Value Error"); AKERR_LIBRARY_OWNER));
akerr_name_for_status(AKERR_RELATIONSHIP, "Relationship Error"); } CLEANUP {
akerr_name_for_status(AKERR_CIRCULAR_REFERENCE, "Circular Reference Error"); } PROCESS(errctx) {
akerr_name_for_status(AKERR_BADEXC, "Invalid akerr_ErrorContext"); } FINISH_NORETURN(errctx);
/* Every AKERR_* code gets a name; tests/err_error_names.c asserts the
* list is exhaustive so a new code cannot be added without one. */
__akerr_name_library_status(AKERR_NULLPOINTER, "Null Pointer Error");
__akerr_name_library_status(AKERR_OUTOFBOUNDS, "Out Of Bounds Error");
__akerr_name_library_status(AKERR_API, "API Error");
__akerr_name_library_status(AKERR_ATTRIBUTE, "Attribute Error");
__akerr_name_library_status(AKERR_TYPE, "Type Error");
__akerr_name_library_status(AKERR_KEY, "Key Error");
__akerr_name_library_status(AKERR_INDEX, "Index Error");
__akerr_name_library_status(AKERR_FORMAT, "Format Error");
__akerr_name_library_status(AKERR_IO, "Input Output Error");
__akerr_name_library_status(AKERR_VALUE, "Value Error");
__akerr_name_library_status(AKERR_RELATIONSHIP, "Relationship Error");
__akerr_name_library_status(AKERR_EOF, "End Of File");
__akerr_name_library_status(AKERR_CIRCULAR_REFERENCE, "Circular Reference Error");
__akerr_name_library_status(AKERR_ITERATOR_BREAK, "Iterator Break");
__akerr_name_library_status(AKERR_NOT_IMPLEMENTED, "Not Implemented");
__akerr_name_library_status(AKERR_BADEXC, "Invalid akerr_ErrorContext");
__akerr_name_library_status(AKERR_STATUS_RANGE_OVERLAP, "Status Range Overlap");
__akerr_name_library_status(AKERR_STATUS_RANGE_FULL, "Status Range Table Full");
__akerr_name_library_status(AKERR_STATUS_RANGE_INVALID, "Invalid Status Range");
__akerr_name_library_status(AKERR_STATUS_NAME_UNRESERVED, "Unreserved Status Name");
__akerr_name_library_status(AKERR_STATUS_NAME_FOREIGN, "Foreign Status Name");
__akerr_name_library_status(AKERR_STATUS_NAME_FULL, "Status Name Registry Full");
__akerr_name_library_status(AKERR_STATUS_NAME_INVALID, "Invalid Status Name");
#if (defined(AKERR_USE_STDLIB) && AKERR_USE_STDLIB == 1) || (!defined(AKERR_USE_STDLIB)) #if (defined(AKERR_USE_STDLIB) && AKERR_USE_STDLIB == 1) || (!defined(AKERR_USE_STDLIB))
akerr_init_errno(); akerr_init_errno();
#endif #endif
inited = 1;
} }
} }
@@ -114,15 +268,245 @@ akerr_ErrorContext *akerr_release_error(akerr_ErrorContext *err)
} }
// returns or sets the name for the given status. /*
// Call with name = NULL to retrieve a status. * Scatter the status across the table. Status values are typically dense runs
* (errno 1..N, then a library's block at its base), which linear probing on the
* raw value would pile into one cluster, so mix the bits first.
*/
static unsigned akerr_status_hash(int status)
{
unsigned h = (unsigned)status;
h ^= h >> 16;
h *= 0x85ebca6bu;
h ^= h >> 13;
h *= 0xc2b2ae35u;
h ^= h >> 16;
return h & (unsigned)(AKERR_STATUS_NAME_SLOTS - 1);
}
/*
* Find the slot holding `status`. With create != 0, claim a free slot for it if
* it is not present yet. Returns NULL when the status is absent and either no
* slot was requested or the registry is full.
*
* The `& (AKERR_STATUS_NAME_SLOTS - 1)` below is load-bearing and fails
* silently: off by one in either direction and the probe indexes past
* akerr_status_names, writing into whatever BSS follows rather than crashing.
* A test that only counts how many names registered before the table filled
* cannot see that -- a probe sequence collapsed to two slots still registers
* "some" names. Any change to the probe sequence, the occupancy cap, or the
* power-of-two assumption needs a test that reads every entry back by its own
* distinct value; tests/err_maxval.c does.
*/
static akerr_StatusName *akerr_status_slot(int status, int create)
{
unsigned slot = akerr_status_hash(status);
for ( int probe = 0; probe < AKERR_STATUS_NAME_SLOTS; probe++ ) {
akerr_StatusName *entry = &akerr_status_names[slot];
if ( entry->used == 0 ) {
if ( create == 0 ||
akerr_status_name_count >= AKERR_MAX_REGISTERED_STATUS_NAMES ) {
return NULL;
}
entry->used = 1;
entry->status = status;
entry->name[0] = '\0';
akerr_status_name_count++;
return entry;
}
if ( entry->status == status ) {
return entry;
}
slot = (slot + 1u) & (unsigned)(AKERR_STATUS_NAME_SLOTS - 1);
}
/* Unreachable: occupancy is capped below the slot count, so the probe above
* always meets a free slot. Present so a future change to that cap cannot
* turn this into a runaway loop. */
return NULL;
}
/* The reservation covering `status`, or NULL if nobody has claimed it. */
static akerr_StatusRange *akerr_range_for_status(int status)
{
for ( int i = 0; i < akerr_status_range_count; i++ ) {
if ( status >= akerr_status_ranges[i].first &&
status <= akerr_status_ranges[i].last ) {
return &akerr_status_ranges[i];
}
}
return NULL;
}
/*
* Shared body of both registration entry points. A NULL owner means the caller
* did not identify itself (the legacy two-argument akerr_name_for_status path):
* the status must still lie inside *some* reservation, but we cannot check that
* it is the caller's. Every refusal raises an error -- a name that silently
* fails to register degrades into "Unknown Error" in stack traces, which is
* exactly the kind of quiet loss this registry exists to prevent -- so the
* message carries everything a caller needs to see in a stack trace.
*/
static akerr_ErrorContext AKERR_NOIGNORE *akerr_store_status_name(const char *owner,
int status,
const char *name)
{
akerr_StatusRange *range;
akerr_StatusName *entry;
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx, (name == NULL), AKERR_STATUS_NAME_INVALID,
"Refusing to name status %d for %s: the name is NULL",
status, owner == NULL ? "an unnamed caller" : owner);
FAIL_NONZERO_RETURN(errctx,
(owner != NULL && ( owner[0] == '\0' ||
strlen(owner) >= AKERR_MAX_STATUS_RANGE_OWNER_LENGTH )),
AKERR_STATUS_NAME_INVALID,
"Refusing to name status %d (\"%s\"): the owner string "
"is empty or longer than %d characters",
status, name, AKERR_MAX_STATUS_RANGE_OWNER_LENGTH - 1);
range = akerr_range_for_status(status);
FAIL_ZERO_RETURN(errctx, range, AKERR_STATUS_NAME_UNRESERVED,
"Refusing to name status %d (\"%s\") for %s: no reserved "
"range contains it. Call akerr_reserve_status_range() first.",
status, name, owner == NULL ? "an unnamed caller" : owner);
FAIL_NONZERO_RETURN(errctx,
(owner != NULL && strcmp(owner, range->owner) != 0),
AKERR_STATUS_NAME_FOREIGN,
"Refusing to name status %d (\"%s\") for %s: that status "
"is in range %d..%d owned by %s.",
status, name, owner,
range->first, range->last, range->owner);
entry = akerr_status_slot(status, 1);
FAIL_ZERO_RETURN(errctx, entry, AKERR_STATUS_NAME_FULL,
"Status name registry is full (%d entries); dropping name "
"\"%s\" for status %d. Rebuild libakerror with a larger "
"AKERR_STATUS_NAME_SLOTS.",
AKERR_MAX_REGISTERED_STATUS_NAMES, name, status);
PASS(errctx, __akerr_copy_string(entry->name, AKERR_MAX_ERROR_NAME_LENGTH, name));
SUCCEED_RETURN(errctx);
}
/*
* Register a name for a status inside a range the caller reserved. Both strings
* are checked here rather than only inside akerr_store_status_name(): the store
* accepts a NULL owner for the legacy akerr_name_for_status() path, so a NULL
* arriving through *this* entry point would be read as "caller did not identify
* itself" and skip the ownership check entirely.
*/
akerr_ErrorContext *akerr_register_status_name(const char *owner, int status, const char *name)
{
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx, (owner == NULL), AKERR_STATUS_NAME_INVALID,
"Refusing to name status %d: the owner string is NULL. "
"Pass the same owner you reserved the range with.",
status);
FAIL_NONZERO_RETURN(errctx, (name == NULL), AKERR_STATUS_NAME_INVALID,
"Refusing to name status %d for %s: the name is NULL",
status, owner);
PASS(errctx, akerr_store_status_name(owner, status, name));
SUCCEED_RETURN(errctx);
}
/*
* Return or set a name. Status magnitude is unrelated to storage size.
*
* The set path is the legacy two-argument form. It returns a name, so it cannot
* hand an error back to its caller and cannot raise: it handles the refusal
* here, converting it to the "Unknown Error" sentinel the way any function that
* must return a value converts a caught error into one.
* akerr_register_status_name() is the form that raises.
*
* The lookup path (name == NULL) deliberately stays clear of all of this. FAIL
* calls it to render a status into a stack trace, so it must not itself need an
* error context.
*/
char *akerr_name_for_status(int status, char *name) char *akerr_name_for_status(int status, char *name)
{ {
if ( status > AKERR_MAX_ERR_VALUE ) { akerr_StatusName *entry;
akerr_init();
if ( name != NULL ) {
PREPARE_ERROR(errctx);
int refused = 0;
ATTEMPT {
CATCH(errctx, akerr_store_status_name(NULL, status, name));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "** REFUSED STATUS NAME **");
refused = 1;
} FINISH_NORETURN(errctx);
if ( refused != 0 ) {
return "Unknown Error";
}
}
entry = akerr_status_slot(status, 0);
if ( entry == NULL ) {
return "Unknown Error"; return "Unknown Error";
} }
if ( name != NULL ) { return entry->name;
strncpy((char *)&__AKERR_ERROR_NAMES[status], name, AKERR_MAX_ERROR_NAME_LENGTH); }
}
return (char *)&__AKERR_ERROR_NAMES[status]; /* Reserve an inclusive status interval and reject collisions. */
akerr_ErrorContext *akerr_reserve_status_range(int first_status, int count, const char *owner)
{
int last_status;
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx,
(count <= 0 || owner == NULL || owner[0] == '\0' ||
strlen(owner) >= AKERR_MAX_STATUS_RANGE_OWNER_LENGTH ||
first_status > INT_MAX - (count - 1)),
AKERR_STATUS_RANGE_INVALID,
"Invalid status range reservation: %d status values from "
"%d for %s (count must be positive, the owner string "
"non-empty and shorter than %d characters, and the range "
"must not overflow int)",
count, first_status, owner == NULL ? "(null)" : owner,
AKERR_MAX_STATUS_RANGE_OWNER_LENGTH);
last_status = first_status + count - 1;
for ( int i = 0; i < akerr_status_range_count; i++ ) {
if ( first_status <= akerr_status_ranges[i].last &&
last_status >= akerr_status_ranges[i].first ) {
if ( first_status == akerr_status_ranges[i].first &&
last_status == akerr_status_ranges[i].last &&
strcmp(owner, akerr_status_ranges[i].owner) == 0 ) {
SUCCEED_RETURN(errctx);
}
FAIL_RETURN(errctx, AKERR_STATUS_RANGE_OVERLAP,
"Status range %d..%d requested by %s overlaps %d..%d "
"owned by %s",
first_status, last_status, owner,
akerr_status_ranges[i].first,
akerr_status_ranges[i].last,
akerr_status_ranges[i].owner);
}
}
FAIL_NONZERO_RETURN(errctx,
(akerr_status_range_count == AKERR_MAX_RESERVED_STATUS_RANGES),
AKERR_STATUS_RANGE_FULL,
"Status range table is full (%d ranges); refusing %d..%d "
"for %s.",
AKERR_MAX_RESERVED_STATUS_RANGES,
first_status, last_status, owner);
/* The owner copy is what commits the entry, so the count only advances
* once it has succeeded -- a half-written reservation would claim the
* range under an empty owner nobody could ever match. */
akerr_status_ranges[akerr_status_range_count].first = first_status;
akerr_status_ranges[akerr_status_range_count].last = last_status;
PASS(errctx, __akerr_copy_string(akerr_status_ranges[akerr_status_range_count].owner,
AKERR_MAX_STATUS_RANGE_OWNER_LENGTH, owner));
akerr_status_range_count++;
SUCCEED_RETURN(errctx);
} }

5
test.sh Normal file
View File

@@ -0,0 +1,5 @@
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure --output-junit "$(pwd)/ctest-junit.xml"
python3 scripts/mutation_test.py --target src/error.c --junit mutation-junit.xml --threshold 65
python3 scripts/coverage.py --junit coverage-junit.xml --threshold 90 --branch-threshold 50

View File

@@ -94,7 +94,7 @@ Re-run after adding tests and confirm the score went up.
## Current status ## Current status
`src/error.c` scores ~74% (the CI gate is set to 65% for headroom). The `src/error.c` scores ~77% (the CI gate is set to 65% for headroom). The
remaining survivors are dominated by: remaining survivors are dominated by:
* **Equivalent mutants** in `akerr_init`: deleting the `memset`/`NULL` setup of * **Equivalent mutants** in `akerr_init`: deleting the `memset`/`NULL` setup of
@@ -106,12 +106,27 @@ remaining survivors are dominated by:
`errctx == NULL` branch, `exit(1)`): killing these needs a subprocess-based `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 test that captures a child's stderr and exit code, rather than the in-process
capturing logger the other tests use. capturing logger the other tests use.
* **Static assertions** (`akerr_assert_name_slots_pow2` and the occupancy cap
it guards): a mutated compile-time assertion that still compiles has no
runtime behavior to observe. Unkillable by construction — the assertion is
itself the test, and `tests/err_maxval.c` covers the runtime consequence.
* **Hash and probe details** in `akerr_status_slot`: dropping one of the
multiply steps in `akerr_status_hash` leaves a worse but still correct hash,
and probing backwards (`slot - 1u`) is an equally valid sequence over a
power-of-two table. Both are behaviorally equivalent.
* **The `capacity <= 0` guard** in `akerr_copy_string`, which is defensive: both
call sites pass a positive constant.
Findings surfaced by mutation testing: Findings surfaced by mutation testing:
* **Fixed:** `AKERR_MAX_ERR_VALUE` was `AKERR_LAST_ERRNO_VALUE + 15`, below * **Superseded:** status names now use a private sparse registry, so the old
`AKERR_NOT_IMPLEMENTED` (+16) and `AKERR_BADEXC` (+17). `akerr_name_for_status` public `AKERR_MAX_ERR_VALUE` ceiling and its consumer ABI mismatch no longer
rejects any status `> AKERR_MAX_ERR_VALUE`, so those codes could never store or exist. `tests/err_maxval.c` covers arbitrary `int` values and registry
return a name and the `akerr_name_for_status(AKERR_BADEXC, ...)` call in exhaustion.
`akerr_init` was dead code (which is why deleting it survived). The max is now * **Fixed:** the open-addressing probe mask (`& (AKERR_STATUS_NAME_SLOTS - 1)`)
`+ 17`, and `tests/err_maxval.c` guards the invariant so it can't regress. could be mutated to `- 0` or `+ 1` — both of which index past the end of the
table — without any test noticing. `tests/err_maxval.c` only asserted that
*some* names registered before the table filled, which a collapsed probe
sequence still satisfies. It now requires a substantial number of entries and
reads every one of them back by its own distinct name, so a probe that
revisits slots fails on both counts.

View File

@@ -63,26 +63,26 @@ int main(void)
akerr_ErrorContext *r; akerr_ErrorContext *r;
r = zero_break(0); r = zero_break(0);
AKERR_CHECK(r != NULL && r->status == AKERR_VALUE); AKERR_CHECK_STATUS(r, AKERR_VALUE);
r = akerr_release_error(r); r = akerr_release_error(r);
AKERR_CHECK(zero_break(7) == NULL); AKERR_CHECK(zero_break(7) == NULL);
r = nonzero_break(7); r = nonzero_break(7);
AKERR_CHECK(r != NULL && r->status == AKERR_INDEX); AKERR_CHECK_STATUS(r, AKERR_INDEX);
r = akerr_release_error(r); r = akerr_release_error(r);
AKERR_CHECK(nonzero_break(0) == NULL); AKERR_CHECK(nonzero_break(0) == NULL);
r = always_break(); r = always_break();
AKERR_CHECK(r != NULL && r->status == AKERR_IO); AKERR_CHECK_STATUS(r, AKERR_IO);
r = akerr_release_error(r); r = akerr_release_error(r);
r = zero_return(0); r = zero_return(0);
AKERR_CHECK(r != NULL && r->status == AKERR_KEY); AKERR_CHECK_STATUS(r, AKERR_KEY);
r = akerr_release_error(r); r = akerr_release_error(r);
AKERR_CHECK(zero_return(7) == NULL); AKERR_CHECK(zero_return(7) == NULL);
r = nonzero_return(7); r = nonzero_return(7);
AKERR_CHECK(r != NULL && r->status == AKERR_TYPE); AKERR_CHECK_STATUS(r, AKERR_TYPE);
r = akerr_release_error(r); r = akerr_release_error(r);
AKERR_CHECK(nonzero_return(0) == NULL); AKERR_CHECK(nonzero_return(0) == NULL);

View File

@@ -75,6 +75,53 @@ static int __attribute__((unused)) akerr_slots_in_use(void)
} \ } \
} while ( 0 ) } while ( 0 )
#define AKERR_CHECK_STATUS(errctx, expected_status) \
do { \
AKERR_CHECK((errctx) != NULL); \
AKERR_CHECK((errctx)->status == (expected_status)); \
} while ( 0 )
/*
* Helpers for functions that report failure by returning akerr_ErrorContext *.
* Both release the context they consume, so a test that makes thousands of
* failing calls cannot exhaust the pool. AKERR_CHECK_RAISES keeps a copy of the
* message for AKERR_CHECK_MESSAGE_CONTAINS, since the context is gone by then.
*/
static char __attribute__((unused)) akerr_last_message[AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH];
#define AKERR_CHECK_SUCCEEDS(expr) \
do { \
akerr_ErrorContext *__akerr_result = (expr); \
if ( __akerr_result != NULL ) { \
fprintf(stderr, "UNEXPECTED ERROR from %s: %d (%s): %s" \
" at %s:%d\n", #expr, __akerr_result->status, \
akerr_name_for_status(__akerr_result->status, NULL), \
__akerr_result->message, __FILE__, __LINE__); \
RELEASE_ERROR(__akerr_result); \
return 1; \
} \
} while ( 0 )
#define AKERR_CHECK_RAISES(expr, expected_status) \
do { \
akerr_ErrorContext *__akerr_result = (expr); \
AKERR_CHECK(__akerr_result != NULL); \
snprintf(akerr_last_message, sizeof(akerr_last_message), "%s", \
__akerr_result->message); \
if ( __akerr_result->status != (expected_status) ) { \
fprintf(stderr, "WRONG STATUS from %s: got %d, want %s" \
" at %s:%d\n", #expr, __akerr_result->status, \
#expected_status, __FILE__, __LINE__); \
RELEASE_ERROR(__akerr_result); \
return 1; \
} \
RELEASE_ERROR(__akerr_result); \
AKERR_CHECK(__akerr_result == NULL); \
} while ( 0 )
#define AKERR_CHECK_MESSAGE_CONTAINS(needle) \
AKERR_CHECK(strstr(akerr_last_message, (needle)) != NULL)
#define AKERR_CHECK_CONTAINS(needle) \ #define AKERR_CHECK_CONTAINS(needle) \
AKERR_CHECK(strstr(akerr_capture_buf, (needle)) != NULL) AKERR_CHECK(strstr(akerr_capture_buf, (needle)) != NULL)

View File

@@ -1,4 +1,5 @@
#include "akerror.h" #include "akerror.h"
#include "err_capture.h"
akerr_ErrorContext *func2(void) akerr_ErrorContext *func2(void)
{ {
@@ -31,6 +32,11 @@ int main(void)
} CLEANUP { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} HANDLE(errctx, AKERR_NULLPOINTER) { } HANDLE(errctx, AKERR_NULLPOINTER) {
akerr_log_method("Caught exception"); AKERR_CHECK_STATUS(errctx, AKERR_NULLPOINTER);
akerr_log_method("Caught exception");
} FINISH_NORETURN(errctx); } FINISH_NORETURN(errctx);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_catch ok\n");
return 0;
} }

View File

@@ -1,4 +1,5 @@
#include "akerror.h" #include "akerror.h"
#include "err_capture.h"
int x; int x;
@@ -34,10 +35,15 @@ int main(void)
} CLEANUP { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} HANDLE(errctx, AKERR_NULLPOINTER) { } HANDLE(errctx, AKERR_NULLPOINTER) {
AKERR_CHECK_STATUS(errctx, AKERR_NULLPOINTER);
if ( x == 0 ) { if ( x == 0 ) {
fprintf(stderr, "Cleanup works\n"); akerr_log_method("Cleanup works\n");
return 0; } else {
return 1;
} }
return 1;
} FINISH_NORETURN(errctx); } FINISH_NORETURN(errctx);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_cleanup ok\n");
return 0;
} }

58
tests/err_copy_string.c Normal file
View File

@@ -0,0 +1,58 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* __akerr_copy_string() is the only place in the library that writes through a
* caller-supplied pointer for a caller-supplied length, so it validates every
* argument and raises rather than returning quietly. Both in-library callers
* check their arguments before calling it, so this test is what drives those
* guards -- without it they are unreachable code that no build ever exercises.
*/
int main(void)
{
char buf[8];
akerr_capture_install();
akerr_init();
/* The happy path: bounded, and always terminated. */
memset(buf, 'x', sizeof(buf));
AKERR_CHECK_SUCCEEDS(__akerr_copy_string(buf, (int)sizeof(buf), "abc"));
AKERR_CHECK(strcmp(buf, "abc") == 0);
/* A source longer than the buffer is truncated, never overrun. */
memset(buf, 'x', sizeof(buf));
AKERR_CHECK_SUCCEEDS(__akerr_copy_string(buf, (int)sizeof(buf), "abcdefghijkl"));
AKERR_CHECK(strlen(buf) == sizeof(buf) - 1);
AKERR_CHECK(buf[sizeof(buf) - 1] == '\0');
AKERR_CHECK(strcmp(buf, "abcdefg") == 0);
/* A capacity of exactly one holds nothing but the terminator. */
memset(buf, 'x', sizeof(buf));
AKERR_CHECK_SUCCEEDS(__akerr_copy_string(buf, 1, "abc"));
AKERR_CHECK(buf[0] == '\0');
AKERR_CHECK(buf[1] == 'x'); /* and wrote nothing past its capacity */
/* NULL pointers raise instead of faulting, and the message says which. */
AKERR_CHECK_RAISES(__akerr_copy_string(NULL, (int)sizeof(buf), "abc"),
AKERR_NULLPOINTER);
AKERR_CHECK_MESSAGE_CONTAINS("destination");
AKERR_CHECK_RAISES(__akerr_copy_string(buf, (int)sizeof(buf), NULL),
AKERR_NULLPOINTER);
AKERR_CHECK_MESSAGE_CONTAINS("source");
/* A capacity with no room for a terminator is a value error, not a write. */
memset(buf, 'x', sizeof(buf));
AKERR_CHECK_RAISES(__akerr_copy_string(buf, 0, "abc"), AKERR_VALUE);
AKERR_CHECK_MESSAGE_CONTAINS("capacity of 0");
AKERR_CHECK_RAISES(__akerr_copy_string(buf, -1, "abc"), AKERR_VALUE);
AKERR_CHECK(buf[0] == 'x'); /* nothing was written */
/* Each refusal handed its context back to the pool. */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_copy_string ok\n");
return 0;
}

View File

@@ -6,7 +6,7 @@
* The library imports system errno codes and their descriptions at build time * The library imports system errno codes and their descriptions at build time
* (scripts/generrno.sh -> akerr_init_errno). Verify: * (scripts/generrno.sh -> akerr_init_errno). Verify:
* - a system errno (EACCES) has a registered, non-empty name; * - a system errno (EACCES) has a registered, non-empty name;
* - an out-of-range status returns the "Unknown Error" sentinel; * - an unregistered status returns the "Unknown Error" sentinel;
* - a system errno can be raised, propagated and handled like any AKERR_* code. * - a system errno can be raised, propagated and handled like any AKERR_* code.
*/ */
@@ -28,7 +28,7 @@ int main(void)
AKERR_CHECK(nm[0] != '\0'); AKERR_CHECK(nm[0] != '\0');
AKERR_CHECK(strcmp(nm, "Unknown Error") != 0); AKERR_CHECK(strcmp(nm, "Unknown Error") != 0);
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_MAX_ERR_VALUE + 5000, NULL), AKERR_CHECK(strcmp(akerr_name_for_status(1000000, NULL),
"Unknown Error") == 0); "Unknown Error") == 0);
PREPARE_ERROR(e); PREPARE_ERROR(e);
@@ -37,6 +37,7 @@ int main(void)
} CLEANUP { } CLEANUP {
} PROCESS(e) { } PROCESS(e) {
} HANDLE(e, EACCES) { } HANDLE(e, EACCES) {
AKERR_CHECK_STATUS(e, EACCES);
handled = 1; handled = 1;
} FINISH_NORETURN(e); } FINISH_NORETURN(e);

View File

@@ -7,9 +7,12 @@
* Verify the names are actually installed (mutation testing showed the * Verify the names are actually installed (mutation testing showed the
* registration calls could be deleted without any test noticing). * registration calls could be deleted without any test noticing).
* *
* Note: AKERR_EOF, AKERR_ITERATOR_BREAK and AKERR_NOT_IMPLEMENTED are omitted -- * This list must stay exhaustive. AKERR_EOF, AKERR_ITERATOR_BREAK and
* they are valid codes but akerr_init does not register a display name for them, * AKERR_NOT_IMPLEMENTED were previously valid codes with no registered name, so
* so akerr_name_for_status returns an empty string rather than a known name. * they rendered as "Unknown Error" in every stack trace that carried them --
* the same class of silent gap that a too-small AKERR_MAX_ERR_VALUE used to
* cause. The sweep below walks the whole AKERR_* offset span so a newly added
* code without a name fails here rather than showing up in production traces.
*/ */
static const struct { static const struct {
@@ -27,8 +30,18 @@ static const struct {
{ AKERR_IO, "Input Output Error" }, { AKERR_IO, "Input Output Error" },
{ AKERR_VALUE, "Value Error" }, { AKERR_VALUE, "Value Error" },
{ AKERR_RELATIONSHIP, "Relationship Error" }, { AKERR_RELATIONSHIP, "Relationship Error" },
{ AKERR_EOF, "End Of File" },
{ AKERR_CIRCULAR_REFERENCE, "Circular Reference Error" }, { AKERR_CIRCULAR_REFERENCE, "Circular Reference Error" },
{ AKERR_ITERATOR_BREAK, "Iterator Break" },
{ AKERR_NOT_IMPLEMENTED, "Not Implemented" },
{ AKERR_BADEXC, "Invalid akerr_ErrorContext" }, { AKERR_BADEXC, "Invalid akerr_ErrorContext" },
{ AKERR_STATUS_RANGE_OVERLAP, "Status Range Overlap" },
{ AKERR_STATUS_RANGE_FULL, "Status Range Table Full" },
{ AKERR_STATUS_RANGE_INVALID, "Invalid Status Range" },
{ AKERR_STATUS_NAME_UNRESERVED, "Unreserved Status Name" },
{ AKERR_STATUS_NAME_FOREIGN, "Foreign Status Name" },
{ AKERR_STATUS_NAME_FULL, "Status Name Registry Full" },
{ AKERR_STATUS_NAME_INVALID, "Invalid Status Name" },
}; };
int main(void) int main(void)
@@ -41,6 +54,30 @@ int main(void)
AKERR_CHECK(strcmp(nm, expected[i].name) == 0); AKERR_CHECK(strcmp(nm, expected[i].name) == 0);
} }
/*
* Every value in the library's own offset span must resolve to a real name.
* AKERR_LAST_ERRNO_VALUE + 7 is the one deliberate hole (a removed code);
* anything else nameless is a code someone added without registering it.
*/
for ( int offset = 1;
offset <= AKERR_LAST_LIBRARY_STATUS - AKERR_LAST_ERRNO_VALUE;
offset++ ) {
int code = AKERR_LAST_ERRNO_VALUE + offset;
char *nm = akerr_name_for_status(code, NULL);
if ( offset == 7 ) {
AKERR_CHECK(strcmp(nm, "Unknown Error") == 0);
continue;
}
if ( strcmp(nm, "Unknown Error") == 0 || nm[0] == '\0' ) {
fprintf(stderr, "AKERR_LAST_ERRNO_VALUE + %d (%d) has no name\n",
offset, code);
return 1;
}
}
/* Every AKERR_* code must sit inside the band the library reserves. */
AKERR_CHECK(AKERR_LAST_LIBRARY_STATUS < AKERR_FIRST_CONSUMER_STATUS);
fprintf(stderr, "err_error_names ok\n"); fprintf(stderr, "err_error_names ok\n");
return 0; return 0;
} }

34
tests/err_format_string.c Normal file
View File

@@ -0,0 +1,34 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* FAIL records the source file and function names with snprintf. Those names
* must be passed as %s ARGUMENTS, not used as the format string -- otherwise a
* path containing a printf conversion (say a build directory with a '%') is
* interpreted as a format and reads nonexistent varargs (undefined behavior).
*
* #line lets us make __FILE__ contain a conversion specifier; the stored name
* must come back verbatim.
*/
#line 1 "pct%dname.c"
akerr_ErrorContext *raise_with_percent_in_filename(void)
{
PREPARE_ERROR(e);
FAIL_RETURN(e, AKERR_VALUE, "boom");
}
#line 22 "tests/err_format_string.c"
int main(void)
{
akerr_init();
akerr_ErrorContext *e = raise_with_percent_in_filename();
AKERR_CHECK(e != NULL);
AKERR_CHECK(strcmp(e->fname, "pct%dname.c") == 0);
e = akerr_release_error(e);
fprintf(stderr, "err_format_string ok\n");
return 0;
}

View File

@@ -8,6 +8,7 @@
static int specific_fired = 0; static int specific_fired = 0;
static int default_fired = 0; static int default_fired = 0;
static int default_status = 0;
akerr_ErrorContext *boom(void) akerr_ErrorContext *boom(void)
{ {
@@ -28,10 +29,12 @@ int main(void)
specific_fired = 1; /* must NOT run: error is AKERR_TYPE */ specific_fired = 1; /* must NOT run: error is AKERR_TYPE */
} HANDLE_DEFAULT(e) { } HANDLE_DEFAULT(e) {
default_fired = 1; default_fired = 1;
default_status = e->status;
} FINISH_NORETURN(e); } FINISH_NORETURN(e);
AKERR_CHECK(specific_fired == 0); AKERR_CHECK(specific_fired == 0);
AKERR_CHECK(default_fired == 1); AKERR_CHECK(default_fired == 1);
AKERR_CHECK(default_status == AKERR_TYPE);
AKERR_CHECK(akerr_slots_in_use() == 0); AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_handle_default ok\n"); fprintf(stderr, "err_handle_default ok\n");
return 0; return 0;

View File

@@ -9,6 +9,7 @@
static int a_fired = 0; static int a_fired = 0;
static int b_fired = 0; static int b_fired = 0;
static int c_fired = 0; static int c_fired = 0;
static int b_status = 0;
akerr_ErrorContext *boom(void) akerr_ErrorContext *boom(void)
{ {
@@ -29,12 +30,14 @@ int main(void)
a_fired = 1; a_fired = 1;
} HANDLE(e, AKERR_TYPE) { } HANDLE(e, AKERR_TYPE) {
b_fired = 1; b_fired = 1;
b_status = e->status;
} HANDLE(e, AKERR_IO) { } HANDLE(e, AKERR_IO) {
c_fired = 1; c_fired = 1;
} FINISH_NORETURN(e); } FINISH_NORETURN(e);
AKERR_CHECK(a_fired == 0); AKERR_CHECK(a_fired == 0);
AKERR_CHECK(b_fired == 1); AKERR_CHECK(b_fired == 1);
AKERR_CHECK(b_status == AKERR_TYPE);
AKERR_CHECK(c_fired == 0); AKERR_CHECK(c_fired == 0);
AKERR_CHECK(akerr_slots_in_use() == 0); AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_handle_dispatch ok\n"); fprintf(stderr, "err_handle_dispatch ok\n");

View File

@@ -11,6 +11,8 @@
static int group_fired = 0; static int group_fired = 0;
static int other_fired = 0; static int other_fired = 0;
static int group_status = 0;
static int other_status = 0;
akerr_ErrorContext *boom(int status) akerr_ErrorContext *boom(int status)
{ {
@@ -28,8 +30,10 @@ akerr_ErrorContext *run(int status)
} HANDLE(e, AKERR_KEY) } HANDLE(e, AKERR_KEY)
HANDLE_GROUP(e, AKERR_INDEX) { HANDLE_GROUP(e, AKERR_INDEX) {
group_fired++; group_fired++;
group_status = e->status;
} HANDLE(e, AKERR_IO) { } HANDLE(e, AKERR_IO) {
other_fired++; other_fired++;
other_status = e->status;
} FINISH(e, false); } FINISH(e, false);
return e; return e;
} }
@@ -40,15 +44,18 @@ int main(void)
run(AKERR_KEY); run(AKERR_KEY);
AKERR_CHECK(group_fired == 1); AKERR_CHECK(group_fired == 1);
AKERR_CHECK(group_status == AKERR_KEY);
AKERR_CHECK(other_fired == 0); AKERR_CHECK(other_fired == 0);
run(AKERR_INDEX); run(AKERR_INDEX);
AKERR_CHECK(group_fired == 2); AKERR_CHECK(group_fired == 2);
AKERR_CHECK(group_status == AKERR_INDEX);
AKERR_CHECK(other_fired == 0); AKERR_CHECK(other_fired == 0);
run(AKERR_IO); run(AKERR_IO);
AKERR_CHECK(group_fired == 2); AKERR_CHECK(group_fired == 2);
AKERR_CHECK(other_fired == 1); AKERR_CHECK(other_fired == 1);
AKERR_CHECK(other_status == AKERR_IO);
AKERR_CHECK(akerr_slots_in_use() == 0); AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_handle_group ok\n"); fprintf(stderr, "err_handle_group ok\n");

View File

@@ -0,0 +1,31 @@
#include "akerror.h"
#include "err_capture.h"
/*
* The library naming one of its own codes is not allowed to fail quietly.
* __akerr_name_library_status() runs from akerr_init() and from the generated
* errno table, neither of which has a caller to raise into, so a refusal there
* goes through FINISH_NORETURN: stack trace, then akerr_handler_unhandled_error,
* which terminates the process.
*
* In a correct build that can only happen with a name table too small to hold
* the library's own entries, which no test can configure (the slot count is
* PRIVATE to the library target). Calling the helper for a status the library
* does not own reaches the same refusal, so this test covers the terminal path
* itself.
*
* Registered in AKERR_WILL_FAIL_TESTS: reaching the end of main() means the
* failure was swallowed, and that is the bug this test exists to catch.
*/
int main(void)
{
akerr_init();
/* Nobody has reserved 9999, so this registration is refused. */
__akerr_name_library_status(9999, "Not The Library's To Name");
fprintf(stderr, "err_library_status_fatal: a refused library-status "
"registration did NOT terminate\n");
return 0;
}

View File

@@ -1,92 +1,165 @@
#include "akerror.h" #include "akerror.h"
#include "err_capture.h" #include "err_capture.h"
#include <regex.h> #include <limits.h>
#include <string.h>
/* /*
* AKERR_MAX_ERR_VALUE sizes the __AKERR_ERROR_NAMES table and is the upper bound * Status magnitude is no longer coupled to a public array bound: any int is a
* akerr_name_for_status() accepts. If it is smaller than the highest AKERR_* * legal status, and storage is a private sparse registry. What bounds the
* code, those codes silently lose their names (this was a real bug: the max was * registry now is its *capacity*, not the value of the largest code.
* +15 while AKERR_BADEXC is +17).
* *
* Rather than hardcode the list of codes (which rots the moment someone adds a * Covers: arbitrary int status values, name truncation, range reservation
* code), this test parses the generated akerror.h, discovers every * semantics (overlap, idempotency, endpoints, validation, overflow), and both
* #define AKERR_<NAME> (AKERR_LAST_ERRNO_VALUE + <N>) * capacity limits -- the range table and the name table -- each of which must
* finds the highest offset actually defined, and verifies AKERR_MAX_ERR_VALUE * raise rather than dropping the registration quietly.
* covers it. The path to the header the library was built from is injected by *
* CMake as AKERR_GENERATED_HEADER. * Every refusal is an error context the caller owns, so each check below also
* asserts the context returns to the pool; a leak here would exhaust the
* 128-slot pool long before these loops finish.
*/ */
#ifndef AKERR_GENERATED_HEADER
#error "AKERR_GENERATED_HEADER (path to generated akerror.h) must be defined"
#endif
int main(void) int main(void)
{ {
FILE *fh = fopen(AKERR_GENERATED_HEADER, "r"); akerr_capture_install();
AKERR_CHECK(fh != NULL); akerr_init();
/* #define AKERR_NAME (AKERR_LAST_ERRNO_VALUE + N) */ /* Any int is a legal status, at either extreme of the range. */
regex_t re; AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(INT_MIN, 1, "min-owner"));
const char *pattern = AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(INT_MAX, 1, "max-owner"));
"^[[:space:]]*#define[[:space:]]+(AKERR_[A-Za-z0-9_]+)[[:space:]]+" AKERR_CHECK_SUCCEEDS(akerr_register_status_name("max-owner", INT_MAX, "Maximum Status"));
"\\(AKERR_LAST_ERRNO_VALUE[[:space:]]*\\+[[:space:]]*([0-9]+)\\)"; AKERR_CHECK_SUCCEEDS(akerr_register_status_name("min-owner", INT_MIN, "Minimum Status"));
AKERR_CHECK(regcomp(&re, pattern, REG_EXTENDED) == 0); AKERR_CHECK(strcmp(akerr_name_for_status(INT_MAX, NULL), "Maximum Status") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MIN, NULL), "Minimum Status") == 0);
int highest_code = -1; /* highest offset among real error codes */ /* A name longer than the buffer is truncated and always terminated. */
char highest_name[64] = ""; AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(1000000, 1, "trunc"));
int max_err_value = -1; /* offset parsed from AKERR_MAX_ERR_VALUE */ const char *long_name =
int code_count = 0; "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-extra";
AKERR_CHECK_SUCCEEDS(akerr_register_status_name("trunc", 1000000, long_name));
char *stored = akerr_name_for_status(1000000, NULL);
AKERR_CHECK(strlen(stored) == AKERR_MAX_ERROR_NAME_LENGTH - 1);
AKERR_CHECK(stored[AKERR_MAX_ERROR_NAME_LENGTH - 1] == '\0');
char line[4096]; /* Reservation: overlap detection, and idempotency for an exact repeat. */
regmatch_t m[3]; AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(256, 16, "component-a"));
while ( fgets(line, sizeof(line), fh) != NULL ) { AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(256, 16, "component-a"));
if ( regexec(&re, line, 3, m, 0) != 0 ) { AKERR_CHECK_RAISES(akerr_reserve_status_range(260, 2, "component-b"),
continue; AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_MESSAGE_CONTAINS("component-a");
/* The library's own 0..255 band is reserved and cannot be encroached on. */
AKERR_CHECK_RAISES(akerr_reserve_status_range(255, 1, "component-b"),
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_MESSAGE_CONTAINS(AKERR_LIBRARY_OWNER);
/* An exact repeat by the owner is still a no-op. */
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(0, AKERR_RESERVED_STATUS_COUNT,
AKERR_LIBRARY_OWNER));
/* Argument validation. */
AKERR_CHECK_RAISES(akerr_reserve_status_range(INT_MAX, 2, "overflow"),
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK_RAISES(akerr_reserve_status_range(300, 0, "empty"),
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK_MESSAGE_CONTAINS("empty"); /* the message names the caller */
AKERR_CHECK_RAISES(akerr_reserve_status_range(300, -1, "negative"),
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK_RAISES(akerr_reserve_status_range(300, 1, NULL),
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK_RAISES(akerr_reserve_status_range(300, 1, ""),
AKERR_STATUS_RANGE_INVALID);
/* Owner strings: 63 chars fit, 64 do not, and neither does anything past. */
char owner63[AKERR_MAX_ERROR_NAME_LENGTH];
char owner64[AKERR_MAX_ERROR_NAME_LENGTH + 1];
char owner70[AKERR_MAX_ERROR_NAME_LENGTH + 7];
memset(owner63, 'a', sizeof(owner63) - 1);
owner63[sizeof(owner63) - 1] = '\0';
memset(owner64, 'b', sizeof(owner64) - 1);
owner64[sizeof(owner64) - 1] = '\0';
memset(owner70, 'c', sizeof(owner70) - 1);
owner70[sizeof(owner70) - 1] = '\0';
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(400, 1, owner63));
AKERR_CHECK_RAISES(akerr_reserve_status_range(401, 1, owner64),
AKERR_STATUS_RANGE_INVALID);
AKERR_CHECK_RAISES(akerr_reserve_status_range(402, 1, owner70),
AKERR_STATUS_RANGE_INVALID);
/* Partial overlaps at either endpoint, and a same-range different owner. */
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(500, 2, "endpoint"));
AKERR_CHECK_RAISES(akerr_reserve_status_range(499, 2, "left"),
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_RAISES(akerr_reserve_status_range(500, 1, "endpoint"),
AKERR_STATUS_RANGE_OVERLAP); /* subset, not an exact repeat */
AKERR_CHECK_RAISES(akerr_reserve_status_range(501, 1, "endpoint"),
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_RAISES(akerr_reserve_status_range(500, 2, "other"),
AKERR_STATUS_RANGE_OVERLAP); /* same range, wrong owner */
/* Claim room for the name-exhaustion sweep before filling the range table. */
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(2000000, 100000, "fill"));
/*
* Range table capacity. The limit is private to src/error.c on purpose, so
* discover it by filling rather than by hardcoding it here.
*/
int ranges_added = 0;
akerr_ErrorContext *range_err = NULL;
for ( int i = 0; i < 100000; i++ ) {
range_err = akerr_reserve_status_range(1000 + (i * 2), 1, "pad");
if ( range_err != NULL ) {
break;
} }
ranges_added++;
}
AKERR_CHECK(ranges_added > 0);
AKERR_CHECK_STATUS(range_err, AKERR_STATUS_RANGE_FULL);
AKERR_CHECK(strstr(range_err->message, "range table is full") != NULL);
RELEASE_ERROR(range_err);
AKERR_CHECK(range_err == NULL);
char name[64]; /*
int nlen = (int)(m[1].rm_eo - m[1].rm_so); * Name table capacity. Exhaustion must be reported, not silent: a dropped
if ( nlen >= (int)sizeof(name) ) { * name degrades every future stack trace for that code to "Unknown Error".
nlen = (int)sizeof(name) - 1; */
} int full_at = -1;
memcpy(name, line + m[1].rm_so, nlen); for ( int i = 0; i < 100000; i++ ) {
name[nlen] = '\0'; char name[32];
snprintf(name, sizeof(name), "Filled %d", i);
char num[16]; akerr_ErrorContext *name_err =
int vlen = (int)(m[2].rm_eo - m[2].rm_so); akerr_register_status_name("fill", 2000000 + i, name);
if ( vlen >= (int)sizeof(num) ) { if ( name_err != NULL ) {
vlen = (int)sizeof(num) - 1; AKERR_CHECK_STATUS(name_err, AKERR_STATUS_NAME_FULL);
} AKERR_CHECK(strstr(name_err->message, "registry is full") != NULL);
memcpy(num, line + m[2].rm_so, vlen); AKERR_CHECK(strstr(name_err->message, "AKERR_STATUS_NAME_SLOTS") != NULL);
num[vlen] = '\0'; RELEASE_ERROR(name_err);
int offset = atoi(num); AKERR_CHECK(name_err == NULL);
full_at = i;
if ( strcmp(name, "AKERR_MAX_ERR_VALUE") == 0 ) { break;
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); * The table must actually hold everything it accepted. A probe sequence
AKERR_CHECK(highest_code > 0); * that revisits slots instead of walking the table -- e.g. masking with
AKERR_CHECK(max_err_value > 0); * SLOTS rather than SLOTS-1 -- both collapses the usable capacity and
* loses earlier entries, and each check below catches it independently.
* The floor assumes at least the default table size (4096 slots).
*/
AKERR_CHECK(full_at > 256);
for ( int i = 0; i < full_at; i++ ) {
char expected[32];
snprintf(expected, sizeof(expected), "Filled %d", i);
AKERR_CHECK(strcmp(akerr_name_for_status(2000000 + i, NULL), expected) == 0);
}
/* Guard against parsing a different header than the one compiled in. */ /* A dropped name reads back as the sentinel, and earlier ones survive. */
AKERR_CHECK(max_err_value == (AKERR_MAX_ERR_VALUE - AKERR_LAST_ERRNO_VALUE)); AKERR_CHECK(strcmp(akerr_name_for_status(2000000 + full_at, NULL),
"Unknown Error") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(INT_MIN, NULL), "Minimum Status") == 0);
/* The actual invariant: every defined code is indexable in the names table. */ /* Thousands of refusals later, every context went back to the pool. */
AKERR_CHECK(max_err_value >= highest_code); AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, fprintf(stderr, "err_maxval ok (%d consumer names before full)\n", full_at);
"err_maxval ok (%d codes parsed, highest %s at +%d, max +%d)\n",
code_count, highest_name, highest_code, max_err_value);
return 0; return 0;
} }

24
tests/err_name_bounds.c Normal file
View File

@@ -0,0 +1,24 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/* Unregistered status values return the sentinel regardless of magnitude. */
int main(void)
{
akerr_init();
/* Below range. */
AKERR_CHECK(strcmp(akerr_name_for_status(-1, NULL), "Unknown Error") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(-9999, NULL), "Unknown Error") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(1000000, NULL),
"Unknown Error") == 0);
/* A valid code must still resolve to its real name. */
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_NULLPOINTER, NULL),
"Null Pointer Error") == 0);
fprintf(stderr, "err_name_bounds ok\n");
return 0;
}

126
tests/err_name_ownership.c Normal file
View File

@@ -0,0 +1,126 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* Reserving a range used to be pure bookkeeping: akerr_name_for_status() would
* name any status for any caller, so two components could still register names
* for the same code -- and HANDLE the same code -- with nothing detecting it.
* Reservation only caught components that both opted in AND declared ranges
* that happened to overlap.
*
* Naming a status is now permitted only inside a reservation:
* - akerr_register_status_name() requires the range to belong to the caller,
* and raises AKERR_STATUS_NAME_* when it does not;
* - the legacy two-argument akerr_name_for_status() set path cannot identify
* its caller, so it can only require that *some* reservation covers the
* status -- still enough to stop a code nobody claimed. It returns a name
* rather than an error context, so its refusals are logged instead.
* Either way the refusal is visible, because a name that fails to register
* degrades the status to "Unknown Error" in every later stack trace.
*/
int main(void)
{
akerr_capture_install();
akerr_init();
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(256, 16, "lib-a"));
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(512, 16, "lib-b"));
/* The owner of a range may name statuses inside it. */
AKERR_CHECK_SUCCEEDS(akerr_register_status_name("lib-a", 256, "A Parse Error"));
AKERR_CHECK_SUCCEEDS(akerr_register_status_name("lib-a", 271, "A Last Error"));
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "A Parse Error") == 0);
/* Naming another owner's status is refused and names the real owner. */
AKERR_CHECK_RAISES(akerr_register_status_name("lib-b", 256, "B Hijack"),
AKERR_STATUS_NAME_FOREIGN);
AKERR_CHECK_MESSAGE_CONTAINS("lib-a");
AKERR_CHECK_MESSAGE_CONTAINS("lib-b");
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "A Parse Error") == 0);
/* Including the library's own reserved band. */
AKERR_CHECK_RAISES(akerr_register_status_name("lib-b", AKERR_VALUE, "B Value"),
AKERR_STATUS_NAME_FOREIGN);
AKERR_CHECK_MESSAGE_CONTAINS(AKERR_LIBRARY_OWNER);
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_VALUE, NULL), "Value Error") == 0);
/* A status nobody reserved cannot be named through either entry point. */
AKERR_CHECK_RAISES(akerr_register_status_name("lib-a", 9999, "Unclaimed"),
AKERR_STATUS_NAME_UNRESERVED);
AKERR_CHECK_MESSAGE_CONTAINS("no reserved range");
AKERR_CHECK(strcmp(akerr_name_for_status(9999, NULL), "Unknown Error") == 0);
/* The legacy path has no caller to raise into, so it logs the refusal.
* It also cannot name the caller, and must say so rather than printing a
* stray owner. */
akerr_capture_reset();
AKERR_CHECK(strcmp(akerr_name_for_status(9999, "Unclaimed Legacy"),
"Unknown Error") == 0);
AKERR_CHECK_CONTAINS("no reserved range");
AKERR_CHECK_CONTAINS("an unnamed caller");
AKERR_CHECK_CONTAINS("REFUSED STATUS NAME");
AKERR_CHECK(strcmp(akerr_name_for_status(9999, NULL), "Unknown Error") == 0);
/* ... and hands the context it raised back to the pool. */
AKERR_CHECK(akerr_slots_in_use() == 0);
/* Boundaries: just outside lib-a's range is not lib-a's to name. */
AKERR_CHECK_RAISES(akerr_register_status_name("lib-a", 255, "Below"),
AKERR_STATUS_NAME_FOREIGN);
AKERR_CHECK_RAISES(akerr_register_status_name("lib-a", 272, "Above"),
AKERR_STATUS_NAME_UNRESERVED);
/* The legacy set path still works inside any reservation. */
AKERR_CHECK(strcmp(akerr_name_for_status(513, "B Legacy"), "B Legacy") == 0);
AKERR_CHECK(strcmp(akerr_name_for_status(513, NULL), "B Legacy") == 0);
/* Re-registering your own status overwrites the name. */
AKERR_CHECK_SUCCEEDS(akerr_register_status_name("lib-a", 256, "A Renamed"));
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "A Renamed") == 0);
/*
* Argument validation. Each message must identify the caller it refused,
* since that message is the whole report a consumer gets.
*/
AKERR_CHECK_RAISES(akerr_register_status_name(NULL, 257, "No Owner"),
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK_MESSAGE_CONTAINS("257");
AKERR_CHECK_RAISES(akerr_register_status_name("", 257, "Empty Owner"),
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK_MESSAGE_CONTAINS("Empty Owner");
AKERR_CHECK_RAISES(akerr_register_status_name("lib-a", 257, NULL),
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK_MESSAGE_CONTAINS("lib-a");
/* Over-long owner strings: 63 characters fit, 64 and beyond do not. */
char owner63[64];
char owner64[65];
char owner70[71];
memset(owner63, 'a', sizeof(owner63) - 1);
owner63[sizeof(owner63) - 1] = '\0';
memset(owner64, 'b', sizeof(owner64) - 1);
owner64[sizeof(owner64) - 1] = '\0';
memset(owner70, 'c', sizeof(owner70) - 1);
owner70[sizeof(owner70) - 1] = '\0';
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(600, 1, owner63));
AKERR_CHECK_SUCCEEDS(akerr_register_status_name(owner63, 600, "Long Owner"));
AKERR_CHECK_RAISES(akerr_register_status_name(owner64, 600, "Too Long"),
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK_MESSAGE_CONTAINS("63");
AKERR_CHECK_RAISES(akerr_register_status_name(owner70, 600, "Far Too Long"),
AKERR_STATUS_NAME_INVALID);
AKERR_CHECK(strcmp(akerr_name_for_status(600, NULL), "Long Owner") == 0);
/* A refused registration must not consume a slot or leave a partial entry. */
AKERR_CHECK(strcmp(akerr_name_for_status(257, NULL), "Unknown Error") == 0);
/* Lookup is unaffected by ownership -- anyone may read any name. */
AKERR_CHECK(strcmp(akerr_name_for_status(271, NULL), "A Last Error") == 0);
/* Every refusal above released its context. */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_name_ownership ok\n");
return 0;
}

View File

@@ -35,6 +35,7 @@ int main(void)
} CLEANUP { } CLEANUP {
} PROCESS(e) { } PROCESS(e) {
} HANDLE(e, AKERR_IO) { } HANDLE(e, AKERR_IO) {
AKERR_CHECK_STATUS(e, AKERR_IO);
handled = 1; handled = 1;
} FINISH_NORETURN(e); } FINISH_NORETURN(e);

View File

@@ -11,6 +11,8 @@
#define ITERATIONS 100000 #define ITERATIONS 100000
static int handled_status = 0;
akerr_ErrorContext *boom(void) akerr_ErrorContext *boom(void)
{ {
PREPARE_ERROR(e); PREPARE_ERROR(e);
@@ -31,6 +33,7 @@ akerr_ErrorContext *one_cycle(void)
} CLEANUP { } CLEANUP {
} PROCESS(e) { } PROCESS(e) {
} HANDLE(e, AKERR_VALUE) { } HANDLE(e, AKERR_VALUE) {
handled_status = e->status;
} FINISH(e, false); } FINISH(e, false);
return e; return e;
} }
@@ -42,7 +45,9 @@ int main(void)
AKERR_CHECK(akerr_slots_in_use() == 0); AKERR_CHECK(akerr_slots_in_use() == 0);
for ( int iter = 0; iter < ITERATIONS; iter++ ) { for ( int iter = 0; iter < ITERATIONS; iter++ ) {
handled_status = 0;
(void)one_cycle(); (void)one_cycle();
AKERR_CHECK(handled_status == AKERR_VALUE);
} }
AKERR_CHECK(akerr_slots_in_use() == 0); AKERR_CHECK(akerr_slots_in_use() == 0);

View File

@@ -0,0 +1,56 @@
#include "akerror.h"
#include "err_capture.h"
/*
* Regression test for the refcount leak: ENSURE_ERROR_READY must increment
* refcount only when it *acquires* a fresh context, not on every FAIL/SUCCEED.
* A function that calls FAIL more than once on the same context and then
* propagates used to arrive at the caller with refcount 2; the caller released
* once, leaking the slot. After enough leaks the pool is exhausted and the
* library exit(1)s.
*/
static int handled_status = 0;
akerr_ErrorContext *validate(void)
{
PREPARE_ERROR(e);
ATTEMPT {
FAIL(e, AKERR_VALUE, "condition 1 failed"); /* acquires the context */
FAIL(e, AKERR_KEY, "condition 2 failed"); /* must NOT re-acquire it */
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true); /* unhandled -> propagate */
SUCCEED_RETURN(e);
}
/* One raise -> catch -> handle cycle; returns NULL (context released). */
akerr_ErrorContext *one_cycle(void)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, validate());
} CLEANUP {
} PROCESS(e) {
} HANDLE(e, AKERR_KEY) {
handled_status = e->status;
} HANDLE(e, AKERR_VALUE) {
} FINISH(e, false);
return e;
}
int main(void)
{
akerr_init();
AKERR_CHECK(akerr_slots_in_use() == 0);
for ( int i = 0; i < 32; i++ ) {
handled_status = 0;
(void)one_cycle();
AKERR_CHECK(handled_status == AKERR_KEY);
}
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_refcount_double_fail ok\n");
return 0;
}

View File

@@ -0,0 +1,55 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* A library may reserve its status range from its own init() before anything in
* the process has raised an error, i.e. before akerr_init() has run. That used
* to be silently destructive: akerr_init() clears the range and name tables, so
* whichever component first triggered it (via PREPARE_ERROR) wiped the earlier
* reservation, and the *next* component to claim the same range was told OK --
* producing exactly the undetected aliasing the registry exists to prevent.
*
* Every public registry entry point now calls akerr_init() itself, so the
* tables are only ever cleared before the first reservation, never after one.
*
* Note this test must not call akerr_init() or PREPARE_ERROR first -- the
* uninitialized entry is the whole point.
*/
int main(void)
{
akerr_capture_install();
/* Cold call: no akerr_init(), no PREPARE_ERROR anywhere yet. */
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(256, 16, "early-lib"));
AKERR_CHECK_SUCCEEDS(akerr_register_status_name("early-lib", 256, "Early Error"));
/* Something else now uses the library for the first time. */
akerr_init();
PREPARE_ERROR(e);
(void)e;
/* The early reservation and its name must both have survived. */
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "Early Error") == 0);
AKERR_CHECK_RAISES(akerr_reserve_status_range(256, 16, "late-lib"),
AKERR_STATUS_RANGE_OVERLAP);
AKERR_CHECK_MESSAGE_CONTAINS("early-lib");
AKERR_CHECK_RAISES(akerr_reserve_status_range(260, 2, "late-lib"),
AKERR_STATUS_RANGE_OVERLAP);
/* The library's own initialization still happened exactly once. */
AKERR_CHECK(strcmp(akerr_name_for_status(AKERR_NULLPOINTER, NULL),
"Null Pointer Error") == 0);
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(0, AKERR_RESERVED_STATUS_COUNT,
AKERR_LIBRARY_OWNER));
/* An identical repeat by the original owner is still idempotent. */
AKERR_CHECK_SUCCEEDS(akerr_reserve_status_range(256, 16, "early-lib"));
/* Raising from a cold registry must not strand a pool slot either. */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_registry_init_order ok\n");
return 0;
}

View File

@@ -27,6 +27,7 @@ int main(void)
} CLEANUP { } CLEANUP {
} PROCESS(e) { } PROCESS(e) {
} HANDLE(e, AKERR_VALUE) { } HANDLE(e, AKERR_VALUE) {
AKERR_CHECK_STATUS(e, AKERR_VALUE);
} FINISH_NORETURN(e); } FINISH_NORETURN(e);
AKERR_CHECK(e == NULL); AKERR_CHECK(e == NULL);

43
tests/err_release_null.c Normal file
View File

@@ -0,0 +1,43 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* akerr_release_error(NULL) is an API contract violation the library reports
* rather than crashes on: it raises AKERR_NULLPOINTER against the internal
* last-ditch context and hands that back, so the caller gets a describable
* error instead of a dereferenced NULL. Nothing exercised that path.
*
* The last-ditch context deliberately lives outside AKERR_ARRAY_ERROR so
* reporting this failure cannot consume a pool slot -- which is exactly what
* akerr_valid_error_address() reports on, and what this test checks.
*/
int main(void)
{
akerr_capture_install();
/* akerr_init() sets up the last-ditch context's stacktrace cursor. */
akerr_init();
AKERR_CHECK(akerr_slots_in_use() == 0);
akerr_ErrorContext *ret = akerr_release_error(NULL);
AKERR_CHECK(ret != NULL);
AKERR_CHECK(ret->status == AKERR_NULLPOINTER);
AKERR_CHECK(strstr(ret->message, "NULL context pointer") != NULL);
/* Reported through FAIL, so the stack trace names the error too. */
AKERR_CHECK(strstr(ret->stacktracebuf, "Null Pointer Error") != NULL);
/* Not a pool slot, and no slot was checked out to report the failure. */
AKERR_CHECK(akerr_valid_error_address(ret) == 0);
AKERR_CHECK(akerr_slots_in_use() == 0);
/* The last-ditch context is a singleton: the same one comes back. */
akerr_ErrorContext *again = akerr_release_error(NULL);
AKERR_CHECK(again == ret);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_release_null ok\n");
return 0;
}

View File

@@ -0,0 +1,71 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* akerr_release_error() drops one reference and only recycles the slot when the
* last one goes away. Two of its refcount edges had no test:
*
* - refcount > 1: the release must decrement and return the context intact,
* not wipe it out from under the reference that is still held.
* - refcount == 0: releasing a context nobody holds must not underflow the
* count; it wipes and returns NULL like any other fully-released slot.
*
* The macro API never produces a refcount above 1 (ENSURE_ERROR_READY only
* increments when it acquires a fresh slot), so this test sets the count
* directly to model a caller that took an extra reference.
*/
int main(void)
{
akerr_capture_install();
akerr_init();
AKERR_CHECK(akerr_slots_in_use() == 0);
/* Check out a slot and give it two holders. */
akerr_ErrorContext *held = akerr_next_error();
AKERR_CHECK(held != NULL);
int slotid = held->arrayid;
held->refcount = 2;
held->status = AKERR_VALUE;
snprintf((char *)held->message, AKERR_MAX_ERROR_CONTEXT_STRING_LENGTH,
"still referenced");
/* First release: one holder left, so the context survives untouched. */
akerr_ErrorContext *ret = akerr_release_error(held);
AKERR_CHECK(ret == held);
AKERR_CHECK(held->refcount == 1);
AKERR_CHECK(held->status == AKERR_VALUE);
AKERR_CHECK(strcmp(held->message, "still referenced") == 0);
AKERR_CHECK(akerr_slots_in_use() == 1);
/* Second release: last holder gone, so the slot is wiped and recycled. */
ret = akerr_release_error(held);
AKERR_CHECK(ret == NULL);
AKERR_CHECK(held->refcount == 0);
AKERR_CHECK(held->status == 0);
AKERR_CHECK(held->message[0] == '\0');
/* The wipe must preserve the slot's identity and stacktrace cursor. */
AKERR_CHECK(held->arrayid == slotid);
AKERR_CHECK(held->stacktracebufptr == (char *)&held->stacktracebuf);
AKERR_CHECK(akerr_slots_in_use() == 0);
/*
* Releasing an unheld slot: refcount is already 0, so there is nothing to
* decrement and the count must not go negative.
*/
akerr_ErrorContext *unheld = akerr_next_error();
AKERR_CHECK(unheld != NULL);
AKERR_CHECK(unheld->refcount == 0);
ret = akerr_release_error(unheld);
AKERR_CHECK(ret == NULL);
AKERR_CHECK(unheld->refcount == 0);
AKERR_CHECK(akerr_slots_in_use() == 0);
/* The pool is still healthy afterwards. */
akerr_ErrorContext *probe = akerr_next_error();
AKERR_CHECK(probe != NULL);
fprintf(stderr, "err_release_refcount ok\n");
return 0;
}

View File

@@ -0,0 +1,54 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* Regression test for the stack-trace buffer overflow. Each frame appended a
* line with snprintf, but passed the *full* buffer length as the size rather
* than the space remaining, and advanced the cursor by snprintf's would-be
* return value. A trace that filled the buffer therefore wrote past the end of
* stacktracebuf and ran the cursor out of bounds.
*
* We place a context in a struct with a guard region right after it, position
* the trace cursor near the end of the buffer, append one more frame, and
* require that nothing was written past the buffer and the cursor stayed in
* bounds.
*/
static struct {
akerr_ErrorContext ctx;
unsigned char guard[512];
} probe;
akerr_ErrorContext *append_frame(akerr_ErrorContext *e)
{
FAIL_RETURN(e, AKERR_VALUE,
"an error message long enough to overflow a nearly full stack trace buffer");
}
int main(void)
{
akerr_init();
memset(&probe, 0x00, sizeof(probe));
memset(probe.guard, 0xAA, sizeof(probe.guard));
akerr_ErrorContext *e = &probe.ctx;
e->refcount = 1;
/* Two bytes short of full: any real frame would overflow the old code. */
e->stacktracebufptr = probe.ctx.stacktracebuf
+ AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH - 2;
(void)append_frame(e);
/* Nothing may have been written past the end of stacktracebuf. */
for ( unsigned i = 0; i < sizeof(probe.guard); i++ ) {
AKERR_CHECK(probe.guard[i] == 0xAA);
}
/* The cursor must remain within the buffer. */
AKERR_CHECK(e->stacktracebufptr
<= probe.ctx.stacktracebuf + AKERR_MAX_ERROR_STACKTRACE_BUF_LENGTH);
fprintf(stderr, "err_stacktrace_bounds ok\n");
return 0;
}

View File

@@ -0,0 +1,100 @@
#include "akerror.h"
#include "err_capture.h"
#include <string.h>
/*
* The registry entry points report failure the same way every other function in
* this library does: they return akerr_ErrorContext *. A refused reservation is
* therefore an ordinary error, and everything that works on an ordinary error
* must work on it -- CATCH, HANDLE, PASS, propagation to the caller, and the
* stack trace an unhandled one prints.
*
* This is what distinguishes the current design from the integer return codes
* it replaced: a consumer that ignores the result gets a compiler warning
* (AKERR_NOIGNORE), and a consumer that catches it but does not handle it gets
* the error propagated out of its init function rather than a silently
* unreserved range.
*/
#define TEST_OWNER "exception-test"
/* A consumer init function in the shape the documentation recommends. */
static akerr_ErrorContext AKERR_NOIGNORE *component_init(int base, const char *owner)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akerr_reserve_status_range(base, 4, owner));
CATCH(errctx, akerr_register_status_name(owner, base, "Component Error"));
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
akerr_capture_install();
akerr_init();
PREPARE_ERROR(errctx);
/* A successful reservation raises nothing at all. */
AKERR_CHECK_SUCCEEDS(component_init(256, TEST_OWNER));
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "Component Error") == 0);
AKERR_CHECK(akerr_slots_in_use() == 0);
/*
* A collision propagates out of the component's init and is caught here.
* HANDLE proves the status is a real, distinct exception a consumer can
* dispatch on -- not an opaque nonzero int.
*/
int handled_overlap = 0;
ATTEMPT {
CATCH(errctx, component_init(258, "other-component"));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_STATUS_RANGE_OVERLAP) {
handled_overlap = 1;
} HANDLE_DEFAULT(errctx) {
handled_overlap = -1;
} FINISH_NORETURN(errctx);
AKERR_CHECK(handled_overlap == 1);
AKERR_CHECK(akerr_slots_in_use() == 0);
/* The name-side refusals dispatch the same way. */
int handled_foreign = 0;
ATTEMPT {
CATCH(errctx, akerr_register_status_name("interloper", 256, "Hijack"));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_STATUS_NAME_FOREIGN) {
handled_foreign = 1;
} FINISH_NORETURN(errctx);
AKERR_CHECK(handled_foreign == 1);
AKERR_CHECK(strcmp(akerr_name_for_status(256, NULL), "Component Error") == 0);
/*
* An unhandled refusal carries a stack trace naming the real owner, so the
* report a consumer gets is the library's normal one.
*/
akerr_capture_reset();
ATTEMPT {
CATCH(errctx, akerr_reserve_status_range(AKERR_VALUE, 1, "encroacher"));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "Reservation refused");
} FINISH_NORETURN(errctx);
AKERR_CHECK_CONTAINS("Reservation refused");
AKERR_CHECK_CONTAINS("Status Range Overlap");
AKERR_CHECK_CONTAINS(AKERR_LIBRARY_OWNER);
AKERR_CHECK_CONTAINS("encroacher");
AKERR_CHECK_CONTAINS("error.c");
/* Nothing above stranded a pool slot. */
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_status_exception ok\n");
return 0;
}

View File

@@ -9,6 +9,7 @@
*/ */
static int wrong_handler_fired = 0; static int wrong_handler_fired = 0;
static int swallowed_status = 0;
akerr_ErrorContext *boom(void) akerr_ErrorContext *boom(void)
{ {
@@ -24,6 +25,7 @@ akerr_ErrorContext *swallow_it(void)
ATTEMPT { ATTEMPT {
CATCH(e, boom()); CATCH(e, boom());
} CLEANUP { } CLEANUP {
swallowed_status = (e != NULL) ? e->status : 0;
} PROCESS(e) { } PROCESS(e) {
} HANDLE(e, AKERR_KEY) { } HANDLE(e, AKERR_KEY) {
wrong_handler_fired = 1; /* does not match AKERR_VALUE */ wrong_handler_fired = 1; /* does not match AKERR_VALUE */
@@ -38,6 +40,7 @@ int main(void)
akerr_ErrorContext *res = swallow_it(); akerr_ErrorContext *res = swallow_it();
AKERR_CHECK(wrong_handler_fired == 0); AKERR_CHECK(wrong_handler_fired == 0);
AKERR_CHECK(swallowed_status == AKERR_VALUE);
AKERR_CHECK(res == NULL); /* released even though unhandled */ AKERR_CHECK(res == NULL); /* released even though unhandled */
AKERR_CHECK(akerr_slots_in_use() == 0); AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_swallow ok\n"); fprintf(stderr, "err_swallow ok\n");

View File

@@ -1,4 +1,10 @@
#include "akerror.h" #include "akerror.h"
#include <stdlib.h>
static void expect_unhandled_nullpointer(akerr_ErrorContext *errctx)
{
exit((errctx != NULL && errctx->status == AKERR_NULLPOINTER) ? 1 : 0);
}
akerr_ErrorContext *func2(void) akerr_ErrorContext *func2(void)
{ {
@@ -25,6 +31,9 @@ akerr_ErrorContext *func1(void)
int main(void) int main(void)
{ {
akerr_init();
akerr_handler_unhandled_error = &expect_unhandled_nullpointer;
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
ATTEMPT { ATTEMPT {
CATCH(errctx, func1()); CATCH(errctx, func1());

View File

@@ -0,0 +1,61 @@
#include "akerror.h"
#include "err_capture.h"
#include <unistd.h>
#include <sys/wait.h>
/*
* The default unhandled-error handler is the library's last stop: it exits the
* process. Both of its exits were untested -- exit(1) for a NULL context (a
* handler invoked with no error at all) and exit(errctx->status) for a real one.
*
* The handler never returns, so each case runs in a forked child and the test
* asserts the exact exit status. That is stricter than a WILL_FAIL test, which
* would pass on any non-zero exit, including one caused by an unrelated bug.
*/
/*
* Run the default handler on ctx in a child process and return the child's exit
* status, or -1 if it did not exit normally. The _exit() sentinel catches a
* handler that returns instead of terminating.
*/
static int handler_exit_status(akerr_ErrorContext *ctx)
{
pid_t pid = fork();
if ( pid == 0 ) {
akerr_default_handler_unhandled_error(ctx);
_exit(99);
}
int status = 0;
if ( pid < 0 || waitpid(pid, &status, 0) != pid || !WIFEXITED(status) ) {
return -1;
}
return WEXITSTATUS(status);
}
int main(void)
{
akerr_capture_install();
akerr_init();
/* No context to report: the handler has nothing to exit with but failure. */
AKERR_CHECK(handler_exit_status(NULL) == 1);
/*
* With a context, the status becomes the exit code. waitpid only reports
* its low 8 bits, which is where AKERR_VALUE (144) lands, and it is neither
* 0 nor the 1 used for the NULL case, so the two exits stay distinguishable.
*/
akerr_ErrorContext *slot = akerr_next_error();
AKERR_CHECK(slot != NULL);
slot->refcount = 1;
slot->status = AKERR_VALUE;
AKERR_CHECK((AKERR_VALUE & 0xff) != 0 && (AKERR_VALUE & 0xff) != 1);
AKERR_CHECK(handler_exit_status(slot) == (AKERR_VALUE & 0xff));
slot = akerr_release_error(slot);
AKERR_CHECK(slot == NULL);
AKERR_CHECK(akerr_slots_in_use() == 0);
fprintf(stderr, "err_unhandled_null ok\n");
return 0;
}