-
Notifications
You must be signed in to change notification settings - Fork 83
Fix watched queries leaking listeners when closed during initialization #1057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Thorsson
wants to merge
1
commit into
powersync-ja:main
Choose a base branch
from
Thorsson:fix/watched-query-close-during-init-leak
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@powersync/shared-internals': patch | ||
| --- | ||
|
|
||
| Fix watched queries leaking listeners when closed during initialization. Closing a watched query | ||
| while it was still resolving tables (or waiting for the database to be ready) registered a | ||
| permanent `tablesUpdated` listener on the database adapter, because `onChangeWithCallback` relied | ||
| on an `abort` event that an already-aborted signal never emits. In `@powersync/react`, every | ||
| `useQuery` mount hit this race and leaked one adapter listener. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
195 changes: 195 additions & 0 deletions
195
packages/shared-internals/tests/client/watched/listenerLeaks.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import { DBAdapter, LockContext, RawQueryResult, Schema } from '@powersync/common'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { BasePowerSyncDatabase } from '../../../src/client/BasePowerSyncDatabase.js'; | ||
|
|
||
| class MockLockContext extends LockContext { | ||
| async executeRaw(): Promise<RawQueryResult> { | ||
| return { columnNames: [], rawRows: [] }; | ||
| } | ||
| } | ||
|
|
||
| class MockDBAdapter extends DBAdapter { | ||
| /** | ||
| * Read locks await this promise before executing. | ||
| * Tests use this to park a query (e.g. `resolveTables`) mid-await. | ||
| */ | ||
| readGate: Promise<void> = Promise.resolve(); | ||
|
|
||
| get name() { | ||
| return 'mock-db'; | ||
| } | ||
|
|
||
| /** | ||
| * The number of registered adapter listeners. Exposed for leak assertions. | ||
| */ | ||
| get registeredListenerCount() { | ||
| return this.listeners.size; | ||
| } | ||
|
|
||
| async close() {} | ||
|
|
||
| async refreshSchema() {} | ||
|
|
||
| async readLock<T>(fn: (tx: LockContext) => Promise<T>): Promise<T> { | ||
| await this.readGate; | ||
| return fn(new MockLockContext()); | ||
| } | ||
|
|
||
| async writeLock<T>(fn: (tx: LockContext) => Promise<T>): Promise<T> { | ||
| return fn(new MockLockContext()); | ||
| } | ||
| } | ||
|
|
||
| class TestPowerSyncDatabase extends BasePowerSyncDatabase { | ||
| /** | ||
| * When set, `waitForReady` awaits this promise. | ||
| * Tests use this to park watched-query initialization mid-await. | ||
| */ | ||
| readyGate: Promise<void> | null = null; | ||
|
|
||
| /** | ||
| * The number of registered database listeners. Exposed for leak assertions. | ||
| */ | ||
| get registeredListenerCount() { | ||
| return this.listeners.size; | ||
| } | ||
|
|
||
| get mockAdapter() { | ||
| return this.database as MockDBAdapter; | ||
| } | ||
|
|
||
| protected openDBAdapter(): DBAdapter { | ||
| return new MockDBAdapter(); | ||
| } | ||
|
|
||
| protected generateSyncStreamImplementation(): never { | ||
| throw new Error('Sync is not required for these tests'); | ||
| } | ||
|
|
||
| protected generateBucketStorageAdapter(): never { | ||
| return null as never; | ||
| } | ||
|
|
||
| protected async _initialize() {} | ||
|
|
||
| // The real initialization runs queries against the SQLite database, which is not required here. | ||
| protected async initialize() { | ||
| this.ready = true; | ||
| } | ||
|
|
||
| async waitForReady(): Promise<void> { | ||
| await this.readyGate; | ||
| } | ||
| } | ||
|
|
||
| const createTestDatabase = () => new TestPowerSyncDatabase({ schema: new Schema([]) }); | ||
|
|
||
| const watchQuery = (db: TestPowerSyncDatabase) => | ||
| db | ||
| .customQuery<{ id: string }>({ | ||
| compile: () => ({ sql: 'SELECT * FROM todos', parameters: [] }), | ||
| execute: async () => [] | ||
| }) | ||
| .watch({}); | ||
|
|
||
| const deferred = () => { | ||
| let resolve!: () => void; | ||
| const promise = new Promise<void>((r) => { | ||
| resolve = r; | ||
| }); | ||
| return { promise, resolve }; | ||
| }; | ||
|
|
||
| /** Waits for pending micro and macro tasks to complete. */ | ||
| const settle = async () => { | ||
| for (let i = 0; i < 10; i++) { | ||
| await new Promise((r) => setTimeout(r)); | ||
| } | ||
| }; | ||
|
|
||
| describe('watched query listener cleanup', () => { | ||
| it('onChangeWithCallback should not register an adapter listener for an already-aborted signal', () => { | ||
| const db = createTestDatabase(); | ||
| const controller = new AbortController(); | ||
| controller.abort(); | ||
|
|
||
| const dispose = db.onChangeWithCallback({ onChange: () => {} }, { signal: controller.signal, tables: ['todos'] }); | ||
|
|
||
| expect(db.mockAdapter.registeredListenerCount).toEqual(0); | ||
| // The returned dispose function should still be safe to call | ||
| dispose(); | ||
| expect(db.mockAdapter.registeredListenerCount).toEqual(0); | ||
| }); | ||
|
|
||
| it('onChangeWithCallback should dispose the adapter listener when the signal aborts', () => { | ||
| const db = createTestDatabase(); | ||
| const controller = new AbortController(); | ||
|
|
||
| db.onChangeWithCallback({ onChange: () => {} }, { signal: controller.signal, tables: ['todos'] }); | ||
|
|
||
| expect(db.mockAdapter.registeredListenerCount).toEqual(1); | ||
| controller.abort(); | ||
| expect(db.mockAdapter.registeredListenerCount).toEqual(0); | ||
| }); | ||
|
|
||
| it('should register and dispose listeners across a watched query lifecycle', async () => { | ||
| const db = createTestDatabase(); | ||
| const baseListenerCount = db.registeredListenerCount; | ||
|
|
||
| const query = watchQuery(db); | ||
| await settle(); | ||
|
|
||
| expect(db.mockAdapter.registeredListenerCount).toEqual(1); | ||
| // The closing and schemaChanged listeners | ||
| expect(db.registeredListenerCount).toEqual(baseListenerCount + 2); | ||
|
|
||
| await query.close(); | ||
| await settle(); | ||
|
|
||
| expect(db.mockAdapter.registeredListenerCount).toEqual(0); | ||
| expect(db.registeredListenerCount).toEqual(baseListenerCount); | ||
| }); | ||
|
|
||
| it('should not leak listeners when a watched query is closed while resolving tables', async () => { | ||
| const db = createTestDatabase(); | ||
| const baseListenerCount = db.registeredListenerCount; | ||
|
|
||
| // Park the query's `resolveTables` call on its read lock | ||
| const readGate = deferred(); | ||
| db.mockAdapter.readGate = readGate.promise; | ||
|
|
||
| // Mimics React's useQuery, which creates a watched query during render | ||
| // and closes it from a mount effect one frame later. | ||
| const query = watchQuery(db); | ||
| await settle(); | ||
| await query.close(); | ||
|
|
||
| readGate.resolve(); | ||
| await settle(); | ||
|
|
||
| expect(db.mockAdapter.registeredListenerCount).toEqual(0); | ||
| expect(db.registeredListenerCount).toEqual(baseListenerCount); | ||
| }); | ||
|
|
||
| it('should not leak listeners when a watched query is closed while waiting for ready', async () => { | ||
| const db = createTestDatabase(); | ||
| const baseListenerCount = db.registeredListenerCount; | ||
|
|
||
| // Park the watched query's initialization on `waitForReady` | ||
| const readyGate = deferred(); | ||
| db.readyGate = readyGate.promise; | ||
|
|
||
| const query = watchQuery(db); | ||
| await settle(); | ||
|
|
||
| // The closing listener is registered before waiting for ready | ||
| expect(db.registeredListenerCount).toEqual(baseListenerCount + 1); | ||
|
|
||
| await query.close(); | ||
| readyGate.resolve(); | ||
| await settle(); | ||
|
|
||
| expect(db.registeredListenerCount).toEqual(baseListenerCount); | ||
| expect(db.mockAdapter.registeredListenerCount).toEqual(0); | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.tsxwhich runs them against the web SDK?