Skip to content

fix!: release retained resources on dispose - #1668

Open
spydon wants to merge 7 commits into
mainfrom
lukasklingsbo/sdk-1441-leak-tracker-audit
Open

fix!: release retained resources on dispose#1668
spydon wants to merge 7 commits into
mainfrom
lukasklingsbo/sdk-1441-leak-tracker-audit

Conversation

@spydon

@spydon spydon commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes three leaks found by auditing the packages and the example apps with leak_tracker. The tracking itself was a one-off investigation and is not part of this change.

Closes SDK-1441.

Leaks fixed

The Supabase singleton pinned the disposed client graph. dispose() tore everything down but never dropped its references, so the disposed SupabaseClient and everything under it (auth, realtime, PostgREST, storage, functions, the http clients) stayed reachable from a static field for the rest of the process.

client is now a getter over a nullable backing field, and dispose() clears _client, _supabaseAuth, _lifecycleListener, _restoreSessionCancellableOperation, _logSubscription and the pending lifecycle operation. Clearing happens before teardown, so a step that throws cannot leave the singleton pinning a half-disposed client, and dispose() returns early when there is nothing to dispose so it is safe to call more than once.

YAJsonIsolate.dispose() hung forever when the isolate was never used. It awaited _createdIsolate.future, which only completes inside initialize(), so an instance that was never exercised never finished disposing and left its ReceivePort open. Reachable through PostgrestClient and FunctionsClient when they are handed a custom isolate that never runs.

It now returns early after closing the port when nothing was spawned. Overlapping dispose() calls share a single shutdown future, and initialize(), decode() and encode() throw a StateError afterwards rather than spawning a replacement isolate onto a closed receive port and waiting for a reply that never arrives.

This one also blocked the fix above: making the existing widget test dispose the singleton turned it into a 10 minute timeout until the isolate teardown was fixed.

Undisposed TextEditingController in the database_crud and passkeys examples. Both rename dialogs built a controller in the calling method and never disposed it.

Each is now a _RenameDialog stateful widget that owns and disposes its controller. Disposing in the caller does not work: whenComplete on the showDialog future fires while the route is still animating out, and the TextField then rebuilds against a disposed controller.

Tests

  • initialization_test.dart gains cases asserting the singleton drops its client reference on dispose and that a second dispose() completes. The first captures the instance before disposing, because Supabase.instance itself refuses to hand out a disposed instance.
  • yet_another_json_isolate gains cases for disposing an unused isolate, disposing twice, overlapping disposals, and using the isolate after disposal. Every disposal in that suite is bounded by a timeout so a regression fails rather than hanging.
  • The existing widget test now tears the singleton down via addTearDown, which also covers the isolate fix in a widget test context. It needs tester.runAsync around both initialize and dispose, see the caveat below.
  • The MockWidget stub now cancels its auth subscription in dispose().

Verification

  • supabase_flutter: 65 tests pass.
  • supabase: 134 tests pass.
  • yet_another_json_isolate: 27, postgrest: 196, functions_client: 48, realtime_client and storage_client all pass.
  • dcm analyze packages is clean.
  • The example fixes were confirmed on an Android emulator against the local stack while the leak tracking was still wired up: database_crud went from one reported leak to none. passkeys needs real platform passkey support and does not run on an emulator, so its identical fix comes from inspection.

Breaking change

Supabase.instance.client is a getter rather than a field, so it can no longer be assigned, and it throws a StateError after dispose() instead of returning the disposed client. Reads on an initialized instance are unchanged.

Found but not fixed here

SupabaseClient.dispose() cannot complete inside testWidgets. The constructor eagerly spawns the JSON isolate, and Isolate.spawn never resolves under the fake clock, so consumers writing widget tests have to wrap both Supabase.initialize and dispose in tester.runAsync. Making dispose() non-blocking would weaken its contract, so it is a design call rather than a drive-by fix.

The other finding from this audit, RealtimeClient.remove() dropping unrelated channels, is fixed separately in #1669.

