π‘οΈ leak_sentinel
A
custom_lintplugin that hunts down disposal-based memory leaks in Flutter β undisposed controllers, uncancelled subscriptions and timers β and fixes them with one click, in your IDE and in CI.
Part of OpenForge β open-source
solutions to the problems developers hit across frameworks. leak_sentinel is
OpenForge's Flutter/Dart tool.
Why this exists
Dart is garbage-collected, so you never free() anything. But Flutter is full
of objects that own resources the GC can't reclaim on its own β a
StreamSubscription keeps its callback (and everything it captures) alive for
the life of the stream; an AnimationController holds a Ticker bound to the
SchedulerBinding; a periodic Timer keeps firing after its widget is gone.
The fix is always the same β release them in State.dispose() β and the bug is
always the same: someone forgot. leak_sentinel finds the ones you forgot.
What it catches
| Rule | Flags a State field that⦠|
Quick-fix |
|---|---|---|
missing_dispose |
is a disposable controller/notifier (AnimationController, TextEditingController, ScrollController, TabController, PageController, FocusNode, ValueNotifier, β¦) never passed to dispose() |
inserts field.dispose() |
uncancelled_subscription |
is a StreamSubscription never cancel()-ed |
inserts field.cancel() |
uncancelled_timer |
is a Timer never cancel()-ed |
inserts field.cancel() |
Each rule understands dispose() β if you already release the resource
anywhere in that method (including field?.dispose()), it stays quiet.
Quick start
Add the plugin and the custom_lint runner as dev dependencies:
# pubspec.yaml
dev_dependencies:
custom_lint: ^0.8.0
leak_sentinel: ^0.1.0
Enable custom_lint in your analyzer config:
# analysis_options.yaml
analyzer:
plugins:
- custom_lint
That's it. Open any file in an IDE with the Dart plugin and leaks show up as warnings with a π‘ quick-fix. From the terminal:
dart run custom_lint # report every leak
dart run custom_lint --fix # report and auto-fix them
The auto-fix in action
Before β a State with two leaks and no dispose():
class _FixMeState extends State<FixMe> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(vsync: this);
late final Timer _timer = Timer.periodic(const Duration(seconds: 1), (_) {});
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
After dart run custom_lint --fix:
class _FixMeState extends State<FixMe> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(vsync: this);
late final Timer _timer = Timer.periodic(const Duration(seconds: 1), (_) {});
@override
Widget build(BuildContext context) => const SizedBox.shrink();
@override
void dispose() {
_controller.dispose();
_timer.cancel();
super.dispose();
}
}
The fix creates dispose() if it's missing, picks the correct release verb per
resource type, and always calls super.dispose() last.
Use it in CI
Fail the build on any new leak:
# .github/workflows/leaks.yaml
- uses: subosito/flutter-action@v2
with: { channel: stable }
- run: flutter pub get
- run: dart run custom_lint # non-zero exit if any leak is found
Configuration
Disable a rule project-wide:
# analysis_options.yaml
custom_lint:
rules:
- uncancelled_timer: false
Silence a single line:
// ignore: missing_dispose
late final AnimationController _intentionallyKept = AnimationController(vsync: this);
Limitations (read these)
leak_sentinel is a static tool and deliberately favours zero false
positives over exhaustiveness. In this first release it:
- needs an explicit type annotation on the field β
final AnimationController c = β¦is checked;final c = AnimationController(β¦)(inferred) is not (yet); - matches a curated list of known types by name β a subclass declared with its own type name isn't recognised until you add it;
- only inspects classes that directly
extend State<β¦>.
It is not a runtime heap-leak detector. For retention that only shows up at
runtime (closures held by singletons, unbounded caches), pair it with the
Flutter team's leak_tracker and
DevTools' memory view. Static and runtime detection are complementary, not
substitutes β see doc/DESIGN.md for where the boundary is.
How it works
Every rule extends a shared ReleaseRule base that walks the AST syntactically
(no reliance on the analyzer's shifting element model): find State subclasses,
collect the fields whose declared type is an owned resource, subtract the ones
already released in dispose(), and report the rest. The fix is a single
DartFileEdit. New rules are typically ~15 lines β see
CONTRIBUTING.md.
Roadmap
Detect inferred-type fields via the resolved element modelRecognise user types transitively (any subtype ofChangeNotifier,Sink, β¦)removeListenerrule foraddListenerwithout a matching removalFlagBuildContextcaptured across anawaitPublish to pub.dev
Contributing
Issues and PRs are very welcome β this is exactly the kind of community-driven fix OpenForge exists for. Start with CONTRIBUTING.md and our Code of Conduct.
License
MIT Β© 2026 OpenForge.
Acknowledgements
Built on custom_lint by Remi Rousselet,
and inspired by the Flutter team's leak_tracker.