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) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 14:23:47 -04:00
parent f35443e84d
commit c6227545a6
2 changed files with 84 additions and 3 deletions

View File

@@ -99,6 +99,7 @@ akerr_ErrorContext *akgl_path_relative(char *root, char *path, akgl_String *dst)
PREPARE_ERROR(e); PREPARE_ERROR(e);
akgl_String *strbuf; akgl_String *strbuf;
char *result; char *result;
bool relative_to_root = false;
FAIL_ZERO_RETURN(e, root, AKERR_NULLPOINTER, "NULL argument"); FAIL_ZERO_RETURN(e, root, AKERR_NULLPOINTER, "NULL argument");
FAIL_ZERO_RETURN(e, path, 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)); IGNORE(akgl_heap_release_string(strbuf));
} PROCESS(e) { } PROCESS(e) {
} HANDLE(e, ENOENT) { } HANDLE(e, ENOENT) {
// Path is not relative to our current working directory // Path is not relative to our current working directory. Resolve it
// Noop - execution proceeds after the break // against root instead -- but after FINISH, not from in here. Returning
return akgl_path_relative_root(root, path, dst); // 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); } FINISH(e, true);
if ( relative_to_root == true ) {
PASS(e, akgl_path_relative_root(root, path, dst));
}
SUCCEED_RETURN(e); SUCCEED_RETURN(e);
} }

View File

@@ -1,8 +1,30 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <akerror.h> #include <akerror.h>
#include <akgl/error.h> #include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/staticstring.h>
#include <akgl/util.h> #include <akgl/util.h>
/**
* @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) akerr_ErrorContext *test_akgl_rectangle_points_nullpointers(void)
{ {
RectanglePoints points; 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) int main(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -316,6 +385,7 @@ int main(void)
CATCH(errctx, test_akgl_collide_point_rectangle_nullpointers()); CATCH(errctx, test_akgl_collide_point_rectangle_nullpointers());
CATCH(errctx, test_akgl_collide_rectangles_nullpointers()); CATCH(errctx, test_akgl_collide_rectangles_nullpointers());
CATCH(errctx, test_akgl_collide_rectangles_logic()); CATCH(errctx, test_akgl_collide_rectangles_logic());
CATCH(errctx, test_akgl_path_relative_releases_contexts());
} CLEANUP { } CLEANUP {
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH_NORETURN(errctx); } FINISH_NORETURN(errctx);