Andrew Kesterson f8425b8729
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m53s
libakstdlib CI Build / sanitizers (push) Successful in 2m51s
libakstdlib CI Build / coverage (push) Successful in 2m43s
libakstdlib CI Build / mutation_test (push) Successful in 12m37s
Gate mutation testing on a measured score, not an inherited one
The threshold had been 80 against src/stdlib.c alone. There are three
more sources now and nobody had measured them, so that number was a
guess carried forward.

Measured: 72.3%, 188 of a 260-mutant sample from the 1701 the four
sources generate. Gate set to 65 -- a ratchet with headroom for the
runner and for the sample shifting as sources change, not a target.

A sample rather than the whole set, because 1701 rebuilds and test runs
is hours. --max-mutants samples by even index rather than at random, so
the same 260 run every time and the gate stays reproducible; sampling
all four files beats exhausting one of them, which is what this job did
before.

72.3% against the 89.6% reported at 0.1.0 is a change in denominator,
not a regression in the tests. That figure covered one 561-line file;
this covers four totalling 1716 lines, and most of the added surface is
argument validation whose mutants are frequently *equivalent* -- 12 of
the 72 survivors are `errno = 0` deleted from a wrapper whose libc call
always sets errno, which no test that could be written would catch. The
README breaks all 72 down and says which are worth acting on; TODO.md
2.4 carries the three clusters that are.

Two of them were real and are fixed here and in the previous commit: the
right child's `depth + 1` in the depth-first walk, and aksl_tree_remove
on an empty tree, which without its guard dereferences NULL. Neither had
a test; both do now. That is what the harness is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:15:08 -04:00
2026-07-30 22:20:54 -04:00
2026-07-30 22:20:54 -04:00
2026-07-29 17:42:38 -04:00

README

build badge

libakstdlib wraps C standard library functions so that they report failures through libakerror's ATTEMPT { ... } HANDLE { ... } error contexts instead of through return codes and errno. It also provides data structures built on the same convention.

Every entry point returns akerr_ErrorContext * and is marked AKERR_NOIGNORE. See TODO.md for the current state of the library and UPGRADING.md if you are coming from 0.1.0, which this release breaks.

What it wraps

Area Source Functions
Memory src/stdlib.c malloc calloc realloc free freep memset memcpy memmove memcmp memchr
Formatted output src/stdlib.c printf fprintf snprintf and their v* forms
String → number src/stdlib.c strtol strtoll strtoul strtoull strtod strtof strtold, and atoi atol atoll atof on top of them
Paths and hashing src/stdlib.c realpath realpath_alloc strhash_djb2 strhash_djb2_str
Strings src/string.c strlen strnlen strcpy strncpy strcat strncat strdup strndup strcmp strncmp strcasecmp strncasecmp strcoll strchr strrchr strstr strcasestr strpbrk strspn strcspn strtok_r strsep strerror
Streams src/stream.c fopen fread fwrite fclose fseek ftell rewind fseeko ftello fgetpos fsetpos fflush setvbuf fgetc fputc ungetc fgets fputs getline getdelim feof ferror clearerr fileno freopen fdopen tmpfile sscanf fscanf remove rename mkstemp mkdtemp
Collections src/collections.c doubly-linked list (bare-node and tracked-container forms), binary search tree, breadth- and depth-first traversal, fixed-capacity hash map, growable string buffer, FNV-1a

Where it deviates from libc, and why

The whole point is to make silent failures loud, so several wrappers are deliberately stricter than the function they are named for. These are the ones that will surprise you:

