Version at 0.2.0: complete the wishlist, document it, gate the docs
Closes what was left of TODO.md sections 1, 2 and 3, and rewrites that
file to hold outstanding items only.
The API break gets a minor bump, because pre-1.0 the soname carries
MAJOR.MINOR and 0.1 and 0.2 are therefore different ABIs. Five
signatures changed and the ato* contract with them; UPGRADING.md is new
and lists every one, with the before/after for the cases the compiler
cannot warn about.
Section 3.1 is finished: reallocarray with the multiplication checked,
aligned_alloc and posix_memalign, asprintf/vasprintf, scanf/vscanf.
Four functions on that list are deliberately absent rather than missing
-- sprintf, strtok, setbuf and perror -- and TODO.md now says which and
why, so nobody adds them thinking they were forgotten.
Section 1.9, the cross-cutting tests:
tests/test_pool.c drives every failure path AKERR_MAX_ARRAY_ERROR
+ 10 times and checks the pool after each round,
because a wrapper that leaks a slot fails a
hundred calls later in unrelated code. It also
asserts that each error names the function and
file it was raised from, which is what catches a
FAIL that migrates into a helper during a
refactor: status right, message right, origin
quietly lying.
tests/negative/ two sources that must FAIL to compile, built with
-Werror and registered WILL_FAIL. AKERR_NOIGNORE
and the format attributes are enforced by the
compiler and by nothing else; drop either and
every ordinary test still passes.
Thread safety is answered rather than tested: the library is not
thread-safe and cannot be made so from here, because libakerror's error
pool is an unlocked process-global array. README.md says so plainly and
TODO.md carries it as the item blocking any future pthread wrappers.
Doxygen is configured and gated. All 147 public functions have @brief,
a @param each, @throws per status and @return; EXTRACT_ALL is off and
WARN_NO_PARAMDOC on, so `cmake --build build --target docs` fails on an
undocumented entity. It ran to 0 warnings. The Doxyfile carries no
version -- cmake/RunDoxygen.cmake feeds PROJECT_NUMBER in from
project(), so that stays the one place a version is written.
CI now builds against the submodule it pins instead of also installing
libakerror@main and never linking it, adds -Werror, and gains a
sanitizer job. The pre-push hook matches, and runs the docs check too.
Coverage: 99.5% of lines (1643/1651), 100% of functions (147/147). The
eight uncovered lines are each uncovered on purpose and TODO.md says
which and why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
205
README.md
205
README.md
@@ -5,12 +5,64 @@
|
||||
`libakstdlib` wraps C standard library functions so that they report failures
|
||||
through [libakerror](https://source.starfort.tech/andrew/libakerror)'s
|
||||
`ATTEMPT { ... } HANDLE { ... }` error contexts instead of through return codes
|
||||
and `errno`. It also provides a few data structures built on the same
|
||||
convention (a doubly-linked list and a binary tree).
|
||||
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: what is covered by tests,
|
||||
which corner cases are still open, and which libc functions are not yet wrapped.
|
||||
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
|
||||
|
||||
@@ -27,27 +79,29 @@ package the parent provides instead.
|
||||
|
||||
### This library's own version
|
||||
|
||||
`libakstdlib` is at **0.1.0**. The version lives in exactly one place — the
|
||||
`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.1.0 | From |
|
||||
| Artifact | Value at 0.2.0 | From |
|
||||
| --- | --- | --- |
|
||||
| `AKSL_VERSION_MAJOR` / `_MINOR` / `_PATCH` | `0` / `1` / `0` | `include/akstdlib_version.h.in` |
|
||||
| `AKSL_VERSION_STRING` | `"0.1.0"` | same |
|
||||
| `AKSL_VERSION_NUMBER` | `100` | same |
|
||||
| `AKSL_VERSION_SONAME` | `"0.1"` | same |
|
||||
| shared library | `libakstdlib.so.0.1.0`, soname `libakstdlib.so.0.1` | `VERSION` / `SOVERSION` |
|
||||
| `pkg-config --modversion akstdlib` | `0.1.0` | `akstdlib.pc` |
|
||||
| `find_package(akstdlib 0.1)` | accepted; `0.2` and `1.0` refused | `akstdlibConfigVersion.cmake` |
|
||||
| `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. `TODO.md` §2.1 still records four confirmed defects
|
||||
whose fixes change documented behaviour, so the API is not being promised yet.
|
||||
While the major version is `0`, **the soname carries `MAJOR.MINOR`**: 0.1 and 0.2
|
||||
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
|
||||
@@ -75,18 +129,18 @@ 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.1.0 keeps working
|
||||
against 0.1.7, which is exactly the promise the shared soname makes.
|
||||
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.2.0 build in under the 0.1 filename, or a package that strips
|
||||
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.1.0 (soname 0.1)
|
||||
loaded : 0.2.0 (0.2.0)
|
||||
MISMATCH DETECTED: compiled against libakstdlib 0.1.0, loaded 0.2.0 (soname 0.2)
|
||||
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
|
||||
@@ -144,11 +198,22 @@ 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: `convert` (the `ato*` family), `format` (the
|
||||
`printf` family), `stream` (`fopen`/`fread`/`fwrite`/`fclose`), `path`
|
||||
(`aksl_realpath`), `strhash`, `memory`, `linkedlist`, `tree`, and
|
||||
`status_registry` (this library's side of the libakerror status-registry
|
||||
contract — see "The libakerror version floor" above).
|
||||
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`.
|
||||
@@ -162,11 +227,21 @@ of them invert the meaning of "Passed":
|
||||
| `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`. |
|
||||
|
||||
So `ctest` reporting all green does **not** mean the library is defect-free — it
|
||||
means the known-good tests passed and the known-bad ones are still failing in the
|
||||
documented way. When a defect is fixed, its test starts passing, CTest reports it
|
||||
as failed with *unexpectedly passed*, and that is the cue to move it from
|
||||
`AKSL_KNOWN_FAILING_TESTS` into `AKSL_TESTS`.
|
||||
**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
|
||||
@@ -181,10 +256,19 @@ 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`. Several of the open items in `TODO.md` §2 only
|
||||
misbehave under instrumentation — the uninitialised `%s` in `aksl_realpath`, the
|
||||
unbounded `vsprintf` behind `aksl_sprintf`, the missing `va_end` in the `printf`
|
||||
family — so new tests for those should be run this way.
|
||||
`-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
|
||||
|
||||
@@ -246,25 +330,42 @@ cmake -S . -B build-coverage -DAKSL_COVERAGE=ON \
|
||||
-DAKSL_COVERAGE_THRESHOLD=90 -DAKSL_COVERAGE_BRANCH_THRESHOLD=40
|
||||
```
|
||||
|
||||
**Where it stands.** `src/stdlib.c` is at **99.1% of lines (217/219)**, **45.1% of
|
||||
branches (519/1151)** and **25/25 functions**, so 90/40 above is a ratchet with
|
||||
headroom rather than a target. The two uncovered lines are both
|
||||
`} HANDLE(e, AKERR_ITERATOR_BREAK) {` — 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 error context whose status is *zero*. That is the
|
||||
pathological case §2.2.1 of `TODO.md` exists to remove, so it is left uncovered
|
||||
deliberately rather than pinned by a test.
|
||||
**Where it stands.** All four sources, with the whole suite:
|
||||
|
||||
Branch coverage sits far below line coverage because most branches in this file
|
||||
| file | lines | branches | functions |
|
||||
|---|---|---|---|
|
||||
| `src/collections.c` | 99.3% (601/605) | 43.5% | 100% (43/43) |
|
||||
| `src/stdlib.c` | 99.3% (557/561) | 46.5% | 100% (50/50) |
|
||||
| `src/stream.c` | 100% (274/274) | 43.4% | 100% (31/31) |
|
||||
| `src/string.c` | 100% (211/211) | 53.8% | 100% (23/23) |
|
||||
| **total** | **99.5% (1643/1651)** | **46.0%** | **100% (147/147)** |
|
||||
|
||||
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. The libakerror 1.0.0 bump made
|
||||
that gap wider without changing a line of this library: the branch denominator
|
||||
went from 661 to 1087 as those macros grew, so the same tests that scored 51.0%
|
||||
(337/661) dropped to 44.3% (481/1087). The gate moved 45 → 40 to match; line and
|
||||
function coverage did not move at all. Adding the `aksl_version_*` family and its
|
||||
tests brought the figure back to 45.1% (519/1151), but the gate stays at 40 —
|
||||
0.1 points of headroom is not a ratchet.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user