Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/opfs-pagehide-worker-terminate.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
{
protected lockAbortController = new AbortController();
protected notifyRemoteClosed: AbortController | undefined;
private finalized = false;

constructor(protected options: WrappedWorkerConnectionOptions<Config>) {
if (options.remoteCanCloseUnexpectedly) {
Expand Down Expand Up @@ -160,14 +161,33 @@ export class WorkerWrappedAsyncDatabaseConnection<Config extends ResolvedWebSQLO
return this.baseConnection.registerOnTableChange(Comlink.proxy(callback));
}

private finalizeClose(): void {
if (this.finalized) {
return;
}
this.finalized = true;
// Ensure cleanup is idempotent if close is triggered from multiple paths.
this.notifyRemoteClosed?.abort();
try {
this.options.remote[Comlink.releaseProxy]();
} catch {
// Proxy can already be released on teardown.
}
this.options.onClose?.();
}

forceClose(): void {
this.lockAbortController.abort();
this.finalizeClose();
}

async close(): Promise<void> {
// 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();
}
}

Expand Down
70 changes: 59 additions & 11 deletions packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -80,28 +82,74 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory {

const workerDBOpener = Comlink.wrap<OpenAsyncDatabaseConnection<WorkerDBOpenerOptions>>(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<WorkerDBOpenerOptions> | 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,
cacheSizeKb,
flags: this.resolvedFlags,
encryptionKey: encryptionKey,
logLevel: this.logger.getLevel()
}),
});
} catch (error) {
cleanupPagehide();
terminateWorker();
throw error;
}

const connection = new WorkerWrappedAsyncDatabaseConnection<WorkerDBOpenerOptions>({
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({
Expand Down
165 changes: 165 additions & 0 deletions packages/web/tests/opfs_pagehide.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> | 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<typeof import('comlink')>('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<typeof Worker>));
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<unknown>((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<unknown>((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);
});
});