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()frompackage:ffiwith zero-overhead - Only allocations made through
package:ffi_leak_trackerallocators are tracked — allocations viacalloc()/malloc()directly are invisible 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 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​
- An allocation is made through a
package:ffi_leak_trackerallocator after tracking is enabled - The allocator registers the allocation with the leak 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 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.');
}