diff --git a/src/ModuleLoader.cpp b/src/ModuleLoader.cpp index 54d2c9d..14d2d69 100644 --- a/src/ModuleLoader.cpp +++ b/src/ModuleLoader.cpp @@ -31,6 +31,13 @@ ModuleLoader::~ModuleLoader() { } std::unique_ptr ModuleLoader::load(const std::string& path, const std::string& name, bool isReload) { + // CRITICAL FIX: Unload any previously loaded library before loading a new one + // This prevents library handle leaks and temp file accumulation + if (libraryHandle) { + logger->debug("šŸ”„ Unloading previous library before loading new one"); + unload(); + } + logLoadStart(path); auto loadStartTime = std::chrono::high_resolution_clock::now(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index da6e655..55ef07c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -180,3 +180,29 @@ add_dependencies(test_04_race_condition TestModule) # CTest integration add_test(NAME RaceConditionHunter COMMAND test_04_race_condition) + +# LeakTestModule pour memory leak detection +add_library(LeakTestModule SHARED + modules/LeakTestModule.cpp +) + +target_link_libraries(LeakTestModule PRIVATE + GroveEngine::core + GroveEngine::impl +) + +# Test 05: Memory Leak Hunter - 200 reload cycles +add_executable(test_05_memory_leak + integration/test_05_memory_leak.cpp +) + +target_link_libraries(test_05_memory_leak PRIVATE + test_helpers + GroveEngine::core + GroveEngine::impl +) + +add_dependencies(test_05_memory_leak LeakTestModule) + +# CTest integration +add_test(NAME MemoryLeakHunter COMMAND test_05_memory_leak) diff --git a/tests/helpers/SystemUtils.cpp b/tests/helpers/SystemUtils.cpp index 94ba66d..8a2db2f 100644 --- a/tests/helpers/SystemUtils.cpp +++ b/tests/helpers/SystemUtils.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include namespace grove { @@ -46,4 +48,48 @@ float getCurrentCPUUsage() { return 0.0f; } +int countTempFiles(const std::string& pattern) { + glob_t globResult; + memset(&globResult, 0, sizeof(globResult)); + + int result = glob(pattern.c_str(), GLOB_TILDE, nullptr, &globResult); + + if (result != 0) { + globfree(&globResult); + return 0; + } + + int count = globResult.gl_pathc; + globfree(&globResult); + + return count; +} + +int getMappedLibraryCount() { + // Count unique .so libraries in /proc/self/maps + std::ifstream file("/proc/self/maps"); + std::string line; + int count = 0; + std::string lastLib; + + while (std::getline(file, line)) { + // Look for lines containing ".so" + size_t soPos = line.find(".so"); + if (soPos != std::string::npos) { + // Extract library path (after last space) + size_t pathStart = line.rfind(' '); + if (pathStart != std::string::npos) { + std::string libPath = line.substr(pathStart + 1); + // Only count if different from last one (avoid duplicates) + if (libPath != lastLib) { + count++; + lastLib = libPath; + } + } + } + } + + return count; +} + } // namespace grove diff --git a/tests/helpers/SystemUtils.h b/tests/helpers/SystemUtils.h index fead9d3..e09d927 100644 --- a/tests/helpers/SystemUtils.h +++ b/tests/helpers/SystemUtils.h @@ -1,5 +1,6 @@ #pragma once #include +#include namespace grove { @@ -7,4 +8,14 @@ size_t getCurrentMemoryUsage(); int getOpenFileDescriptors(); float getCurrentCPUUsage(); +/** + * @brief Count temp files matching pattern (e.g., "/tmp/grove_module_*") + */ +int countTempFiles(const std::string& pattern); + +/** + * @brief Get number of mapped .so libraries from /proc/self/maps + */ +int getMappedLibraryCount(); + } // namespace grove diff --git a/tests/integration/test_04_race_condition.cpp b/tests/integration/test_04_race_condition.cpp index 941b6c0..7fdf9ff 100644 --- a/tests/integration/test_04_race_condition.cpp +++ b/tests/integration/test_04_race_condition.cpp @@ -102,10 +102,14 @@ int main() { // CRITICAL: Lock moduleSystem during entire reload std::lock_guard lock(moduleSystemMutex); - // Extract module + // Extract module and save state auto module = moduleSystem->extractModule(); auto state = module->getState(); + // CRITICAL: Destroy old module BEFORE reloading + // The loader.load() will unload the old .so + module.reset(); + // Reload auto newModule = loader.load(modulePath, "TestModule", true); @@ -113,8 +117,7 @@ int main() { if (!newModule) { corruptedLoads++; reloadFailures++; - // Re-register old module - moduleSystem->registerModule("TestModule", std::move(module)); + // Can't recover - old module already destroyed } else { // VALIDATE MODULE INTEGRITY bool isCorrupted = false; @@ -142,8 +145,7 @@ int main() { if (isCorrupted) { corruptedLoads++; reloadFailures++; - // Re-register old module - moduleSystem->registerModule("TestModule", std::move(module)); + // Can't recover - old module already destroyed } else { // Module is valid, restore state and register newModule->setState(*state); diff --git a/tests/integration/test_05_memory_leak.cpp b/tests/integration/test_05_memory_leak.cpp new file mode 100644 index 0000000..32f9692 --- /dev/null +++ b/tests/integration/test_05_memory_leak.cpp @@ -0,0 +1,428 @@ +// ============================================================================ +// test_05_memory_leak.cpp - Memory Leak Hunter +// ============================================================================ +// Tests that repeated hot-reload cycles do not leak memory +// +// Strategy: +// - Load the same .so file 200 times (no recompilation) +// - Measure memory usage every 5 seconds +// - Verify temp file cleanup +// - Check for library handle leaks +// +// Success criteria: +// - < 10 MB total memory growth +// - < 50 KB average memory per reload +// - Temp files cleaned up (≤ 2 at any time) +// - No increase in mapped .so count +// - 100% reload success rate +// ============================================================================ + +#include "grove/ModuleLoader.h" +#include "grove/SequentialModuleSystem.h" +#include "grove/JsonDataNode.h" +#include "../helpers/SystemUtils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace grove; +namespace fs = std::filesystem; + +// ============================================================================ +// Configuration +// ============================================================================ + +const int TOTAL_RELOADS = 200; +const int RELOAD_INTERVAL_MS = 500; +const int MEMORY_CHECK_INTERVAL_MS = 5000; +const float TARGET_FPS = 60.0f; +const int MAX_TEST_TIME_SECONDS = 180; + +// ============================================================================ +// State tracking +// ============================================================================ + +struct MemorySnapshot { + int reloadCount; + size_t memoryBytes; + int tempFiles; + int mappedLibs; + std::chrono::steady_clock::time_point timestamp; +}; + +std::atomic g_running{true}; +std::atomic g_reloadCount{0}; +std::atomic g_reloadSuccesses{0}; +std::atomic g_reloadFailures{0}; +std::atomic g_crashes{0}; +std::vector g_snapshots; +std::mutex g_snapshotMutex; + +// ============================================================================ +// Reload Scheduler Thread +// ============================================================================ + +void reloadSchedulerThread(ModuleLoader& loader, SequentialModuleSystem* moduleSystem, + const fs::path& modulePath, std::mutex& moduleSystemMutex) { + std::cout << " Starting ReloadScheduler thread...\n"; + + while (g_running && g_reloadCount < TOTAL_RELOADS) { + std::this_thread::sleep_for(std::chrono::milliseconds(RELOAD_INTERVAL_MS)); + + if (!g_running) break; + + try { + std::lock_guard lock(moduleSystemMutex); + + // Extract current module and save state + auto module = moduleSystem->extractModule(); + auto state = module->getState(); + auto config = std::make_unique("config", + dynamic_cast(module->getConfiguration()).getJsonData()); + + // CRITICAL: Destroy old module BEFORE reloading to avoid use-after-free + // The loader.load() will unload the old .so, so we must destroy the module first + module.reset(); + + // Reload the same .so file + auto newModule = loader.load(modulePath, "LeakTestModule", true); + + g_reloadCount++; + + if (newModule) { + // Restore state + newModule->setConfiguration(*config, nullptr, nullptr); + newModule->setState(*state); + + // Register new module + moduleSystem->registerModule("LeakTestModule", std::move(newModule)); + g_reloadSuccesses++; + } else { + // Reload failed - we can't recover (old module destroyed) + g_reloadFailures++; + } + } catch (...) { + g_crashes++; + g_reloadFailures++; + g_reloadCount++; + } + } + + std::cout << " ReloadScheduler thread finished.\n"; +} + +// ============================================================================ +// Memory Monitor Thread +// ============================================================================ + +void memoryMonitorThread() { + std::cout << " Starting MemoryMonitor thread...\n"; + + auto startTime = std::chrono::steady_clock::now(); + + while (g_running) { + std::this_thread::sleep_for(std::chrono::milliseconds(MEMORY_CHECK_INTERVAL_MS)); + + if (!g_running) break; + + MemorySnapshot snapshot; + snapshot.reloadCount = g_reloadCount; + snapshot.memoryBytes = getCurrentMemoryUsage(); + snapshot.tempFiles = countTempFiles("/tmp/grove_module_*"); + snapshot.mappedLibs = getMappedLibraryCount(); + snapshot.timestamp = std::chrono::steady_clock::now(); + + { + std::lock_guard lock(g_snapshotMutex); + g_snapshots.push_back(snapshot); + } + + // Print progress + float memoryMB = snapshot.memoryBytes / (1024.0f * 1024.0f); + int progress = (snapshot.reloadCount * 100) / TOTAL_RELOADS; + + std::cout << "\n Progress: " << snapshot.reloadCount << " reloads (" << progress << "%)\n"; + std::cout << " Memory: " << std::fixed << std::setprecision(1) << memoryMB << " MB\n"; + std::cout << " Temp files: " << snapshot.tempFiles << "\n"; + std::cout << " Mapped .so: " << snapshot.mappedLibs << "\n"; + } + + std::cout << " MemoryMonitor thread finished.\n"; +} + +// ============================================================================ +// Engine Thread +// ============================================================================ + +void engineThread(SequentialModuleSystem* moduleSystem, std::mutex& moduleSystemMutex) { + std::cout << " Starting Engine thread (60 FPS)...\n"; + + const float frameTime = 1.0f / TARGET_FPS; + auto lastFrame = std::chrono::steady_clock::now(); + int frameCount = 0; + + while (g_running && g_reloadCount < TOTAL_RELOADS) { + auto now = std::chrono::steady_clock::now(); + float deltaTime = std::chrono::duration(now - lastFrame).count(); + + if (deltaTime >= frameTime) { + try { + std::lock_guard lock(moduleSystemMutex); + moduleSystem->processModules(deltaTime); + frameCount++; + } catch (...) { + g_crashes++; + } + + lastFrame = now; + } else { + // Sleep for remaining time + int sleepMs = static_cast((frameTime - deltaTime) * 1000); + if (sleepMs > 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + } + } + } + + std::cout << " Engine thread finished (" << frameCount << " frames processed).\n"; +} + +// ============================================================================ +// Main Test +// ============================================================================ + +int main() { + std::cout << "================================================================================\n"; + std::cout << "TEST: Memory Leak Hunter - " << TOTAL_RELOADS << " Reload Cycles\n"; + std::cout << "================================================================================\n\n"; + + // Find module path + fs::path modulePath = "build/tests/libLeakTestModule.so"; + if (!fs::exists(modulePath)) { + std::cerr << "āŒ Module not found: " << modulePath << "\n"; + return 1; + } + + std::cout << "Setup:\n"; + std::cout << " Module path: " << modulePath << "\n"; + std::cout << " Total reloads: " << TOTAL_RELOADS << "\n"; + std::cout << " Interval: " << RELOAD_INTERVAL_MS << "ms\n"; + std::cout << " Expected time: ~" << (TOTAL_RELOADS * RELOAD_INTERVAL_MS / 1000) << "s\n\n"; + + // Create module loader and system + ModuleLoader loader; + auto moduleSystem = std::make_unique(); + std::mutex moduleSystemMutex; + + // Disable verbose logging for performance + moduleSystem->setLogLevel(spdlog::level::err); + + // Load initial module + try { + auto module = loader.load(modulePath, "LeakTestModule", false); + if (!module) { + std::cerr << "āŒ Failed to load LeakTestModule\n"; + return 1; + } + + nlohmann::json configJson = nlohmann::json::object(); + auto config = std::make_unique("config", configJson); + module->setConfiguration(*config, nullptr, nullptr); + moduleSystem->registerModule("LeakTestModule", std::move(module)); + std::cout << "āœ“ Initial module loaded\n\n"; + } catch (const std::exception& e) { + std::cerr << "āŒ Failed to load initial module: " << e.what() << "\n"; + return 1; + } + + // Baseline memory + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + size_t baselineMemory = getCurrentMemoryUsage(); + int baselineMappedLibs = getMappedLibraryCount(); + float baselineMB = baselineMemory / (1024.0f * 1024.0f); + + std::cout << "Baseline memory: " << std::fixed << std::setprecision(1) << baselineMB << " MB\n"; + std::cout << "Baseline mapped .so: " << baselineMappedLibs << "\n\n"; + + // Start threads + auto startTime = std::chrono::steady_clock::now(); + + std::thread reloadThread(reloadSchedulerThread, std::ref(loader), moduleSystem.get(), + modulePath, std::ref(moduleSystemMutex)); + std::thread monitorThread(memoryMonitorThread); + std::thread engThread(engineThread, moduleSystem.get(), std::ref(moduleSystemMutex)); + + // Wait for completion or timeout + auto deadline = startTime + std::chrono::seconds(MAX_TEST_TIME_SECONDS); + + while (g_running && g_reloadCount < TOTAL_RELOADS) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + if (std::chrono::steady_clock::now() > deadline) { + std::cout << "\nāš ļø Test timeout after " << MAX_TEST_TIME_SECONDS << " seconds\n"; + break; + } + } + + // Stop threads + g_running = false; + + reloadThread.join(); + monitorThread.join(); + engThread.join(); + + auto endTime = std::chrono::steady_clock::now(); + float durationSeconds = std::chrono::duration(endTime - startTime).count(); + + // Final measurements + size_t finalMemory = getCurrentMemoryUsage(); + int finalMappedLibs = getMappedLibraryCount(); + int finalTempFiles = countTempFiles("/tmp/grove_module_*"); + + float finalMB = finalMemory / (1024.0f * 1024.0f); + float growthMB = (finalMemory - baselineMemory) / (1024.0f * 1024.0f); + + // ======================================================================== + // Results Summary + // ======================================================================== + + std::cout << "\n================================================================================\n"; + std::cout << "MEMORY LEAK HUNTER SUMMARY\n"; + std::cout << "================================================================================\n\n"; + + std::cout << "Duration: " << static_cast(durationSeconds) << "s\n\n"; + + std::cout << "Reloads:\n"; + std::cout << " Total: " << g_reloadCount << "\n"; + std::cout << " Successes: " << g_reloadSuccesses; + if (g_reloadCount > 0) { + float successRate = (g_reloadSuccesses * 100.0f) / g_reloadCount; + std::cout << " (" << std::fixed << std::setprecision(1) << successRate << "%)"; + } + std::cout << "\n"; + std::cout << " Failures: " << g_reloadFailures << "\n\n"; + + std::cout << "Memory Analysis:\n"; + std::cout << " Baseline: " << std::fixed << std::setprecision(1) << baselineMB << " MB\n"; + std::cout << " Final: " << finalMB << " MB\n"; + std::cout << " Growth: " << growthMB << " MB"; + + if (growthMB < 10.0f) { + std::cout << " āœ…"; + } else { + std::cout << " āŒ"; + } + std::cout << "\n"; + + float memoryPerReloadKB = 0.0f; + if (g_reloadCount > 0) { + memoryPerReloadKB = (growthMB * 1024.0f) / g_reloadCount; + } + std::cout << " Per reload: " << std::fixed << std::setprecision(1) << memoryPerReloadKB << " KB"; + if (memoryPerReloadKB < 50.0f) { + std::cout << " āœ…"; + } else { + std::cout << " āŒ"; + } + std::cout << "\n\n"; + + std::cout << "Resource Cleanup:\n"; + std::cout << " Temp files: " << finalTempFiles; + if (finalTempFiles <= 2) { + std::cout << " āœ…"; + } else { + std::cout << " āŒ"; + } + std::cout << "\n"; + + std::cout << " Mapped .so: " << finalMappedLibs; + if (finalMappedLibs <= baselineMappedLibs + 2) { + std::cout << " (stable) āœ…"; + } else { + std::cout << " (leak: +" << (finalMappedLibs - baselineMappedLibs) << ") āŒ"; + } + std::cout << "\n\n"; + + std::cout << "Stability:\n"; + std::cout << " Crashes: " << g_crashes; + if (g_crashes == 0) { + std::cout << " āœ…"; + } else { + std::cout << " āŒ"; + } + std::cout << "\n\n"; + + // ======================================================================== + // Validation + // ======================================================================== + + std::cout << "Validating results...\n"; + + bool passed = true; + + // 1. Memory growth < 10 MB + if (growthMB > 10.0f) { + std::cout << " āŒ Memory growth too high: " << growthMB << " MB (need < 10 MB)\n"; + passed = false; + } else { + std::cout << " āœ“ Memory growth: " << growthMB << " MB (< 10 MB)\n"; + } + + // 2. Memory per reload < 50 KB + if (memoryPerReloadKB > 50.0f) { + std::cout << " āŒ Memory per reload too high: " << memoryPerReloadKB << " KB (need < 50 KB)\n"; + passed = false; + } else { + std::cout << " āœ“ Memory per reload: " << memoryPerReloadKB << " KB (< 50 KB)\n"; + } + + // 3. Temp files cleaned + if (finalTempFiles > 2) { + std::cout << " āŒ Temp files not cleaned up: " << finalTempFiles << " (need ≤ 2)\n"; + passed = false; + } else { + std::cout << " āœ“ Temp files cleaned: " << finalTempFiles << " (≤ 2)\n"; + } + + // 4. No .so handle leaks + if (finalMappedLibs > baselineMappedLibs + 2) { + std::cout << " āŒ Library handle leak: +" << (finalMappedLibs - baselineMappedLibs) << "\n"; + passed = false; + } else { + std::cout << " āœ“ No .so handle leaks\n"; + } + + // 5. Reload success rate + float successRate = g_reloadCount > 0 ? (g_reloadSuccesses * 100.0f) / g_reloadCount : 0.0f; + if (successRate < 100.0f) { + std::cout << " āŒ Reload success rate: " << std::fixed << std::setprecision(1) + << successRate << "% (need 100%)\n"; + passed = false; + } else { + std::cout << " āœ“ Reload success rate: 100%\n"; + } + + // 6. No crashes + if (g_crashes > 0) { + std::cout << " āŒ Crashes detected: " << g_crashes << "\n"; + passed = false; + } else { + std::cout << " āœ“ No crashes\n"; + } + + std::cout << "\n================================================================================\n"; + if (passed) { + std::cout << "Result: āœ… PASSED\n"; + } else { + std::cout << "Result: āŒ FAILED\n"; + } + std::cout << "================================================================================\n"; + + return passed ? 0 : 1; +} diff --git a/tests/modules/LeakTestModule.cpp b/tests/modules/LeakTestModule.cpp new file mode 100644 index 0000000..ae780b9 --- /dev/null +++ b/tests/modules/LeakTestModule.cpp @@ -0,0 +1,136 @@ +// ============================================================================ +// LeakTestModule.cpp - Module for memory leak detection testing +// ============================================================================ + +#include "grove/IModule.h" +#include "grove/IDataNode.h" +#include "grove/JsonDataNode.h" +#include "grove/JsonDataValue.h" +#include +#include +#include +#include +#include + +namespace grove { + +class LeakTestModule : public IModule { +private: + std::vector workBuffer; + int processCount = 0; + int lastChecksum = 0; + IIO* io = nullptr; + ITaskScheduler* scheduler = nullptr; + std::unique_ptr config; + +public: + LeakTestModule() = default; + ~LeakTestModule() override = default; + + void process(const IDataNode& input) override { + processCount++; + + // Simulate real workload with allocations (1 MB working buffer) + workBuffer.resize(1024 * 1024); + std::fill(workBuffer.begin(), workBuffer.end(), + static_cast(processCount % 256)); + + // Small temporary allocations (simulate logging/processing) + std::vector logs; + logs.reserve(100); + for (int i = 0; i < 100; i++) { + logs.push_back("Process iteration " + std::to_string(processCount)); + } + + // Simulate some data processing + int sum = 0; + for (size_t i = 0; i < workBuffer.size(); i += 1024) { + sum += workBuffer[i]; + } + lastChecksum = sum; + } + + void setConfiguration(const IDataNode& configNode, IIO* ioPtr, ITaskScheduler* schedulerPtr) override { + this->io = ioPtr; + this->scheduler = schedulerPtr; + config = std::make_unique("config", nlohmann::json::object()); + } + + const IDataNode& getConfiguration() override { + if (!config) { + config = std::make_unique("config", nlohmann::json::object()); + } + return *config; + } + + std::unique_ptr getHealthStatus() override { + nlohmann::json health = { + {"status", "healthy"}, + {"processCount", processCount}, + {"lastChecksum", lastChecksum}, + {"bufferSize", workBuffer.size()} + }; + return std::make_unique("health", health); + } + + void shutdown() override { + // Clean up + workBuffer.clear(); + workBuffer.shrink_to_fit(); + } + + std::unique_ptr getState() override { + nlohmann::json state = { + {"processCount", processCount}, + {"lastChecksum", lastChecksum} + }; + + // Simulate storing large state data (100 KB blob as base64) + std::vector stateBlob(100 * 1024); + std::fill(stateBlob.begin(), stateBlob.end(), 0xAB); + + // Store as array of ints in JSON (simpler than base64 for test purposes) + std::vector blobData; + blobData.reserve(stateBlob.size()); + for (auto byte : stateBlob) { + blobData.push_back(byte); + } + state["stateBlob"] = blobData; + + return std::make_unique("state", state); + } + + void setState(const IDataNode& state) override { + processCount = state.getInt("processCount", 0); + lastChecksum = state.getInt("lastChecksum", 0); + + // Note: We don't need to restore stateBlob, it's just for testing memory + // during serialization + } + + std::string getType() const override { + return "LeakTestModule"; + } + + bool isIdle() const override { + return true; + } +}; + +} // namespace grove + +// ============================================================================ +// Module Factory +// ============================================================================ + +extern "C" { + +grove::IModule* createModule() { + return new grove::LeakTestModule(); +} + +void destroyModule(grove::IModule* module) { + delete module; +} + +} // extern "C"