Wrapper Deviation
aksl_free(NULL) AKERR_NULLPOINTER. free(NULL) is legal and does nothing; in a codebase that routes every allocation through aksl_malloc, a pointer you believed was live turning out NULL means something upstream did not happen.
aksl_malloc(0, &p) AKERR_VALUE. There is nothing useful to hand back, and malloc(0) returning NULL without setting errno is how an error with status 0 used to get raised.
aksl_atoi and friends Report bad conversions. atoi(3) has no error channel at all: junk converts to 0 and overflow wraps. Base 10, whole string, ERANGE on overflow.
aksl_strcpy / strncpy / strcat / strncat Take the destination's size, which the libc originals cannot be called safely without. Truncation is AKERR_OUTOFBOUNDS and writes nothing. aksl_strncpy always terminates and never NUL-pads.
aksl_snprintf Truncation is AKERR_OUTOFBOUNDS, not a short success. There is no aksl_sprintf: an error-handling wrapper around an unbounded write is the sharp edge this library exists to remove.
aksl_memcpy Overlapping ranges are AKERR_VALUE rather than undefined behaviour. Use aksl_memmove.
aksl_fread / aksl_fwrite Require a transferred-count out-param, and report a short transfer with no stream error as AKERR_IO rather than as success.
aksl_sscanf / aksl_fscanf Take the number of conversions you expect. Comparing scanf(3)'s return against that by hand at every call site is the check everyone eventually forgets.
aksl_realpath Takes the destination's length and refuses anything below PATH_MAX, because realpath(3) cannot be bounded.
aksl_list_pop Takes the head by reference, because popping the head has to move it.
Searching (strchr, strstr, memchr, aksl_list_find, aksl_hashmap_get, …) Finding nothing is success with a NULL or zero result, not an error. Absent is an ordinary answer.
aksl_strtok Does not exist. strtok(3) keeps its state in a hidden static; use aksl_strtok_r or aksl_strsep.

Thread safety

This library is not thread-safe, and cannot be made so from here.

libakerror hands out error contexts from AKERR_ARRAY_ERROR, a process-global array with no locking, and every entry point in this library takes a slot from it on any failure path. Two threads raising errors concurrently can be handed the same slot. errno is thread-local so the wrapped calls themselves are fine; the error reporting is not.

There is no TSan test here because there is nothing to verify — the answer is known and it is "no". Fixing it means locking or thread-local storage in libakerror's pool, which is that library's decision to make; TODO.md §1.9 records it. Until then: confine libakstdlib calls to one thread, or serialise them yourself.

The wrappers add no state of their own beyond that. aksl_strtok_r and aksl_strsep keep their state in the caller's saveptr, and no function here uses a static buffer.

Building

git submodule update --init --recursive   # deps/libakerror
cmake -S . -B build
cmake --build build
cmake --install build

A top-level build compiles the vendored deps/libakerror. When libakstdlib is consumed as a subproject, it uses whatever akerror::akerror target or installed package the parent provides instead.

This library's own version

libakstdlib is at 0.2.0. The version lives in exactly one place — the project() call in CMakeLists.txt — and flows from there into everything else, so a bump is a one-line edit:

Artifact Value at 0.2.0 From
AKSL_VERSION_MAJOR / _MINOR / _PATCH 0 / 2 / 0 include/akstdlib_version.h.in
AKSL_VERSION_STRING "0.2.0" same
AKSL_VERSION_NUMBER 200 same
AKSL_VERSION_SONAME "0.2" same
shared library libakstdlib.so.0.2.0, soname libakstdlib.so.0.2 VERSION / SOVERSION
pkg-config --modversion akstdlib 0.2.0 akstdlib.pc
find_package(akstdlib 0.2) accepted; 0.1 and 1.0 refused akstdlibConfigVersion.cmake

akstdlib_version.h is generated — that is why there is no such file in the source tree, only the .in template beside akstdlib.h. Don't hand-edit the copy in your build directory; change the template or project().

It is 0.x deliberately. The 0.1 → 0.2 bump was itself an ABI break — fixing the confirmed defects changed five signatures and the ato* contract, all of it listed in UPGRADING.md — and the API is not being promised until the wishlist in TODO.md §3 has settled. While the major version is 0, the soname carries MAJOR.MINOR: 0.1 and 0.2 are different ABIs and the loader will not substitute one for the other. At 1.0 the soname becomes MAJOR alone — the if(PROJECT_VERSION_MAJOR EQUAL 0) in CMakeLists.txt and the matching #if in tests/test_version.c are the two places that encode this, and they are tested against each other.

AKSL_VERSION_NUMBER is computed rather than written as a literal, because a literal 000100 is octal in C and would make 0.1.0 compare as 64.

Compiled-against vs. loaded

The macros above record what a caller was compiled against. What it actually loaded is a different question, and the two can disagree:

int major, minor, patch;
akerr_ErrorContext *e = aksl_version(&major, &minor, &patch);  /* the loaded .so */
const char *v = aksl_version_string();                         /* likewise */

e = AKSL_VERSION_CHECK();   /* compares the two; AKERR_VALUE on a mismatch */

AKSL_VERSION_CHECK() is a macro on purpose: it expands at your call site, so it captures the AKSL_VERSION_* you were built with and passes them to a function that compares against the values baked into the library. Calling aksl_version_check() with hand-written numbers defeats the whole mechanism.

Compatibility is defined as "same soname", so pre-1.0 both major and minor must match and the patch level is ignored — a caller built against 0.2.0 keeps working against 0.2.7, which is exactly the promise the shared soname makes.

In normal use the soname catches the mismatch first, at load time, and the check never fires. It earns its keep when the soname is bypassed: a hand-install that drops a 0.3.0 build in under the 0.2 filename, or a package that strips versioning. Then the loader is happy and only the check notices:

compiled against : 0.2.0 (soname 0.2)
loaded           : 0.3.0 (0.3.0)
MISMATCH DETECTED: compiled against libakstdlib 0.2.0, loaded 0.3.0 (soname 0.3)

The libakerror version floor

libakerror 1.0.0 or newer is required. That release made the status-name table private, moved consumer status codes into a band starting at AKERR_FIRST_CONSUMER_STATUS (256), made range ownership enforced rather than advisory, and gave the library an soname — see deps/libakerror/UPGRADING.md. It is a source and ABI break, so pairing this header with an older akerror.h is not a compile problem you can work around; the pairing is simply invalid.

Three things enforce the floor, because no single one covers every way the library gets consumed:

Mechanism Where Catches
#error on a missing AKERR_FIRST_CONSUMER_STATUS include/akstdlib.h a stale akerror.h earlier on the include path, at the first diagnostic rather than as a pile of errors inside src/stdlib.c
Requires: akerror >= 1.0.0 akstdlib.pc.in a pkg-config consumer, which also now gets -lakerror transitively
find_dependency(akerror) cmake/akstdlib.cmake.in a find_package(akstdlib) consumer, which previously failed with a bare "akerror::akerror not found" out of the generated targets file

The header guard feature-tests rather than version-tests because libakerror publishes no version macro; AKERR_FIRST_CONSUMER_STATUS is the symbol 1.0.0 introduced, so its absence is what "older than 1.0.0" actually looks like. The CMake path requests no version for the same kind of reason: libakerror installs no akerrorConfigVersion.cmake, so find_dependency(akerror 1.0.0) would be refused for want of a version file no matter which akerror is installed.

libakstdlib defines no status codes of its own. It raises libakerror's AKERR_* codes and propagates the host's errno values, all of which live in libakerror's reserved 0255 band, so it reserves no range and an application is free to allocate from AKERR_FIRST_CONSUMER_STATUS without coordinating with it. tests/test_status_registry.c pins that, along with the requirement that every status this library raises actually has a name registered — an unnamed one degrades to "Unknown Error" in every later stack trace, which nothing else would notice.

Testing

There are four harnesses. The first three take seconds; the fourth takes about half an hour.

1. The test suite

cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure

Tests live one per file in tests/test_<name>.c and share the helpers in tests/aksl_capture.hAKSL_CHECK() for plain assertions (unlike assert() it survives -DNDEBUG), AKSL_CHECK_STATUS(call, expected) to run a wrapper and assert on the status it returns, aksl_temp_file() for tests that need a real file to work on, and an AKSL_RUN() driver that additionally fails any test which leaks a slot from libakerror's error pool.

One file per area of the API: memory, format (the printf family), convert (the ato* family) and strto (the family underneath it), stream (fopen/fread/fwrite/fclose) and streamio (everything else in src/stream.c), string, path (aksl_realpath), strhash, linkedlist, tree, collections (the list and tree additions), hashmap, strbuf, version, status_registry (this library's side of the libakerror status-registry contract — see "The libakerror version floor" above), and pool.

pool is the odd one out: it asserts two cross-cutting properties rather than any function's behaviour. Every failure path is driven AKERR_MAX_ARRAY_ERROR + 10 times with the pool checked after each round, because a wrapper that raises an error and forgets to release it does not fail visibly — it fails a hundred-odd calls later in whatever unrelated code asks for a slot next. And every error is checked to name the function and file it was actually raised from, which is what catches a FAIL that migrates into a shared helper during a refactor: the status stays right, the message stays right, and the origin quietly starts lying.

To add a test, drop tests/test_mything.c in place and add mything to AKSL_TESTS in CMakeLists.txt.

Reading the results. CMakeLists.txt splits tests into three lists, and two of them invert the meaning of "Passed":

List Meaning
AKSL_TESTS Ordinary tests. Must exit 0.
AKSL_WILL_FAIL_TESTS Expected to abort by design — an unhandled error reaching FINISH_NORETURN, or a deliberate contract violation. Marked WILL_FAIL, so a non-zero exit is a pass.
AKSL_KNOWN_FAILING_TESTS Assert the correct behaviour of a confirmed defect (see TODO.md §2.1). Also marked WILL_FAIL.

Both of those lists are currently empty, which is the news: all six confirmed defects in TODO.md §2.1 are fixed, and the four tests that used to sit in AKSL_KNOWN_FAILING_TESTS are folded back into the tests for the things they test, where they now have to keep passing rather than keep failing visibly. The mechanism stays for the next one. When a defect is fixed its known-failing test starts passing, CTest reports it as failed with unexpectedly passed, and that is the cue to move it into AKSL_TESTS.

Two more entries, negative_noignore and negative_format_mismatch, are compile-time assertions rather than programs. Each builds a source file under tests/negative/ with -Werror and is marked WILL_FAIL, so the test passes only when the compile fails. They exist because AKERR_NOIGNORE and AKSL_PRINTF_FORMAT are enforced by the compiler and by nothing else: drop either attribute in a refactor and every test still passes, the library still builds, and the guarantee just quietly stops existing.

Every test is capped with a 30-second CTest TIMEOUT. The list and tree code is full of loops whose termination hangs on a single condition, so a bug of that shape hangs the suite rather than failing it.

1a. The installed package

cmake -S deps/libakerror -B build-akerror && cmake --build build-akerror
cmake --install build-akerror --prefix /some/prefix
cmake --install build --prefix /some/prefix
cmake -S tests/consumer -B build-consumer -DCMAKE_PREFIX_PATH=/some/prefix
cmake --build build-consumer && ./build-consumer/consumer

The suite links the build tree, so it says nothing about whether an installed libakstdlib is usable. tests/consumer/ is a standalone project that does the things only an install exercises: find_package(akstdlib 0.2) against the generated version file, akstdlibConfig.cmake's find_dependency(akerror), and the exported akstdlib::akstdlib target. It touches one function from each of the four sources, so a library installed with a source file missing from its link line fails here rather than in whatever consumer finds it next.

Note that libakerror has to be installed too. A top-level build compiles the vendored copy with EXCLUDE_FROM_ALL, so cmake --install on this project installs only this project -- and an installed libakstdlib whose find_dependency(akerror) cannot resolve is not usable. Install the submodule's copy, not libakerror@main: that is the version this repository pins and tests against, and it is what CI does.

2. Sanitizers

cmake -S . -B build-asan -DAKSL_SANITIZE=ON
cmake --build build-asan
ctest --test-dir build-asan --output-on-failure

Builds the library, the tests and the vendored libakerror with ASan + UBSan and -fno-sanitize-recover=all. Three of the defects fixed in 0.2.0 only misbehaved under instrumentation — the uninitialised %s in aksl_realpath's error path, the unbounded vsprintf behind the old aksl_sprintf, and the missing va_end in the printf family — and the tests that pin them are written to be run this way. tests/test_path.c deliberately passes an uninitialised buffer on every failure path for exactly that reason.

One test needs help from the sanitizer to test the same thing the normal build does: tests/test_memory.c asks for SIZE_MAX / 2 bytes to check that a refused allocation reports ENOMEM and leaves *dst NULL. Plain malloc returns NULL; ASan treats a request that large as a bug in the caller and aborts before malloc returns at all. CMakeLists.txt sets ASAN_OPTIONS=allocator_may_return_null=1 for that one binary so the contract under test stays the same in both builds.

3. Code coverage

cmake -S . -B build-coverage -DAKSL_COVERAGE=ON
cmake --build build-coverage --target coverage

-DAKSL_COVERAGE=ON compiles the library and the tests with --coverage -O0, and wires the report into the suite itself, so a plain ctest --test-dir build-coverage also produces it. Two extra CTest entries appear, held in place by a CTest fixture rather than by declaration order, so they work under ctest -j too:

Test When Does
coverage_reset before every other test deletes the accumulated .gcda counters
coverage_report after every other test aggregates gcov output, prints the summary, applies the threshold gate

The reset matters: gcov counters are cumulative, so without it each report would fold in every earlier run and overstate coverage.

coverage here is this project's target. libakerror ships a coverage target of its own and, unlike its mutation target, does not namespace it when embedded, so a top-level -DAKSL_COVERAGE=ON build would collide on the name and fail to configure at all. CMakeLists.txt renames the dependency's to akerror_coverage on the way past — it drives its own instrumented build tree, so cmake --build build-coverage --target akerror_coverage still works. The workaround goes away when libakerror namespaces it upstream; see TODO.md §2.3.

CTest hides the output of a passing test, so coverage_report also writes build-coverage/coverage-summary.txt (the same text report) and build-coverage/coverage.xml (Cobertura, for CI publishers). The coverage target above prints the report to the terminal for you; otherwise read the file or use ctest --test-dir build-coverage -V -R coverage_report.

The report lists per-file line, branch and function coverage, then every uncovered line and every function the suite never called — that listing is the actionable part, the same way surviving mutants are for the harness below.

Drive the script directly for anything narrower:

scripts/coverage.py --build build-coverage                     # report on disk counters
scripts/coverage.py --build build-coverage --summary-only       # totals only
scripts/coverage.py --build build-coverage --include tests      # coverage of the tests themselves
scripts/coverage.py --build build-coverage --run-tests          # reset, run ctest, report
scripts/coverage.py --build build-coverage --threshold 90 --branch-threshold 40

It needs nothing but Python 3 and gcc's own gcov — no lcov, gcovr or genhtml.

To gate on coverage, set the threshold at configure time; coverage_report then fails below it, and the same regression-ratchet logic applies as for the mutation score:

cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \
      -DAKSL_COVERAGE_THRESHOLD=90 -DAKSL_COVERAGE_BRANCH_THRESHOLD=40

Where it stands. All four sources, with the whole suite:

file lines branches functions
src/collections.c 99.3% (601/605) 43.5% 100% (43/43)
src/stdlib.c 99.4% (614/618) 46.5% 100% (55/55)
src/stream.c 100% (282/282) 43.4% 100% (33/33)
src/string.c 100% (211/211) 53.8% 100% (23/23)
total 99.5% (1708/1716) 46.0% 100% (154/154)

so the 90/40 gate above is a ratchet with headroom rather than a target. Eight lines are uncovered and each is uncovered on purpose:

  • Four } HANDLE(e, AKERR_ITERATOR_BREAK) { lines. In libakerror that macro begins with the break; belonging to PROCESS's case 0: arm, which is only reachable when a callback returns a non-NULL context whose status is zero — the pathological case §2.2.1 exists to remove. Left uncovered rather than pinned by a test that would have to manufacture it.
  • Two in strbuf_reserve, the size_t overflow guard on a doubling that would wrap. Reaching it needs a buffer within a factor of two of SIZE_MAX, which is not a test, it is a hang.
  • Two in aksl_fread/aksl_fwrite, the short transfer with neither feof nor ferror set. Every way of producing a short transfer on Linux sets one or the other; the branch is there because the standard permits neither, not because anything reaches it. TODO.md §1.2 records it as still open.

Branch coverage sits far below line coverage because most branches in these files are inside the FAIL_*/ATTEMPT/FINISH macro expansions — pool exhaustion, stack-trace buffer limits, akerr_valid_error_address failures — and belong to libakerror's own suite rather than to this one. Every FAIL_ZERO_RETURN in the tree contributes several branches that this library has no way to reach. The libakerror 1.0.0 bump made that gap wider without changing a line here: the branch denominator per call site grew, so identical tests scored lower. Chasing the number would mean testing libakerror's macros, which is what libakerror's mutation suite is for — macros expand at the call site, so coverage cannot see them properly from either side.

Two caveats. Coverage is measured at -O0, because the optimizer reorders lines until per-line counts stop matching the source — so a coverage build is not the build to profile. And gcov flushes its counters at normal process exit, which an AKSL_WILL_FAIL_TESTS entry that aborts by design never reaches: such a test contributes no coverage data at all, so lines only it reaches are reported as uncovered.

4. Mutation testing

The suite tells you the library works. Mutation testing tells you the suite works: it breaks the library in small ways, one at a time, and checks that the tests notice.

cmake --build build --target mutation        # src/stdlib.c + include/akstdlib.h

or drive the script directly for a faster or narrower run:

scripts/mutation_test.py --target src/stdlib.c            # C source only
scripts/mutation_test.py --target src/stdlib.c --list     # enumerate, build nothing
scripts/mutation_test.py --target src/stdlib.c --max-mutants 20
scripts/mutation_test.py --target src/stdlib.c --threshold 80

A mutant that makes the tests fail is killed (good); one the tests still pass is a survivor, and names a missing test. The score is killed / total, and the run prints every survivor with file:line and the exact edit. The harness never touches your working tree — it copies the repo to a scratch directory and mutates the copy.

CI runs a 260-mutant sample across all four sources with --threshold 65. A sample, because 1701 mutants each needing a full rebuild and test run is hours — and --max-mutants samples by even index, not at random, so the same 260 run every time and the gate is reproducible. Sampling all four files beats exhausting one of them, which is what this job used to do.

Where it stands: 72.3% (188/260 killed). That is a ratchet with headroom, not a target.

It is well below the 89.6% this project reported at 0.1.0, and the difference is denominator rather than tests. That figure covered one 561-line file; this covers four totalling 1716 lines, and most of the new surface is argument validation whose mutants are frequently equivalent — a mutation that cannot change observable behaviour, so no test could ever kill it. The clearest example:

errno = 0;                      /* delete this line */
*dst = malloc(size);
FAIL_ZERO_RETURN(e, *dst, AKSL_ERRNO_OR(ENOMEM), "%zu bytes", size);

Deleting the errno = 0 is undetectable, because malloc always sets errno when it fails. The line is still right to have — it is what makes AKSL_ERRNO_OR's contract sound, and it matters for the calls that don't set errno — but no test distinguishes the two versions. Twelve of the 72 survivors are that line in twelve different wrappers.

The survivors do break down usefully:

Survivors What Verdict
12 errno = 0 deleted before a call that always sets errno Equivalent. Not a missing test.
14 FAIL_* guards deleted or their constants shifted Mixed — the constant shifts are undetectable where the test names the same constant symbolically; the deletions are real.
7 SUCCEED_RETURN deleted The function falls off the end and returns whatever is in the return register, which is often NULL by luck. Needs an assertion on a side effect, not on the status.
3 va_end deleted Undetectable on x86-64 SysV, where va_end is a no-op. Real UB, invisible here.
3 FINISH(e, true)FINISH(e, false) Real: an error swallowed instead of propagated. Worth a test.
33 the rest Individually listed with file:line and the exact edit in the published report.

Two of them were real gaps and are now fixed: the depth-first walk's depth + 1 on the right child (nothing had ever recursed right more than three deep, so a right-leaning tree would have blown the stack the depth cap exists to protect), and aksl_tree_remove on an empty tree, which without its guard dereferences NULL. Both are in the suite now — which is what the harness is for.

The pre-push hook

.githooks/pre-push runs the fast harnesses — the default build and the sanitizer build, each followed by ctest — before letting a push out. Enable it once per clone:

git config core.hooksPath .githooks

It only builds when there are commits to push (a branch deletion is a no-op), and it builds under .git/aksl-prepush so it never disturbs your own build/.

AKSL_HOOK_MUTATION=1 git push    # also run the mutation gate (slow)
git push --no-verify             # skip the hook entirely

Other knobs: AKSL_MUTATION_THRESHOLD (default 80, keep it in step with .gitea/workflows/ci.yaml) and AKSL_HOOK_BUILD_DIR.

Description
A C standard library that implements libakerror error handling
Readme 668 KiB
Languages
C 87.6%
Python 6.2%
CMake 5.1%
Shell 0.9%
Emacs Lisp 0.2%