From c6227545a687ba8ec71a7331f5a825edea8321e7 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Fri, 31 Jul 2026 14:23:47 -0400 Subject: [PATCH] Release the error context when a path resolves against its root akgl_path_relative took its ENOENT branch by returning from inside the HANDLE block, which skips the RELEASE_ERROR that FINISH ends with. The handled context was never given back, so one entry of AKERR_ARRAY_ERROR was lost per call and the 129th call hit "Unable to pull an error context from the array!" and exited the process. That branch is not an edge case: every asset path inside a tilemap is resolved relative to the map's own directory, so it is taken several times per map load. A game that loaded fifty levels died in the loader. It was found by a benchmark that loaded the fixture map in a loop, which is the first thing in this tree to call it more than a hundred times. The fallback now runs after FINISH, so the context is released on the way past. tests/util.c resolves AKERR_MAX_ARRAY_ERROR * 2 paths through the branch and asserts the pool is where it started -- against the old code that test does not fail, it terminates the suite. Co-Authored-By: Claude Opus 5 (1M context) --- src/util.c | 17 ++++++++++--- tests/util.c | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/util.c b/src/util.c index 2e3ef2a..863c646 100644 --- a/src/util.c +++ b/src/util.c @@ -99,6 +99,7 @@ akerr_ErrorContext *akgl_path_relative(char *root, char *path, akgl_String *dst) PREPARE_ERROR(e); akgl_String *strbuf; char *result; + bool relative_to_root = false; FAIL_ZERO_RETURN(e, root, AKERR_NULLPOINTER, "NULL argument"); FAIL_ZERO_RETURN(e, path, AKERR_NULLPOINTER, "NULL argument"); @@ -115,10 +116,20 @@ akerr_ErrorContext *akgl_path_relative(char *root, char *path, akgl_String *dst) IGNORE(akgl_heap_release_string(strbuf)); } PROCESS(e) { } HANDLE(e, ENOENT) { - // Path is not relative to our current working directory - // Noop - execution proceeds after the break - return akgl_path_relative_root(root, path, dst); + // Path is not relative to our current working directory. Resolve it + // against root instead -- but after FINISH, not from in here. Returning + // from inside a HANDLE block skips the RELEASE_ERROR that FINISH ends + // with, so the handled context is never given back to + // AKERR_ARRAY_ERROR. That leaks one context per call, and the 129th + // call takes the whole process down with "Unable to pull an error + // context from the array!". Every map load resolves several paths this + // way. + relative_to_root = true; } FINISH(e, true); + + if ( relative_to_root == true ) { + PASS(e, akgl_path_relative_root(root, path, dst)); + } SUCCEED_RETURN(e); } diff --git a/tests/util.c b/tests/util.c index ed931cf..ecf74e8 100644 --- a/tests/util.c +++ b/tests/util.c @@ -1,8 +1,30 @@ #include #include #include +#include +#include #include +/** + * @brief How many entries of AKERR_ARRAY_ERROR are currently held by somebody. + * + * The error contexts are a fixed pool exactly like the object pools: a context + * is in use while its reference count is non-zero, and a function that finishes + * without releasing one has leaked a slot out of AKERR_MAX_ARRAY_ERROR. + */ +static int live_error_contexts(void) +{ + int live = 0; + int i = 0; + + for ( i = 0; i < AKERR_MAX_ARRAY_ERROR; i++ ) { + if ( AKERR_ARRAY_ERROR[i].refcount != 0 ) { + live += 1; + } + } + return live; +} + akerr_ErrorContext *test_akgl_rectangle_points_nullpointers(void) { RectanglePoints points; @@ -306,6 +328,53 @@ akerr_ErrorContext *test_akgl_collide_rectangles_logic(void) } +/** + * @brief Resolving a path through the root fallback must give its context back. + * + * akgl_path_relative tries the working directory first and falls back to + * resolving against @p root when that reports ENOENT. That fallback used to be + * taken by returning from inside the HANDLE block, which skips the + * RELEASE_ERROR that FINISH ends with -- so every call down that branch leaked + * one entry of AKERR_ARRAY_ERROR, and the 129th call aborted the whole process + * with "Unable to pull an error context from the array!". A single map load + * resolves several paths this way. + * + * The loop runs well past AKERR_MAX_ARRAY_ERROR on purpose: at the old + * behaviour this test does not fail, it terminates the suite. + */ +akerr_ErrorContext *test_akgl_path_relative_releases_contexts(void) +{ + PREPARE_ERROR(errctx); + akgl_String *dst = NULL; + int before = 0; + int after = 0; + int i = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_heap_next_string(&dst)); + before = live_error_contexts(); + + for ( i = 0; i < (AKERR_MAX_ARRAY_ERROR * 2); i++ ) { + PASS(errctx, akgl_path_relative("assets", "testcharacter.json", dst)); + } + after = live_error_contexts(); + + ATTEMPT { + if ( after != before ) { + FAIL_BREAK( + errctx, + AKGL_ERR_BEHAVIOR, + "akgl_path_relative leaked %d error context(s) over %d root-fallback resolutions", + (after - before), + (AKERR_MAX_ARRAY_ERROR * 2)); + } + } CLEANUP { + IGNORE(akgl_heap_release_string(dst)); + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + int main(void) { PREPARE_ERROR(errctx); @@ -316,6 +385,7 @@ int main(void) CATCH(errctx, test_akgl_collide_point_rectangle_nullpointers()); CATCH(errctx, test_akgl_collide_rectangles_nullpointers()); CATCH(errctx, test_akgl_collide_rectangles_logic()); + CATCH(errctx, test_akgl_path_relative_releases_contexts()); } CLEANUP { } PROCESS(errctx) { } FINISH_NORETURN(errctx);