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

View File

@@ -200,6 +200,7 @@ set(AKGL_PUBLIC_HEADERS
assets
audio
character
collision_arena
controller
draw
error
@@ -255,11 +256,46 @@ add_custom_target(${AKGL_CONTROLLERDB_TARGET}
COMMENT "Fetching controller mappings and rewriting ${GAMECONTROLLERDB_H} ..."
)
# libccd supplies the collision narrowphase (GJK, EPA and MPR).
#
# Its sources are listed here rather than add_subdirectory()'d. Four things in
# deps/libccd/CMakeLists.txt make it unusable as a subproject, and none of them
# is ours to fix in a submodule:
#
# - project(libccd) declares no LANGUAGES, so it enables CXX and makes a C++
# toolchain a requirement of every build of libakgl. CI installs gcc.
# - it declares option(BUILD_SHARED_LIBS ... ON), a global cache variable, and
# calls include(CTest), which would register its own suites into ours and
# build the LGPL-licensed CU framework under src/testsuites/.
# - install(FILES "${CMAKE_BINARY_DIR}/ccd.pc") reads the *top level* binary
# dir while configure_file writes CMAKE_CURRENT_BINARY_DIR. Those are the
# same directory standalone and different ones embedded, so `cmake --install`
# fails on a missing file.
# - cmake_minimum_required(VERSION 2.8.11) warns on CMake 3.28 and is a hard
# error on CMake 4.
#
# Compiling the sources directly, the way deps/semver/semver.c already is, sidesteps
# all four and never configures the LGPL testsuites at all.
set(CCD_SINGLE 1)
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src/ccd/config.h.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/ccd/config.h)
set(AKGL_CCD_SOURCES
deps/libccd/src/ccd.c
deps/libccd/src/mpr.c
deps/libccd/src/polytope.c
deps/libccd/src/support.c
deps/libccd/src/vec3.c)
# Add include directories
include_directories(${SDL3_INCLUDE_DIRS})
add_library(akgl SHARED
deps/semver/semver.c
${AKGL_CCD_SOURCES}
src/actor.c
src/collision.c
src/collision_arena.c
src/actor_state_string_names.c
src/audio.c
src/text.c
@@ -304,6 +340,38 @@ target_compile_options(akgl PRIVATE ${AKGL_WARNING_FLAGS})
# akerror or akstdlib -- those are separate targets.)
set_source_files_properties(deps/semver/semver.c PROPERTIES COMPILE_OPTIONS "-w")
# libccd is vendored on the same terms as semver: -w so a future bump cannot fail
# this build on a warning we do not own.
#
# src/ccd_arena_shim.h is what keeps the "libakgl does not call malloc at
# runtime" promise true with the whole of libccd compiled in. Injected with
# -include, it redirects both halves of libccd's allocation -- the
# __CCD_ALLOC_MEMORY macro every CCD_ALLOC* expands to, and the four bare free()
# calls -- at the arena in src/collision_arena.c. It applies to these five files
# and nothing else.
#
# The redirect lives in that header rather than in COMPILE_DEFINITIONS because
# CMake cannot carry a function-like macro through a -D: it dropped it silently,
# and libccd went on calling the C library while free() became a no-op. Do not
# move it back.
#
# CCD_STATIC_DEFINE plus -fvisibility=hidden keeps every ccd* symbol out of
# libakgl.so's dynamic table. That is not tidiness: AGENTS.md records a shipped
# defect where an exported `renderer` in this library was preempted by a
# same-named symbol in a test executable, and a game linking both libakgl and a
# system libccd would hit exactly that.
set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES
COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h"
COMPILE_DEFINITIONS "CCD_STATIC_DEFINE")
# PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers`
# suite compiles each public header as the first include of a translation unit
# with only include/ available, so a public header that pulled in <ccd/ccd.h>
# would fail there immediately.
target_include_directories(akgl PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src"
"${CMAKE_CURRENT_BINARY_DIR}")
add_library(akgl::akgl ALIAS akgl)
add_executable(charviewer util/charviewer.c)
@@ -316,6 +384,7 @@ set(AKGL_TEST_SUITES
audio
bitmasks
character
collision_arena
controller
draw
error

View File

@@ -0,0 +1,160 @@
/**
* @file collision_arena.h
* @brief The bump allocator libccd allocates from, so that libakgl does not.
*
* libakgl does not call `malloc` at runtime. Every object comes from a fixed
* array declared in `akgl/heap.h`, and that is a promise the manual makes in
* seven places and `AGENTS.md` makes as a rule. libccd's narrowphase does not
* know that: its EPA path builds an expanding polytope out of `realloc` and
* `free`, and `ccdGJKPenetration` documents a `-2` return for the case where
* that fails.
*
* Rather than compile a hand-picked subset of libccd's sources -- which puts a
* file list in this repository that really lives upstream, and rots silently on
* the next bump -- the whole library is compiled and pointed at this arena.
* `CMakeLists.txt` redirects `__CCD_ALLOC_MEMORY`, and `src/ccd_arena_shim.h`
* redirects `free`, on libccd's translation units only.
*
* @section arena_lifetime The lifetime is one narrowphase call
*
* `akgl_collision_test` resets the arena on entry, so every allocation lives
* exactly as long as the query that made it. libccd frees its polytope before
* returning in any case, so nothing needs to survive, and that is what lets
* `akgl_ccd_arena_free` be a no-op and the allocator be a pointer bump. There
* is no free list, no coalescing and no fragmentation, because there is no
* reuse within a call.
*
* @section arena_exhaustion Exhaustion is reported, not fatal
*
* A refused allocation returns `NULL`, which libccd already handles -- it
* unwinds and returns `-2`, which `akgl_collision_test` turns into
* #AKGL_ERR_COLLISION naming the high-water mark. A too-small arena is a loud
* failure with a number in it, not a crash and not a silently missed collision.
*
* @warning **Not thread safe.** There is one arena and it has no lock. libakgl
* serialises its frame behind `akgl_game.statelock`; a caller running
* narrowphase queries from two threads needs a different design here,
* not a mutex bolted on.
*
* @note These are exported so the test suite can check that libccd really is
* allocating from here, and so a benchmark can record the high-water
* mark. A game has no reason to call them.
*/
#ifndef _AKGL_COLLISION_ARENA_H_
#define _AKGL_COLLISION_ARENA_H_
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <akerror.h>
#include <akgl/types.h>
/**
* @brief Bytes of static storage the arena hands out.
*
* Sized by measurement rather than derivation: the benchmark records the
* high-water mark and this is set above the worst observed case. It bounds
* memory the same way `ccd_t::max_iterations` bounds time, and both must be
* bounded -- libccd's own default for the latter is `(unsigned long)-1`.
*/
#ifndef AKGL_CCD_ARENA_BYTES
#define AKGL_CCD_ARENA_BYTES (64 * 1024)
#endif
/**
* @brief Allocate or grow a block, in the shape `realloc(3)` has.
*
* This is what `__CCD_ALLOC_MEMORY` expands to inside libccd. A `NULL` @p ptr
* allocates; a non-`NULL` one allocates a new block and copies the old contents
* across, because a bump allocator cannot grow in place unless the block is the
* most recent one, and it is not worth a special case for a path that runs at
* most a few times per query.
*
* @param ptr Block to grow, or `NULL` to allocate a new one.
* @param size Bytes wanted. A @p size of 0 releases nothing and returns `NULL`,
* matching what libccd does with the result.
* @return A pointer into the arena, or `NULL` when the arena is full.
*/
void *akgl_ccd_arena_realloc(void *ptr, size_t size);
/**
* @brief Release a block. A no-op, because the whole arena is released at once.
*
* @param ptr Block to release. `NULL` is accepted, as `free(3)` accepts it.
*/
void akgl_ccd_arena_free(void *ptr);
/** @brief Drop everything. Called at the top of each narrowphase query. */
void akgl_ccd_arena_reset(void);
/**
* @brief Bytes currently handed out.
*
* Zero after `akgl_ccd_arena_reset`, and non-zero after a narrowphase query
* that allocated -- releasing a block does not lower it, because
* akgl_ccd_arena_free is a no-op and the whole arena is released at once.
*
* Non-zero after a query is what proves libccd is allocating from here rather
* than from the C library, which is worth checking rather than assuming: the
* redirect lived in CMake's COMPILE_DEFINITIONS at first, where a function-like
* macro cannot survive, and it was dropped without a diagnostic.
*/
size_t akgl_ccd_arena_used(void);
/** @brief The most bytes ever handed out at once, since the process started. */
size_t akgl_ccd_arena_highwater(void);
/** @brief How many allocations have been refused for want of room. */
uint32_t akgl_ccd_arena_exhausted(void);
/*
* The following is part of the internal API. It is exposed so the test suite
* can reach it and is not meant to be called by a game.
*/
/**
* @brief Run one narrowphase query through libccd's allocating path.
*
* Two boxes of half-extent 1 are placed @p separation apart on x and tested
* with `ccdGJKPenetration` -- the EPA path, chosen because EPA is the half of
* libccd that allocates and therefore the half that exercises the arena. A
* narrowphase on the frame path prefers MPR, which allocates nothing.
*
* This exists so a test can reach two things nothing else can. That a real
* libccd query allocates from the arena rather than the C library, which
* akgl_ccd_arena_used() shows. And that no `free` inside libccd escapes to the
* C library, which is shown by the process surviving: the real `free(3)` given
* a pointer it never issued is undefined behaviour, and glibc aborts on it.
*
* @param separation Distance between the two box centres on x. Below 2.0 they
* overlap; above it they do not.
* @param hit Receives whether the boxes intersect. Required.
* @param depth Receives the penetration depth, or 0 when they do not
* intersect. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p hit or @p depth is `NULL`.
* @throws AKGL_ERR_COLLISION If the arena ran out of room. The message carries
* the high-water mark and the current ceiling.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_arena_selftest(float32_t separation, bool *hit, float32_t *depth);
/**
* @brief Narrow the arena, so a test can drive a real query into exhaustion.
*
* The interesting failure is not that akgl_ccd_arena_realloc returns `NULL` --
* that is one comparison. It is what happens afterwards: libccd has to notice,
* unwind its half-built polytope, and return `-2`, and the caller has to turn
* that into #AKGL_ERR_COLLISION rather than reporting a missed collision or
* dereferencing the `NULL`. Reaching that path any other way means rebuilding
* the library with a smaller #AKGL_CCD_ARENA_BYTES, which CI cannot do and a
* reader cannot repeat.
*
* @param limit Usable bytes. 0, or anything above #AKGL_CCD_ARENA_BYTES,
* restores the full arena.
*/
void akgl_ccd_arena_set_limit(size_t limit);
#endif

View File

@@ -75,11 +75,12 @@
#define AKGL_ERR_HEAP (AKGL_ERR_BASE + 2) /**< A heap pool has no free object left to hand out */
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
#define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */
// One past the last libakgl status. The reservation is all-or-nothing -- a
// subset or superset of an existing one is refused -- so this must stay one
// past the highest code above.
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 5)
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 6)
#define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE)
/**

62
src/ccd_arena_shim.h Normal file
View File

@@ -0,0 +1,62 @@
/**
* @file ccd_arena_shim.h
* @brief Points libccd's deallocation at libakgl's arena. Injected, not included.
*
* libccd allocates through one macro -- `__CCD_ALLOC_MEMORY` in
* `deps/libccd/src/alloc.h`, which every one of `CCD_ALLOC`, `CCD_ALLOC_ARR`
* and `CCD_REALLOC_ARR` expands to -- and that one is redirected on the command
* line, in `CMakeLists.txt`, where it is visible next to the sources it applies
* to.
*
* Deallocation has no such seam. libccd calls `free()` directly at four sites
* (`ccd.c:170`, `polytope.h:200`, `:222`, `:247`), so a macro is the only way to
* reach them without patching a submodule we do not own. This file is that
* macro, injected into libccd's translation units with `-include` and into
* nothing else.
*
* **`<stdlib.h>` is included first and that ordering is the whole point.**
* `-include` places this file ahead of the translation unit's own text, so
* defining `free` before pulling `<stdlib.h>` in would rewrite the C library's
* own declaration of it -- `void akgl_ccd_arena_free(void *)` where the header
* meant to declare `free`. Including it here first leaves that declaration
* intact, and `alloc.h`'s own `#include <stdlib.h>` later is a no-op through its
* include guard.
*
* Passing an arena pointer to the real `free(3)` is undefined behaviour, so a
* site this macro fails to reach is a heap corruption rather than a leak. What
* proves the interception is total is that `akgl_ccd_arena_used()` returns to 0
* after a narrowphase call, which `tests/collision.c` asserts.
*/
#ifndef _AKGL_CCD_ARENA_SHIM_H_
#define _AKGL_CCD_ARENA_SHIM_H_
#include <stdlib.h>
/*
* libccd's own allocation header, reached through the PRIVATE include path
* CMakeLists.txt puts deps/libccd/src on. Pulling it in *here* is what lets the
* redefinition below stick: it defines __CCD_ALLOC_MEMORY unconditionally, so a
* definition made before it would simply be overwritten, and the translation
* unit's own `#include "alloc.h"` later is a no-op through its include guard.
*
* This was originally a -D on the compile line. CMake's COMPILE_DEFINITIONS
* cannot carry a function-like macro -- the parentheses and comma do not
* survive -- and it dropped the definition without a diagnostic, so libccd went
* on calling the C library's realloc while the `free` below quietly discarded
* the result. That leaks rather than crashes, and no test of the *outcome* --
* do these two boxes collide -- would have noticed. What catches it is
* asserting the wiring: that a query allocates from the arena at all.
*/
#include <alloc.h>
#include <akgl/collision_arena.h>
#undef __CCD_ALLOC_MEMORY
#define __CCD_ALLOC_MEMORY(type, ptr_old, size) \
((type *)akgl_ccd_arena_realloc((void *)(ptr_old), (size)))
#undef free
#define free(p) akgl_ccd_arena_free(p)
#endif

