leak_sentinel 0.1.0
leak_sentinel: ^0.1.0 copied to clipboard
A custom_lint plugin that finds disposal-based memory leaks in Flutter — undisposed controllers, uncancelled subscriptions and timers — and fixes them with one click.
🛡️ 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 model
- ❌ Recognise user types transitively (any subtype of
ChangeNotifier,Sink, …) - ❌
removeListenerrule foraddListenerwithout a matching removal - ❌ Flag
BuildContextcaptured across anawait - ❌ Publish 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.