/** * @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 #include #include #include #include #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; akerr_ErrorContext *inner = NULL; 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. * * The loop body cannot use CATCH: it reports by `break`ing, which would * leave the loop rather than the ATTEMPT block and turn a failing query * into a silently short run. Collect the failure and hand it over after. */ for ( i = 0; i < 64; i++ ) { inner = akgl_collision_arena_selftest(1.0f, &hit, &depth); if ( inner != NULL ) { break; } } CATCH(errctx, inner); 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; }