Skip to content

Memory Leak in TracyVector: Missing Element Destructors for Non-Trivial Types #1192

@chaowyc

Description

@chaowyc

Labels Suggestion

  • bug
  • memory-leak
  • critical

Issue Body


Description

TracyVector has a critical memory leak when used with non-trivially-destructible types. The destructor only calls free() on the internal buffer without invoking element destructors, causing resource leaks in nested containers and types with manual resource management.

TracyVector is a fundamental data structure in Tracy, widely used throughout the codebase:

Usage Statistics in Tracy Server

Total Vector usage: 25+ distinct uses in tracy/server/TracyEvent.hpp alone

Critical nested Vector usage (from TracyWorker.hpp):

// Line ~500: Zone event hierarchy
Vector<Vector<short_ptr<ZoneEvent>>> zoneChildren;

// Line ~520: GPU event hierarchy  
Vector<Vector<short_ptr<GpuEvent>>> gpuChildren;

// Line ~540: Ghost zone tracking
Vector<Vector<GhostZone>> ghostChildren;

// Line ~560: Zone vector cache
Vector<Vector<short_ptr<ZoneEvent>>> zoneVectorCache;

Other common uses:

  • Timeline storage: Vector<short_ptr<ZoneEvent>> timeline
  • Stack tracking: Vector<short_ptr<ZoneEvent>> stack
  • Message queues: Vector<short_ptr<MessageData>> messages
  • Sample data: Vector<SampleData> samples
  • Memory events: Vector<MemEvent> data
  • Frame tracking: Vector<FrameEvent> frames
  • Lock timelines: Vector<LockEventPtr> timeline

Impact: The bug affects all non-trivially-destructible types stored in Vector, with nested Vectors being the most critical case as they leak inner buffers.

Affected Code

File: tracy/server/TracyVector.hpp

Buggy locations:

  1. Destructor ~Vector() (line ~250)
  2. Move assignment operator=(Vector&&) (line ~91)
  3. clear() function (line ~110)
  4. Realloc() function (line ~305)

Minimal Reproduction

#include "tracy/server/TracyVector.hpp"
#include <cstdio>

struct ResourceOwner {
    int* data_;
    ResourceOwner(int val) : data_(new int(val)) {
        printf("Allocated %p\n", (void*)data_);
    }
    ~ResourceOwner() {
        printf("Freeing %p\n", (void*)data_);
        delete data_;  // Never called with buggy TracyVector!
    }
};

int main() {
    tracy::Vector<ResourceOwner> vec;
    vec.push_back(ResourceOwner(1));
    vec.push_back(ResourceOwner(2));
    // Destructors never called → memory leak!
    return 0;
}

AddressSanitizer Output:

Direct leak of 4 byte(s) in 1 object(s) allocated from:
    #0 in operator new(unsigned long)
    #1 in ResourceOwner::ResourceOwner(int)

Direct leak of 4 byte(s) in 1 object(s) allocated from:
    #0 in operator new(unsigned long)
    #1 in ResourceOwner::ResourceOwner(int)

SUMMARY: AddressSanitizer: 8 byte(s) leaked in 2 allocation(s).

Real-World Impact: Nested Vectors

Tracy's actual production code (TracyWorker.hpp:500):

Vector<Vector<short_ptr<ZoneEvent>>> zoneChildren;

This demonstrates a nested container scenario. With the buggy implementation:

tracy::Vector<tracy::Vector<int>> outer;
tracy::Vector<int> inner1;
inner1.push_back(1);
inner1.push_back(2);
outer.push_back(std::move(inner1));

// When outer destructs:
// 1. free(outer.m_ptr) is called
// 2. BUT inner Vector destructors are NEVER called!
// 3. Result: inner1.m_ptr memory is leaked

AddressSanitizer confirms:

Direct leak of 8 byte(s) in 1 object(s) allocated from:
    #1 in tracy::Vector<int>::Realloc() TracyVector.hpp:308
    #4 in test_nested_vector()

SUMMARY: AddressSanitizer: 16 byte(s) leaked (nested Vector buffers)

Root Cause Analysis

Current implementation (buggy):

~Vector() {
    if (m_ptr) {
        free(m_ptr);  // ❌ Only frees memory, doesn't call m_ptr[i].~T()
    }
}

Why this fails:

  • Vector<int> is NOT trivially destructible (has user-defined destructor)
  • When Vector<Vector<T>> destructs, outer free() doesn't call inner destructors
  • Inner m_ptr buffers are never freed → memory leak

Type traits check:

static_assert(!std::is_trivially_destructible_v<tracy::Vector<int>>,
              "Vector has user-defined destructor!");

Proposed Fix

see PR https://github.com/wolfpld/tracy/pull/1191

Use if constexpr with type traits to call destructors only when needed:

~Vector() {
    if (m_ptr) {
        if constexpr (!std::is_trivially_destructible_v<T>) {
            for (uint32_t i = 0; i < m_size; i++) {
                m_ptr[i].~T();  // ✅ Explicitly call destructors
            }
        }
        free(m_ptr);
    }
}

Same pattern applies to:

  1. Move assignment operator
  2. clear() function
  3. Realloc() function

PoC: How to Reproduce Using Provided Test Suite

Step 1: save the test file to test_before_after_fix.cpp

/**
 * @file test_before_after_fix.cpp
 * @brief Before/After Fix Comparison Test - Memory Leak Detection with AddressSanitizer
 * 
 * Compilation Method 1 - Test Original Version (with leaks):
 *   cd /Test/c-cpp-container-optimizer/benchmarks
 *   g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -I. \
 *       test_before_after_fix.cpp tracy/server/TracyMemory.cpp -o test_original
 *   ./test_original
 * 
 * Compilation Method 2 - Test Fixed Version (no leaks):
 *   cd /Test/c-cpp-container-optimizer/benchmarks
 *   g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g -I. \
 *       -DUSE_FIXED_VERSION \
 *       test_before_after_fix.cpp tracy/server/TracyMemory.cpp -o test_fixed
 *   ./test_fixed
 */

#include <iostream>


#ifdef USE_FIXED_VERSION
    // For fixed version, also include the fixed version and use it
    #include "tracy/server/TracyVector.hpp"
    template<typename T>
    using VectorImpl = tracy::Vector<T>;
    #define VERSION_NAME "Fixed Version (TracyVector.hpp)"
    #define VERSION_TAG "FIXED"
#else
    // Include Tracy Vector
    // #include "tracy/server/TracyVector_original.hpp"
    #include "tracy/server/TracyVector.hpp"
    // Test original version
    template<typename T>
    using VectorImpl = tracy::Vector<T>;
    #define VERSION_NAME "Original Version (TracyVector.hpp)"
    #define VERSION_TAG "ORIGINAL"
#endif

// Include Tracy's actual types  
#include "tracy/server/TracyEvent.hpp"

// Test non-trivial type with resource management
class ResourceOwner {
public:
    int* data_;
    int id_;
    
    ResourceOwner(int val) : data_(new int(val)), id_(val) {
        std::cout << "    [ResourceOwner " << id_ << "] Constructor, allocated: " << data_ << "\n";
    }
    
    ~ResourceOwner() {
        if (data_) {
            std::cout << "    [ResourceOwner " << id_ << "] Destructor, deleting: " << data_ << "\n";
            delete data_;
        } else {
            std::cout << "    [ResourceOwner " << id_ << "] Destructor (moved-from)\n";
        }
    }
    
    ResourceOwner(const ResourceOwner& other) 
        : data_(new int(*other.data_)), id_(other.id_) {
        std::cout << "    [ResourceOwner " << id_ << "] Copy constructor\n";
    }
    
    ResourceOwner(ResourceOwner&& other) noexcept 
        : data_(other.data_), id_(other.id_) {
        other.data_ = nullptr;
        std::cout << "    [ResourceOwner " << id_ << "] Move constructor\n";
    }
    
    ResourceOwner& operator=(ResourceOwner&& other) noexcept {
        if (this != &other) {
            delete data_;
            data_ = other.data_;
            id_ = other.id_;
            other.data_ = nullptr;
        }
        return *this;
    }
};

// POD type
struct PodType {
    int id;
    double value;
};

// ============================================================================
// Test Functions
// ============================================================================

void test_non_trivial_type() {
    std::cout << "\n--- TEST 1: Non-Trivial Type (ResourceOwner) ---\n";
    std::cout << "Creating Vector<ResourceOwner>...\n\n";
    
    {
        VectorImpl<ResourceOwner> vec;
        
        std::cout << "Adding ResourceOwner(1):\n";
        vec.push_back(ResourceOwner(1));
        
        std::cout << "\nAdding ResourceOwner(2):\n";
        vec.push_back(ResourceOwner(2));
        
        std::cout << "\nVector size: " << vec.size() << "\n\n";
        std::cout << "--- Vector about to destruct ---\n";
    }
    std::cout << "--- Vector destruction completed ---\n\n";
}

