From 4df368704575a6957ab9631e597294e486687f0b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 7 Mar 2026 21:53:19 -0500 Subject: [PATCH] CI memory safety tests --- .gitignore | 1 + CMakeLists.txt | 73 ++++++++- sanitizers/asan.supp | 33 +++++ sanitizers/ubsan.supp | 25 ++++ sanitizers/valgrind-mcrf.supp | 269 ++++++++++++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 sanitizers/asan.supp create mode 100644 sanitizers/ubsan.supp create mode 100644 sanitizers/valgrind-mcrf.supp diff --git a/.gitignore b/.gitignore index 85e3751..0abd0da 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ CMakeFiles/ Makefile *.zip __lib/ +__lib_debug/ __lib_windows/ build-windows/ build_windows/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 4342b79..8da9de7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,27 @@ option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full ga # Game shell mode - fullscreen canvas, no REPL chrome (for itch.io / standalone web games) option(MCRF_GAME_SHELL "Use minimal game-only HTML shell (no REPL)" OFF) +# Debug/sanitizer build options +option(MCRF_SANITIZE_ADDRESS "Build with AddressSanitizer" OFF) +option(MCRF_SANITIZE_UNDEFINED "Build with UBSan" OFF) +option(MCRF_SANITIZE_THREAD "Build with ThreadSanitizer" OFF) +option(MCRF_DEBUG_PYTHON "Link against debug CPython from __lib_debug/" OFF) + +# Validate mutually exclusive sanitizers +if(MCRF_SANITIZE_ADDRESS AND MCRF_SANITIZE_THREAD) + message(FATAL_ERROR "ASan and TSan are mutually exclusive. Use one or the other.") +endif() + +# Validate debug Python library exists when requested +if(MCRF_DEBUG_PYTHON) + if(NOT EXISTS "${CMAKE_SOURCE_DIR}/__lib_debug/libpython3.14.so.1.0") + message(FATAL_ERROR + "__lib_debug/libpython3.14.so.1.0 not found.\n" + "Build it first: tools/build_debug_python.sh") + endif() + message(STATUS "Using debug CPython from __lib_debug/") +endif() + # Emscripten builds: use SDL2 if specified, otherwise fall back to headless if(EMSCRIPTEN) if(MCRF_SDL2) @@ -189,6 +210,9 @@ elseif(MCRF_HEADLESS) python3.14 m dl util pthread) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux) + if(MCRF_DEBUG_PYTHON) + link_directories(${CMAKE_SOURCE_DIR}/__lib_debug) + endif() link_directories(${CMAKE_SOURCE_DIR}/__lib) endif() elseif(MCRF_CROSS_WINDOWS) @@ -245,6 +269,9 @@ else() m dl util pthread ${OPENGL_LIBRARIES}) include_directories(${CMAKE_SOURCE_DIR}/deps/platform/linux) + if(MCRF_DEBUG_PYTHON) + link_directories(${CMAKE_SOURCE_DIR}/__lib_debug) + endif() link_directories(${CMAKE_SOURCE_DIR}/__lib) endif() @@ -256,6 +283,36 @@ add_executable(mcrogueface ${SOURCES}) # Our SDL2 backend is separate from libtcod's SDL3 renderer target_compile_definitions(mcrogueface PRIVATE NO_SDL) +# Sanitizer instrumentation — applied to mcrogueface target only (not imported libs) +if(MCRF_SANITIZE_ADDRESS) + message(STATUS "AddressSanitizer enabled") + target_compile_options(mcrogueface PRIVATE + -fsanitize=address -fno-omit-frame-pointer -g -O1) + target_link_options(mcrogueface PRIVATE + -fsanitize=address) +endif() + +if(MCRF_SANITIZE_UNDEFINED) + message(STATUS "UndefinedBehaviorSanitizer enabled") + target_compile_options(mcrogueface PRIVATE + -fsanitize=undefined -fno-sanitize=function,vptr -g -O1) + target_link_options(mcrogueface PRIVATE + -fsanitize=undefined -fno-sanitize=function,vptr) +endif() + +if(MCRF_SANITIZE_THREAD) + message(STATUS "ThreadSanitizer enabled") + target_compile_options(mcrogueface PRIVATE + -fsanitize=thread -g -O1) + target_link_options(mcrogueface PRIVATE + -fsanitize=thread) +endif() + +# Enable Py_DEBUG when linking against debug CPython (matches pydebug ABI) +if(MCRF_DEBUG_PYTHON) + target_compile_definitions(mcrogueface PRIVATE Py_DEBUG) +endif() + # Define MCRF_HEADLESS for headless builds (excludes SFML/ImGui code) if(MCRF_HEADLESS) target_compile_definitions(mcrogueface PRIVATE MCRF_HEADLESS) @@ -377,9 +434,19 @@ add_custom_command(TARGET mcrogueface POST_BUILD ${CMAKE_SOURCE_DIR}/src/scripts $/scripts) # Copy Python standard library to build directory -add_custom_command(TARGET mcrogueface POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory - ${CMAKE_SOURCE_DIR}/__lib $/lib) +if(MCRF_DEBUG_PYTHON) + # Copy all libs first (SFML, libtcod, Python stdlib), then overwrite with debug Python + add_custom_command(TARGET mcrogueface POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/__lib $/lib + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_SOURCE_DIR}/__lib_debug/libpython3.14.so.1.0 + $/lib/libpython3.14.so.1.0) +else() + add_custom_command(TARGET mcrogueface POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/__lib $/lib) +endif() # On Windows, copy DLLs to executable directory if(MCRF_CROSS_WINDOWS) diff --git a/sanitizers/asan.supp b/sanitizers/asan.supp new file mode 100644 index 0000000..9ec44f0 --- /dev/null +++ b/sanitizers/asan.supp @@ -0,0 +1,33 @@ +# McRogueFace AddressSanitizer Suppression File +# +# Minimal — most CPython false positives are handled by: +# - PYTHONMALLOC=malloc (bypasses pymalloc) +# - detect_leaks=0 (CPython has intentional lifetime leaks) +# +# Usage (via ASAN_OPTIONS or LSAN_OPTIONS): +# LSAN_OPTIONS="suppressions=sanitizers/asan.supp" +# +# Format: one suppression per line, prefix with "leak:" for leak suppressions +# See https://github.com/google/sanitizers/wiki/AddressSanitizerLeakSanitizer + +# CPython interned strings — intentionally never freed +leak:_PyUnicode_InternInPlace +leak:PyUnicode_InternFromString + +# CPython type objects — intentionally immortal +leak:PyType_Ready +leak:_PyType_Ready + +# CPython small int cache ([-5, 256]) — allocated once, never freed +leak:_PyLong_Init + +# CPython GIL — allocated once per interpreter +leak:PyThread_allocate_lock + +# CPython import system caches +leak:PyImport_ImportModule +leak:_PyImport_FindExtensionObject + +# dlopen — loaded shared libraries are intentionally kept resident +leak:dlopen +leak:_dl_open diff --git a/sanitizers/ubsan.supp b/sanitizers/ubsan.supp new file mode 100644 index 0000000..0444910 --- /dev/null +++ b/sanitizers/ubsan.supp @@ -0,0 +1,25 @@ +# McRogueFace UndefinedBehaviorSanitizer Suppression File +# +# CPython uses function pointer casts extensively (PyCFunction signatures +# don't match the actual function types). These are "safe" in practice +# on all ABIs CPython targets, but UBSan flags them. +# +# The primary defense is -fno-sanitize=function,vptr in compile flags. +# This file is a backup for any that slip through. +# +# Usage: +# UBSAN_OPTIONS="suppressions=sanitizers/ubsan.supp" +# +# Format: type:source_pattern + +# CPython function pointer cast patterns +function:modules/cpython/* +function:deps/cpython/* + +# CPython object header type punning (PyObject* -> specific types) +alignment:modules/cpython/* +alignment:deps/cpython/* + +# libtcod internal casts (C library with void* patterns) +function:*libtcod* +function:*tcod* diff --git a/sanitizers/valgrind-mcrf.supp b/sanitizers/valgrind-mcrf.supp new file mode 100644 index 0000000..a85d96c --- /dev/null +++ b/sanitizers/valgrind-mcrf.supp @@ -0,0 +1,269 @@ +# +# McRogueFace Valgrind Suppression File +# +# Adapted from CPython's Misc/valgrind-python.supp with _PyObject_Free +# and _PyObject_Realloc entries uncommented and updated for 64-bit. +# +# Usage: +# valgrind --tool=memcheck --suppressions=sanitizers/valgrind-mcrf.supp \ +# --leak-check=full --error-exitcode=42 ./build-debug/mcrogueface ... +# +# NOTE: For best results, run with PYTHONMALLOC=malloc so that all Python +# allocations go through system malloc and are fully visible to Valgrind. +# When using PYTHONMALLOC=malloc, the pymalloc suppressions below are not +# needed, but they're kept for cases where you want to run without it. +# + +############################################################################### +# CPython pymalloc internals (address_in_range) +############################################################################### + +{ + ADDRESS_IN_RANGE/Invalid read of size 4 + Memcheck:Addr4 + fun:address_in_range +} + +{ + ADDRESS_IN_RANGE/Invalid read of size 4 + Memcheck:Value4 + fun:address_in_range +} + +{ + ADDRESS_IN_RANGE/Invalid read of size 8 (x86_64) + Memcheck:Value8 + fun:address_in_range +} + +{ + ADDRESS_IN_RANGE/Conditional jump depends on uninitialised value + Memcheck:Cond + fun:address_in_range +} + +############################################################################### +# _PyObject_Free — pymalloc's free; reads pool headers that look +# uninitialised to Valgrind. Updated from Addr4/Value4 to Addr8/Value8 +# for 64-bit systems. +############################################################################### + +{ + _PyObject_Free/Invalid read of size 4 + Memcheck:Addr4 + fun:_PyObject_Free +} + +{ + _PyObject_Free/Invalid read of size 4 + Memcheck:Value4 + fun:_PyObject_Free +} + +{ + _PyObject_Free/Use of uninitialised value of size 8 + Memcheck:Addr8 + fun:_PyObject_Free +} + +{ + _PyObject_Free/Use of uninitialised value of size 8 + Memcheck:Value8 + fun:_PyObject_Free +} + +{ + _PyObject_Free/Conditional jump depends on uninitialised value + Memcheck:Cond + fun:_PyObject_Free +} + +############################################################################### +# _PyObject_Realloc — same pymalloc pool-header reads as _PyObject_Free +############################################################################### + +{ + _PyObject_Realloc/Invalid read of size 4 + Memcheck:Addr4 + fun:_PyObject_Realloc +} + +{ + _PyObject_Realloc/Invalid read of size 4 + Memcheck:Value4 + fun:_PyObject_Realloc +} + +{ + _PyObject_Realloc/Use of uninitialised value of size 8 + Memcheck:Addr8 + fun:_PyObject_Realloc +} + +{ + _PyObject_Realloc/Use of uninitialised value of size 8 + Memcheck:Value8 + fun:_PyObject_Realloc +} + +{ + _PyObject_Realloc/Conditional jump depends on uninitialised value + Memcheck:Cond + fun:_PyObject_Realloc +} + +############################################################################### +# CPython intentional leaks — interned strings, type objects, small int +# cache, and other objects that live for the entire process lifetime +############################################################################### + +{ + Suppress leaking the GIL after a fork + Memcheck:Leak + fun:malloc + fun:PyThread_allocate_lock + fun:PyEval_ReInitThreads +} + +{ + Suppress leaking the autoTLSkey + Memcheck:Leak + fun:malloc + fun:PyThread_create_key + fun:_PyGILState_Init + ... +} + +{ + Handle pthread leak (possibly leaked) + Memcheck:Leak + fun:calloc + fun:allocate_dtv + fun:_dl_allocate_tls_storage + fun:_dl_allocate_tls +} + +{ + Handle pthread leak (possibly leaked) + Memcheck:Leak + fun:memalign + fun:_dl_allocate_tls_storage + fun:_dl_allocate_tls +} + +############################################################################### +# dlopen internals — these leak by design (loaded libraries stay resident) +############################################################################### + +{ + dlopen without dlclose (strdup via cache lookup) + Memcheck:Leak + fun:malloc + fun:malloc + fun:strdup + fun:_dl_load_cache_lookup +} + +{ + dlopen without dlclose (strdup via map object) + Memcheck:Leak + fun:malloc + fun:malloc + fun:strdup + fun:_dl_map_object +} + +{ + dlopen without dlclose (new object via malloc) + Memcheck:Leak + fun:malloc + fun:* + fun:_dl_new_object +} + +{ + dlopen without dlclose (new object via calloc) + Memcheck:Leak + fun:calloc + fun:* + fun:_dl_new_object +} + +{ + dlopen without dlclose (check map versions) + Memcheck:Leak + fun:calloc + fun:* + fun:_dl_check_map_versions +} + +############################################################################### +# CPython false positives +############################################################################### + +{ + bpo-38118: Valgrind false alarm on GCC builtin strcmp + Memcheck:Cond + fun:PyUnicode_Decode +} + +{ + Uninitialised byte(s) false alarm (bpo-35561) + Memcheck:Param + epoll_ctl(event) + fun:epoll_ctl + fun:pyepoll_internal_ctl +} + +{ + wcscmp false positive in command line parsing + Memcheck:Addr8 + fun:wcscmp + fun:_PyOS_GetOpt + ... +} + +############################################################################### +# CPython 3.14 specific — type slot lookups can read union members that +# Valgrind thinks are uninitialised (they're valid via different union paths) +############################################################################### + +{ + CPython type slot union reads + Memcheck:Cond + fun:_Py_type_getattro + ... +} + +{ + CPython type slot union reads (value variant) + Memcheck:Value8 + fun:_Py_type_getattro + ... +} + +############################################################################### +# SFML / OpenGL driver internals — graphics drivers have their own allocators +# that produce false positives. Only suppress known-safe patterns. +############################################################################### + +{ + Mesa/OpenGL driver init leaks + Memcheck:Leak + ... + obj:*/dri/*_dri.so +} + +{ + X11 display connection leaks + Memcheck:Leak + ... + fun:XOpenDisplay +} + +{ + SFML font/texture init + Memcheck:Leak + ... + fun:*sf*Font* +}