Skip to main content

Leak Tracking

package:ffi_leak_tracker helps you find and diagnose native memory leaks in Dart FFI code. It tracks allocations made through its custom allocators and answers one question precisely:

Which native allocations made from Dart were never freed, and where were they allocated?

When enabled, the tracker records every allocation — capturing the address, size, type, call stack, and timestamp — and can report any that were never freed.

A few things worth knowing upfront:

  • Tracking is opt-in and off by default
  • In release builds, adaptive allocators compile away to calloc() / malloc() with zero-overhead
  • Only allocations made through package:ffi_leak_tracker allocators are tracked; — calls to calloc() / malloc() directly are not visible to the tracker.

Allocators​

package:ffi_leak_tracker provides four allocators:

AllocatorZeroes memoryRelease build
adaptiveCalloc✓compile away to calloc
adaptiveMalloc✗compile away to malloc
diagnosticCalloc✓retains tracking
diagnosticMalloc✗retains tracking

Adaptive allocators are the right default for most code They participate in leak tracking in debug and profile builds and disappear entirely in release builds, so there is no overhead.

Diagnostic allocators retain tracking in all build modes. Use them only when you need to investigate leaks that reproduce exclusively in release builds — they carry measurable overhead and are not suitable as general-purpose allocators.

Enabling Tracking​

Importing package:ffi_leak_tracker and using its allocators does not automatically start tracking. This lets you control exactly when tracking is active and avoid recording allocations you don't care about.

Globally​

Call LeakTracker.enable() early in your program to enable tracking for the entire process lifetime:

void main() {
LeakTracker.enable();
// ... rest of your code
}

For most applications, you only want tracking in debug and profile builds. Use LeakTracker.enableInDebug() instead — it is tree-shaken away in release builds and is equivalent to wrapping LeakTracker.enable() in a !kReleaseMode guard:

void main() {
LeakTracker.enableInDebug();
// ... rest of your code
}

To stop recording, call LeakTracker.disable(). To query the current state, read LeakTracker.enabled.

Scoped Tracking​

To isolate tracking to a specific operation — such as a single test or benchmark — use LeakTracker.runScoped():

LeakTracker.runScoped(() {
// Only allocations made inside this callback are tracked.
final ptr = adaptiveCalloc<Int32>();
adaptiveCalloc.free(ptr);
});

Tracking is automatically enabled for the duration of the callback. The scope maintains its own isolated registry, independent of any outer tracking context. An optional filter parameter lets you suppress specific allocations within the scope.

Reporting Leaks​

Call LeakTracker.emit() at any point to print outstanding (un-freed) allocations:

void main() {
LeakTracker.enableInDebug();
// ... rest of your code
LeakTracker.emit();
}

By default, output goes to the console. Pass a custom LeaksEmitter to redirect it — for example, to a JSON file, a logging framework, or a test reporter:

LeakTracker.emit(emitter: const .json('leaks.json'));

// Or with a fully custom emitter:
LeakTracker.emit(emitter: const MyCustomEmitter());

LeakTracker.emit() does not clear the registry. To reset it, call LeakTracker.reset().

To assert that no leaks are present — and throw a LeakTrackerException if any tracked allocations remain — use LeakTracker.verifyNoLeaks(). The debug-only variant, LeakTracker.verifyNoLeaksInDebug(), is a no-op in release builds:

void main() {
LeakTracker.enableInDebug();
// ... rest of your code
LeakTracker.verifyNoLeaksInDebug();
}

Leak Detection in Tests​

The snippet below enables tracking for an entire test suite, resets the registry before each test, and asserts no leaks remain after each test — with no boilerplate required in the tests themselves:

import 'dart:ffi';

import 'package:ffi_leak_tracker/ffi_leak_tracker.dart';
import 'package:test/test.dart';

void main() {
setUpAll(LeakTracker.enableInDebug);

setUp(LeakTracker.reset);

tearDown(LeakTracker.verifyNoLeaksInDebug);

test('my native memory test', () {
final ptr = adaptiveCalloc<Int32>();
// ... test code
adaptiveCalloc.free(ptr);
});

// ... more tests
}

How It Works​

  1. An allocation is made through a package:ffi_leak_tracker allocator while tracking is enabled.
  2. The allocator registers the allocation with the tracker, recording its address, size, type, timestamp, and current call stack.
  3. When the memory is freed via the same allocator's free(), the tracker removes the record from its registry.
  4. When LeakTracker.emit() or LeakTracker.verifyNoLeaks() is called, any allocation still present in the registry is reported as a leak.

Example​

import 'dart:ffi';

import 'package:ffi_leak_tracker/ffi_leak_tracker.dart';

void main() {
// Enable tracking only in debug/profile builds.
LeakTracker.enableInDebug();

print('Allocating memory without freeing it...');
final ptr = adaptiveCalloc<Int32>();

// Fix the leak by uncommenting:
// adaptiveCalloc.free(ptr);

print('Verifying for leaks...');

// In debug builds this throws if any allocations remain.
LeakTracker.verifyNoLeaksInDebug();

print('No leaks detected.');
}