void test_pod_type() {
    std::cout << "\n--- TEST 2: POD Type (PodType) ---\n";
    std::cout << "Creating Vector<PodType>...\n\n";
    
    {
        VectorImpl<PodType> vec;
        vec.push_back(PodType{1, 1.5});
        vec.push_back(PodType{2, 2.5});
        std::cout << "  Added 2 elements, size=" << vec.size() << "\n";
    }
    std::cout << "  ✅ POD type test completed\n\n";
}

void test_nested_vector() {
    std::cout << "\n--- TEST 3: Nested Vector (Vector<Vector<int>>) ---\n";
    std::cout << "This is a critical test!\n\n";
    
    {
        VectorImpl<VectorImpl<int>> nested;
        
        VectorImpl<int> inner1;
        inner1.push_back(1);
        inner1.push_back(2);
        nested.push_back(std::move(inner1));
        
        VectorImpl<int> inner2;
        inner2.push_back(3);
        inner2.push_back(4);
        nested.push_back(std::move(inner2));
        
        std::cout << "  Outer Vector size: " << nested.size() << "\n";
        std::cout << "  Inner[0] size: " << nested[0].size() << "\n";
        std::cout << "  Inner[1] size: " << nested[1].size() << "\n\n";
        
        std::cout << "--- Nested Vector about to destruct ---\n";
    }
    std::cout << "--- Nested Vector destruction completed ---\n\n";
}

void test_tracy_symbol_location() {
    std::cout << "\n--- TEST 4: Tracy Actual Type (Vector<SymbolLocation>) ---\n";
    std::cout << "Testing Tracy's real SymbolLocation struct...\n\n";
    
    {
        VectorImpl<tracy::SymbolLocation> vec;
        
        tracy::SymbolLocation loc1{0x1000, 100};
        tracy::SymbolLocation loc2{0x2000, 200};
        
        vec.push_back(loc1);
        vec.push_back(loc2);
        
        std::cout << "  Added 2 SymbolLocations, size=" << vec.size() << "\n";
        std::cout << "  [0]: addr=0x" << std::hex << vec[0].addr << std::dec << ", len=" << vec[0].len << "\n";
        std::cout << "  [1]: addr=0x" << std::hex << vec[1].addr << std::dec << ", len=" << vec[1].len << "\n\n";
    }
    std::cout << "  ✅ Tracy SymbolLocation test completed\n\n";
}

void test_tracy_zone_event() {
    std::cout << "\n--- TEST 5: Tracy ZoneEvent (Vector<short_ptr<ZoneEvent>>) ---\n";
    std::cout << "Testing Tracy's ZoneEvent with short_ptr...\n\n";
    
    {
        VectorImpl<tracy::short_ptr<tracy::ZoneEvent>> vec;
        
        // Create ZoneEvents on heap (as Tracy does)
        auto* event1 = new tracy::ZoneEvent();
        auto* event2 = new tracy::ZoneEvent();
        
        event1->SetStartSrcLoc(1000, 1);
        event2->SetStartSrcLoc(2000, 2);
        
        tracy::short_ptr<tracy::ZoneEvent> ptr1(event1);
        tracy::short_ptr<tracy::ZoneEvent> ptr2(event2);
        
        vec.push_back(ptr1);
        vec.push_back(ptr2);
        
        std::cout << "  Added 2 ZoneEvent pointers, size=" << vec.size() << "\n";
        std::cout << "  [0]: Start=" << vec[0]->Start() << ", SrcLoc=" << vec[0]->SrcLoc() << "\n";
        std::cout << "  [1]: Start=" << vec[1]->Start() << ", SrcLoc=" << vec[1]->SrcLoc() << "\n\n";
        
        // Manual cleanup for this test
        delete event1;
        delete event2;
    }
    std::cout << "  ✅ Tracy ZoneEvent test completed\n\n";
}

void test_tracy_nested_zone_children() {
    std::cout << "\n--- TEST 6: CRITICAL - Tracy's Actual Usage: Vector<Vector<short_ptr<ZoneEvent>>> ---\n";
    std::cout << "This mirrors the real bug in TracyWorker.hpp: zoneChildren field!\n\n";
    
    {
        // This is the exact type used in Tracy's Worker class
        VectorImpl<VectorImpl<tracy::short_ptr<tracy::ZoneEvent>>> zoneChildren;
        
        // Create first child vector
        VectorImpl<tracy::short_ptr<tracy::ZoneEvent>> children1;
        auto* child1 = new tracy::ZoneEvent();
        child1->SetStartSrcLoc(100, 1);
        children1.push_back(tracy::short_ptr<tracy::ZoneEvent>(child1));
        
        // Create second child vector
        VectorImpl<tracy::short_ptr<tracy::ZoneEvent>> children2;
        auto* child2 = new tracy::ZoneEvent();
        child2->SetStartSrcLoc(200, 2);
        children2.push_back(tracy::short_ptr<tracy::ZoneEvent>(child2));
        
        zoneChildren.push_back(std::move(children1));
        zoneChildren.push_back(std::move(children2));
        
        std::cout << "  Outer Vector size: " << zoneChildren.size() << "\n";
        std::cout << "  Inner[0] size: " << zoneChildren[0].size() << "\n";
        std::cout << "  Inner[1] size: " << zoneChildren[1].size() << "\n\n";
        
        std::cout << "--- Nested zoneChildren Vector about to destruct ---\n";
        
        // Manual cleanup
        delete child1;
        delete child2;
    }
    std::cout << "--- Nested zoneChildren Vector destruction completed ---\n\n";
}

