Take libakerror 2.0.1 and drop the workaround it makes obsolete
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 19s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 17s

Every libakgl test suite could report success while failing. libakerror's
default unhandled-error handler ended in exit(errctx->status), an exit
status is one byte wide, and libakgl's band starts at 256 -- so
AKGL_ERR_SDL, the most common failure a library built on SDL can have,
exited 0 and CTest recorded a pass. tests/character.c aborted at its
second of four tests on a bad renderer and was green for months.

0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in
tests/testutil.h, and TODO.md ended the entry saying any consumer's
suites have the same problem and it was worth raising upstream. It was.
2.0.1 fixes it at the source: akerr_exit() owns the mapping and the
default handler calls it, so 0 exits 0, 1 through 255 exit themselves,
and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The
trap and its 21 call sites are gone.

Verified by putting the original failure back rather than by reading the
release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main
exits 125 and CTest reports a failure. A standalone consumer raising the
same status unhandled exits 125 where it exited 0 before.

tests/actor.c installs its own handler and called exit(errctx->status)
from it, which is the same defect one layer up. It calls akerr_exit()
now.

2.0.0 also makes the error pool and the status registry thread safe,
which libakgl needs more than it knew: audio_stream_callback raises
error contexts on SDL's audio thread. With an unlocked pool that
callback and the main thread could scan AKERR_ARRAY_ERROR at the same
time and be handed the same slot. The comment there says so.

This is a hard dependency floor, not a preference. 2.0.0 moved
__akerr_last_ignored to thread-local storage and made akerr_next_error()
return a context that already holds its reference, and both expand at
libakgl's call sites -- and at a consumer's, because akerror.h is part
of libakgl's public interface. Mixing headers and libraries across that
line double-counts every reference and never returns a pool slot. The
soname moved to libakerror.so.2; include/akgl/error.h now also feature-
tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe
for 2.0.1 since libakerror publishes no version macro.

0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it
re-exports through its headers is not.

TODO.md records the pkg-config gap this makes sharper -- akgl.pc names
no dependencies at all, so nothing tells a pkg-config consumer which
libakerror it needs.

Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall
-Werror. libakgl.so.0.7 links libakerror.so.2.

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 13:05:43 -04:00
parent 1b90f2ebd5
commit e4aa6a5084
28 changed files with 92 additions and 85 deletions

View File

