Report the failures that used to be crashes

Closes Defects items 30 and 31 and Known-and-still-open items 1, 2, 5, 9 and 11.

Both string accessors in json_helpers.c ended their ATTEMPT block with
FINISH(errctx, false), which swallows the failure, and then strncpy'd through
the pointer akgl_heap_next_string never set. So the one condition the pool
exists to report -- it is full, which in practice means something is not
releasing -- arrived as a segfault somewhere else entirely. It is
FINISH(errctx, true) now, and tests/json_helpers.c claims every slot and
asserts AKGL_ERR_HEAP comes back out of both. That test segfaults against the
old code, which is also how the tilemap leak test in the previous commit
confirmed this one.

akgl_tilemap_release tested layers[i].texture and destroyed
tilesets[i].texture, so every tileset texture was freed twice on one release
and no image layer's texture was freed at all. Pointers are cleared as they go,
so a second release is safe instead of a use-after-free.

akgl_game_update_fps called game.lowfpsfunc() unguarded, on a path taken on
frame one because fps is 0 for the first second. Only akgl_game_init installs
it, and renderer.h documents the other path deliberately -- a host that owns
its window binds a backend instead. It installs the default when it finds NULL.

akgl_controller_pushmap and akgl_controller_default checked only the upper
bound, so a negative id indexed before akgl_controlmaps.

The two test-harness helpers were quietly worthless. akgl_render_and_compare
drew t1 on both passes, so it always reported a match and every image assertion
built on it asserted nothing; and akgl_compare_sdl_surfaces memcmp'd
s1->pitch * s1->h bytes out of both surfaces without checking that the second
was the same size, so a smaller one was read past its end. Both fixed, both
tested. tests/util.c also now calls the collide-point test it has defined and
never run.

25/25 pass, memcheck clean, reindent --check and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:32:11 -04:00
parent 230278d303
commit 21d69192e3
12 changed files with 309 additions and 15 deletions

View File

@@ -328,6 +328,46 @@ akerr_ErrorContext *test_game_state_lock(void)
SUCCEED_RETURN(e);
}
/**
* @brief akgl_game_update_fps must not call a lowfpsfunc nobody installed.
*
* `game.fps` is 0 for the first second of the process, which is under the
* threshold, so this fires on frame one. Only akgl_game_init installs the
* default -- and renderer.h documents the other path deliberately: a host that
* owns its own window calls akgl_render_2d_bind instead. Such an embedder
* crashed here on its first frame, through a NULL function pointer.
*/
akerr_ErrorContext *test_game_updateFPS_without_a_lowfps_handler(void)
{
PREPARE_ERROR(e);
ATTEMPT {
set_game_identity();
// Exactly the state a host that never called akgl_game_init is in.
akgl_game.lowfpsfunc = NULL;
akgl_game.fps = 0;
akgl_game.framesSinceUpdate = 0;
akgl_game.lastFPSTime = SDL_GetTicksNS();
akgl_game_update_fps();
TEST_ASSERT(e, akgl_game.lowfpsfunc != NULL,
"akgl_game_update_fps left lowfpsfunc NULL");
TEST_ASSERT(e, akgl_game.lowfpsfunc == &akgl_game_lowfps,
"akgl_game_update_fps installed something other than the default");
// And it keeps working on the frames after.
akgl_game_update_fps();
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 2,
"frames counted as %d over two updates, expected 2",
akgl_game.framesSinceUpdate);
} CLEANUP {
akgl_game.lowfpsfunc = &akgl_game_lowfps;
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/** @brief Cleared while the helper thread should keep holding the state mutex. */
static SDL_AtomicInt lockholder_release;
@@ -494,6 +534,7 @@ int main(void)
CATCH(errctx, test_game_state_lock());
CATCH(errctx, test_game_state_lock_budget());
CATCH(errctx, test_game_updateFPS());
CATCH(errctx, test_game_updateFPS_without_a_lowfps_handler());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);