Skip to content

Memory retention: ~6 MB/cycle heap growth when creating/destroying artboards with data binding #88

@mfazekas

Description

@mfazekas

Summary

When repeatedly creating and destroying ArtboardInstance + ViewModelInstance with data binding on a .riv file that has nested ViewModel properties, heap memory grows ~6 MB per cycle and is never reclaimed — even after all rcp smart pointers go out of scope.

This is reproducible in pure C++ with no platform wrapper involved. Originally reported via rive-ios (rive-app/rive-ios#427) where it manifests as ~150 MB growth over 10 mount/unmount cycles.

Reproduction

The .riv file used is blinko.riv from the rive-ios example assets. It has 10 ViewModels (7 of 8 default instance properties are nested ViewModel types), 3 nested artboards, and 38 data binds on the default state machine.

C++ reproducer (Catch2 test, runs in rive-runtime test harness)
#include <rive/file.hpp>
#include <rive/artboard.hpp>
#include <rive/animation/state_machine_instance.hpp>
#include <rive/viewmodel/viewmodel_instance.hpp>
#include "rive_file_reader.hpp"
#include <catch.hpp>
#include <cstdio>
#ifdef __APPLE__
#include <malloc/malloc.h>
#include <mach/mach.h>
#else
#include <malloc.h>
#endif

using namespace rive;

static size_t getHeapUsage()
{
#ifdef __APPLE__
    malloc_statistics_t stats;
    size_t total = 0;
    unsigned int count = 0;
    malloc_zone_t** zones = nullptr;
    kern_return_t err = malloc_get_all_zones(
        mach_task_self(), nullptr, (vm_address_t**)&zones, &count);
    if (err == KERN_SUCCESS)
    {
        for (unsigned int i = 0; i < count; i++)
        {
            malloc_zone_statistics(zones[i], &stats);
            total += stats.size_in_use;
        }
    }
    return total;
#else
    struct mallinfo mi = mallinfo();
    return mi.uordblks;
#endif
}

TEST_CASE("heap growth with data binding", "[data binding]")
{
    auto file = ReadRiveFile("assets/blinko.riv");
    REQUIRE(file != nullptr);

    constexpr int NUM_CYCLES = 10;
    constexpr int FRAMES_PER_CYCLE = 30;

    // Warm up — first cycle allocates caches, font data, etc.
    {
        auto artboard = file->artboardDefault()->instance();
        auto vmi = file->createDefaultViewModelInstance(artboard.get());
        if (vmi)
        {
            auto machine = artboard->defaultStateMachine();
            if (machine)
            {
                machine->bindViewModelInstance(vmi);
                for (int f = 0; f < FRAMES_PER_CYCLE; f++)
                    machine->advanceAndApply(0.016f);
            }
        }
    }

    size_t baselineHeap = getHeapUsage();

    for (int i = 0; i < NUM_CYCLES; i++)
    {
        {
            auto artboard = file->artboardDefault()->instance();
            auto vmi = file->createDefaultViewModelInstance(artboard.get());
            auto machine = artboard->defaultStateMachine();
            machine->bindViewModelInstance(vmi);
            machine->advanceAndApply(0.0f);
            for (int f = 0; f < FRAMES_PER_CYCLE; f++)
                machine->advanceAndApply(0.016f);
            // artboard, machine, vmi all go out of scope here
        }

        size_t heap = getHeapUsage();
        printf("Cycle %2d: growth_from_baseline=%.1f MB\n",
               i, (heap - baselineHeap) / (1024.0 * 1024.0));
    }

    size_t finalHeap = getHeapUsage();
    double totalGrowth = (double)(finalHeap - baselineHeap) / (1024.0 * 1024.0);
    printf("Total growth: %.1f MB (%.2f MB/cycle)\n", totalGrowth, totalGrowth / NUM_CYCLES);

    // After all objects are destroyed, heap should return near baseline
    REQUIRE(totalGrowth < 5.0);  // Currently fails: ~63 MB growth
}

Run with:

cd tests/unit_tests
./test.sh -m "[data binding]"

Observed output

Baseline heap after warmup: 15.2 MB

Cycle  0: growth_from_baseline=6.3 MB
Cycle  1: growth_from_baseline=12.5 MB
Cycle  2: growth_from_baseline=18.8 MB
Cycle  3: growth_from_baseline=25.1 MB
Cycle  4: growth_from_baseline=31.4 MB
Cycle  5: growth_from_baseline=37.7 MB
Cycle  6: growth_from_baseline=44.0 MB
Cycle  7: growth_from_baseline=50.3 MB
Cycle  8: growth_from_baseline=56.6 MB
Cycle  9: growth_from_baseline=62.9 MB

Total growth: 62.9 MB (6.29 MB/cycle)

Heap grows linearly at ~6.3 MB per cycle and never returns to baseline despite all rcp<ArtboardInstance>, rcp<ViewModelInstance>, and unique_ptr<StateMachineInstance> going out of scope.

Expected behavior

Heap should return near baseline after each cycle since all objects are destroyed.

Environment

  • macOS 15.7.3, Apple M2 Max
  • rive-runtime at commit a8887748 (main as of 2026-03-24)
  • .riv file: blinko.riv from rive-ios Example-iOS/Assets/

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions