Make the error pool and status registry thread safe
Every entry point may now be called from any thread. akerr_init() runs exactly once however many threads race into it, the pool hands each slot to exactly one thread, and reservations, registrations and lookups are serialized against each other. One recursive lock covers both tables (src/lock.h, private). Recursive because raising an error re-enters the library -- FAIL needs a pool slot and a status name -- and single because two locks would mean an ordering to get wrong. Registry bodies that use the early-returning FAIL_*_RETURN macros are split into *_locked functions behind wrappers that take and release the lock on one path; consumer callbacks are never called under it. This is an ABI break, hence 2.0.0 and SOVERSION 2: - akerr_next_error() now returns a context that already holds its reference. Finding a free slot and claiming it has to be one operation, or two threads scanning at once are handed the same slot. ENSURE_ERROR_READY no longer increments. - __akerr_last_ignored is thread-local, as is the last-ditch context used to report akerr_release_error(NULL). The threading backend is chosen at configure time by AKERR_THREADS (auto, pthread, none). auto fails the configure when it cannot find POSIX threads rather than quietly building a library that reports itself thread safe and is not. generrno.sh stamps the decision into the generated header as AKERR_THREAD_SAFE, so a consumer cannot disagree with the library about it. Tests: err_threads_init, err_threads_pool and err_threads_registry assert exclusive slot ownership, exactly one winner for a contested range, and every registered name readable back under contention. AKERR_SANITIZE builds the library and the tests with any sanitizer; scripts/thread_test.sh runs the suite under ThreadSanitizer and CI runs it. Removing the pool lock makes both the sanitizer and the plain assertions fail, so the tests are not vacuous. Documented in README.md and UPGRADING.md, including what this does not cover: renaming a status while another thread looks it up, and which of two simultaneous unhandled errors sets the exit status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
TODO.md
48
TODO.md
@@ -2,18 +2,20 @@
|
||||
|
||||
Working notes for `libakerror`. Outstanding items only.
|
||||
|
||||
## 1. The test suite has no sanitizer run
|
||||
## 1. Only ThreadSanitizer is wired into CI, not ASan/UBSan
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -48,12 +50,23 @@ 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
|
||||
## 5. Renaming a status is not safe against a concurrent lookup
|
||||
|
||||
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.
|
||||
`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
|
||||
|
||||
@@ -72,8 +85,9 @@ 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.
|
||||
`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.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user