Give libccd a static arena so libakgl still allocates nothing

libakgl does not call malloc at runtime. That is stated in seven places in the
manual and is AGENTS.md's second standing rule, and every object comes from a
fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its
expanding polytope out of realloc and free, and ccdGJKPenetration documents a
-2 return for when that fails.

The alternative was to compile only the three files MPR needs and leave EPA out
of the build. That works and it puts a copy of somebody else's source list in
this repository, where it rots silently on the next submodule bump. This instead
compiles all of libccd and points its allocator at a bump allocator over static
BSS, which keeps the promise literally true -- and keeps EPA available rather
than amputated, for the day a precise contact manifold is worth having.

The arena is reset at the top of each query rather than freed block by block, so
the lifetime is one narrowphase call, `free` is a no-op, and allocation is a
pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding
to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud
failure with a number in it rather than a silently missed collision, which would
be a floor an actor falls through reported as success.

One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The
64 KB ceiling is that with about nine times headroom, and the high-water mark is
reported so the next reader can re-derive it rather than trust this line.

The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in
CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a
mistake worth recording: CMake cannot carry a function-like macro through a -D,
so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling
the C library's realloc while the shim's `free` quietly discarded the results --
strictly worse than doing nothing, and invisible, because it leaks rather than
crashes.

What caught it was insisting the test prove the wiring rather than the outcome.
The first version asserted the arena balanced back to zero after a query, which
turned out to be the wrong assertion for a different reason -- free is a no-op
by design, so it cannot balance -- but a test that had merely checked "two boxes
collide" would have passed throughout, against an allocator nothing was using.
The suite now asserts what is actually provable: that a query allocates from the
arena at all, and that the process survives, since glibc aborts when the real
free(3) is handed a pointer it never issued.

Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts
the band and the names agree, and it caught the missing one immediately.
-fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of
libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a
shipped defect where an exported `renderer` was preempted by a same-named symbol
elsewhere, and a game linking a system libccd would hit exactly that.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:29:05 -04:00
parent 6bb241cfb9
commit 46232a5ad1
9 changed files with 848 additions and 2 deletions

278
tests/collision_arena.c Normal file
View File