// ============================================================================
// Main Program
// ============================================================================

int main() {
    std::cout << "\n";
    std::cout << "╔════════════════════════════════════════════════════════════════╗\n";
    std::cout << "║                                                                ║\n";
    std::cout << "║        Tracy Vector - AddressSanitizer Memory Leak Test       ║\n";
    std::cout << "║                                                                ║\n";
    std::cout << "╚════════════════════════════════════════════════════════════════╝\n\n";
    
    std::cout << "Current Test Version: " << VERSION_NAME << "\n";
    std::cout << "Version Tag: " << VERSION_TAG << "\n\n";
    
    std::cout << "════════════════════════════════════════════════════════════════\n";
    
#ifdef USE_FIXED_VERSION
    std::cout << "\nFix Description:\n";
    std::cout << "  ✅ Smart destruction with if constexpr\n";
    std::cout << "  ✅ Non-trivial types call element destructors\n";
    std::cout << "  ✅ POD types have zero overhead (compile-time optimization)\n\n";
    
    std::cout << "Code Example:\n";
    std::cout << "  ~Vector() {\n";
    std::cout << "      if (m_ptr) {\n";
    std::cout << "          if constexpr (!std::is_trivially_destructible_v<T>) {\n";
    std::cout << "              for (uint32_t i = 0; i < m_size; i++) {\n";
    std::cout << "                  m_ptr[i].~T();  // ✅ Call element destructors\n";
    std::cout << "              }\n";
    std::cout << "          }\n";
    std::cout << "          free(m_ptr);\n";
    std::cout << "      }\n";
    std::cout << "  }\n";
#else
    std::cout << "\nOriginal Implementation Problems:\n";
    std::cout << "  ❌ Does not call element destructors\n";
    std::cout << "  ❌ Non-trivial types will leak memory\n";
    std::cout << "  ❌ Vector<Vector<T>> will leak inner Vectors\n\n";
    
    std::cout << "Original Code:\n";
    std::cout << "  ~Vector() {\n";
    std::cout << "      if (m_ptr) {\n";
    std::cout << "          free(m_ptr);  // ❌ Does not call m_ptr[i].~T()\n";
    std::cout << "      }\n";
    std::cout << "  }\n";
#endif
    
    std::cout << "\n════════════════════════════════════════════════════════════════\n";
    std::cout << "Running tests...\n";
    std::cout << "════════════════════════════════════════════════════════════════\n";
    
    test_non_trivial_type();
    test_pod_type();
    test_nested_vector();
    test_tracy_symbol_location();
    test_tracy_zone_event();
    test_tracy_nested_zone_children();
    
    std::cout << "\n════════════════════════════════════════════════════════════════\n";
    std::cout << "Test Completed\n";
    std::cout << "════════════════════════════════════════════════════════════════\n\n";
    
#ifdef USE_FIXED_VERSION
    std::cout << "✅ Expected Result: AddressSanitizer should NOT report memory leaks\n";
    std::cout << "   All element destructors are called correctly\n\n";
    std::cout << "How it works:\n";
    std::cout << "  - if constexpr checks if T needs destructor at compile time\n";
    std::cout << "  - For non-trivial types: calls m_ptr[i].~T() for each element\n";
    std::cout << "  - For POD types: skips destructor calls (zero overhead)\n\n";
#else
    std::cout << "⚠️  Expected Result: AddressSanitizer WILL report memory leaks\n\n";
    std::cout << "Expected leak report breakdown:\n";
    std::cout << "  Leak #1-2: TEST 3 - Nested Vector<Vector<int>>\n";
    std::cout << "    - Direct leak of 8 byte(s): inner1.m_ptr (2 ints × 4 bytes)\n";
    std::cout << "    - Direct leak of 8 byte(s): inner2.m_ptr (2 ints × 4 bytes)\n";
    std::cout << "    - Source: Vector<int>::Realloc() at TracyVector.hpp:308\n";
    std::cout << "    - Why: Outer Vector doesn't call inner Vector destructors\n\n";
    
    std::cout << "  Leak #3-4: TEST 6 - Tracy's Vector<Vector<short_ptr<ZoneEvent>>>\n";
    std::cout << "    - Direct leak of 6 byte(s): children1.m_ptr (1 short_ptr × 6 bytes)\n";
    std::cout << "    - Direct leak of 6 byte(s): children2.m_ptr (1 short_ptr × 6 bytes)\n";
    std::cout << "    - Source: Vector<short_ptr<ZoneEvent>>::AllocMore()\n";
    std::cout << "    - Why: Same bug - outer Vector doesn't call inner destructors\n";
    std::cout << "    - ⚠️  This is Tracy's ACTUAL production code bug!\n\n";
    
    std::cout << "  Leak #5-6: TEST 1 - ResourceOwner\n";
    std::cout << "    - Direct leak of 4 byte(s): ResourceOwner(1).data_ (new int)\n";
    std::cout << "    - Direct leak of 4 byte(s): ResourceOwner(2).data_ (new int)\n";
    std::cout << "    - Source: operator new in ResourceOwner::ResourceOwner()\n";
    std::cout << "    - Why: Vector doesn't call ~ResourceOwner(), so data_ never deleted\n\n";
    
    std::cout << "  TOTAL: 36 bytes leaked in 6 allocations\n";
    std::cout << "    = 16 bytes (nested Vector m_ptr)\n";
    std::cout << "    + 12 bytes (Tracy zoneChildren m_ptr)\n";
    std::cout << "    +  8 bytes (ResourceOwner data_)\n\n";
    
    std::cout << "Stack traces will show:\n";
    std::cout << "  - Allocation call chain (bottom to top)\n";
    std::cout << "  - Exact line numbers where memory was allocated\n";
    std::cout << "  - Function names in the call stack\n\n";
#endif
    
    return 0;
}

