Skip to content
Merged
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/wa-sqlite-opfs-init-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@treecrdt/wa-sqlite': patch
---

Honor the requested OPFS fallback policy in worker runtimes, close failed SQLite and OPFS resources, retry allowed memory fallback with a fresh module, and release SharedWorker ports when initialization or teardown fails.
10 changes: 8 additions & 2 deletions packages/treecrdt-wa-sqlite/e2e/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ let openClient: TreecrdtClient | null = null;

type LifecycleOptions = {
docId: string;
fallback?: 'memory' | 'throw';
filename: string;
runtime: LifecycleRuntime;
/** Pins differently configured clients to one SharedWorker so lifecycle cleanup is observable. */
sharedWorkerName?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why is it necessary to provide the name of the shared worker?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The explicit name is needed for these regression tests, where want it to be the same name to make sure stale port/session cleanup works. I added a comment

};

export type LifecycleState = {
Expand All @@ -38,8 +41,11 @@ export type LifecycleState = {
async function createOpfsLifecycleClient(opts: LifecycleOptions): Promise<TreecrdtClient> {
return createTreecrdtClient({
docId: opts.docId,
storage: { type: 'opfs', filename: opts.filename, fallback: 'throw' },
runtime: { type: opts.runtime },
storage: { type: 'opfs', filename: opts.filename, fallback: opts.fallback ?? 'throw' },
runtime:
opts.runtime === 'shared-worker' && opts.sharedWorkerName
? { type: 'shared-worker', name: opts.sharedWorkerName }
: { type: opts.runtime },
});
}

Expand Down
155 changes: 132 additions & 23 deletions packages/treecrdt-wa-sqlite/e2e/tests/lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { test, expect, type Page } from '@playwright/test';

type LifecycleHarness = NonNullable<Window['__treecrdtLifecycle']>;
type LifecycleRuntime = 'direct' | 'dedicated-worker' | 'shared-worker';
type LifecycleOptions = Parameters<LifecycleHarness['drop']>[0];
type LifecycleRuntime = LifecycleOptions['runtime'];

const scenarios: Array<{
runtime: LifecycleRuntime;
Expand Down Expand Up @@ -39,52 +40,42 @@ async function support(page: Page): Promise<ReturnType<LifecycleHarness['support
});
}

async function drop(
page: Page,
opts: { docId: string; filename: string; runtime: LifecycleRuntime },
) {
async function drop(page: Page, opts: LifecycleOptions) {
await page.evaluate(async (dropOpts) => {
const harness = window.__treecrdtLifecycle;
if (!harness) throw new Error('__treecrdtLifecycle not available');
await harness.drop(dropOpts);
}, opts);
}

async function write(
page: Page,
opts: {
docId: string;
filename: string;
runtime: LifecycleRuntime;
closeBeforeReload?: boolean;
},
) {
async function write(page: Page, opts: LifecycleOptions & { closeBeforeReload?: boolean }) {
return page.evaluate(async (writeOpts) => {
const harness = window.__treecrdtLifecycle;
if (!harness) throw new Error('__treecrdtLifecycle not available');
return await harness.write(writeOpts);
}, opts);
}

async function read(
page: Page,
opts: { docId: string; filename: string; runtime: LifecycleRuntime },
) {
async function read(page: Page, opts: LifecycleOptions) {
return page.evaluate(async (readOpts) => {
const harness = window.__treecrdtLifecycle;
if (!harness) throw new Error('__treecrdtLifecycle not available');
return await harness.read(readOpts);
}, opts);
}

function expectReloadedTree(
function expectLifecycleTree(
state: Awaited<ReturnType<typeof read>>,
expected: { mode: 'direct' | 'worker'; runtime: LifecycleRuntime },
expected: {
mode: 'direct' | 'worker';
runtime: LifecycleRuntime;
storage?: 'memory' | 'opfs';
},
) {
expect(state).toMatchObject({
mode: expected.mode,
runtime: expected.runtime,
storage: 'opfs',
storage: expected.storage ?? 'opfs',
headLamport: 2,
parentExists: true,
childExists: true,
Expand Down Expand Up @@ -125,15 +116,15 @@ test.describe('browser OPFS lifecycle', () => {
...opts,
closeBeforeReload: reloadCase.closeBeforeReload,
});
expectReloadedTree(initialState, {
expectLifecycleTree(initialState, {
mode: scenario.expectedMode,
runtime: scenario.runtime,
});

await page.reload({ waitUntil: 'load' });
await waitForHarness(page);

expectReloadedTree(await read(page, opts), {
expectLifecycleTree(await read(page, opts), {
mode: scenario.expectedMode,
runtime: scenario.runtime,
});
Expand All @@ -143,4 +134,122 @@ test.describe('browser OPFS lifecycle', () => {
});
}
}

test('opens a usable dedicated-worker memory fallback after OPFS open fails', async ({
page,
}, testInfo) => {
if (testInfo.project.name !== 'chromium-dev') test.skip();
test.setTimeout(120_000);
page.on('console', (msg) => console.log(`[page][${msg.type()}] ${msg.text()}`));

const suffix = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
const opts: LifecycleOptions = {
docId: `lifecycle-fallback-${suffix}`,
fallback: 'memory',
filename: `/${'x'.repeat(512)}.db`,
runtime: 'dedicated-worker',
};

await waitForHarness(page);
const opfsSupport = await support(page);
if (!opfsSupport.available) test.skip(true, `OPFS unavailable: ${opfsSupport.reason}`);
expect(new TextEncoder().encode(opts.filename).byteLength).toBeGreaterThan(512);

expectLifecycleTree(await write(page, { ...opts, closeBeforeReload: true }), {
mode: 'worker',
runtime: 'dedicated-worker',
storage: 'memory',
});
});

test('releases a SharedWorker port after failed OPFS initialization', async ({
page,
}, testInfo) => {
if (testInfo.project.name !== 'chromium-dev') test.skip();
test.setTimeout(120_000);
page.on('console', (msg) => console.log(`[page][${msg.type()}] ${msg.text()}`));

const suffix = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
const sharedWorkerName = `lifecycle-recovery-${suffix}`;
const failedOpts: LifecycleOptions = {
docId: `lifecycle-recovery-failed-${suffix}`,
filename: `/${'x'.repeat(512)}.db`,
runtime: 'shared-worker',
sharedWorkerName,
};
const firstOpts: LifecycleOptions = {
docId: `lifecycle-recovery-first-${suffix}`,
filename: `/lifecycle-recovery-first-${suffix}.db`,
runtime: 'shared-worker',
sharedWorkerName,
};
const secondOpts: LifecycleOptions = {
docId: `lifecycle-recovery-second-${suffix}`,
filename: `/lifecycle-recovery-second-${suffix}.db`,
runtime: 'shared-worker',
sharedWorkerName,
};

await waitForHarness(page);
const opfsSupport = await support(page);
if (!opfsSupport.available) test.skip(true, `OPFS unavailable: ${opfsSupport.reason}`);
expect(new TextEncoder().encode(failedOpts.filename).byteLength).toBeGreaterThan(512);

try {
await expect(write(page, failedOpts)).rejects.toThrow(/sqlite3_open_v2|OPFS requested/);

expectLifecycleTree(await write(page, { ...firstOpts, closeBeforeReload: true }), {
mode: 'worker',
runtime: 'shared-worker',
});

expectLifecycleTree(await write(page, { ...secondOpts, closeBeforeReload: true }), {
mode: 'worker',
runtime: 'shared-worker',
});
} finally {
await drop(page, { ...firstOpts, runtime: 'direct' }).catch(() => {});
await drop(page, { ...secondOpts, runtime: 'direct' }).catch(() => {});
}
});

test('releases a SharedWorker port after drop', async ({ page }, testInfo) => {
if (testInfo.project.name !== 'chromium-dev') test.skip();
test.setTimeout(120_000);
page.on('console', (msg) => console.log(`[page][${msg.type()}] ${msg.text()}`));

const suffix = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
const sharedWorkerName = `lifecycle-drop-${suffix}`;
const optsFor = (label: string): LifecycleOptions => ({
docId: `lifecycle-drop-${label}-${suffix}`,
filename: `/lifecycle-drop-${label}-${suffix}.db`,
runtime: 'shared-worker',
sharedWorkerName,
});
const droppedOpts = optsFor('first');
const firstReuseOpts = optsFor('second');
const secondReuseOpts = optsFor('third');

await waitForHarness(page);
const opfsSupport = await support(page);
if (!opfsSupport.available) test.skip(true, `OPFS unavailable: ${opfsSupport.reason}`);

try {
await drop(page, droppedOpts);

expectLifecycleTree(await write(page, { ...firstReuseOpts, closeBeforeReload: true }), {
mode: 'worker',
runtime: 'shared-worker',
});

// Closing the only live port must reset the worker so the same name can serve another store.
expectLifecycleTree(await write(page, { ...secondReuseOpts, closeBeforeReload: true }), {
mode: 'worker',
runtime: 'shared-worker',
});
} finally {
await drop(page, { ...firstReuseOpts, runtime: 'direct' }).catch(() => {});
await drop(page, { ...secondReuseOpts, runtime: 'direct' }).catch(() => {});
}
});
});
64 changes: 41 additions & 23 deletions packages/treecrdt-wa-sqlite/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export async function createBrowserTreecrdtClient(
baseUrl,
filename: storage.filename,
storage: shouldUseOpfs ? 'opfs' : 'memory',
requireOpfs: storage.requireOpfs,
fallback: storage.fallback,
docId,
workerUrl: runtime.type === 'shared-worker' ? runtime.workerUrl : undefined,
name:
Expand All @@ -188,7 +188,7 @@ export async function createBrowserTreecrdtClient(
baseUrl,
filename: storage.filename,
storage: shouldUseOpfs ? 'opfs' : 'memory',
requireOpfs: storage.requireOpfs,
fallback: storage.fallback,
docId,
workerUrl: runtime.type === 'dedicated-worker' ? runtime.workerUrl : undefined,
});
Expand Down Expand Up @@ -320,7 +320,7 @@ async function createWorkerClient(opts: {
filename?: string;
storage: StorageMode;
docId: string;
requireOpfs?: boolean;
fallback: 'memory' | 'throw';
workerUrl?: string | URL;
}): Promise<TreecrdtClient> {
const materialized = createClientMaterializationDispatcher();
Expand Down Expand Up @@ -396,7 +396,13 @@ async function createWorkerClient(opts: {
// init
let initResult: RpcResult<'init'>;
try {
initResult = await call('init', [opts.baseUrl ?? '/', opts.filename, opts.storage, opts.docId]);
initResult = await call('init', [
opts.baseUrl ?? '/',
opts.filename,
opts.storage,
opts.docId,
opts.fallback,
]);
} catch (error) {
cleanup();
throw error;
Expand All @@ -409,7 +415,7 @@ async function createWorkerClient(opts: {
materialized.enableCrossTab({ docId: opts.docId, filename: effectiveFilename });
}

if (opts.requireOpfs && effectiveStorage !== 'opfs') {
if (opts.fallback === 'throw' && effectiveStorage !== 'opfs') {
const reason = initResult?.opfsError ? `: ${initResult.opfsError}` : '';
try {
if (!terminalError) await call('close', [] as RpcParams<'close'>);
Expand Down Expand Up @@ -459,7 +465,7 @@ async function createSharedWorkerClient(opts: {
storage: StorageMode;
docId: string;
name: string;
requireOpfs?: boolean;
fallback: 'memory' | 'throw';
workerUrl?: string | URL;
}): Promise<TreecrdtClient> {
const sharedWorker = opts.workerUrl
Expand Down Expand Up @@ -525,18 +531,8 @@ async function createSharedWorkerClient(opts: {
for (const { reject } of pending.values()) reject(err);
pending.clear();
};
port.addEventListener('message', onMessage);
port.addEventListener('messageerror', onMessageError);
port.start();

const initResult = (await call('init', [
opts.baseUrl ?? '/',
opts.filename,
opts.storage,
opts.docId,
])) as { storage?: StorageMode; filename?: string; opfsError?: string } | undefined;
const effectiveStorage: StorageMode = initResult?.storage === 'opfs' ? 'opfs' : 'memory';
const cleanup = () => {
if (closed) return;
closed = true;
materialized.close();
for (const { reject } of pending.values()) reject(closedError);
Expand All @@ -545,9 +541,33 @@ async function createSharedWorkerClient(opts: {
port.removeEventListener('messageerror', onMessageError);
port.close();
};
port.addEventListener('message', onMessage);
port.addEventListener('messageerror', onMessageError);
port.start();

if (opts.requireOpfs && effectiveStorage !== 'opfs') {
const reason = initResult?.opfsError ? `: ${initResult.opfsError}` : '';
let initResult: RpcResult<'init'>;
try {
initResult = await call('init', [
opts.baseUrl ?? '/',
opts.filename,
opts.storage,
opts.docId,
opts.fallback,
]);
} catch (err) {
try {
if (!terminalError) await call('close', [] as RpcParams<'close'>);
} catch {
// Initialization may not have completed, but the shared worker still needs its port removed.
} finally {
cleanup();
}
throw err;
}
const { storage: effectiveStorage, opfsError } = initResult;

if (opts.fallback === 'throw' && effectiveStorage !== 'opfs') {
const reason = opfsError ? `: ${opfsError}` : '';
try {
if (!terminalError) await call('close', [] as RpcParams<'close'>);
} catch {
Expand All @@ -562,19 +582,17 @@ async function createSharedWorkerClient(opts: {
if (closed) return;
try {
if (!terminalError) await call('close', [] as RpcParams<'close'>);
cleanup();
} finally {
// noop
cleanup();
}
};

const dropImpl = async () => {
if (closed) return;
try {
if (!terminalError) await call('drop', [] as RpcParams<'drop'>);
cleanup();
} finally {
// noop
cleanup();
}
};

Expand Down
Loading
Loading