132
src/collision.c Normal file
View File

@@ -0,0 +1,132 @@
/**
* @file collision.c
* @brief The collision narrowphase, and the only place libccd is visible.
*
* Nothing else in the tree includes `<ccd/ccd.h>`. The translation between
* libakgl's shapes and libccd's support-function protocol is `static` here, so
* a change of narrowphase library is a change to one file and no header.
*
* At present this holds only the arena's end-to-end check; the shapes, the
* contact and the resolver arrive with the rest of the collision work.
*/
#include <akerror.h>
#include <akgl/collision_arena.h>
#include <akgl/error.h>
#include <akgl/types.h>
#include <ccd/ccd.h>
#include <ccd/quat.h>
#include <ccd/vec3.h>
/**
* @brief Ceiling on narrowphase iterations.
*
* libccd's own default, set by `CCD_INIT`, is `(unsigned long)-1` -- an
* unbounded loop inside a frame. A pair that reaches this cap is reported as
* not colliding, which is the same answer a missed collision gives, but it
* terminates. Bounding the iterations also bounds what the arena can be asked
* for, since the polytope grows with them.
*/
#define AKGL_COLLISION_MAX_ITERATIONS 100
/** @brief An axis-aligned box, in the shape libccd's callbacks want. */
typedef struct {
ccd_vec3_t pos; /**< World centre. */
ccd_vec3_t half; /**< Half-extents on each axis. */
} collision_box;
/**
* @brief Furthest point of a box in a given direction.
*
* The support function *is* the shape as far as libccd is concerned: hand it a
* direction, get back the extreme point. For an axis-aligned box that is the
* corner in the direction's octant, which is three sign tests.
*
* There is no rotation here, and none anywhere in this library yet -- util.h
* says so and both example games depend on it. Adding it means rotating @p dir
* into the box's local frame at the top of this function and rotating the
* result back, and nothing else in the narrowphase changes. See TODO.md,
* "Actor rotation".
*/
static void collision_box_support(const void *obj, const ccd_vec3_t *dir, ccd_vec3_t *dest)
{
const collision_box *box = (const collision_box *)obj;
ccdVec3Set(dest,
ccdSign(ccdVec3X(dir)) * ccdVec3X(&box->half),
ccdSign(ccdVec3Y(dir)) * ccdVec3Y(&box->half),
ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&box->half));
ccdVec3Add(dest, &box->pos);
}
/**
* @brief Centre of a box.
*
* MPR requires this and GJK does not: `ccdMPRPenetration` calls `center1` and
* `center2` unconditionally, so leaving either `NULL` is a null dereference on
* the first contact rather than a degraded answer.
*/
static void collision_box_center(const void *obj, ccd_vec3_t *dest)
{
const collision_box *box = (const collision_box *)obj;
ccdVec3Copy(dest, &box->pos);
}
akerr_ErrorContext *akgl_collision_arena_selftest(float32_t separation, bool *hit, float32_t *depth)
{
collision_box a;
collision_box b;
ccd_t ccd;
ccd_real_t ccddepth = 0.0;
ccd_vec3_t dir;
ccd_vec3_t pos;
int result = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, hit, AKERR_NULLPOINTER, "NULL hit flag reference");
FAIL_ZERO_RETURN(errctx, depth, AKERR_NULLPOINTER, "NULL depth reference");
CCD_INIT(&ccd);
ccd.support1 = collision_box_support;
ccd.support2 = collision_box_support;
ccd.center1 = collision_box_center;
ccd.center2 = collision_box_center;
ccd.max_iterations = AKGL_COLLISION_MAX_ITERATIONS;
ccdVec3Set(&a.pos, 0.0, 0.0, 0.0);
ccdVec3Set(&a.half, 1.0, 1.0, 1.0);
ccdVec3Set(&b.pos, (ccd_real_t)separation, 0.0, 0.0);
ccdVec3Set(&b.half, 1.0, 1.0, 1.0);
/*
* The arena's lifetime is one query. Resetting on entry rather than on exit
* means a query that returns early still leaves the arena clean for the
* next one.
*/
akgl_ccd_arena_reset();
/*
* ccdGJKPenetration and not ccdMPRPenetration, deliberately. This is the
* EPA path, and EPA is the half of libccd that allocates -- so it is the
* half that proves the arena is wired up. A narrowphase on the frame path
* would prefer MPR, which allocates nothing at all.
*/
result = ccdGJKPenetration(&a, &b, &ccd, &ccddepth, &dir, &pos);
if ( result == -2 ) {
FAIL_RETURN(
errctx,
AKGL_ERR_COLLISION,
"Collision arena exhausted at %zu of %d bytes; raise AKGL_CCD_ARENA_BYTES",
akgl_ccd_arena_highwater(),
AKGL_CCD_ARENA_BYTES
);
}
*hit = (result == 0);
*depth = (float32_t)ccddepth;
SUCCEED_RETURN(errctx);
}

