Files
libakerror/TODO.md

160 lines
8.4 KiB
Markdown
Raw Normal View History

2026-07-30 13:22:50 -04:00
# TODO
Working notes for `libakerror`. Outstanding items only.
Make the error pool and status registry thread safe Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00
## 1. Only ThreadSanitizer is wired into CI, not ASan/UBSan
Make the error pool and status registry thread safe Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00
`AKERR_SANITIZE` builds the library and the tests with any sanitizer list, and
CI runs `-DAKERR_SANITIZE=thread` through `scripts/thread_test.sh`. Nothing runs
`address,undefined` yet, and that is the one that covers the original
motivation: mutation testing caught an out-of-bounds probe in the status-name
hash table that the suite could not, because the failure mode was a write into
adjacent BSS, which does not crash. Sharpening one test closed that instance;
ASan would catch the whole class regardless of how sharp the assertions are.
Make the error pool and status registry thread safe Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00
The machinery is in place — this is one more job in
`.gitea/workflows/ci.yaml` running
`cmake -S . -B build/asan -DAKERR_SANITIZE=address,undefined`. Left separate
because ASan and TSan cannot be combined in one build.
## 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
Raise errors from the status registry instead of returning codes 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
"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.
Make the error pool and status registry thread safe Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00
## 5. Renaming a status is not safe against a concurrent lookup
Make the error pool and status registry thread safe Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00
`akerr_name_for_status(status, NULL)` returns a pointer into the registry rather
than a copy, which is what makes it usable from inside `FAIL` — it needs no
buffer and no error context of its own. Registering a *second* name for a status
that already has one (`tests/err_name_ownership.c` covers that it is allowed)
overwrites that buffer in place, so a thread reading the name at that moment can
see a torn string. Every other registry operation is serialized; this one cannot
be, because the reader is outside the lock by the time it reads the characters.
Documented in README.md and UPGRADING.md as "register names during
initialization". Closing it properly means making a registered name immutable —
either refusing a rename outright (a behavior change, and
`tests/err_name_ownership.c` asserts the current contract), or copying names
into a bump-allocated arena and publishing the pointer with a release store, so
a rename allocates new storage instead of rewriting live storage. The arena is
the better answer; it costs a second capacity limit and its exhaustion path.
## 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.
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
Use the library's own error idioms inside the library 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
## 7. `akerr_init()`'s own reservation failure is untested
Raise errors from the status registry instead of returning codes 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
Use the library's own error idioms inside the library 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
`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.
Raise errors from the status registry instead of returning codes 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
Use the library's own error idioms inside the library 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
Closing it means a second library target built with tiny tables plus a
Make the error pool and status registry thread safe Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:31:22 -04:00
`WILL_FAIL` test linked against it. Nothing in the CMake does that yet: the
sanitizer and coverage options vary the *flags* of the one library target, not
its compile definitions.
Raise errors from the status registry instead of returning codes 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
Use the library's own error idioms inside the library 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
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.
## 8. Mutation testing judges concurrency mutants without a sanitizer
`scripts/mutation_test.py` configures each mutant build with the default CMake
options, so a mutant that only breaks under concurrency is judged by a suite
running without ThreadSanitizer. Deleting the pool's `akerr_mutex_lock()` call
survives the run even though it is a real race: rebuilt and run directly, that
mutant fails `tests/err_threads_pool.c` in 4 of 10 runs, and fails under
`scripts/thread_test.sh` in 5 of 5. So 81.2% is a floor for that category, not a
verdict.
Closing it means a `--cmake-arg` passthrough on the harness so the mutant build
can be configured with `-DAKERR_SANITIZE=thread`. The whole run then costs a
TSan-instrumented suite per mutant (roughly 6s instead of 0.4s), so it belongs
behind a flag rather than in the default target or in CI.
Document and test handing an error context between threads The thread-safety section filed two different things under "does not cover, and cannot": sharing a context between threads, and passing one to another thread. Only the first is unsupported. Transfer already works by construction -- the reference count is the only field the library reads across an ownership boundary, and it is only ever touched under the pool lock, so akerr_release_error() does not care which thread checked the slot out. The pool is process-global, not thread-local, so a context outlives the thread that raised it. Calling that unsupported told readers the worker/collector shape was off the table, which either cost them the pattern or cost them the stack trace when they rolled their own struct instead. Split the bullet: transfer joins the covered list and gets its own section with the rule, the worked pattern, and the four receiving-side hazards (PREPARE_ERROR cannot adopt, CATCH assigns over the pointer, FINISH in a void helper still parses its return, and an unhandled error now terminates from the collector's thread). Sharing keeps the "cannot" bullet, narrowed to what it actually is. err_threads_handoff.c proves it: the existing thread tests all keep every context on the thread that raised it, so the transfer path was exercised nowhere. Seven producers hand errors to one collector through a bounded mutex/condvar queue -- the mutex is the thing under test, since it is what publishes the unlocked content writes -- and the collector asserts the context is still a live slot at refcount 1, that message and trace arrive whole and in each producer's order, that the slot was never recycled in flight, and that a thread which never called akerr_next_error() can release it. A second phase reads a context whose raising thread has already exited. Also document why copying a context by assignment is silently wrong: stacktracebufptr is self-referential, so the copy's cursor points into the source's buffer and the first append corrupts a slot the copier no longer owns. TODO.md records the akerr_copy_error() shape that would fix it and the trigger for building it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:35:18 -04:00
## 9. No way to keep an error context and report it at the same time
A context can be handed to another thread and released there -- `README.md` now
documents that pattern and `tests/err_threads_handoff.c` proves it -- but it is a
*move*. A thread that wants to both keep its error and report it upward has to
read the fields out into its own record, and it loses the stack trace doing so,
because `stacktracebuf` is the one thing that cannot be usefully summarized.
Copying the struct is not a workaround. `stacktracebufptr` is self-referential
(`include/akerror.tmpl.h`), so `akerr_ErrorContext c = *src;` leaves the copy's
cursor pointing into the source's buffer -- the copy logs correctly and then
corrupts a slot it does not own the first time anything appends to it. `arrayid`
is restored after the wipe in `akerr_release_error()` (`src/error.c:395-398`), so
a copied id makes the destination impersonate the source's slot for the life of
the process.
If this is ever worth an API, the shape is:
```c
akerr_ErrorContext AKERR_NOIGNORE *akerr_copy_error(akerr_ErrorContext *source,
akerr_ErrorContext *destination);
```
taking the destination as a parameter rather than allocating it. An allocating
copy could fail on pool exhaustion, and reporting *that* failure needs a pool
slot, so it would have to abort -- adding a third `exit()` site to a library that
deliberately has two. Caller-allocates puts the pool pressure where it can be
managed. The copy must repair four fields: `arrayid` (the destination's own),
`refcount` (set to 1, never inherited), `handled` (false -- a copy is a fresh
obligation, or `FINISH_NORETURN` on the receiving side drops it silently), and
`stacktracebufptr` (re-anchored to the destination's buffer at the *same offset*,
so a later append continues the trace instead of overwriting it).
Not worth building yet: no consumer needs it. The trigger is a consumer that
needs a worker's stack *trace*, not just its status and message, at the join
point -- `libakstdlib`'s planned `pthread_*` wrappers are the likely first.
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
## 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.