Report the failures libakstdlib's wrappers were already catching

Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather
than new code, so the interesting part is what libakgl was not yet
calling: it links libakstdlib and uses ten of its wrappers while calling
the raw libc function at a good many more sites. Four of those were
carrying a real defect.

akgl_game_save checked every write and threw away the close. Every write
goes through aksl_fwrite, and for a record this size all of them land in
stdio's buffer -- nothing reaches the device until the close flushes it,
so a full disk, an exceeded quota or an NFS server going away is
reported only there. The function returned success over a savegame that
was never written. aksl_fclose surfaces it. tests/game.c reaches the
path through /dev/full, which accepts writes and fails at the flush;
against the unfixed code the test reports "expected a failure, got
success" with ENOSPC.

The close has to drop the FILE * before the status is examined, because
fclose(3) disassociates the stream either way and CLEANUP would close it
again. Written the other way round it aborted in glibc with "double free
detected in tcache" the first time a close actually failed, which is how
that ordering was found.

akgl_game_load's end-of-file check used fgetc(3), which spells "end of
file" and "the read failed" with the same EOF -- a disk error there read
as a clean end and passed the check it was meant to fail. aksl_fgetc
tells them apart. Extracted to require_at_eof, because inverting the
sense of a call inline needs a nested ATTEMPT whose break binds to the
wrong switch.

Two snprintf sites were truncating under a silenced
-Wformat-truncation. The compiler was right about both. The tileset one
handed the shortened path to IMG_LoadTexture, so a length error was
reported as a missing file, with SDL naming a path the caller never
wrote. Both use aksl_snprintf now and the suppression is gone; nothing
in the tree uses those macros any more. tests/tilemap.c covers the join,
and against the unfixed code it gets AKGL_ERR_SDL where it wants
AKERR_OUTOFBOUNDS.

The two src/game.c strncpy sites the fixed-width copy sweep missed --
the savegame name field and the libversion stamp -- use aksl_strncpy and
sizeof rather than a repeated 32.

Also makes tests/physics_sim.c deterministic. It placed gravity_time at
"now - dt" and let the real clock supply the step, so a machine busy
enough to deschedule the process between that store and the
SDL_GetTicksNS() inside simulate got a longer step. It went red once,
under a parallel ctest. It now sets max_timestep to the step it wants
and gravity_time to zero, so the bound supplies the step and the
scheduler cannot reach it.

TODO.md records what is deliberately not adopted: the pure-arithmetic
wrappers, which can only fail on NULL and which the tree already spells
two ways, and the collections, which the registries do not want because
SDL owns the property-set lifetime. AGENTS.md has the rule and the
fclose ordering trap.

26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
This commit is contained in:
2026-08-01 12:36:36 -04:00
parent 147ab7dc78
commit 1b90f2ebd5
12 changed files with 347 additions and 51 deletions

View File

@@ -464,6 +464,47 @@ before it.
`-Wstringop-truncation` catches the first form at `-O2`. Nothing catches the
`memcpy` form; that one was found by reading its neighbours.
## Reaching For libakstdlib
`deps/libakstdlib` wraps most of libc so a failure arrives as an
`akerr_ErrorContext *` with context instead of a return value the caller has to
remember to check. Use the wrapper **wherever the underlying call can actually
fail**, which in practice means anything touching a file, a format string, or a
fixed-width destination:
| Instead of | Use | Because |
|---|---|---|
| `fclose` | `aksl_fclose` | The flush happens here, so a full disk or an exceeded quota is reported *only* here |
| `fgetc` | `aksl_fgetc` | `fgetc(3)` returns `EOF` for both end-of-file and a read error; this raises `AKERR_EOF` and `AKERR_IO` separately |
| `snprintf` | `aksl_snprintf` | Truncation becomes `AKERR_OUTOFBOUNDS` naming both lengths, instead of a silently short string |
| `strncpy` into a fixed field | `aksl_strncpy` | See **Copying Into Fixed-Width Fields** |
| `fopen`/`fread`/`fwrite`/`realpath`/`atof`/`atoi` | the `aksl_` form | Already the convention in this tree |
**`fclose(3)` disassociates the stream whether or not it succeeds.** Drop the
`FILE *` before you look at the status, or `CLEANUP` closes it a second time:
```c
tmpfp = fp;
fp = NULL;
CATCH(errctx, aksl_fclose(tmpfp));
```
Written the other way round this aborts in glibc with `double free detected in
tcache`, and only on the path where a close actually fails -- which is to say,
never during development. `/dev/full` is how `tests/game.c` reaches it: writes
to that device are accepted and the `ENOSPC` surfaces at the flush.
**Do not convert the pure-arithmetic calls.** `memset`, `memcpy`, `strlen`,
`strcmp` and friends can only fail on a NULL argument, and the tree is
inconsistent about them already; see `TODO.md`, "libakstdlib wrappers not yet
adopted", before starting a sweep.
**Never silence `-Wformat-truncation`.** `include/akgl/error.h` still exports
`DISABLE_GCC_WARNING_FORMAT_TRUNCATION` and nothing uses it. Both sites that
did were genuinely truncating, and both reported the length problem as
something else -- one as a missing texture file, with SDL naming a path the
caller never wrote. `aksl_snprintf` is the answer.
## Fixing Defects
**Read what the code does before deciding what it should do.** Every rule below