Files
libakstdlib/TODO.md
Andrew Kesterson 669b2b395f
All checks were successful
libakstdlib CI Build / cmake_build (push) Successful in 2m55s
libakstdlib CI Build / sanitizers (push) Successful in 2m52s
libakstdlib CI Build / coverage (push) Successful in 2m43s
libakstdlib CI Build / mutation_test (push) Successful in 12m30s
Record that there is no directory-reading wrapper
opendir/readdir/closedir have no aksl_* counterpart, so a consumer that
wants to list a directory has to call them itself and report through errno
in a file where everything else reports through an akerr_ErrorContext *.

Found from akbasic, which needs it for Commodore BASIC's DIRECTORY verb and
refuses the verb rather than working around the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:07 -04:00

378 lines
18 KiB
Markdown

# TODO
Working notes for `libakstdlib`. **Outstanding items only** — anything fixed comes
out of this file and goes into `UPGRADING.md`, the tests, or a comment beside the
code, whichever is the right place to be reminded of it.
Ordered by blast radius: what blocks other work first, what is merely wrong
second, what is missing last.
## Where the library stands
| | |
|---|---|
| Wrapped | 154 public functions across `src/stdlib.c`, `src/string.c`, `src/stream.c`, `src/collections.c` |
| Tests | 17 CTest binaries plus 2 negative-compile entries, green under the default and sanitizer builds |
| Line coverage | 99.5% (1708/1716) |
| Function coverage | 100% (154/154) |
| Doxygen | 100% of 154, gated — `cmake --build build --target docs` fails on an undocumented function, parameter or return |
| Mutation score | 72.3% (188/260 sampled from 1701), gated at 65 |
The six confirmed defects that used to head this file are fixed and
`AKSL_KNOWN_FAILING_TESTS` is empty. What they were, and what changed as a
result, is in `UPGRADING.md`.
---
## 1. Blocked on libakerror
These are not fixable from inside this repository. Each one currently costs
something here, and the cost is what makes them worth carrying.
### 1.1 The error pool is a process-global array with no locking
**This is what makes the library single-threaded**, and it is the largest open
item by some distance.
`AKERR_ARRAY_ERROR` is a fixed array in `deps/libakerror/src/error.c`, handed out
by `akerr_next_error()` with no synchronisation of any kind. Every entry point in
this library takes a slot from it on any failure path, so two threads raising
errors concurrently can be handed the same slot and will corrupt each other's
message, status and stack trace.
**Consequence.** `README.md` says plainly that the library is not thread-safe.
That is honest, and it is also a hard ceiling: §4's `pthread_*` and socket
wrappers cannot be written until this is resolved, because a threading API
nobody can call from a thread is not an API.
**What closing it would touch.** libakerror's pool — either a mutex around
`akerr_next_error`/`akerr_release_error`, or thread-local slot arrays, which
would suit the bounded-preallocation style better and cost nothing on the
single-threaded path. Then a TSan job in `.gitea/workflows/ci.yaml` and a
concurrent smoke test here, and the warning in `README.md` comes out.
### 1.2 `IGNORE()` logs a context and never releases it
`deps/libakerror/include/akerror.tmpl.h:308`. The macro assigns the context to
`__akerr_last_ignored`, logs it, and stops. The slot is never returned to the
pool, so every `IGNORE()` on a failing call leaks one — and after
`AKERR_MAX_ARRAY_ERROR` of them the pool is exhausted and `ENSURE_ERROR_READY`
calls `exit(1)`.
**Consequence here.** `aksl_tree_iterate`'s `CLEANUP` block (`src/stdlib.c`)
cannot use `IGNORE()` to drop a queue-drain failure and open-codes the
log-then-release by hand instead, with a comment saying why. It is four lines
that should be one.
**What closing it would touch.** One `RELEASE_ERROR` in the macro; then delete
the workaround here.
### 1.3 libakerror does not namespace its `coverage` target when embedded
`deps/libakerror/CMakeLists.txt:172` versus `:189` — it namespaces `mutation` and
not `coverage`.
**Consequence here.** A `-DAKSL_COVERAGE=ON` top-level build fails to configure
at all: *"another target with the same name already exists"*. `CMakeLists.txt`
shadows `add_custom_target` for the duration of the `add_subdirectory()` call and
renames the dependency's to `akerror_coverage`.
**What closing it would touch.** Apply the same
`CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR` test upstream that the
`mutation` target already has; then delete the shadow here, which sits directly
above the `add_test` shadow and shares its comment.
### 1.4 libakerror installs no `akerrorConfigVersion.cmake`
**Consequence here.** `cmake/akstdlib.cmake.in` has to call
`find_dependency(akerror)` with no version, because a request for one would be
refused for want of a version file no matter what is installed. The 1.0.0 floor
therefore rests on `akstdlib.pc`'s `Requires:` and the `#error` guard in
`akstdlib.h`, neither of which covers a `find_package` consumer.
**What closing it would touch.** One `write_basic_package_version_file()` call
upstream — libakstdlib already does this correctly and can be copied — then add
the `1.0.0` floor to the `find_dependency` here.
---
## 2. Known gaps in what is already wrapped
### 2.1 A short transfer with neither EOF nor a stream error is untested
`src/stdlib.c`, the `FAIL_RETURN(e, AKERR_IO, "short read: ...")` in `aksl_fread`
and its counterpart in `aksl_fwrite`. Two of the eight uncovered lines in the
whole library.
The standard permits a short transfer with neither indicator set, so the branch
is correct to have. Every way of actually producing one on Linux sets `feof` or
`ferror` first, so nothing in the suite reaches it.
**Consequence.** Low: the code is a handful of lines and reviewed, but it is the
only error path in the library that has never executed.
**What closing it would touch.** A `FILE *` over a custom stream — `fopencookie`
on glibc, `funopen` on the BSDs — whose read function returns a short count
without setting either flag. That is a platform-specific test helper in
`tests/aksl_capture.h` guarded on the platform, which is why it has not been
written yet rather than an oversight.
### 2.2 The string-buffer overflow guard is untestable
`src/collections.c`, the `capacity = needed` arm in `strbuf_reserve`. Reaching it
needs an `aksl_StrBuf` within a factor of two of `SIZE_MAX`, which is not a test,
it is a hang. The other two uncovered lines.
**Consequence.** None known. It is there because doubling a capacity is a
multiplication, and an unguarded one is how a growable buffer turns into a heap
overflow.
**What closing it would touch.** Nothing worth doing. Recorded so the coverage
listing does not read as an oversight.
### 2.3 `aksl_version_check()` ignores its `patch` argument
`src/stdlib.c`, the `(void)patch`. Correct for the current "same soname" rule —
patch level never breaks the ABI — but the parameter exists only so the error
message can name the caller's full version.
**Consequence.** None today. If a future compatibility rule needs the patch level
to participate, that is the line to change, and the `#if` in
`tests/test_version.c` is the test that encodes the rule.
### 2.4 Surviving mutants worth turning into assertions
The mutation harness samples 260 of 1701 mutants and kills 72.3% of them. Most of
the 72 survivors are equivalent mutants rather than missing tests — `README.md`
has the full breakdown — but three clusters are real work:
- **`FINISH(e, true)``FINISH(e, false)`, 3 survivors.** An error swallowed
instead of propagated out of an `ATTEMPT` block, and nothing notices. Each one
is a call whose failure path is exercised but whose *propagation* is not: the
test asserts the status the callee raised without checking it came from the
callee rather than being re-raised locally. `tests/test_pool.c`'s origin
assertions are the shape of the fix.
- **`SUCCEED_RETURN` deleted, 7 survivors.** The function falls off the end and
returns whatever is in the return register, which is NULL often enough to pass.
These need an assertion on the *side effect* — the buffer that was filled, the
node that was linked — rather than on the returned status.
- **`FAIL_*` guards deleted, ~6 of the 14 in that group.** Each is an argument
check nothing drives. The other 8 in the group are constant shifts that no test
can catch, because the test names the same constant symbolically and moves with
it.
Two survivors in this class were real and are fixed: the right child's
`depth + 1` in the depth-first walk, and `aksl_tree_remove` on an empty tree.
**What closing them would touch.** Only `tests/`. Raise the `--threshold` in
`.gitea/workflows/ci.yaml` and `.githooks/pre-push` in step, as a ratchet.
### 2.5 Four uncovered `HANDLE(e, AKERR_ITERATOR_BREAK)` lines
Macro artifacts rather than gaps. In libakerror that macro begins with the
`break;` belonging to `PROCESS`'s `case 0:` arm, reachable only when a callback
returns a non-NULL context whose status is *zero* — which is the pathological case
the errno-fallback work removed. Left uncovered deliberately rather than pinned
by a test that would have to manufacture it.
---
## 3. Deliberate omissions
Recorded so nobody adds them thinking they were forgotten. Each is a decision,
and each can be revisited with an argument.
| Not wrapped | Why |
|---|---|
| `sprintf`, `vsprintf` | Cannot be bounded. An error-handling wrapper around an unbounded write is the sharp edge this library exists to remove. `aksl_snprintf` and `aksl_asprintf` cover both real uses. |
| `strtok` | Keeps its state in a hidden static, so two interleaved tokenisations corrupt each other silently and any use from a thread is a bug. `aksl_strtok_r` and `aksl_strsep` cover it. |
| `strcpy`, `strcat` as libc spells them | Cannot be called safely without the destination's size. The wrappers take it. |
| `setbuf` | Exactly `setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ)` and strictly less expressive. Wrapping it would add a second way to say one thing. |
| `perror` | Writes to stderr and consults a global. `aksl_strerror` is the akerror-native equivalent and knows this library's own statuses as well as errno's. |
| `strerror_r` | Two incompatible functions share that name and which one you get depends on feature-test macros a consumer cannot influence from inside this header. `aksl_strerror` is built on libakerror's registry instead. |
---
## 4. Not yet wrapped
Ordered by how much a caller of this library would miss them. §3.1 and §3.6 of
the old numbering are done; what follows is what is left.
### 4.1 POSIX file and process API
The most likely next surface. Nothing here is blocked; it is simply not written.
**`unistd.h` / `fcntl.h`**
- [ ] `open`, `close`, `read`, `write`, `pread`, `pwrite`, `lseek`
- [ ] `readv`, `writev`
- [ ] `dup`, `dup2`, `pipe`, `fcntl`
- [ ] `fsync`, `fdatasync`, `truncate`, `ftruncate`
- [ ] `unlink`, `link`, `symlink`, `readlink`, `rmdir`, `mkdir`
- [ ] `access`, `faccessat`, `chmod`, `fchmod`, `chown`, `fchown`, `umask`
- [ ] `chdir`, `fchdir`, `getcwd`
- [ ] `isatty`, `ttyname_r`
- [ ] `sysconf`, `pathconf`
- [ ] `sleep`, `usleep`, `nanosleep`
The short-read/short-write contract is the interesting part: `read(2)` returning
fewer bytes than asked for is *normal* on a pipe or a socket and a failure on a
regular file, so the wrapper needs the same transferred-count out-param
`aksl_fread` has, and callers need to be told which case they are in.
**`sys/stat.h`**
- [ ] `stat`, `fstat`, `lstat`, `fstatat`
- [ ] `statvfs`, `fstatvfs`
**`dirent.h`**
- [ ] `opendir`, `fdopendir`, `readdir`, `closedir`, `rewinddir`, `scandir`
`readdir(3)` returning NULL for both "end of directory" and "error, check errno"
is the same conflation `aksl_fgetc` and `aksl_fgets` already untangle, and should
be untangled the same way: AKERR_EOF for the end, the errno for the error.
**Process control**
- [ ] `fork`, the `exec*` family, `waitpid`, `wait`
- [ ] `posix_spawn`
- [ ] `system`, `popen`, `pclose`
- [ ] `getpid`, `getppid`, `getuid`, `geteuid`, `setuid`, `setgid`
- [ ] `atexit`, `exit`, `_exit`, `abort` — mostly to give akerror a shutdown hook
- [ ] `getenv`, `setenv`, `unsetenv`, `putenv`, `clearenv`
`fork` needs thinking about before it is wrapped: the error pool is inherited by
the child, and any context live at the moment of the fork exists twice
afterwards.
**`sys/mman.h`**
- [ ] `mmap`, `munmap`, `mprotect`, `msync`, `madvise`
### 4.2 Time
- [ ] `time`, `clock_gettime`, `clock_getres`, `gettimeofday`
- [ ] `localtime_r`, `gmtime_r`, `mktime`, `timegm`, `difftime`
- [ ] `strftime`, `strptime`
- [ ] `clock`, `times`
`strftime(3)` returns 0 for both "the output was empty" and "it did not fit",
which is the bounded-write ambiguity `aksl_snprintf` already resolves.
### 4.3 Sorting, searching and the rest of `stdlib.h`
- [ ] `qsort`, `qsort_r`, `bsearch` — a comparator that fails currently has
nowhere to put the error. An akerror-aware comparator signature, shaped
like the `aksl_TreeCompareFunc` the tree functions already take, would be a
genuine improvement over libc rather than a wrapper around it.
- [ ] `abs`, `labs`, `llabs`, `div`, `ldiv`, `lldiv` — note that
`abs(INT_MIN)` is undefined behaviour, which is exactly the kind of silent
trap worth surfacing.
- [ ] `rand`, `srand`, `random`, `srandom`, `getrandom`/`arc4random`
### 4.4 Larger surfaces
- [ ] **Sockets**: `socket`, `bind`, `listen`, `accept`, `connect`, the
`send`/`recv` families, `shutdown`, `setsockopt`/`getsockopt`,
`getaddrinfo`/`freeaddrinfo`/`gai_strerror`, `inet_ntop`/`inet_pton`.
`getaddrinfo` is the interesting one: it has its own error space, neither
errno nor akerror, which needs mapping into a registered status range.
- [ ] **Multiplexing**: `select`, `poll`, `ppoll`, `epoll_*`
- [ ] **Signals**: `sigaction`, `sigprocmask`, `sigemptyset`/`sigaddset`, `kill`,
`raise`, `signalfd`. A handler cannot raise an akerror context — the pool is
not async-signal-safe — so the wrapper covers installation and masking only,
and that limit should be documented rather than discovered.
- [ ] **Threads**: `pthread_create`/`join`/`detach`, `pthread_mutex_*`,
`pthread_cond_*`, `pthread_rwlock_*`, `sem_*`. **Blocked on §1.1.**
- [ ] **Dynamic loading**: `dlopen`, `dlsym`, `dlclose`, `dlerror`. `dlerror(3)`
is the same "returns a string and clears itself" trap as `strerror`.
- [ ] **Locale / wide chars**: `setlocale`, `mbstowcs`, `wcstombs`, `iconv_*`
- [ ] **Math**: the `math.h` functions that set `errno` or raise FP exceptions
(`sqrt`, `log`, `pow`, `acos`, …). Probably a dedicated `libakmath` rather
than more surface here — the failure model is `fetestexcept`, not errno, and
it does not resemble anything else in this library.
### 4.5 Data structures
The list and tree API is complete against what §3.6 asked for. What a second
consumer might want next:
- [ ] A dynamic array / vector, on the same "caller owns the storage" terms as
`aksl_HashMap`.
- [ ] A hash map keyed on something other than a string — the current one copies
keys into fixed slots, which is right for identifiers and wrong for
anything large or binary.
- [ ] Balanced insertion for the tree. It is a plain unbalanced BST and says so;
sorted input gives a degenerate chain that `AKSL_TREE_MAX_DEPTH` then
refuses. Bounded rather than dangerous, but a red-black or AVL variant is
the honest fix if anyone inserts sorted data in earnest.
---
## 5. Evidence from the first full consumer
`akbasic` (`source.starfort.tech/andrew/akbasic`) is a ~6,300-line C interpreter
built on this library and `libakerror`. It was the first consumer to exercise the
whole surface rather than a corner of it, and what it *could not* use is what
prioritised the work above.
**The number that started it.** Across `src/`, akbasic made **10 calls into this
library and 116 to raw libc** — a library whose value proposition is "turn silent
libc failures into error contexts", bypassed 92% of the time by the consumer most
committed to it.
| Raw libc it had to use | Count | Now available as |
|---|---|---|
| `strlen` | 37 | `aksl_strlen` |
| `snprintf` | 28 | `aksl_snprintf` |
| `strcmp` | 16 | `aksl_strcmp` |
| `memcpy` / `memset` | 16 | `aksl_memcpy` / `aksl_memset` |
| `strncpy` | 15 | `aksl_strncpy` |
| `strtoll` / `strtod` | 2 | `aksl_strtoll` / `aksl_strtod` |
| `fgets` | 2 | `aksl_fgets` |
| `strstr` | 1 | `aksl_strstr` |
**All four things the port had to write for itself now exist here.**
1. **A strict `strtoll`/`strtod` wrapper** (`akbasic/src/convert.c`, ~60 lines).
The `aksl_strto*` family is that, with the endptr/`errno`/range contract.
akbasic's own `TODO.md` §1.9 formally bans the `aksl_ato*` family because
routing four diagnosable errors through it would have turned them into wrong
answers — `VAL("garbage")` silently returning `0.0`. That ban can be lifted:
the `ato*` forms report failures now. **`src/convert.c` can be deleted.**
2. **A fixed-capacity string-keyed hash table** (`akbasic/src/symtab.c`, ~130
lines, needed three times over). `aksl_hashmap_*` is that table generalised,
with tombstones on delete, which the original did not have.
3. **The bounded-copy-with-truncation-as-error idiom, at ten sites.**
`aksl_strcpy` and `aksl_strncpy` are exactly that idiom.
4. **Uppercase folding for case-insensitive lookup, three times.**
`aksl_strcasecmp` and `aksl_strncasecmp`.
**And the four confirmed-with-impact items are closed.** §2.2.4's unbounded
`aksl_sprintf` is gone; §2.2.2's unchecked `aksl_fopen` arguments are checked, so
`akbasic_cmd_dload`'s hand-rolled validation and its comment pointing here can go;
§2.2.6's sign-extended djb2 reads bytes unsigned; and §2.1.4's missing `va_end`
which akbasic's stdio text sink ran on every line of program output — is fixed.
**Still true, and still shaping the wishlist.** akbasic uses no allocator, no
lists and no trees, drawing everything from fixed pools by design. A consumer that
does allocate would weight §4.1's `open`/`read`/`write` far higher than this one
does. The next thing worth doing is porting akbasic onto this release and
counting the calls again.
## Requested by consumers
1. **No directory-reading wrapper.** There is no `aksl_opendir` / `aksl_readdir` /
`aksl_closedir`, so a consumer that wants to list a directory has to call
`opendir(3)` itself and step outside the error convention every other call in
its file follows — reporting through `errno` where everything around it
reports through an `akerr_ErrorContext *`.
`akbasic` hit this implementing Commodore BASIC's `DIRECTORY` verb, and
refuses the verb rather than working around it: `src/runtime_disk.c` reports
"DIRECTORY is not implemented: libakstdlib has no directory-reading wrapper
yet". The shape it wants is the one the `aksl_f*` family already has — a
handle out through a pointer parameter, `NULL` on success, `ENOENT` and
`EACCES` propagated as themselves.
A wrapper would want tests for: a directory that does not exist, one that
cannot be searched, an empty one, and one whose entries outlast a single
read — plus whatever the `d_type` portability story turns out to be, since
not every filesystem fills it in.