Adjust tracy/server/TracySort.hpp to enable PoC to include the related types.

#ifndef __TRACYSORT_HPP__
#define __TRACYSORT_HPP__

// Use tracy_pdqsort.h instead of ppqsort.h to avoid external dependency
#include "tracy_pdqsort.h"

// Create compatibility layer for ppqsort API using tracy pdqsort
namespace ppqsort {
    namespace execution {
        struct par_t {};
        static constexpr par_t par{};
    }
    
    template<typename Execution, typename Iter, typename Compare>
    inline void sort(Execution, Iter begin, Iter end, Compare comp) {
        // Delegate to tracy::pdqsort
        tracy::pdqsort_branchless(begin, end, comp);
    }
}

#endif

Step 2: Compile with ORIGINAL (buggy) TracyVector

g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g \
    -I. test_before_after_fix.cpp tracy/server/TracyMemory.cpp \
    -DTRACY_ENABLE -o test_original

# Run and observe leaks
./test_original 2>&1 | tail -50

Expected output (BEFORE fix):

==XXXXX==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 8 byte(s) in 1 object(s) allocated from:
    #1 in tracy::Vector<int>::Realloc() TracyVector.hpp:308
    #4 in test_nested_vector() test_before_after_fix.cpp:134

[... 5 more leaks ...]

SUMMARY: AddressSanitizer: 36 byte(s) leaked in 6 allocation(s).

Step 3: Apply the fix and recompile

# Rename TracyVector.hpp to backup
mv tracy/server/TracyVector.hpp tracy/server/TracyVector_original.hpp

# Apply fixed version (from PR)
cp TracyVector_Fixed.hpp tracy/server/TracyVector.hpp

# Recompile
g++ -std=c++17 -fsanitize=address -fno-omit-frame-pointer -g \
    -I. test_before_after_fix.cpp tracy/server/TracyMemory.cpp \
    -DTRACY_ENABLE -o test_fixed

# Run and verify NO leaks
./test_fixed 2>&1 | tail -20

Expected output (AFTER fix):

════════════════════════════════════════════════════════════════
Test Completed
════════════════════════════════════════════════════════════════

Expected Result: AddressSanitizer should NOT report memory leaks
   All element destructors are called correctly

(clean exit with code 0 - no leak reports)

Step 4: Compare results

The test clearly demonstrates:

  • Before: 36 bytes leaked (inner Vector/ResourceOwner buffers never freed)
  • After: 0 bytes leaked (all destructors properly called)

Environment

  • Tracy version: Latest master branch (tested on commit from Nov 2025)
  • Compiler: GCC 11+ with -std=c++17
  • Detection tool: AddressSanitizer/LeakSanitizer (-fsanitize=address)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions