Skip to main content

Leak Tracking

package:ffi_leak_tracker helps you find and diagnose native memory leaks in Dart FFI code by tracking allocations made through its custom allocators.

It is designed to answer one question precisely:

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

When enabled, the leak tracker records every allocation made through its allocators — capturing the size, type, call stack, and timestamp — and can report any that were not freed.

A few things worth knowing upfront:

  • Tracking is opt-in and disabled by default
  • In release builds, adaptive allocators compile away to calloc() / malloc() from package:ffi with zero-overhead
  • Only allocations made through package:ffi_leak_tracker allocators are tracked — allocations via calloc() / malloc() directly are invisible 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 application code. They participate in leak tracking in debug and profile builds and disappear entirely in release builds.

Diagnostic allocators retain tracking regardless of build mode. Use them only when you need to investigate leaks that reproduce exclusively in release builds — they introduce measurable overhead and should not be used as general-purpose allocators.

Enabling Tracking​

Leak tracking must be explicitly enabled before any allocations you want to track are made. Allocations performed before tracking is enabled are not retroactively recorded.

Globally​

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

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

To enable only in debug and profile builds — which is the recommended approach for most applications — use LeakTracker.enableInDebug():

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

This is equivalent to wrapping enable() in a !kReleaseMode check and ensures adaptive allocators compile away cleanly in release builds.

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

Scoped (Zones)​

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

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

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

Reporting Leaks​

To emit a report of outstanding allocations at any point, call LeakTracker.emit():

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

By default this prints to the console. You can pass a custom LeaksEmitter to redirect output — for example to a file in JSON format, a logging framework, or a test reporter:

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

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

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

To assert that no leaks are present — for example in a test — use LeakTracker.verifyNoLeaks(), which throws a LeakTrackerException if any tracked allocations remain outstanding.

To perform this check only in debug and profile builds, use LeakTracker.verifyNoLeaksInDebug():

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

And here's how you might use it in a test:

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
}

This will automatically enable tracking for all tests, reset the registry before each test, and verify that no leaks remain after each test — without any boilerplate in the tests themselves.

How It Works​

  1. An allocation is made through a package:ffi_leak_tracker allocator after tracking is enabled
  2. The allocator registers the allocation with the leak 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 tracker's 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.');
}