Skip to content

Memory leak when creating new animation with different parameters #130

@michaellee1019

Description

@michaellee1019

Im noticing a memory leak over time when creating new animations. For my project, the way I would like to do animations is to replace the existing animation that was previously running. Its not an AnimationSequence, because I'd like to stop running one animation for another. The animation selected is from an external event (a message from i2c).

Here is an example load test script to demonstrate the issue. It seems like something is not internally cleaning up the animation object. I'm using an Adafruit RP2040 SCORPIO with the 8 channel neopixel support:

"""
Memory Leak Test for Adafruit LED Animation Library
Demonstrates memory consumption and leaks when creating/destroying animation objects

This script creates Solid animations with random colors, runs them for 10 seconds,
then creates new animations with different parameters. Memory usage is tracked
to demonstrate the memory leak issue.

Expected behavior: Memory should return to baseline after gc.collect()
Actual behavior: Memory continues to increase with each animation creation
"""

import time
import board
import gc
import random
import supervisor

from adafruit_led_animation.animation.solid import Solid
from adafruit_led_animation.color import RED, GREEN, BLUE, WHITE, YELLOW, PURPLE, ORANGE
from adafruit_neopxl8 import NeoPxl8

# Test configuration
STRAND_COUNT = 8
PIXELS_PER_STRAND = 50
TOTAL_PIXELS = STRAND_COUNT * PIXELS_PER_STRAND
TEST_CYCLES = 20  # Number of animation create/destroy cycles
ANIMATION_DURATION = 1  # Seconds per animation

# Available colors for random selection
COLORS = [RED, GREEN, BLUE, WHITE, YELLOW, PURPLE, ORANGE]

def log_memory(label=""):
    """Log detailed memory usage information"""
    if not supervisor.runtime.serial_connected:
        return
    
    try:
        # Force garbage collection before measurement
        gc.collect()
        
        free = gc.mem_free()
        alloc = gc.mem_alloc()
        total = free + alloc
        percent_used = (alloc * 100) // total
        
        print(f"MEMORY [{label}]: Free={free:,}B, Used={alloc:,}B, Total={total:,}B, %Used={percent_used}%")
        return percent_used, alloc, total
    except Exception as e:
        print(f"MEMORY [{label}]: Error - {e}")
        return None, None, None

def create_test_pixels():
    """Create the LED pixel object"""
    print(f"Creating {STRAND_COUNT} strands with {PIXELS_PER_STRAND} pixels each")
    
    pixels = NeoPxl8(
        board.NEOPIXEL0,
        TOTAL_PIXELS,
        num_strands=STRAND_COUNT,
        auto_write=False,
        brightness=0.3,
    )
    
    # Turn off all pixels initially
    pixels.fill((0, 0, 0))
    pixels.show()
    
    return pixels

def run_memory_leak_test():
    """Main test function that demonstrates the memory leak"""
    print("=" * 60)
    print("ADAFRUIT LED ANIMATION LIBRARY MEMORY LEAK TEST")
    print("=" * 60)
    print(f"Test Configuration:")
    print(f"  - Strands: {STRAND_COUNT}")
    print(f"  - Pixels per strand: {PIXELS_PER_STRAND}")
    print(f"  - Total pixels: {TOTAL_PIXELS}")
    print(f"  - Test cycles: {TEST_CYCLES}")
    print(f"  - Animation duration: {ANIMATION_DURATION}s per cycle")
    print("=" * 60)
    
    # Initial memory measurement
    print("\n1. BASELINE MEMORY MEASUREMENT")
    baseline_percent, baseline_used, baseline_total = log_memory("BASELINE")
    
    # Create pixel object
    print("\n2. CREATING PIXEL OBJECT")
    pixels = create_test_pixels()
    pixel_percent, pixel_used, pixel_total = log_memory("AFTER PIXEL CREATION")
    
    pixel_overhead = pixel_used - baseline_used if baseline_used else 0
    print(f"Pixel object overhead: {pixel_overhead:,} bytes")
    
    # Track memory usage over test cycles
    memory_history = []
    
    print(f"\n3. STARTING {TEST_CYCLES} ANIMATION CYCLES")
    print("Each cycle: Create animation -> Run 10s -> Destroy -> GC")
    print("-" * 60)
    
    for cycle in range(TEST_CYCLES):
        cycle_start_time = time.monotonic()
        
        print(f"\nCycle {cycle + 1}/{TEST_CYCLES}")
        
        # Memory before creating animation
        pre_percent, pre_used, pre_total = log_memory(f"CYCLE {cycle + 1} - BEFORE CREATE")
        
        # Create a new Solid animation with random color
        random_color = random.choice(COLORS)
        print(f"  Creating Solid animation with color {random_color}")
        
        try:
            animation = Solid(pixels, color=random_color)
            post_create_percent, post_create_used, post_create_total = log_memory(f"CYCLE {cycle + 1} - AFTER CREATE")
            
            # Calculate animation object overhead
            animation_overhead = post_create_used - pre_used if pre_used else 0
            print(f"  Animation object overhead: {animation_overhead:,} bytes")
            
            # Run animation for specified duration
            print(f"  Running animation for {ANIMATION_DURATION} seconds...")
            end_time = time.monotonic() + ANIMATION_DURATION
            
            while time.monotonic() < end_time:
                animation.animate()
                time.sleep(0.1)  # Small delay to prevent overwhelming the system
            
            # Memory after running animation
            post_run_percent, post_run_used, post_run_total = log_memory(f"CYCLE {cycle + 1} - AFTER RUN")
            
            # Destroy animation object
            print("  Destroying animation object...")
            animation = None
            
            # Force garbage collection
            print("  Running garbage collection...")
            gc.collect()
            gc.collect()  # Double GC to ensure cleanup
            
            # Memory after cleanup
            post_gc_percent, post_gc_used, post_gc_total = log_memory(f"CYCLE {cycle + 1} - AFTER GC")
            
            # Calculate memory leak
            if baseline_used and post_gc_used:
                leak_bytes = post_gc_used - baseline_used - pixel_overhead
                print(f"  Memory leak vs baseline: {leak_bytes:,} bytes")
                
                # Store memory data for summary
                memory_history.append({
                    'cycle': cycle + 1,
                    'baseline': baseline_used,
                    'after_create': post_create_used,
                    'after_run': post_run_used,
                    'after_gc': post_gc_used,
                    'animation_overhead': animation_overhead,
                    'total_leak': leak_bytes,
                    'percent_used': post_gc_percent
                })
        
        except MemoryError as e:
            print(f"  MEMORY ERROR in cycle {cycle + 1}: {e}")
            log_memory(f"CYCLE {cycle + 1} - MEMORY ERROR")
            break
        except Exception as e:
            print(f"  ERROR in cycle {cycle + 1}: {e}")
            continue
        
        # Brief pause between cycles
        time.sleep(1)
    
    # Final summary
    print("\n" + "=" * 60)
    print("MEMORY LEAK TEST SUMMARY")
    print("=" * 60)
    
    if memory_history:
        print(f"{'Cycle':<6} {'%Used':<6} {'Leak(B)':<10} {'AnimObj(B)':<12} {'Status'}")
        print("-" * 50)
        
        for data in memory_history:
            status = "LEAK" if data['total_leak'] > 1000 else "OK"
            print(f"{data['cycle']:<6} {data['percent_used']:<6} {data['total_leak']:<10,} {data['animation_overhead']:<12,} {status}")
        
        # Calculate total leak
        final_leak = memory_history[-1]['total_leak'] if memory_history else 0
        avg_animation_overhead = sum(d['animation_overhead'] for d in memory_history) // len(memory_history)
        
        print("-" * 50)
        print(f"Final memory leak: {final_leak:,} bytes")
        print(f"Average animation overhead: {avg_animation_overhead:,} bytes per object")
        print(f"Expected memory after GC: ~{baseline_used + pixel_overhead:,} bytes")
        print(f"Actual memory after GC: ~{memory_history[-1]['after_gc']:,} bytes")
        
        if final_leak > 1000:
            print("\n🚨 MEMORY LEAK DETECTED!")
            print("Animation objects are not being properly garbage collected.")
            print("Each animation creation leaves memory that is never freed.")
        else:
            print("\n✅ No significant memory leak detected.")
    
    print("\n" + "=" * 60)
    print("TEST COMPLETE - Please include this output in your bug report")
    print("=" * 60)

# Run the test
if __name__ == "__main__":
    try:
        run_memory_leak_test()
    except KeyboardInterrupt:
        print("\nTest interrupted by user")
    except Exception as e:
        print(f"\nTest failed with error: {e}")
        log_memory("ERROR STATE")

Results:

============================================================
MEMORY LEAK TEST SUMMARY
============================================================
Cycle  %Used  Leak(B)    AnimObj(B)   Status
--------------------------------------------------
1      21     80         352          OK
2      21     160        352          OK
3      21     240        352          OK
4      21     320        336          OK
5      21     400        352          OK
6      22     496        352          OK
7      22     576        336          OK
8      22     656        320          OK
9      22     736        352          OK
10     22     848        352          OK
11     22     928        336          OK
12     22     1,008      352          LEAK
13     22     1,088      352          LEAK
14     22     1,168      352          LEAK
15     22     1,248      336          LEAK
16     22     1,328      320          LEAK
17     22     1,408      352          LEAK
18     22     1,552      352          LEAK
19     22     1,632      352          LEAK
20     22     1,712      352          LEAK
21     22     1,792      352          LEAK
22     22     1,872      320          LEAK
23     22     1,952      352          LEAK
24     22     2,032      352          LEAK
25     22     2,112      352          LEAK
26     23     2,192      352          LEAK
27     23     2,272      352          LEAK
28     23     2,352      352          LEAK
29     23     2,432      352          LEAK
30     23     2,512      352          LEAK
31     23     2,592      352          LEAK
32     23     2,672      352          LEAK
33     23     2,752      352          LEAK
34     23     2,960      352          LEAK
35     23     3,040      352          LEAK
36     23     3,120      352          LEAK
37     23     3,200      352          LEAK
38     23     3,280      352          LEAK
39     23     3,360      352          LEAK
40     23     3,440      352          LEAK
41     23     3,520      352          LEAK
42     23     3,600      352          LEAK
43     23     3,680      352          LEAK
44     23     3,760      352          LEAK
45     23     3,840      352          LEAK
46     24     3,920      352          LEAK
47     24     4,000      352          LEAK
48     24     4,080      352          LEAK
49     24     4,160      352          LEAK
50     24     4,240      352          LEAK
51     24     4,320      352          LEAK
52     24     4,400      352          LEAK
53     24     4,480      352          LEAK
54     24     4,560      352          LEAK
55     24     4,640      352          LEAK
56     24     4,720      352          LEAK
57     24     4,800      352          LEAK
58     24     4,880      352          LEAK
59     24     4,960      352          LEAK
60     24     5,040      352          LEAK
61     24     5,120      352          LEAK
62     24     5,200      352          LEAK
63     24     5,280      352          LEAK
64     24     5,360      352          LEAK
65     24     5,440      352          LEAK
66     25     5,776      352          LEAK
67     25     5,856      352          LEAK
68     25     5,936      320          LEAK
69     25     6,016      352          LEAK
70     25     6,096      352          LEAK
71     25     6,176      352          LEAK
72     25     6,256      352          LEAK
73     25     6,336      352          LEAK
74     25     6,416      352          LEAK
75     25     6,496      352          LEAK
76     25     6,576      352          LEAK
77     25     6,656      352          LEAK
78     25     6,736      352          LEAK
79     25     6,816      352          LEAK
80     25     6,896      352          LEAK
81     25     6,976      352          LEAK
82     25     7,056      352          LEAK
83     25     7,136      352          LEAK
84     25     7,216      352          LEAK
85     26     7,296      352          LEAK
86     26     7,376      352          LEAK
87     26     7,456      352          LEAK
88     26     7,536      352          LEAK
89     26     7,616      352          LEAK
90     26     7,696      352          LEAK
91     26     7,776      352          LEAK
92     26     7,856      352          LEAK
93     26     7,936      352          LEAK
94     26     8,016      352          LEAK
95     26     8,096      352          LEAK
96     26     8,176      352          LEAK
97     26     8,256      352          LEAK
98     26     8,336      352          LEAK
99     26     8,416      352          LEAK
100    26     8,496      352          LEAK
--------------------------------------------------
Final memory leak: 8,496 bytes
Average animation overhead: 350 bytes per object
Expected memory after GC: ~36,976 bytes
Actual memory after GC: ~45,472 bytes

🚨 MEMORY LEAK DETECTED!
Animation objects are not being properly garbage collected.
Each animation creation leaves memory that is never freed.

============================================================
TEST COMPLETE
============================================================

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions