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_trackerallocators are tracked; — calls tocalloc()/malloc()directly are not visible to the tracker.
Allocators​
package:ffi_leak_tracker provides four allocators:
| Allocator | Zeroes memory | Release 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​
- An allocation is made through a
package:ffi_leak_trackerallocator while tracking is enabled. - The allocator registers the allocation with the tracker, recording its address, size, type, timestamp, and current call stack.
- When the memory is freed via the same allocator's
free(), the tracker removes the record from its registry. - When
LeakTracker.emit()orLeakTracker.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.');
}