@@ -592,13 +592,17 @@ if ( defaulted != NULL ) {
}
```
**Every suite's `main()` must call `TEST_TRAP_UNHANDLED_ERRORS()` immediately
after `akgl_error_init()`.** libakerror's default unhandled-error handler ends
in `exit(errctx->status)`, `exit` keeps only the low byte, and libakgl's status
band starts at 256 — so a suite that failed with `AKGL_ERR_SDL` exited 0 and
CTest recorded a pass. That is not hypothetical: `tests/character.c` aborted at
its second of four tests on a bad renderer and was green until 0.5.0. The trap
in `tests/testutil.h` collapses any status a byte cannot carry onto 1. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward.
**Never `exit(3)` on an akerr status — call `akerr_exit()`.** An exit status is
one byte wide and libakgl's status band starts at 256, so `exit(AKGL_ERR_SDL)`
is a wait status of 0 and a shell sees a clean run. Every suite in this
directory once reported success on the most common failure a library built on
SDL can have: `tests/character.c` aborted at its second of four tests on a bad
renderer and was green until 0.5.0. `akerr_exit()` (libakerror 2.0.1) maps 0 to
0, 1255 to themselves, and anything else to
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). The default unhandled-error handler
calls it, so an ordinary suite needs no trap of its own — `tests/testutil.h`
carried one until 0.6.0 and it is gone. A suite that installs its *own* handler
still has to call `akerr_exit()` from it; `tests/actor.c` does. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward.
### Physics simulations

View File

@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
# The single source of truth for the library version. It drives the generated
# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
# Version field in akgl.pc. Bump it here and nowhere else.
project(akgl VERSION 0.6.0 LANGUAGES C)
project(akgl VERSION 0.7.0 LANGUAGES C)
# Memory checking reuses the suites that already exist -- `ctest -T memcheck`
# runs every registered test under valgrind -- rather than adding programs of its
@@ -135,8 +135,17 @@ akgl_add_vendored_dependency(SDL3_image::SDL3_image deps/SDL_image)
akgl_add_vendored_dependency(SDL3_mixer::SDL3_mixer deps/SDL_mixer)
akgl_add_vendored_dependency(SDL3_ttf::SDL3_ttf deps/SDL_ttf)
# libakerror 1.0.0 sizes its own status-name registry; consumers no longer do.
# libakgl claims its status codes at runtime in akgl_heap_init() instead.
# libakerror sizes its own status-name registry; consumers no longer do. libakgl
# claims its status codes at runtime in akgl_heap_init() instead.
#
# The floor is 2.0.1, and it is a hard one. 2.0.0 moved __akerr_last_ignored to
# thread-local storage and made akerr_next_error() return a context that already
# holds its reference -- both of which expand at *libakgl's* call sites through
# IGNORE and the FAIL macros, and at a *consumer's* too, because akerror.h is
# part of libakgl's public interface. A tree that mixes headers and libraries
# across that line double-counts every reference and never returns a pool slot.
# The soname moved to libakerror.so.2 to stop it happening by accident; the
# #error in include/akgl/error.h catches a stale header in an install tree.
set(AKGL_SUPPRESS_DEPENDENCY_TESTS FALSE)
@@ -165,7 +174,8 @@ endif()
# No version here: libakerror ships no akerrorConfigVersion.cmake, so asking
# for one makes find_package reject every install. The floor is enforced by
# the #error in include/akgl/error.h instead, which feature-tests
# AKERR_FIRST_CONSUMER_STATUS.
# AKERR_FIRST_CONSUMER_STATUS and AKERR_EXIT_STATUS_UNREPRESENTABLE -- the
# latter arriving in 2.0.1, which is the version libakgl needs.
if(NOT TARGET akerror::akerror)
find_package(akerror REQUIRED)
endif()

51
TODO.md
View File

@@ -412,20 +412,25 @@ the symbol collision described in item 4 — and exited 0 because that status wa
enough that `TODO.md` recorded the suite as passing and wondered which change
had fixed it. Nothing had; it had stopped running.
**Fixed** in `tests/testutil.h`: `TEST_TRAP_UNHANDLED_ERRORS()` installs a
handler that collapses any status a byte cannot carry onto 1, and every suite's
`main()` calls it immediately after `akgl_error_init()`. `tests/error.c` calls
it after its first test instead, because that suite has no `akgl_error_init()`
in `main()` and libakerror's lazy `akerr_init()` would overwrite the handler.
**Fixed twice.** 0.5.0 worked around it here, with a
`TEST_TRAP_UNHANDLED_ERRORS()` in `tests/testutil.h` that installed a handler
collapsing any status a byte cannot carry onto 1. Once installed, no other suite
changed colour, so this was not masking anything beyond `character` — but it
could have been at any time, and nothing would have said so.
Once installed, no other suite changed colour, so this was not masking anything
beyond `character` — but it could have been at any time, and nothing would have
said so.
That entry ended "any consumer's test suites have this problem; that is worth
raising upstream", and it was. **libakerror 2.0.1 fixes it at the source**:
`akerr_exit()` owns the status-to-exit-code mapping and the default handler
calls it, so 0 exits 0, 1 through 255 exit themselves, and anything else exits
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125) rather than a low byte that is either
a lie or a claim of success. There is no wider `exit()` to reach for —
`_exit()`, `_Exit()`, `quick_exit()` and the raw `exit_group` syscall all
truncate the same way, and even `waitid()` reports the truncated value.
Worth knowing: the fix belongs here rather than in libakerror only because
`exit(status)` is a reasonable thing for a library to do when statuses fit in a
byte. libakerror's own band does. Consumers' bands start at 256 by construction,
so any consumer's test suites have this problem. That is worth raising upstream.
The workaround is gone as of 0.6.0, along with its 21 call sites. Verified by
putting the original failure back: a `FAIL_BREAK(AKGL_ERR_SDL)` in
`tests/character.c`'s `main` now exits 125 and CTest records a failure, where it
exited 0 and passed before.
## Coverage status
@@ -1884,6 +1889,28 @@ currently promises the opposite. `aksl_strncpy` already reports exactly that
status when the bytes do not fit, so the change is to stop capping `n` at
`size - 1` and let it raise.
## akgl.pc names no dependencies
`akgl.pc.in` carries `Libs: -L${libdir} -lakgl` and no `Requires:` or
`Requires.private:` line, so a consumer that finds libakgl through pkg-config is
told nothing about libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer,
SDL3_ttf or jansson.
That has always been wrong, and libakerror 2.x makes it sharper: `akerror.h` is
part of libakgl's *public* interface -- `IGNORE` and the `FAIL_*` macros expand
in the consumer's own translation units and reference `__akerr_last_ignored`,
which is thread-local as of 2.0.0. A consumer that builds against whatever
`akerror.h` happens to be on its include path and links whatever `libakerror.so`
the loader finds can get a mismatch that pkg-config had every opportunity to
prevent and did not mention.
The fix is a `Requires:` line for the libraries whose headers libakgl's headers
include (akerror, akstdlib, SDL3) and `Requires.private:` for the rest, plus a
version floor of `akerror >= 2.0.1`. Not done here because it changes what
`pkg-config --libs akgl` emits for every existing consumer, and that wants to be
its own commit with an install-tree test behind it. The CMake path is not
affected -- `akglConfig.cmake` re-finds the targets.
## libakstdlib wrappers not yet adopted
`libakgl` links `libakstdlib` and uses ten of its wrappers. It calls the raw

View File

@@ -24,7 +24,20 @@
* See deps/libakerror/UPGRADING.md.
*/
#ifndef AKERR_FIRST_CONSUMER_STATUS
#error "libakgl requires libakerror >= 1.0.0: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror."
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror."
#endif
// 2.0.0 is an ABI break -- akerr_next_error() returns a context that already
// holds its reference, and __akerr_last_ignored is thread-local -- and both of
// those expand at *this* library's call sites through IGNORE and the FAIL
// macros. A libakgl built against a 1.x header and linked against 2.x
// double-counts every reference and never returns a slot to the pool. The
// soname moved to libakerror.so.2 so the two cannot be mixed by accident, but
// the header can still be stale in an install tree, so check it here too.
//
// AKERR_EXIT_STATUS_UNREPRESENTABLE arrived in 2.0.1 and is the narrowest thing
// to probe for: libakerror publishes no version macro.
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
#endif
// Silences -Wformat-truncation on a string concatenation that genuinely may not

View File

@@ -273,6 +273,14 @@ static void SDLCALL audio_stream_callback(void *userdata, SDL_AudioStream *strea
if ( errctx != NULL ) {
// There is nobody to return an error to on the audio thread, and
// refusing to write leaves SDL underrunning. Report and go quiet.
//
// Raising an error here at all is only safe from libakerror 2.0.0
// on. Before that the pool was unlocked, so this callback and the
// main thread could scan AKERR_ARRAY_ERROR at the same time and be
// handed the same slot -- two threads writing one context, and one
// of them releasing it out from under the other. 2.0.0 makes
// finding a free slot and claiming it one operation under a lock.
// libakgl requires it; see the guard in include/akgl/error.h.
LOG_ERROR_WITH_MESSAGE(errctx, "** AUDIO CALLBACK **");
errctx->handled = true;
errctx = akerr_release_error(errctx);

View File

@@ -30,7 +30,9 @@ void handle_unhandled_error_noexit(akerr_ErrorContext *errctx)
return;
}
if ( UNHANDLED_ERROR_BEHAVIOR == UNHANDLED_ERROR_EXIT ) {
exit(errctx->status);
// akerr_exit rather than exit(3): a status past 255 does not survive a
// wait status, and this suite's are AKGL_* codes, which all are.
akerr_exit(errctx->status);
}
if ( UNHANDLED_ERROR_BEHAVIOR == UNHANDLED_ERROR_SET ) {
unhandled_error_context = errctx;
@@ -1037,7 +1039,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_registry_init_actor());
CATCH(errctx, akgl_registry_init_sprite());
CATCH(errctx, akgl_registry_init_spritesheet());

View File

@@ -602,7 +602,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_AUDIO),

View File

@@ -286,7 +286,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");

View File

@@ -858,7 +858,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD),

View File

@@ -638,7 +638,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_BREAK(

View File

@@ -95,12 +95,9 @@ int main(void)
ATTEMPT {
// Unlike every other suite, this one has no akgl_error_init() in
// main() -- the first test is what brings the subsystem up. The trap
// therefore goes after it: libakerror installs its own default handler
// from the lazy akerr_init() inside that first call, and would
// overwrite anything set beforehand.
// main() -- the first test is what brings the subsystem up, and
// asserting that it does is the point of it.
CATCH(errctx, test_error_init_owns_the_status_band());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_error_init_is_idempotent());
} CLEANUP {
} PROCESS(errctx) {

View File

@@ -775,7 +775,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());

View File

@@ -362,7 +362,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());

View File

@@ -528,7 +528,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, load_fixture());

View File

@@ -901,7 +901,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());

View File

@@ -752,7 +752,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
akgl_physics = &akgl_default_physics;
akgl_camera = &akgl_default_camera;

View File

@@ -784,7 +784,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());

View File

@@ -616,7 +616,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
// SDL_GetTicksNS() counts from SDL's own initialization, and
// akgl_physics_simulate measures dt against it. Without this the clock
// epoch would be established by the first call inside the first

View File

@@ -288,7 +288,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_akgl_registry_init_creation_failures());
CATCH(errctx, test_akgl_get_property_copies_only_the_value());
CATCH(errctx, test_actor_state_string_names());

View File

@@ -250,7 +250,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
memset(&bound, 0x00, sizeof(akgl_RenderBackend));
FAIL_ZERO_BREAK(

View File

@@ -242,7 +242,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");

View File

@@ -241,7 +241,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
printf("test_fresh_heap_gives_string ....\n");
test_fresh_heap_gives_strings();
reset_string_heap();

View File

@@ -43,40 +43,6 @@ static inline int test_string_pool_used(void)
return used;
}
/**
* @brief Exit with a status the shell can actually see.
*
* libakerror's default unhandled-error handler ends in `exit(errctx->status)`.
* `exit` keeps only the low byte of what it is given, and libakgl's own
* statuses start at `AKERR_FIRST_CONSUMER_STATUS`, which is 256 -- so
* #AKGL_ERR_SDL exits 0, and CTest records a pass. Every suite in this
* directory that failed for the most common reason in this library therefore
* reported success. `tests/character.c` did exactly that: it aborted at its
* second of four tests on a bad renderer and was green for months.
*
* This collapses any status that a byte cannot carry onto 1. It does not log --
* `FINISH_NORETURN` has already logged the context and its stack by the time it
* calls the handler.
*/
static inline void test_handler_unhandled_error(akerr_ErrorContext *errctx)
{
int status = ( errctx == NULL ) ? 1 : (errctx->status & 0xFF);
if ( status == 0 ) {
status = 1;
}
exit(status);
}
/**
* @brief Install test_handler_unhandled_error(). Call once, after akgl_error_init().
*
* akgl_error_init() reaches akerr_init(), which installs libakerror's default
* handler, so this has to come after it rather than before.
*/
#define TEST_TRAP_UNHANDLED_ERRORS() \
(akerr_handler_unhandled_error = &test_handler_unhandled_error)
/** @brief Fail the enclosing ATTEMPT block unless @p cond holds. */
#define TEST_ASSERT(e, cond, ...) \
if ( ! (cond) ) { \

View File

@@ -392,7 +392,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
memset(&testbackend, 0x00, sizeof(akgl_RenderBackend));
FAIL_ZERO_BREAK(
errctx,

View File

@@ -742,7 +742,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_gamemap = &akgl_default_gamemap;
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");

View File

@@ -457,7 +457,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_akgl_rectangle_points_nullpointers());
CATCH(errctx, test_akgl_rectangle_points_math());
CATCH(errctx, test_akgl_collide_point_rectangle_nullpointers());

View File

@@ -99,7 +99,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_version_string_matches_components());
CATCH(errctx, test_version_linked_matches_compiled());
CATCH(errctx, test_version_at_least_boundaries());