Add DUSK_TRACK_MEMORY allocation tracker and memoryCheckUnfreed report

memoryAllocate() becomes a macro capturing __FILE__/__LINE__ into a
fixed-size tracking table when DUSK_TRACK_MEMORY is enabled (Linux only
for now), so a leak surviving to shutdown can be traced back to its call
site. Replaces engineDispose's hard assertTrue with memoryCheckUnfreed(),
which reports unfreed allocations (with call sites when tracking is on,
just a count otherwise) instead of aborting.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-13 09:19:24 -05:00
co-authored by Claude Sonnet 5
parent 6af0e9cf28
commit 93554b39ba
4 changed files with 160 additions and 2 deletions
+12
View File
@@ -28,6 +28,17 @@ target_link_libraries(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
set(DUSK_BACKTRACE ON CACHE BOOL "Enable backtrace support for assert failures.")
# Records the file/line of every memoryAllocate() call into a fixed-size
# tracking table (removed again on memoryFree()) so a leak still present at
# engineDispose()'s memoryGetAllocatedCount() check can be traced back to
# where it was allocated. Linux only for now.
set(DUSK_TRACK_MEMORY OFF CACHE BOOL "Track memoryAllocate() call sites to help find memory leaks.")
if(DUSK_TRACK_MEMORY)
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_TRACK_MEMORY
)
endif()
# Define platform-specific macros.
target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_SDL2
@@ -45,4 +56,5 @@ target_compile_definitions(${DUSK_LIBRARY_TARGET_NAME} PUBLIC
DUSK_TIME_DYNAMIC
DUSK_NETWORK_IPV6
DUSK_THREAD_PTHREAD
DUSK_TRACK_MEMORY
)
+2
View File
@@ -108,5 +108,7 @@ errorret_t engineDispose(void) {
errorChain(assetDispose());
perfDispose();
memoryCheckUnfreed();
errorOk();
}
+106
View File
@@ -8,6 +8,27 @@
#include "memory.h"
#include "assert/assert.h"
#include "util/math.h"
#include "log/log.h"
#ifdef DUSK_TRACK_MEMORY
// Fixed-size, pre-allocated table of live memoryAllocate() calls, keyed by
// pointer - deliberately never grows (backed by static storage, not
// malloc) so the tracker itself can never allocate, recurse or leak. Raise
// this if it's ever exhausted (see the assert in memoryAllocateTracked).
#define MEMORY_TRACK_MAX 16384
typedef struct {
void *ptr;
const char_t *file;
int_t line;
size_t size;
} memorytrackentry_t;
static memorytrackentry_t MEMORY_TRACK_ENTRIES[MEMORY_TRACK_MAX];
static size_t MEMORY_TRACK_COUNT = 0;
#endif
size_t memoryGetAllocatedCount(void) {
return MEMORY_POINTERS_IN_USE;
@@ -18,6 +39,32 @@ void memoryTrack(void *ptr) {
MEMORY_POINTERS_IN_USE++;
}
#ifdef DUSK_TRACK_MEMORY
void * memoryAllocateTracked(
const size_t size, const char_t *file, const int_t line
) {
assertTrue(size > 0, "Cannot allocate 0 bytes of memory.");
void *ptr = malloc(size);
assertNotNull(ptr, "Memory allocation failed.");
memoryTrack(ptr);
assertTrue(
MEMORY_TRACK_COUNT < MEMORY_TRACK_MAX,
"Memory tracking table is full - raise MEMORY_TRACK_MAX."
);
MEMORY_TRACK_ENTRIES[MEMORY_TRACK_COUNT++] = (memorytrackentry_t){
.ptr = ptr,
.file = file,
.line = line,
.size = size
};
return ptr;
}
#else
void * memoryAllocate(const size_t size) {
assertTrue(size > 0, "Cannot allocate 0 bytes of memory.");
void *ptr = malloc(size);
@@ -26,6 +73,8 @@ void * memoryAllocate(const size_t size) {
return ptr;
}
#endif
void * memoryAlign(size_t alignment, size_t size) {
assertTrue(alignment > 0, "Alignment must be greater than 0.");
assertTrue(size > 0, "Cannot allocate 0 bytes of memory.");
@@ -35,8 +84,65 @@ void * memoryAlign(size_t alignment, size_t size) {
return ptr;
}
void memoryCheckUnfreed(void) {
size_t count = memoryGetAllocatedCount();
if(count == 0) {
logDebug("memoryCheckUnfreed: no unfreed allocations.\n");
return;
}
#ifdef DUSK_TRACK_MEMORY
logDebug("memoryCheckUnfreed: %zu unfreed allocation(s):\n", count);
size_t trackedBytes = 0;
for(size_t i = 0; i < MEMORY_TRACK_COUNT; i++) {
memorytrackentry_t *entry = &MEMORY_TRACK_ENTRIES[i];
logDebug(
" - %zu bytes allocated at %s:%d\n",
entry->size, entry->file, entry->line
);
trackedBytes += entry->size;
}
logDebug(" (%zu bytes across %zu tracked allocations)\n",
trackedBytes, MEMORY_TRACK_COUNT
);
// memoryAlign()/memoryTrack() callers aren't recorded into
// MEMORY_TRACK_ENTRIES yet, so the tracked count can fall short of the
// real total - call that out rather than silently under-reporting.
size_t untracked = count - MEMORY_TRACK_COUNT;
if(untracked > 0) {
logDebug(
" + %zu more unfreed allocation(s) with no call site recorded "
"(from memoryAlign()/memoryTrack())\n",
untracked
);
}
#else
logDebug(
"memoryCheckUnfreed: %zu unfreed allocation(s) - rebuild with "
"DUSK_TRACK_MEMORY=ON for call site details.\n",
count
);
#endif
}
void memoryFree(void *ptr) {
assertNotNull(ptr, "Cannot free NULL memory.");
#ifdef DUSK_TRACK_MEMORY
// Only pointers from memoryAllocate() are in here (memoryAlign/
// memoryTrack callers aren't tracked yet), so a miss here is expected,
// not an error - just leave the table alone.
for(size_t i = 0; i < MEMORY_TRACK_COUNT; i++) {
if(MEMORY_TRACK_ENTRIES[i].ptr == ptr) {
// Swap-remove - order doesn't matter for leak tracking.
MEMORY_TRACK_ENTRIES[i] = MEMORY_TRACK_ENTRIES[--MEMORY_TRACK_COUNT];
break;
}
}
#endif
free(ptr);
MEMORY_POINTERS_IN_USE--;
}
+38
View File
@@ -24,6 +24,42 @@ size_t memoryGetAllocatedCount(void);
*/
void memoryTrack(void *ptr);
/**
* Reports (via logDebug) whether any memoryAllocate()-family allocation is
* still outstanding - intended to be called once, near shutdown, after
* every subsystem has had a chance to dispose (see engineDispose). With
* DUSK_TRACK_MEMORY defined, prints exactly where (file:line) and how many
* bytes each still-unfreed allocation came from; without it, there's
* nowhere to report a call site from, so it only prints how many blocks
* remain outstanding.
*/
void memoryCheckUnfreed(void);
#ifdef DUSK_TRACK_MEMORY
/**
* Allocates memory, recording the call site (file/line) into a small
* fixed-size tracking table so a block still allocated at shutdown (see
* engineDispose's memoryGetAllocatedCount() check) can be traced back to
* where it came from. memoryFree removes the matching entry again once the
* pointer is freed. Only called via the memoryAllocate(size) macro below
* when DUSK_TRACK_MEMORY is defined - callers keep writing
* memoryAllocate(size) exactly as without it.
*
* @param size The size of the memory to allocate.
* @param file Call site file name (__FILE__).
* @param line Call site line number (__LINE__).
* @return The allocated memory.
*/
void * memoryAllocateTracked(
const size_t size, const char_t *file, const int_t line
);
#define memoryAllocate(size) \
memoryAllocateTracked((size), __FILE__, __LINE__)
#else
/**
* Allocates memory.
*
@@ -32,6 +68,8 @@ void memoryTrack(void *ptr);
*/
void * memoryAllocate(const size_t size);
#endif
/**
* Allocates aligned memory.
*