Skip to content

Fix watched queries leaking listeners when closed during initialization - #1057

Open
Thorsson wants to merge 1 commit into
powersync-ja:mainfrom
Thorsson:fix/watched-query-close-during-init-leak
Open

Fix watched queries leaking listeners when closed during initialization#1057
Thorsson wants to merge 1 commit into
powersync-ja:mainfrom
Thorsson:fix/watched-query-close-during-init-leak

Conversation

@Thorsson

Copy link
Copy Markdown

Overview

Closing a WatchedQuery while its initialization is still in flight permanently leaks listeners. @powersync/react's useQuery hits this race on every mount, so React/React Native apps leak one tablesUpdated adapter listener per mounted query and degrade over time.

Three compounding gaps, innermost first:

  1. BasePowerSyncDatabase.onChangeWithCallback (packages/shared-internals/src/client/BasePowerSyncDatabase.ts:770) registers this.database.registerListener({ tablesUpdated }) unconditionally and cleans up only via resolvedOptions.signal?.addEventListener('abort', ...). An already-aborted signal never emits abort, so the adapter listener can never be removed.
  2. OnChangeQueryProcessor.linkQuery / DifferentialQueryProcessor.linkQuery await db.resolveTables(...) and then call db.onChangeWithCallback(...) without re-checking this.closed || abortSignal.aborted, discarding the returned dispose function — relying entirely on the broken already-aborted-signal path above.
  3. AbstractQueryProcessor.init registers the closing listener, awaits db.waitForReady(), registers schemaChanged, and only then assigns this.disposeListeners. A close() that lands during the await finds disposeListeners === null, so both database listeners leak (schemaChanged is even registered after the query is already closed).

The trigger in React: useWatchedQuery creates the watched query in a useState initializer during render and close()s it from the mount effect one frame later, while init is still parked on waitForReady()/resolveTables().

Changes

  • onChangeWithCallback returns a no-op dispose immediately when the signal is already aborted, before allocating the executor or registering the adapter listener.
  • Both linkQuery implementations bail after the awaited resolveTables when the query was closed or aborted.
  • AbstractQueryProcessor.init disposes the closing listener and returns when close() happened during waitForReady().
  • Regression tests in packages/shared-internals/tests/client/watched/listenerLeaks.test.ts assert real BaseObserver listener counts across the abort/close races; the three leak tests fail on current main.

Fixes #1056.

@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a9803d4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@powersync/shared-internals Patch
@powersync/adapter-sql-js Patch
@powersync/capacitor Patch
@powersync/node Patch
@powersync/react-native Patch
@powersync/web Patch
@powersync/diagnostics-app Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@simolus3 simolus3 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.

Thank you for the contribution!

OnChangeQueryProcessor.linkQuery / DifferentialQueryProcessor.linkQuery await db.resolveTables(...) and then call db.onChangeWithCallback(...) without re-checking this.closed || abortSignal.aborted, discarding the returned dispose function — relying entirely on the broken already-aborted-signal path above.

But now that that's fixed, do we still need these changes? Relying on the abort signal to be both accurate and actually honored by onChangeWithCallback feels like the most elegant solution to me, so unless there's another bug here the linkQuery changes feel superfluous?

import { describe, expect, it } from 'vitest';
import { BasePowerSyncDatabase } from '../../../src/client/BasePowerSyncDatabase.js';

class MockLockContext extends LockContext {

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.

Instead of testing against a mock database, can we write regression tests against a real instance? E.g. in packages/react/tests/useQuery.test.tsx which runs them against the web SDK?

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.

Every React useQuery mount permanently leaks a tablesUpdated adapter listener (close-during-init race in watched queries)

2 participants