@@ -0,0 +1,278 @@
/**
* @file collision_arena.c
* @brief The bump allocator libccd allocates from, and the proof it is wired up.
*
* Two things have to be true and they are proved differently.
*
* **libccd allocates from the arena and not from the C library.** Proved by
* `akgl_ccd_arena_used()` being non-zero after a query that allocated. Before
* the redirect was moved out of CMake's COMPILE_DEFINITIONS -- which cannot
* carry a function-like macro and dropped it without a word -- this read zero
* while libccd happily called the real `realloc`, so it is not a formality.
*
* **No `free()` inside libccd reaches the C library.** Proved by the process
* still being alive. Handing an arena pointer to the real `free(3)` is
* undefined behaviour, and glibc aborts on it rather than tolerating it, so a
* suite that runs to completion after a query that allocated has demonstrated
* the interception. There is no balance to check: `akgl_ccd_arena_free` is a
* no-op by design, because the whole arena is released at once.
*/
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/collision_arena.h>
#include <akgl/error.h>
#include "testutil.h"
akerr_ErrorContext *test_arena_bump_and_reset(void)
{
void *a = NULL;
void *b = NULL;
size_t used = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
akgl_ccd_arena_reset();
TEST_ASSERT(errctx, (akgl_ccd_arena_used() == 0), "a reset arena is not empty");
a = akgl_ccd_arena_realloc(NULL, 64);
TEST_ASSERT(errctx, (a != NULL), "a 64 byte allocation from an empty arena failed");
used = akgl_ccd_arena_used();
TEST_ASSERT(errctx, (used >= 64), "64 bytes were handed out but only %zu accounted for", used);
b = akgl_ccd_arena_realloc(NULL, 64);
TEST_ASSERT(errctx, (b != NULL), "a second allocation failed");
TEST_ASSERT(errctx, (b != a), "two allocations returned the same block");
TEST_ASSERT(errctx, (akgl_ccd_arena_used() > used), "a second allocation did not advance the arena");
// Every block is aligned, because libccd allocates structures of doubles
// and pointers and this allocator does not know which.
TEST_ASSERT(errctx, ((((uintptr_t)a) % 16) == 0), "block a is not 16 byte aligned");
TEST_ASSERT(errctx, ((((uintptr_t)b) % 16) == 0), "block b is not 16 byte aligned");
// Freeing is a no-op by design: the whole arena goes at once.
akgl_ccd_arena_free(a);
akgl_ccd_arena_free(NULL);
TEST_ASSERT(errctx, (akgl_ccd_arena_used() > 0), "free() released a block it should have ignored");
akgl_ccd_arena_reset();
TEST_ASSERT(errctx, (akgl_ccd_arena_used() == 0), "reset did not empty the arena");
} CLEANUP {
akgl_ccd_arena_reset();
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_arena_realloc_preserves_contents(void)
{
unsigned char *block = NULL;
unsigned char *grown = NULL;
int i = 0;
bool intact = true;
PREPARE_ERROR(errctx);
ATTEMPT {
akgl_ccd_arena_reset();
block = (unsigned char *)akgl_ccd_arena_realloc(NULL, 32);
TEST_ASSERT(errctx, (block != NULL), "the initial allocation failed");
for ( i = 0; i < 32; i++ ) {
block[i] = (unsigned char)(i + 1);
}
grown = (unsigned char *)akgl_ccd_arena_realloc(block, 128);
TEST_ASSERT(errctx, (grown != NULL), "growing a block failed");
for ( i = 0; i < 32; i++ ) {
if ( grown[i] != (unsigned char)(i + 1) ) {
intact = false;
}
}
TEST_ASSERT(errctx, (intact == true), "growing a block did not carry its contents across");
// Shrinking copies only what fits, and must not read past the source.
grown = (unsigned char *)akgl_ccd_arena_realloc(grown, 16);
TEST_ASSERT(errctx, (grown != NULL), "shrinking a block failed");
TEST_ASSERT(errctx, (grown[0] == 1), "shrinking a block lost its contents");
// A zero-size request is not an allocation.
TEST_ASSERT(errctx, (akgl_ccd_arena_realloc(NULL, 0) == NULL),
"a zero byte allocation returned a block");
} CLEANUP {
akgl_ccd_arena_reset();
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_arena_exhaustion_refuses(void)
{
void *block = NULL;
uint32_t before = 0;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
akgl_ccd_arena_reset();
before = akgl_ccd_arena_exhausted();
// A single request larger than the whole arena is refused rather than
// satisfied by running off the end of the static array.
TEST_ASSERT(errctx, (akgl_ccd_arena_realloc(NULL, AKGL_CCD_ARENA_BYTES * 2) == NULL),
"an allocation larger than the arena was satisfied");
TEST_ASSERT(errctx, (akgl_ccd_arena_exhausted() > before),
"a refused allocation was not counted");
// And filling it a block at a time ends in a refusal, not a corruption.
akgl_ccd_arena_reset();
for ( i = 0; i < ((AKGL_CCD_ARENA_BYTES / 1024) + 8); i++ ) {
block = akgl_ccd_arena_realloc(NULL, 1024);
if ( block == NULL ) {
break;
}
}
TEST_ASSERT(errctx, (block == NULL), "the arena never refused, so it never filled");
TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= AKGL_CCD_ARENA_BYTES),
"the arena handed out %zu bytes of a %d byte pool",
akgl_ccd_arena_used(), AKGL_CCD_ARENA_BYTES);
} CLEANUP {
akgl_ccd_arena_reset();
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief A real libccd query, and the balance that proves the shim is complete.
*
* `akgl_collision_arena_selftest` runs `ccdGJKPenetration`, which is the EPA
* path and therefore the allocating one. If any of libccd's four bare `free()`
* calls had escaped `src/ccd_arena_shim.h` it would have been handed an arena
* pointer, and glibc would have aborted rather than returned -- so reaching the
* assertion at all is half the proof. The other half is the balance: the arena
* must be empty afterwards, because libccd frees its polytope before returning
* and every one of those frees is a no-op that leaves `arena_used` alone only
* if it reached ours.
*/
akerr_ErrorContext *test_arena_drives_a_real_query(void)
{
bool hit = false;
float32_t depth = 0.0f;
size_t highwater = 0;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
akgl_ccd_arena_reset();
// Half-extent 1 each, centres 1 apart: overlapping by 1 on x.
CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth));
TEST_ASSERT(errctx, (hit == true), "two overlapping boxes were reported as not colliding");
TEST_ASSERT_FEQ(errctx, depth, 1.0f, "overlapping boxes penetrate by %f, expected 1", depth);
highwater = akgl_ccd_arena_highwater();
TEST_ASSERT(errctx, (highwater > 0),
"a GJK/EPA query allocated nothing, so it did not reach the arena");
/*
* Printed because it is the number AKGL_CCD_ARENA_BYTES is sized from,
* and because it is only meaningful before the exhaustion case
* deliberately fills the arena -- the high-water mark is never reset.
*/
printf("one GJK/EPA box pair: high-water %zu bytes of %d\n",
highwater, AKGL_CCD_ARENA_BYTES);
/*
* The arena is what libccd allocated from. If the __CCD_ALLOC_MEMORY
* redirect were missing -- as it was, silently, while it lived in
* CMake's COMPILE_DEFINITIONS -- this would be zero and libccd would be
* calling the C library instead.
*
* That the process is still running is the other half: every free()
* inside libccd was handed an arena pointer, and glibc aborts when the
* real free(3) is given one it did not issue.
*/
TEST_ASSERT(errctx, (akgl_ccd_arena_used() > 0),
"a query allocated nothing from the arena, so libccd is not using it");
// Boxes well apart do not collide, and still leave the arena balanced.
CATCH(errctx, akgl_collision_arena_selftest(8.0f, &hit, &depth));
TEST_ASSERT(errctx, (hit == false), "two separated boxes were reported as colliding");
// Exactly touching. Degenerate configurations are where a narrowphase
// divides by zero, and the libccd revision pinned here (v2.1) is the one
// that fixed exactly that in ccdMPRPenetration.
CATCH(errctx, akgl_collision_arena_selftest(2.0f, &hit, &depth));
/*
* Each query resets the arena on entry, so a thousand of them in a row
* cost what one costs. Without that, a no-op free and a bump allocator
* would fill 64 KB in nine queries.
*/
for ( i = 0; i < 64; i++ ) {
CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth));
}
TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= highwater),
"64 queries used %zu bytes where one used %zu; the arena is not being reset",
akgl_ccd_arena_used(), highwater);
/*
* Starve a real query and require it to report. This is the path that
* matters and the one a unit test of the allocator cannot reach: libccd
* has to notice the NULL, unwind its half-built polytope, and return -2,
* and the caller has to turn that into AKGL_ERR_COLLISION rather than
* quietly reporting "no collision" -- which would be a floor an actor
* falls through, reported as success.
*/
akgl_ccd_arena_set_limit(32);
TEST_EXPECT_STATUS(errctx, AKGL_ERR_COLLISION,
akgl_collision_arena_selftest(1.0f, &hit, &depth),
"a query against a starved arena");
akgl_ccd_arena_set_limit(0);
// And the full arena still works afterwards, so the limit is a knob and
// not a one-way door.
CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth));
TEST_ASSERT(errctx, (hit == true), "the query failed after the arena was restored");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_collision_arena_selftest(1.0f, NULL, &depth),
"the selftest with a NULL hit flag");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_collision_arena_selftest(1.0f, &hit, NULL),
"the selftest with a NULL depth");
} CLEANUP {
akgl_ccd_arena_set_limit(0);
akgl_ccd_arena_reset();
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, test_arena_bump_and_reset());
CATCH(errctx, test_arena_realloc_preserves_contents());
/*
* The real query runs before the exhaustion case, so the high-water mark
* it prints is what a narrowphase query actually costs rather than what
* a test deliberately filling the arena costs.
*/
CATCH(errctx, test_arena_drives_a_real_query());
CATCH(errctx, test_arena_exhaustion_refuses());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}

View File

@@ -32,7 +32,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void)
{ AKGL_ERR_REGISTRY, "Registry Error" },
{ AKGL_ERR_HEAP, "Heap Error" },
{ AKGL_ERR_BEHAVIOR, "Behavior Error" },
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" }
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" },
{ AKGL_ERR_COLLISION, "Collision Error" }
};
bool named = true;
int i = 0;