Summary by CodeRabbit

  • New Features

    • Renaming tasks and passkeys now opens a dedicated dialog with the current name prefilled.
    • Saved names are automatically trimmed of leading and trailing spaces.
  • Bug Fixes

    • Improved cleanup and disposal behavior for Supabase and JSON processing resources.
    • Repeated or early disposal is now handled safely.
    • Attempts to use disposed services now provide clear state errors.
    • Improved cleanup during authentication and widget testing scenarios.

@spydon
spydon requested a review from a team as a code owner August 7, 2026 09:31
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d32bea1-26e0-4339-8726-ea0e706ea1db

📥 Commits

Reviewing files that changed from the base of the PR and between 7c48e8a and 30ab0f0.

📒 Files selected for processing (5)
  • packages/supabase_flutter/lib/src/supabase.dart
  • packages/supabase_flutter/test/initialization_test.dart
  • packages/supabase_flutter/test/widget_test.dart
  • packages/yet_another_json_isolate/lib/src/_isolates_io.dart
  • packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart
 ________________________________________________________________
< 'Works on my machine' is not a QA strategy, it's a confession. >
 ----------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d32bea1-26e0-4339-8726-ea0e706ea1db

📥 Commits

Reviewing files that changed from the base of the PR and between 7c48e8a and 30ab0f0.

📒 Files selected for processing (5)
  • packages/supabase_flutter/lib/src/supabase.dart
  • packages/supabase_flutter/test/initialization_test.dart
  • packages/supabase_flutter/test/widget_test.dart
  • packages/yet_another_json_isolate/lib/src/_isolates_io.dart
  • packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart
💤 Files with no reviewable changes (2)
  • packages/supabase_flutter/test/widget_test.dart
  • packages/supabase_flutter/test/initialization_test.dart
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart
  • packages/yet_another_json_isolate/lib/src/_isolates_io.dart
  • packages/supabase_flutter/lib/src/supabase.dart

📝 Walkthrough

Walkthrough

The PR extracts rename dialogs into stateful widgets, improves Supabase singleton disposal and client access checks, and makes JSON isolate disposal safe for unused and repeated calls. Tests cover the updated lifecycle behavior.

Changes

Rename dialog ownership

Layer / File(s) Summary
Stateful rename dialogs
examples/database_crud/lib/main.dart, examples/passkeys/lib/main.dart
Rename dialogs now own and dispose their text controllers. Save returns trimmed input; cancel returns null.

Supabase singleton cleanup

Layer / File(s) Summary
Supabase disposal state
packages/supabase_flutter/lib/src/supabase.dart
The client uses private nullable storage with a guarded public getter. Disposal clears retained resources and lifecycle state.
Supabase cleanup validation
packages/supabase_flutter/test/widget_test.dart, packages/supabase_flutter/test/widget_test_stubs.dart, packages/supabase_flutter/test/initialization_test.dart
Tests perform asynchronous initialization and cleanup, cancel auth subscriptions, and verify post-disposal client access and repeated disposal.

JSON isolate disposal

Layer / File(s) Summary
Safe isolate shutdown
packages/yet_another_json_isolate/lib/src/_isolates_io.dart, packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart
YAJsonIsolate caches disposal, supports unused and repeated disposal, and rejects operations after disposal. Tests cover these cases.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels: v3

Suggested reviewers: tr00d

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary resource-release and disposal fixes covered by the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lukasklingsbo/sdk-1441-leak-tracker-audit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses resource-retention leaks across the Supabase Flutter monorepo by making disposal paths actually release references (so objects can be GC’d) and by adding leak_tracker-based regression coverage in unit/widget/integration tests.

Changes:

  • Make teardown paths safe and leak-free (notably YAJsonIsolate.dispose() and Supabase singleton disposal semantics).
  • Add leak-tracking harnesses and targeted leak tests for supabase and supabase_flutter.
  • Enable leak tracking in the Flutter test suites (including example integration tests) via flutter_test_config.dart, with necessary ignore rules for global Flutter caches.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart Adds regression tests ensuring YAJsonIsolate.dispose() completes when unused and when called twice.
