Skip to content
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

[Fix] OPFS Sleeping Tabs Sync Deadlock #498

Draft
wants to merge 3 commits into
base: fix-tests
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/blue-pets-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@powersync/tanstack-react-query': patch
'@powersync/drizzle-driver': patch
'@powersync/kysely-driver': patch
'@powersync/attachments': patch
'@powersync/common': patch
'@powersync/react': patch
'@powersync/vue': patch
'@powersync/web': patch
---

Test for dev packages
5 changes: 5 additions & 0 deletions .changeset/tidy-stingrays-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/web': minor
---

Ensured OPFS tabs are not frozen or put to sleep by browsers. This prevents potential deadlocks in the syncing process.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AppSchema } from '@/library/powersync/AppSchema';
import { SupabaseConnector } from '@/library/powersync/SupabaseConnector';
import { CircularProgress } from '@mui/material';
import { PowerSyncContext } from '@powersync/react';
import { PowerSyncDatabase } from '@powersync/web';
import { PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web';
import Logger from 'js-logger';
import React, { Suspense } from 'react';
import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext';
Expand All @@ -13,9 +13,10 @@ export const useSupabase = () => React.useContext(SupabaseContext);

export const db = new PowerSyncDatabase({
schema: AppSchema,
database: {
dbFilename: 'example.db'
}
database: new WASQLiteOpenFactory({
dbFilename: 'one.sqlite',
vfs: WASQLiteVFS.OPFSCoopSyncVFS
})
});

export const SystemProvider = ({ children }: { children: React.ReactNode }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ export type WrappedWorkerConnectionOptions<Config extends ResolvedWebSQLOpenOpti
export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLOpenOptions = ResolvedWebSQLOpenOptions>
implements AsyncDatabaseConnection
{
constructor(protected options: WrappedWorkerConnectionOptions<Config>) {}
protected releaseSharedConnectionLock: (() => void) | null;
protected lockAbortController: AbortController;

constructor(protected options: WrappedWorkerConnectionOptions<Config>) {
this.releaseSharedConnectionLock = null;
this.lockAbortController = new AbortController();
}

protected get baseConnection() {
return this.options.baseConnection;
Expand All @@ -44,6 +50,38 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
*/
async shareConnection(): Promise<SharedConnectionWorker> {
const { identifier, remote } = this.options;
/**
* Hold a navigator lock in order to avoid features such as Chrome's frozen tabs,
* or Edge's sleeping tabs from pausing the thread for this connection.
* This promise resolves once a lock is obtained.
* This lock will be held as long as this connection is open.
* The `shareConnection` method should not be called on multiple tabs concurrently.
*/
await new Promise<void>((lockObtained) =>
navigator.locks
.request(
`shared-connection-${this.options.identifier}`,
{
signal: this.lockAbortController.signal
},
async () => {
lockObtained();

// Free the lock when the connection is already closed.
if (this.lockAbortController.signal.aborted) {
return;
}

// Hold the lock while the shared connection is in use.
await new Promise<void>((releaseLock) => {
// We can use the resolver to free the lock
this.releaseSharedConnectionLock = releaseLock;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you just wait for the lockAbortController.signal here, instead of using a separate callback?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah we can do that. I initially started testing without the AbortController where just this promise was used, using the AbortController signal is cleaner. This callback will be removed.

});
}
)
// We aren't concerned with errors here
.catch(() => {})
);

const newPort = await remote[Comlink.createEndpoint]();
return { port: newPort, identifier };
Expand All @@ -58,6 +96,9 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
}

async close(): Promise<void> {
// Abort any pending lock requests.
this.lockAbortController.abort();
this.releaseSharedConnectionLock?.();
await this.baseConnection.close();
this.options.remote[Comlink.releaseProxy]();
this.options.onClose?.();
Expand Down
Loading