diff --git a/.changeset/opfs-pagehide-worker-terminate.md b/.changeset/opfs-pagehide-worker-terminate.md new file mode 100644 index 000000000..3eff3e1a6 --- /dev/null +++ b/.changeset/opfs-pagehide-worker-terminate.md @@ -0,0 +1,5 @@ +--- +"@powersync/web": patch +--- + +Defer dedicated OPFS worker termination on pagehide until open completes and ignore repeat pagehide events to avoid lock leaks. diff --git a/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts b/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts index e03bf8aa8..47b2803bf 100644 --- a/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts +++ b/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts @@ -32,6 +32,7 @@ export class WorkerWrappedAsyncDatabaseConnection) { if (options.remoteCanCloseUnexpectedly) { @@ -160,14 +161,33 @@ export class WorkerWrappedAsyncDatabaseConnection { // Abort any pending lock requests. this.lockAbortController.abort(); try { await this.withRemote(() => this.baseConnection.close()); } finally { - this.options.remote[Comlink.releaseProxy](); - this.options.onClose?.(); + this.finalizeClose(); } } diff --git a/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts b/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts index 487d121aa..6691f4a4f 100644 --- a/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts +++ b/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts @@ -57,6 +57,8 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory { cacheSizeKb = DEFAULT_CACHE_SIZE_KB, encryptionKey } = this.waOptions; + const shouldForceCloseOnPagehide = + vfs === WASQLiteVFS.OPFSCoopSyncVFS || vfs === WASQLiteVFS.AccessHandlePoolVFS; if (!enableMultiTabs) { this.logger.warn('Multiple tabs are not enabled in this browser'); @@ -80,11 +82,42 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory { const workerDBOpener = Comlink.wrap>(workerPort); - return new WorkerWrappedAsyncDatabaseConnection({ - remote: workerDBOpener, - // This tab owns the worker, so we're guaranteed to outlive it. - remoteCanCloseUnexpectedly: false, - baseConnection: await workerDBOpener({ + let pagehideHandler: ((event: PageTransitionEvent) => void) | null = null; + let pagehideTriggered = false; + let wrapped: WorkerWrappedAsyncDatabaseConnection | null = null; + const terminateWorker = () => { + if (workerPort instanceof Worker) { + workerPort.terminate(); + } else { + workerPort.close(); + } + }; + const cleanupPagehide = () => { + if (pagehideHandler && typeof window !== 'undefined') { + window.removeEventListener('pagehide', pagehideHandler); + pagehideHandler = null; + } + }; + + if (shouldForceCloseOnPagehide && workerPort instanceof Worker && typeof window !== 'undefined') { + // Register early so refresh/navigation during open still releases OPFS locks. + pagehideHandler = (_event: PageTransitionEvent) => { + if (pagehideTriggered) { + return; + } + pagehideTriggered = true; + if (wrapped) { + wrapped.forceClose(); + return; + } + // Defer termination until open completes so OPFS locks can be released. + }; + window.addEventListener('pagehide', pagehideHandler); + } + + let baseConnection: AsyncDatabaseConnection; + try { + baseConnection = await workerDBOpener({ dbFilename: this.options.dbFilename, vfs, temporaryStorage, @@ -92,16 +125,31 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory { flags: this.resolvedFlags, encryptionKey: encryptionKey, logLevel: this.logger.getLevel() - }), + }); + } catch (error) { + cleanupPagehide(); + terminateWorker(); + throw error; + } + + const connection = new WorkerWrappedAsyncDatabaseConnection({ + remote: workerDBOpener, + // This tab owns the worker, so we're guaranteed to outlive it. + remoteCanCloseUnexpectedly: false, + baseConnection, identifier: this.options.dbFilename, onClose: () => { - if (workerPort instanceof Worker) { - workerPort.terminate(); - } else { - workerPort.close(); - } + cleanupPagehide(); + terminateWorker(); } }); + wrapped = connection; + + if (pagehideTriggered) { + connection.forceClose(); + } + + return connection; } else { // Don't use a web worker return new WASqliteConnection({ diff --git a/packages/web/tests/opfs_pagehide.test.ts b/packages/web/tests/opfs_pagehide.test.ts new file mode 100644 index 000000000..7fb005978 --- /dev/null +++ b/packages/web/tests/opfs_pagehide.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AsyncDatabaseConnection } from '../src/db/adapters/AsyncDatabaseConnection'; +import { + DEFAULT_WEB_SQL_FLAGS, + TemporaryStorageOption, + type ResolvedWebSQLOpenOptions +} from '../src/db/adapters/web-sql-flags'; + +let WASQLiteOpenFactory: typeof import('@powersync/web').WASQLiteOpenFactory; +let WASQLiteVFS: typeof import('@powersync/web').WASQLiteVFS; + +let nextOpenPromise: Promise | null = null; +const baseConfig: ResolvedWebSQLOpenOptions = { + dbFilename: 'crm.sqlite', + flags: DEFAULT_WEB_SQL_FLAGS, + temporaryStorage: TemporaryStorageOption.MEMORY, + cacheSizeKb: 1 +}; + +const baseConnection: AsyncDatabaseConnection = { + init: async () => {}, + close: async () => {}, + markHold: async () => 'hold', + releaseHold: async () => {}, + isAutoCommit: async () => true, + execute: async () => ({ rows: { _array: [], length: 0 }, rowsAffected: 0, insertId: 0 }), + executeRaw: async () => [], + executeBatch: async () => ({ rows: { _array: [], length: 0 }, rowsAffected: 0, insertId: 0 }), + registerOnTableChange: async () => () => {}, + getConfig: async () => baseConfig +}; + +vi.mock('comlink', async () => { + const actual = await vi.importActual('comlink'); + return { + ...actual, + wrap: () => { + const opener = (() => nextOpenPromise ?? Promise.resolve(baseConnection)) as unknown as ReturnType< + typeof actual.wrap + >; + opener[actual.releaseProxy] = () => {}; + return opener; + } + }; +}); + +describe('OPFS pagehide cleanup', { sequential: true }, () => { + let originalWorker: typeof Worker; + let workers: Worker[] = []; + let terminated = false; + let terminateCount = 0; + + beforeEach(() => { + terminated = false; + terminateCount = 0; + workers = []; + nextOpenPromise = null; + originalWorker = window.Worker; + window.Worker = new Proxy(originalWorker, { + construct(target, args) { + const instance = new target(...(args as ConstructorParameters)); + workers.push(instance); + const originalTerminate = instance.terminate.bind(instance); + instance.terminate = () => { + terminated = true; + terminateCount += 1; + return originalTerminate(); + }; + return instance; + } + }); + }); + + afterEach(() => { + workers.forEach((worker) => { + try { + worker.terminate(); + } catch { + // Ignore termination errors during cleanup. + } + }); + window.Worker = originalWorker; + }); + + beforeEach(async () => { + ({ WASQLiteOpenFactory, WASQLiteVFS } = await import('@powersync/web')); + }); + + it('terminates dedicated worker on pagehide for OPFS VFS', async () => { + const factory = new WASQLiteOpenFactory({ + dbFilename: `pagehide-${crypto.randomUUID()}.db`, + vfs: WASQLiteVFS.OPFSCoopSyncVFS, + flags: { enableMultiTabs: false, useWebWorker: true } + }); + + await factory.openConnection(); + + const cached = new Event('pagehide') as PageTransitionEvent; + Object.defineProperty(cached, 'persisted', { value: true }); + window.dispatchEvent(cached); + + expect(terminated).toBe(true); + }); + + it('defers termination until open completes when pagehide fires during open for OPFS VFS', async () => { + let resolveOpen!: (connection: unknown) => void; + nextOpenPromise = new Promise((resolve) => { + resolveOpen = resolve; + }); + + const factory = new WASQLiteOpenFactory({ + dbFilename: `pagehide-${crypto.randomUUID()}.db`, + vfs: WASQLiteVFS.OPFSCoopSyncVFS, + flags: { enableMultiTabs: false, useWebWorker: true } + }); + + const openTask = factory.openConnection(); + + const pagehide = new Event('pagehide') as PageTransitionEvent; + window.dispatchEvent(pagehide); + const terminatedAfterPagehide = terminated; + + resolveOpen(baseConnection); + await openTask; + await Promise.resolve(); + + expect(terminatedAfterPagehide).toBe(false); + expect(terminated).toBe(true); + }); + + it('ignores repeated pagehide events during open for OPFS VFS', async () => { + let resolveOpen!: (connection: unknown) => void; + nextOpenPromise = new Promise((resolve) => { + resolveOpen = resolve; + }); + + const factory = new WASQLiteOpenFactory({ + dbFilename: `pagehide-${crypto.randomUUID()}.db`, + vfs: WASQLiteVFS.OPFSCoopSyncVFS, + flags: { enableMultiTabs: false, useWebWorker: true } + }); + + const openTask = factory.openConnection(); + + const firstPagehide = new Event('pagehide') as PageTransitionEvent; + window.dispatchEvent(firstPagehide); + const terminatedAfterFirst = terminated; + const terminateCountAfterFirst = terminateCount; + + const secondPagehide = new Event('pagehide') as PageTransitionEvent; + window.dispatchEvent(secondPagehide); + const terminatedAfterSecond = terminated; + const terminateCountAfterSecond = terminateCount; + + resolveOpen(baseConnection); + await openTask; + await Promise.resolve(); + + expect(terminatedAfterFirst).toBe(false); + expect(terminatedAfterSecond).toBe(false); + expect(terminateCountAfterFirst).toBe(0); + expect(terminateCountAfterSecond).toBe(0); + expect(terminateCount).toBe(1); + }); +});