packages/yet_another_json_isolate/lib/src/_isolates_io.dart Makes dispose() idempotent and non-blocking when the isolate was never spawned (closes ReceivePort).
packages/supabase/test/leak_utils.dart Introduces a test-side leak tracking harness (LeakScope, collectLeaks, expectNoLeaks).
packages/supabase/test/leak_utils_test.dart Adds self-tests verifying the harness actually reports notDisposed and notGCed leaks.
packages/supabase/test/leak_test.dart Adds leak tests covering SupabaseClient, sub-clients, realtime channels, and stream/auth subscriptions.
packages/supabase/pubspec.yaml Adds leak_tracker as a dev dependency for leak tests.
packages/supabase_flutter/test/widget_test.dart Updates widget test to use tester.runAsync for isolate spawning and disposes the singleton to satisfy leak tracking.
packages/supabase_flutter/test/widget_test_stubs.dart Stores/cancels auth subscription to avoid retaining listeners across tests/leak tracking.
packages/supabase_flutter/test/leak_utils.dart Adds a Flutter test leak harness (similar to supabase package) for non-widget leak scenarios.
packages/supabase_flutter/test/leak_test.dart Adds leak tests validating Supabase.initialize()/dispose() cycles release client/auth references.
packages/supabase_flutter/test/flutter_test_config.dart Enables LeakTesting and tracks all classes for the supabase_flutter test suite.
packages/supabase_flutter/pubspec.yaml Adds leak_tracker and leak_tracker_flutter_testing dev dependencies.
packages/supabase_flutter/lib/src/supabase.dart Converts client to a getter over a nullable backing field and clears singleton references in dispose().
examples/storage_transforms/pubspec.yaml Adds leak_tracker_flutter_testing dev dependency for integration tests.
examples/storage_transforms/integration_test/flutter_test_config.dart Enables leak tracking and ignores Flutter image-cache classes to avoid false positives.
examples/realtime_room/pubspec.yaml Adds leak_tracker_flutter_testing dev dependency for integration tests.
examples/realtime_room/integration_test/flutter_test_config.dart Enables leak tracking for integration tests.
examples/passkeys/pubspec.yaml Adds leak_tracker_flutter_testing dev dependency for integration tests.
examples/passkeys/lib/main.dart Refactors rename dialog to own/dispose its TextEditingController.
examples/passkeys/integration_test/flutter_test_config.dart Enables leak tracking for integration tests.
examples/edge_functions/pubspec.yaml Adds leak_tracker_flutter_testing dev dependency for integration tests.
examples/edge_functions/integration_test/flutter_test_config.dart Enables leak tracking for integration tests.
examples/database_crud/pubspec.yaml Adds leak_tracker_flutter_testing dev dependency for integration tests.
examples/database_crud/lib/main.dart Refactors rename dialog to own/dispose its TextEditingController.
examples/database_crud/integration_test/flutter_test_config.dart Enables leak tracking for integration tests.
examples/authentication/pubspec.yaml Adds leak_tracker_flutter_testing dev dependency for integration tests.
examples/authentication/integration_test/flutter_test_config.dart Enables leak tracking for integration tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/supabase_flutter/lib/src/supabase.dart
Comment thread packages/supabase_flutter/lib/src/supabase.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/passkeys/lib/main.dart`:
- Around line 199-201: Update the FilledButton callback in the rename flow to
trim _name.text before passing it to Navigator.pop. Ensure the value checked by
_rename for emptiness and saved as the passkey name is the trimmed input, while
preserving the existing Save behavior.

In `@packages/supabase_flutter/test/widget_test.dart`:
- Around line 18-41: Register singleton cleanup immediately after the successful
Supabase.initialize call by adding an addTearDown callback that disposes
Supabase.instance via tester.runAsync. Remove the later assertion-dependent
disposal call, while preserving the existing initialization and test flow.

In `@packages/supabase/test/leak_utils_test.dart`:
- Line 15: Update the test teardown around _retained so every retained
SupabaseClient is disposed before the collection is cleared. Preserve the
existing teardown behavior while ensuring the notDisposed scenario releases its
JSON isolate and owned resources.

In `@packages/yet_another_json_isolate/lib/src/_isolates_io.dart`:
- Line 23: Add a shared _ensureNotDisposed() guard that throws
StateError('YAJsonIsolate has been disposed') when _isDisposed is true, and
invoke it at the start of initialize(), decode(), and encode(). Ensure calls
after dispose are rejected before initialization or isolate work begins,
including decode() and encode().
- Around line 49-51: Update dispose() to store the future representing the first
cleanup operation, and have subsequent calls return that same future instead of
completing immediately. Ensure the shared future covers awaiting
_createdIsolate.future and cancelling _events, while preserving the existing
idempotent behavior; add a test that invokes dispose() twice before awaiting
either result and verifies both calls complete together.

In
`@packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart`:
- Around line 29-37: Update the “dispose completes when called twice” test to
bound the first isolate.dispose() await with the same five-second timeout as the
second call, or apply a shared deadline covering both disposal calls.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a33b15f2-73f5-46cf-b5e9-cc3522fc0975

📥 Commits

Reviewing files that changed from the base of the PR and between 7a04241 and 75c53d9.

📒 Files selected for processing (27)
  • examples/authentication/integration_test/flutter_test_config.dart
  • examples/authentication/pubspec.yaml
  • examples/database_crud/integration_test/flutter_test_config.dart
  • examples/database_crud/lib/main.dart
  • examples/database_crud/pubspec.yaml
  • examples/edge_functions/integration_test/flutter_test_config.dart
  • examples/edge_functions/pubspec.yaml
  • examples/passkeys/integration_test/flutter_test_config.dart
  • examples/passkeys/lib/main.dart
  • examples/passkeys/pubspec.yaml
  • examples/realtime_room/integration_test/flutter_test_config.dart
  • examples/realtime_room/pubspec.yaml
  • examples/storage_transforms/integration_test/flutter_test_config.dart
  • examples/storage_transforms/pubspec.yaml
  • packages/supabase/pubspec.yaml
  • packages/supabase/test/leak_test.dart
  • packages/supabase/test/leak_utils.dart
  • packages/supabase/test/leak_utils_test.dart
  • packages/supabase_flutter/lib/src/supabase.dart
  • packages/supabase_flutter/pubspec.yaml
  • packages/supabase_flutter/test/flutter_test_config.dart
  • packages/supabase_flutter/test/leak_test.dart
  • packages/supabase_flutter/test/leak_utils.dart
  • packages/supabase_flutter/test/widget_test.dart
  • packages/supabase_flutter/test/widget_test_stubs.dart
  • packages/yet_another_json_isolate/lib/src/_isolates_io.dart
  • packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart

Comment thread examples/passkeys/lib/main.dart
Comment thread packages/supabase_flutter/test/widget_test.dart Outdated
Comment thread packages/supabase/test/leak_utils_test.dart Outdated
Comment thread packages/yet_another_json_isolate/lib/src/_isolates_io.dart Outdated
Comment thread packages/yet_another_json_isolate/lib/src/_isolates_io.dart Outdated
Fixes three leaks found by auditing the packages and the example apps with
leak_tracker. The tracking itself was a one-off investigation and is not
part of this change.

The Supabase singleton disposed everything it owned but never dropped the
references, so the disposed SupabaseClient and its whole graph stayed
reachable from a static field for the rest of the process. `client` is now
a getter over a nullable field and `dispose()` clears every reference it
holds.

YAJsonIsolate.dispose() awaited a completer that only completes inside
initialize(), so disposing an instance that was never used hung forever and
left its ReceivePort open. It now returns early when nothing was spawned,
and is idempotent.

Both example rename dialogs created a TextEditingController in the calling
method and never disposed it. Each is now a stateful dialog widget that
owns its controller.

BREAKING CHANGE: Supabase.instance.client is now a getter rather than a
field, and throws after dispose() instead of returning the disposed client.
@spydon
spydon force-pushed the lukasklingsbo/sdk-1441-leak-tracker-audit branch from 75c53d9 to 03800ca Compare August 7, 2026 09:40
@spydon spydon changed the title fix!: release retained resources on dispose and add leak_tracker coverage fix!: release retained resources on dispose Aug 7, 2026
spydon added 2 commits August 7, 2026 11:56
`Supabase.client` threw a bare null check error in release builds, where the
assert is stripped. It now throws a StateError with the same message in every
mode.

`Supabase.dispose()` was not safe to call twice: the second call went through
the `client` getter, which throws once the reference is cleared. It now
returns early when there is nothing to dispose, and drops its references
before tearing anything down so a throwing step cannot leave the singleton
pinning a half-disposed client.

`YAJsonIsolate` rejected nothing after disposal, so a later decode would
spawn a replacement isolate onto a closed receive port and then wait for a
reply that never arrives. It now throws a StateError, and overlapping
dispose() calls share one shutdown future rather than the second reporting
completion early.

Also registers the widget test's singleton teardown with addTearDown so a
failing assertion cannot leak it into later tests, and trims the submitted
name in the passkeys example.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/supabase_flutter/lib/src/supabase.dart`:
- Around line 229-247: Update the dispose flow to dispose the captured
lifecycleListener immediately after clearing _targetLifecycleState, before any
asynchronous teardown. Capture the existing _pendingLifecycleOperation and await
it after cancelling restoreSession and logSubscription but before disposing
currentClient, ensuring queued lifecycle work completes before
currentClient.dispose().
- Around line 243-247: Update the cleanup sequence around
restoreSession.cancel(), logSubscription.cancel(), currentClient.dispose(),
supabaseAuth.dispose(), and lifecycleListener.dispose() so every captured
resource cleanup is attempted even if an earlier operation throws. Use
try/finally or equivalent error aggregation while preserving propagation of
cleanup failures.

