39 Commits

Author SHA1 Message Date
6eac6d78f6 Remove dead install variable
Some checks failed
libakerror CI Build / cmake_build (push) Has been cancelled
libakerror CI Build / coverage (push) Has been cancelled
libakerror CI Build / mutation_test (push) Has been cancelled
2026-08-03 13:18:42 -04:00
fdb8ffaad0 Merge pull request 'Annotate intentional handler fallthrough' (#19) from 18 into main
All checks were successful
libakerror CI Build / coverage (push) Successful in 2m47s
libakerror CI Build / cmake_build (push) Successful in 2m53s
libakerror CI Build / mutation_test (push) Successful in 39m42s
Reviewed-on: #19
2026-08-03 12:14:30 -04:00
7d0e467181 Disable ThreadSanitizer CI pending runner fix
All checks were successful
libakerror CI Build / cmake_build (push) Successful in 2m53s
libakerror CI Build / coverage (push) Successful in 2m50s
libakerror CI Build / mutation_test (push) Successful in 37m30s
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 09:31:03 -04:00
997828116b Install the ThreadSanitizer ASLR runner
Some checks failed
libakerror CI Build / cmake_build (push) Successful in 2m53s
libakerror CI Build / coverage (push) Successful in 2m52s
libakerror CI Build / thread_sanitizer (push) Failing after 2m52s
libakerror CI Build / mutation_test (push) Successful in 43m58s
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 07:49:28 -04:00
0a6cb303f5 Run ThreadSanitizer tests without ASLR
Some checks failed
libakerror CI Build / coverage (push) Successful in 2m49s
libakerror CI Build / thread_sanitizer (push) Failing after 2m54s
libakerror CI Build / cmake_build (push) Successful in 3m3s
libakerror CI Build / mutation_test (push) Has been cancelled
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 07:28:40 -04:00
a902c95155 Annotate intentional handler fallthrough
Some checks failed
libakerror CI Build / coverage (push) Successful in 2m51s
libakerror CI Build / cmake_build (push) Successful in 2m53s
libakerror CI Build / thread_sanitizer (push) Failing after 5m53s
libakerror CI Build / mutation_test (push) Successful in 37m49s
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 23:52:04 -04:00
11cc5578fe Repoint the last TODO.md citations at the tracker
Some checks failed
libakerror CI Build / coverage (push) Successful in 2m48s
libakerror CI Build / cmake_build (push) Successful in 2m50s
libakerror CI Build / thread_sanitizer (push) Failing after 2m47s
libakerror CI Build / mutation_test (push) Successful in 39m57s
docs/building.md sent a reader to TODO.md for what a stdlib replacement must
provide; the AKERR_USE_STDLIB=OFF build does not compile at all, which is issue
#12, so it says that instead.

Found while sweeping the consumer repositories: the handler macros trip
-Wimplicit-fallthrough, which is why libakgl cannot adopt -Wextra. That was
recorded only in libakgl and is now issue #18 -- the fourth defect in this
library found written down in a consumer rather than here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 22:01:26 -04:00
39f20def72 Merge pull request 'Move outstanding work from TODO.md into the issue tracker' (#17) from todo-to-issues into main
Some checks failed
libakerror CI Build / coverage (push) Successful in 2m50s
libakerror CI Build / cmake_build (push) Successful in 2m53s
libakerror CI Build / thread_sanitizer (push) Failing after 2m54s
libakerror CI Build / mutation_test (push) Successful in 37m44s
Reviewed-on: #17
2026-08-02 19:47:11 -04:00
23df4069ab Point AGENTS.md at the issue tracker for outstanding work
The agent instructions now say where new work goes -- an issue on the forge,
carrying status::grooming until its scope is settled -- and that TODO.md is the
record: why the handler ladder is a major-version change, why a copied
akerr_ErrorContext is a trap, why validating more inputs lowers branch coverage,
and why the mutation score is a floor.

The instruction that matters most here is about consumers. Three defects in this
library -- IGNORE() leaking a context, the un-namespaced coverage target, and the
missing akerrorConfigVersion.cmake -- were written down in libakstdlib's notes
and never here, so each was worked around once per consumer and nobody saw the
pattern. Filing upstream should cost a consumer one issue instead of one
workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 19:24:49 -04:00
24469e83d4 Move outstanding work from TODO.md into the issue tracker
Fourteen issues on source.starfort.tech/andrew/libakerror, labelled by kind and
blast radius and milestoned by what they can land in: 2.0.x for anything that
breaks no ABI, 2.1.0 for additive surface, 3.0.0 for the handler-ladder rewrite.
Everything filed carries status::grooming.

Three of the fourteen were not in this file at all. They are defects in this
library that were written down in a consumer's TODO instead: IGNORE() leaking a
context (#14), the coverage target not being namespaced when embedded (#15),
and no akerrorConfigVersion.cmake being installed (#16). libakstdlib carries a
workaround for each. That is the finding worth keeping -- a library whose
consumers record its defects in their own files cannot see them, and the
workaround gets written once per consumer.

TODO.md keeps the reasoning: why the handler ladder is a major-version change,
why a copied akerr_ErrorContext is a trap in two specific fields, why
validating more inputs lowers branch coverage by construction, why the mutation
score is a floor, and why a registered name is returned by pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 19:00:22 -04:00
4cc2b71936 Split the README reference material into docs/
The README was 794 lines: the summary, the design rationale, the whole macro
reference, the threading contract, the build internals and the exit-status
specification in one file. It is now 178 lines -- summary, installation,
quickstart, and an index -- and the reference material lives in docs/, one file
per topic: architecture, usage, status-codes, uncaught-errors, exit-status,
thread-safety, building.

The prose moved as written. Inbound references followed it: UPGRADING.md,
TODO.md, include/akerror.tmpl.h and tests/err_threads_handoff.c now name the
docs/ file that owns the text they cite, and AGENTS.md says where new
documentation goes so the README does not grow back.

Five factual errors fixed in the moved text:

- Both NULL-pointer examples inverted their test. FAIL_ZERO_* fails when the
  expression is zero, so `(somePointer == NULL)` failed on a *valid* pointer.
  They now read `(somePointer != NULL)`.
- AKERROR_NOIGNORE, four times including the #define, is AKERR_NOIGNORE.
- FINISH_NORExbTURN is FINISH_NORETURN.
- "functiions" is "functions".
- The architecture link pointed at include/akerror.h, which is generated and
  not in the tree; it points at include/akerror.tmpl.h.

The quickstart is new text. It compiles under -Wall -Wextra -Werror and was run
through all three of its paths: handled usage error exits 0, unhandled IO error
prints a trace and exits with the status, success exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 16:56:07 -04:00
53a0122786 Fix two comments that misstate exit sites and NULL validity
Both found while auditing the pool for the cross-thread transfer guarantee.

AGENTS.md said the two exit() calls that bypass akerr_exit() are "both in
src/error.c". ENSURE_ERROR_READY's pool-exhaustion abort is in
include/akerror.tmpl.h. The count of abort sites is load-bearing -- it is
the argument against any API that could fail on pool exhaustion -- so
pointing at the wrong file makes it hard to check.

akerr_valid_error_address() returns 1 for NULL, which is right for VALID()
(NULL is how a function reports success) and a trap for anything reading it
as "is this a pool slot". Say so where the function is defined.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 16:35:26 -04:00
e66b5154b7 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 16:35:18 -04:00
eb41c9f1de Record the mutation score after the exit-status fix
src/error.c now scores 81.4%: 245 of 301 mutants killed, 211 by a
failing test, 24 by failing to compile, and 10 by hanging the suite.
The population grew by 8 with akerr_exit(), and all 8 die, as do the 4
in akerr_default_handler_unhandled_error() -- no survivor anywhere in
either function.

Two of those are worth naming. Mutating the guard to status < 1 (the
sentinel-for-zero behaviour this library deliberately does not have) and
mutating the NULL-context exit(1) to exit(0) both die, so the tests pin
the two ways an exit could start claiming success rather than just the
one that was broken.

That moves the default handler out of the "needs a subprocess test"
survivor category noted here: it has one now. The default *logger* is
still in it -- every other test swaps in the capturing logger, so
nothing watches what the real one writes to stderr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 07:57:33 -04:00
69dd884fa8 Stop an unhandled error from exiting zero
An unhandled error could kill the process and still report success. The
default handler ended in exit(errctx->status), and an exit status is one
byte wide: the kernel keeps the low 8 bits of the argument and discards
the rest. Consumer statuses start at AKERR_FIRST_CONSUMER_STATUS (256),
so the first status any consumer can reserve exited 0 and a shell saw a
clean run. Status 300 exited 44, an unrelated error's code.

There is no wider exit() to reach for. _exit(), _Exit(), quick_exit()
and the raw exit_group syscall all truncate identically, and even
waitid(), whose si_status is a full int, reports the truncated value --
the truncation happened before the parent looked.

akerr_exit() now owns that mapping and the default handler calls it: 0
exits 0, 1 through 255 exit the status, and anything else exits
AKERR_EXIT_STATUS_UNREPRESENTABLE (125) rather than a low byte that is
either a lie or a claim of success. Only values that were already being
delivered wrong behave differently. Call it instead of exit() anywhere
you leave the process on a status; it is declared AKERR_NORETURN.

akerr_exit(0) exits 0, because 0 is this library's success status. That
is not a hole in the rule: PROCESS opens with case 0, which marks a zero
status handled, so a successful context never reaches FINISH_NORETURN's
call to the handler at all.

tests/err_exit_status.c drives one table through akerr_exit() and
through the default handler in forked children and requires identical
exit codes, so the handler cannot grow a mapping of its own. With the
clamp removed it fails with "akerr_exit(256) exited 0, want 125". The
full-width status was already reaching the log and still does, which the
same test asserts against the captured stack trace.

2.0.1. No ABI break: the soname stays libakerror.so.2 and nothing that
already existed changed shape. akerr_exit() is a new exported symbol, so
a consumer that starts calling it needs 2.0.1 at link time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 07:25:37 -04:00
e5b1f996cf Record the mutation score and the concurrency mutants it misses
src/error.c now scores 81.2%: 238 of 293 mutants killed, 204 by a
failing test, 24 by failing to compile, and 10 by hanging the suite --
deleting akerr_mutex_init() or the akerr_initializing re-entry guard
deadlocks the first test, which is the right answer for a broken lock.

Lock deletions are the one survivor category where surviving does not
mean harmless, so measure it rather than assume: rebuilt, the surviving
"delete the pool lock" mutant fails tests/err_threads_pool.c in 4 of 10
runs and fails under scripts/thread_test.sh in 5 of 5, with no false
positive on the unmutated library. The property assertions alone are a
coin flip on a missing lock; the sanitizer run is what holds that line.
The harness builds mutants with default CMake options and so never sees
it -- TODO item 8.

Also warn that a sanitized test binary run by hand does not inherit the
halt_on_error CTest gives it, and will print a race and still exit 0.
That is how the 5-of-5 above first read as 2 of 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:45:20 -04:00
496cb58251 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:31:22 -04:00
499384ed0d 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-30 21:53:33 -04:00
aa953df598 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-30 20:58:47 -04:00
c2d077ab89 Reduce TODO.md to outstanding work
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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-30 18:19:53 -04:00
3d627df55d 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-30 18:19:25 -04:00
f42e2bdcd8 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
5757051239 TODO.md 2026-07-30 13:22:50 -04:00
73e14f7d5b Expand error release and handler test coverage 2026-07-30 02:05:31 -04:00
ee3ff2564c 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-30 02:05:30 -04:00
f4dfb3acb9 Avoid mutation target collisions when embedded 2026-07-29 18:01:58 -04:00
b2f8e2d9bf Add CLAUDE.md 2026-07-29 17:42:56 -04:00
89d6fd7d51 Expand error status test coverage
- 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
6a0b605661 Fix format-string use of __FILE__/__func__ and name_for_status lower bound
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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-28 10:52:29 -04:00
0f70039bb7 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-28 09:32:55 -04:00
616a8c58c4 Document that CATCH/FAIL_*_BREAK must not be used inside a loop
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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-28 08:49:29 -04:00
80a41ea1cd Include passed tests in the JUnit report summary
The reporter warned "No annotations found ... configure 'include_passed' as
'true'" because with annotate_only the summary only listed failures. Set
include_passed: true on both reporter steps so the job summary table lists the
passing tests too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 21:15:13 -04:00
43099212fa Work around Gitea Checks API 404 in the JUnit reporter
mikepenz/action-junit-report defaults to creating a check run via the Checks
API, which Gitea does not support -- the call 404s and the publish step fails
(mikepenz/action-junit-report#23). Set annotate_only: true on both reporter
steps to skip check creation, and detailed_summary: true so results still show
up in the job summary (which Gitea's runner does render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 21:10:09 -04:00
47c79df353 Emit JUnit XML from tests + mutation, consume it in CI
Produce machine-readable results and surface them in the Gitea pipeline:

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 20:51:28 -04:00
f7a668d386 Derive err_maxval's code set from the header, not a hardcoded list
The previous err_maxval hardcoded the list of AKERR_* codes, which silently
rots the moment a code is added. Instead, parse the generated akerror.h at
runtime: discover every "#define AKERR_<NAME> (AKERR_LAST_ERRNO_VALUE + N)",
take the highest offset actually defined, and assert AKERR_MAX_ERR_VALUE covers
it. A compile-time cross-check ties the parsed ceiling to the compiled macro so
the test can't pass by reading a stale header.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 17:26:32 -04:00
2322d21f19 Fix AKERR_MAX_ERR_VALUE to cover all AKERR_* codes
AKERR_MAX_ERR_VALUE was AKERR_LAST_ERRNO_VALUE + 15, but the highest defined
code, AKERR_BADEXC, is + 17 (AKERR_NOT_IMPLEMENTED is + 16). akerr_name_for_status
rejects any status above the max, so those codes could never have a registered
name and the AKERR_BADEXC registration in akerr_init was dead code -- a gap
found by mutation testing. Bump the max to + 17.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 17:03:53 -04:00
af109cdec7 Reformat new test files with Stroustrup style
Apply emacs CC-mode "stroustrup" style (c-basic-offset 4, indent-tabs-mode t)
to the test files added in the previous commit, matching the existing house
style. Whitespace only; no behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 16:35:32 -04:00
7cfca60b9f Expand test coverage for error-handling macros and error pool
Add 11 CTest programs and a shared test helper covering gaps left by the
original four tests (which only exercised FAIL/CATCH/HANDLE and CLEANUP):

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-27 16:18:17 -04:00
4 changed files with 44 additions and 31 deletions

View File

@@ -67,34 +67,6 @@ jobs:
fail_on_failure: 'false' fail_on_failure: 'false'
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."
thread_sanitizer:
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
# The thread tests assert exclusive ownership of pool slots and reserved
# ranges, which is checkable without tooling and runs in the job above.
# This is the run that proves there is no data race underneath them.
# libtsan arrives with gcc (libgcc-N-dev depends on it); the script
# disables ASLR because TSan aborts on kernels with vm.mmap_rnd_bits > 28.
- name: thread sanitizer
run: |
scripts/thread_test.sh build/tsan --output-junit "$(pwd)/tsan-junit.xml"
- name: publish thread sanitizer results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'tsan-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'true'
- run: echo "🍏 This job's status is ${{ job.status }}."
mutation_test: mutation_test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -107,6 +107,19 @@ if(AKERR_SANITIZE AND NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
"AKERR_SANITIZE requires GCC or Clang, not ${CMAKE_C_COMPILER_ID}") "AKERR_SANITIZE requires GCC or Clang, not ${CMAKE_C_COMPILER_ID}")
endif() endif()
if(AKERR_SANITIZE STREQUAL "thread" AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
# ThreadSanitizer reserves a fixed, very large virtual-address range. Linux
# ASLR can put a loader mapping inside it before the runtime starts, which
# makes every test fail with "unexpected memory mapping" before main().
# Run the instrumented tests under the normal util-linux ASLR wrapper.
find_program(AKERR_SETARCH_EXECUTABLE setarch)
if(NOT AKERR_SETARCH_EXECUTABLE)
message(FATAL_ERROR
"AKERR_SANITIZE=thread on Linux requires setarch (util-linux) to "
"start tests with ASLR disabled.")
endif()
endif()
function(akerr_instrument_for_sanitizers _target) function(akerr_instrument_for_sanitizers _target)
if(AKERR_SANITIZE) if(AKERR_SANITIZE)
target_compile_options(${_target} PRIVATE target_compile_options(${_target} PRIVATE
@@ -252,7 +265,13 @@ foreach(_test IN LISTS AKERR_TESTS)
target_link_libraries(test_${_test} PRIVATE Threads::Threads) target_link_libraries(test_${_test} PRIVATE Threads::Threads)
endif() endif()
akerr_instrument_for_sanitizers(test_${_test}) akerr_instrument_for_sanitizers(test_${_test})
if(AKERR_SANITIZE STREQUAL "thread" AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_test(NAME ${_test}
COMMAND ${AKERR_SETARCH_EXECUTABLE} ${CMAKE_SYSTEM_PROCESSOR}
-R $<TARGET_FILE:test_${_test}>)
else()
add_test(NAME ${_test} COMMAND test_${_test}) add_test(NAME ${_test} COMMAND test_${_test})
endif()
# A sanitizer report is a test failure. Without halt_on_error the runtime # A sanitizer report is a test failure. Without halt_on_error the runtime
# prints and continues, which leaves a race to be noticed in the log by # prints and continues, which leaves a race to be noticed in the log by
# somebody reading it -- and under a race storm the reporting itself is slow # somebody reading it -- and under a race storm the reporting itself is slow
@@ -263,6 +282,14 @@ foreach(_test IN LISTS AKERR_TESTS)
endif() endif()
endforeach() endforeach()
# HANDLE_GROUP deliberately enters the next case label. Keep that public macro
# compiling with the warning enabled, so a future macro edit cannot restore the
# warning for consumers which adopt -Wextra.
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(test_err_handle_group PRIVATE
-Werror=implicit-fallthrough)
endif()
set_tests_properties( set_tests_properties(
${AKERR_WILL_FAIL_TESTS} ${AKERR_WILL_FAIL_TESTS}
PROPERTIES WILL_FAIL TRUE PROPERTIES WILL_FAIL TRUE
@@ -310,7 +337,6 @@ if(Python3_FOUND)
) )
endif() endif()
set(main_lib_dest "lib/my_library-${MY_LIBRARY_VERSION}")
install(TARGETS akerror install(TARGETS akerror
EXPORT akerrorTargets EXPORT akerrorTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}

View File

@@ -58,4 +58,6 @@ cmake --install build
`PATH_MAX` and `NULL` are used unconditionally but only included under the `PATH_MAX` and `NULL` are used unconditionally but only included under the
stdlib branch, so the header's includes need untangling before stdlib branch, so the header's includes need untangling before
`-DAKERR_USE_STDLIB=OFF` builds. The list above still states what a replacement `-DAKERR_USE_STDLIB=OFF` builds. The list above still states what a replacement
must provide. See [TODO.md](../TODO.md). must provide. That configuration does not currently compile -- `bool`, `PATH_MAX` and `NULL`
are used unconditionally but included only under the stdlib branch. See
[issue #12](https://source.starfort.tech/andrew/libakerror/issues/12).

View File

@@ -391,6 +391,18 @@ akerr_ErrorContext AKERR_NOIGNORE *__akerr_copy_string(char *destination, int ca
* Defines for the ATTEMPT/CATCH/CLEANUP/PROCESS/HANDLE/FINISH process * Defines for the ATTEMPT/CATCH/CLEANUP/PROCESS/HANDLE/FINISH process
*/ */
/*
* HANDLE_GROUP deliberately enters the next case label. GCC and clang both
* understand this spelling in the C modes supported by libakerror. Other
* compilers keep the established control flow without receiving an attribute
* they do not implement.
*/
#if defined(__GNUC__) || defined(__clang__)
#define AKERR_FALLTHROUGH __attribute__((fallthrough));
#else
#define AKERR_FALLTHROUGH
#endif
#define ATTEMPT \ #define ATTEMPT \
switch ( 0 ) { \ switch ( 0 ) { \
case 0: \ case 0: \
@@ -443,6 +455,7 @@ akerr_ErrorContext AKERR_NOIGNORE *__akerr_copy_string(char *destination, int ca
__err_context->handled = true; __err_context->handled = true;
#define HANDLE_GROUP(__err_context, __err_status) \ #define HANDLE_GROUP(__err_context, __err_status) \
AKERR_FALLTHROUGH \
case __err_status: \ case __err_status: \
__err_context->stacktracebufptr = (char *)&__err_context->stacktracebuf; \ __err_context->stacktracebufptr = (char *)&__err_context->stacktracebuf; \
__err_context->handled = true; __err_context->handled = true;