142
src/collision_arena.c Normal file
View File

@@ -0,0 +1,142 @@
/**
* @file collision_arena.c
* @brief The bump allocator libccd allocates from. See akgl/collision_arena.h.
*/
#include <string.h>
#include <akgl/collision_arena.h>
/**
* @brief Alignment every block starts on.
*
* `max_align_t` is what C11 offers and is 16 on x86-64. libccd allocates
* structures of doubles and pointers, so 16 covers everything it asks for
* without needing to know what it asked for.
*/
#define ARENA_ALIGN 16
/**
* @brief Bookkeeping stored immediately before each block.
*
* Only the size is kept, and only because `realloc` has to know how much to
* copy when it grows a block. It is padded to #ARENA_ALIGN so the payload that
* follows is aligned too.
*/
typedef struct {
size_t size; /**< Payload bytes, not counting this header. */
uint8_t pad[ARENA_ALIGN - sizeof(size_t)]; /**< Keeps the payload on an ARENA_ALIGN boundary. */
} arena_Header;
/** @brief The arena. Static storage, so this file allocates nothing, ever. */
static uint8_t arena_bytes[AKGL_CCD_ARENA_BYTES];
/** @brief Offset of the next free byte. */
static size_t arena_used;
/** @brief Largest arena_used ever reached. Diagnostic; never reset. */
static size_t arena_highwater;
/** @brief Allocations refused for want of room. Diagnostic; never reset. */
static uint32_t arena_exhausted;
/**
* @brief Usable bytes. #AKGL_CCD_ARENA_BYTES unless a test has narrowed it.
*
* Exists so the exhaustion path can be driven end to end through libccd. The
* mapping from a refused allocation to #AKGL_ERR_COLLISION runs through libccd's
* own unwinding and its `-2` return, and the only other way to reach it is to
* rebuild the library with a smaller ceiling -- which is not something CI can do
* and not something a reader can repeat.
*/
static size_t arena_limit = AKGL_CCD_ARENA_BYTES;
/** @brief Round @p n up to the next #ARENA_ALIGN boundary. */
static size_t arena_align(size_t n)
{
return ((n + (ARENA_ALIGN - 1)) & ~((size_t)(ARENA_ALIGN - 1)));
}
void *akgl_ccd_arena_realloc(void *ptr, size_t size)
{
arena_Header *header = NULL;
size_t need = 0;
void *out = NULL;
if ( size == 0 ) {
return NULL;
}
need = sizeof(arena_Header) + arena_align(size);
if ( (arena_used + need) > arena_limit ) {
/*
* Refuse rather than grow. libccd checks its allocations and unwinds to
* a -2 return, which akgl_collision_test reports as AKGL_ERR_COLLISION
* with the high-water mark in the message, so the operator finds out
* what to raise AKGL_CCD_ARENA_BYTES to.
*/
arena_exhausted += 1;
return NULL;
}
header = (arena_Header *)&arena_bytes[arena_used];
header->size = size;
out = (void *)(&arena_bytes[arena_used + sizeof(arena_Header)]);
arena_used += need;
if ( arena_used > arena_highwater ) {
arena_highwater = arena_used;
}
/*
* Growing means allocating and copying: a bump allocator can only extend
* the most recent block in place, and libccd's reallocs are a handful per
* query against an arena that is reset per query. The old block is left
* where it is and reclaimed by the reset with everything else.
*/
if ( ptr != NULL ) {
header = (arena_Header *)((uint8_t *)ptr - sizeof(arena_Header));
if ( header->size < size ) {
memcpy(out, ptr, header->size);
} else {
memcpy(out, ptr, size);
}
}
return out;
}
void akgl_ccd_arena_free(void *ptr)
{
/*
* Nothing to do. Every block dies with the arena at the top of the next
* query, which is what makes this allocator a pointer bump rather than a
* heap. The function exists so that libccd's free() calls -- redirected
* here by src/ccd_arena_shim.h -- do not reach the C library holding a
* pointer it never issued.
*/
(void)ptr;
}
void akgl_ccd_arena_reset(void)
{
arena_used = 0;
}
size_t akgl_ccd_arena_used(void)
{
return arena_used;
}
size_t akgl_ccd_arena_highwater(void)
{
return arena_highwater;
}
uint32_t akgl_ccd_arena_exhausted(void)
{
return arena_exhausted;
}
void akgl_ccd_arena_set_limit(size_t limit)
{
if ( (limit == 0) || (limit > AKGL_CCD_ARENA_BYTES) ) {
arena_limit = AKGL_CCD_ARENA_BYTES;
} else {
arena_limit = limit;
}
}

View File

@@ -20,5 +20,6 @@ akerr_ErrorContext *akgl_error_init(void)
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_HEAP, "Heap Error"));
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error"));
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt"));
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_COLLISION, "Collision Error"));
SUCCEED_RETURN(errctx);
}

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;