In `@packages/yet_another_json_isolate/lib/src/_isolates_io.dart`:
- Around line 33-35: Wrap the overlong documentation line in the isolate
initialization comment so every Dart source line stays within 80 characters,
preserving the existing wording and formatting the file with dart format.

In
`@packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart`:
- Around line 43-51: Update the concurrent dispose test around isolate.dispose()
to assert that the futures returned by the first and second calls are identical
before awaiting completion. Keep the existing Future.wait timeout assertion to
verify successful shutdown, ensuring the test covers both Future identity and
completion behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fc5ef80a-2423-44c3-9ecf-1c2d9dbb6595

📥 Commits

Reviewing files that changed from the base of the PR and between 03800ca and 7c48e8a.

📒 Files selected for processing (6)
  • examples/passkeys/lib/main.dart
  • packages/supabase_flutter/lib/src/supabase.dart
  • packages/supabase_flutter/test/initialization_test.dart
  • packages/supabase_flutter/test/widget_test.dart
  • packages/yet_another_json_isolate/lib/src/_isolates_io.dart
  • packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/supabase_flutter/test/widget_test.dart
  • examples/passkeys/lib/main.dart

Comment thread packages/supabase_flutter/lib/src/supabase.dart
Comment thread packages/supabase_flutter/lib/src/supabase.dart Outdated
Comment thread packages/yet_another_json_isolate/lib/src/_isolates_io.dart Outdated
Comment thread packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart Outdated
spydon added 4 commits August 7, 2026 12:07
The lifecycle listener is now disposed before anything is awaited, so no
event can be queued while the client is being torn down. Operations queued
earlier already abort as stale against the cleared target state.

Each teardown step now runs even if an earlier one throws, so a failure part
way through no longer leaves the rest of the graph alive. The first error is
rethrown once everything has been attempted.

Also asserts that overlapping YAJsonIsolate.dispose() calls return the same
future, and wraps an over long doc comment.
Clearing the target lifecycle state stops a queued operation from rejoining
channels, but an operation that already passed that check can be half way
through `realtime.connect()`. That connect raced the `realtime.disconnect()`
inside the client teardown and could reopen the socket after it closed.

Dispose now waits for the captured lifecycle operation before disposing the
client, so the two are serialized.
Keeps the dartdoc, including the wrapped `initialize()` line, and removes the
`//` comments explaining the reasoning. Also drops the reset of
`_pendingLifecycleOperation`: the field holds a completed chain that captures
nothing beyond the already permanent singleton, and it is chained onto
harmlessly by the next cycle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants