Fix watched queries leaking listeners when closed during initialization - #1057
Fix watched queries leaking listeners when closed during initialization#1057Thorsson wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: a9803d4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
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
left a comment
There was a problem hiding this comment.
Thank you for the contribution!
OnChangeQueryProcessor.linkQuery/DifferentialQueryProcessor.linkQueryawait db.resolveTables(...)and then calldb.onChangeWithCallback(...)without re-checkingthis.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 { |
There was a problem hiding this comment.
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?
Overview
Closing a
WatchedQuerywhile its initialization is still in flight permanently leaks listeners.@powersync/react'suseQueryhits this race on every mount, so React/React Native apps leak onetablesUpdatedadapter listener per mounted query and degrade over time.Three compounding gaps, innermost first:
BasePowerSyncDatabase.onChangeWithCallback(packages/shared-internals/src/client/BasePowerSyncDatabase.ts:770) registersthis.database.registerListener({ tablesUpdated })unconditionally and cleans up only viaresolvedOptions.signal?.addEventListener('abort', ...). An already-aborted signal never emitsabort, so the adapter listener can never be removed.OnChangeQueryProcessor.linkQuery/DifferentialQueryProcessor.linkQueryawait db.resolveTables(...)and then calldb.onChangeWithCallback(...)without re-checkingthis.closed || abortSignal.aborted, discarding the returned dispose function — relying entirely on the broken already-aborted-signal path above.AbstractQueryProcessor.initregisters theclosinglistener, awaitsdb.waitForReady(), registersschemaChanged, and only then assignsthis.disposeListeners. Aclose()that lands during the await findsdisposeListeners === null, so both database listeners leak (schemaChangedis even registered after the query is already closed).The trigger in React:
useWatchedQuerycreates the watched query in auseStateinitializer during render andclose()s it from the mount effect one frame later, while init is still parked onwaitForReady()/resolveTables().Changes
onChangeWithCallbackreturns a no-op dispose immediately when the signal is already aborted, before allocating the executor or registering the adapter listener.linkQueryimplementations bail after the awaitedresolveTableswhen the query was closed or aborted.AbstractQueryProcessor.initdisposes theclosinglistener and returns whenclose()happened duringwaitForReady().packages/shared-internals/tests/client/watched/listenerLeaks.test.tsassert realBaseObserverlistener counts across the abort/close races; the three leak tests fail on currentmain.Fixes #1056.