πŸ›‘οΈ leak_sentinel

CI License: MIT style: custom_lint

A custom_lint plugin 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, …)
  • removeListener rule for addListener without a matching removal
  • Flag BuildContext captured across an await
  • 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.

Libraries

leak_sentinel