From 744f4221e2ac24669ab1a1b15b38e87b860a7ae4 Mon Sep 17 00:00:00 2001 From: my-lalex Date: Mon, 28 Sep 2026 19:04:51 +0000 Subject: [PATCH 1/4] Release the open lock when an OPFSAdaptiveVFS open fails. Without readwrite-unsafe access handles, jOpen takes the file's Web Lock and then its access handle. When the handle cannot be acquired - the file held by another context, or by a worker just terminated whose handles the engine has not reclaimed yet - the catch returns SQLITE_CANTOPEN with the lock still held and the request channel open. SQLite does not call xClose after a failed xOpen, so nothing releases them. Every later open of that file then waits for the lock forever, in the same worker and in others, until the worker that failed terminates. The catch now releases the lock, closes the channel and forgets the file. Co-Authored-By: Claude Opus 5.5 --- src/examples/OPFSAdaptiveVFS.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/examples/OPFSAdaptiveVFS.js b/src/examples/OPFSAdaptiveVFS.js index 7deaa2e0..1b3e79f9 100644 --- a/src/examples/OPFSAdaptiveVFS.js +++ b/src/examples/OPFSAdaptiveVFS.js @@ -124,6 +124,13 @@ export class OPFSAdaptiveVFS extends WebLocksMixin(FacadeVFS) { return VFS.SQLITE_OK; } catch (e) { this.lastError = e; + + // SQLite does not call xClose after a failed xOpen, so release here + // whatever this open acquired before it failed. + const file = this.mapIdToFile.get(fileId); + file?.openLockReleaser?.(); + file?.handleRequestChannel?.close(); + this.mapIdToFile.delete(fileId); return VFS.SQLITE_CANTOPEN; } } From b490933082badb426d201366f7beaf437cf23f21 Mon Sep 17 00:00:00 2001 From: my-lalex Date: Mon, 28 Sep 2026 19:04:51 +0000 Subject: [PATCH 2/4] Add an OPFSAdaptiveVFS test for recovery after a failed open. A worker holds an exclusive access handle on the database file, so the open fails - which is expected. The holder then lets go and the same worker opens the database again, which has to succeed. Against the previous behaviour it never completed, waiting for the lock the failed open had kept; the test bounds it at 5 s and reports it as hung. The path is the one taken without readwrite-unsafe access handles, as on Firefox. The test worker removes FileSystemSyncAccessHandle's mode before loading the VFS, so that Chromium takes it too. The holder from vfs_handle_recovery.js is exported for this. Co-Authored-By: Claude Opus 5.5 --- test/OPFSAdaptiveVFS.test.js | 2 + test/vfs_handle_recovery.js | 2 +- test/vfs_open_lock_recovery-worker.js | 36 +++++++++++++++ test/vfs_open_lock_recovery.js | 64 +++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 test/vfs_open_lock_recovery-worker.js create mode 100644 test/vfs_open_lock_recovery.js diff --git a/test/OPFSAdaptiveVFS.test.js b/test/OPFSAdaptiveVFS.test.js index 0a2861c8..aadaea28 100644 --- a/test/OPFSAdaptiveVFS.test.js +++ b/test/OPFSAdaptiveVFS.test.js @@ -4,6 +4,7 @@ import { vfs_xAccess } from "./vfs_xAccess.js"; import { vfs_xClose } from "./vfs_xClose.js"; import { vfs_xRead } from "./vfs_xRead.js"; import { vfs_xWrite } from "./vfs_xWrite.js"; +import { vfs_open_lock_recovery } from "./vfs_open_lock_recovery.js"; const CONFIG = 'OPFSAdaptiveVFS'; const BUILDS = ['asyncify', 'jspi']; @@ -22,6 +23,7 @@ describe(CONFIG, function() { vfs_xClose(context); vfs_xRead(context); vfs_xWrite(context); + vfs_open_lock_recovery({ build }); }); } }); diff --git a/test/vfs_handle_recovery.js b/test/vfs_handle_recovery.js index 6393162d..5b4873b6 100644 --- a/test/vfs_handle_recovery.js +++ b/test/vfs_handle_recovery.js @@ -22,7 +22,7 @@ const HOLDER_SRC = ` * Holds an exclusive access handle on a file, in a worker of its own: * createSyncAccessHandle is not available on the main thread. */ -function createHolder() { +export function createHolder() { const url = URL.createObjectURL( new Blob([HOLDER_SRC], { type: 'text/javascript' })); const worker = new Worker(url); diff --git a/test/vfs_open_lock_recovery-worker.js b/test/vfs_open_lock_recovery-worker.js new file mode 100644 index 00000000..20cc27cb --- /dev/null +++ b/test/vfs_open_lock_recovery-worker.js @@ -0,0 +1,36 @@ +// A worker for vfs_open_lock_recovery.js, holding one connection. Where the +// engine offers readwrite-unsafe access handles, OPFSAdaptiveVFS takes no +// lock on open; the test removes that feature first, so that the path taken +// is the one of engines without it. +import * as SQLite from '../src/sqlite-api.js'; + +const BUILDS = new Map([ + ['asyncify', '../dist/wa-sqlite-async.mjs'], + ['jspi', '../dist/wa-sqlite-jspi.mjs'], +]); + +const searchParams = new URLSearchParams(location.search); +const ready = (async () => { + if (searchParams.get('unsafe') === 'off') { + delete globalThis.FileSystemSyncAccessHandle.prototype.mode; + } + const { default: moduleFactory } = await import(BUILDS.get(searchParams.get('build'))); + const { OPFSAdaptiveVFS } = await import('../src/examples/OPFSAdaptiveVFS.js'); + const module = await moduleFactory(); + const sqlite3 = SQLite.Factory(module); + const vfs = await OPFSAdaptiveVFS.create('lock-recovery', module); + sqlite3.vfs_register(vfs, true); + return sqlite3; +})(); + +addEventListener('message', async ({ data }) => { + const sqlite3 = await ready; + try { + const db = await sqlite3.open_v2(data.filename); + await sqlite3.exec(db, 'SELECT 1'); + await sqlite3.close(db); + postMessage({ ok: true }); + } catch (e) { + postMessage({ ok: false, error: e.message }); + } +}); diff --git a/test/vfs_open_lock_recovery.js b/test/vfs_open_lock_recovery.js new file mode 100644 index 00000000..991932e3 --- /dev/null +++ b/test/vfs_open_lock_recovery.js @@ -0,0 +1,64 @@ +import { createHolder } from "./vfs_handle_recovery.js"; + +const OPEN_BOUND_MS = 5000; + +/** + * Recovery after an open whose access handle could not be acquired, on the + * path OPFSAdaptiveVFS takes without readwrite-unsafe access handles: there + * it takes the file's lock first, then the handle. When the handle fails, + * the lock must be released with it. + * @param {{ build: string }} params + */ +export function vfs_open_lock_recovery({ build }) { + describe('vfs_open_lock_recovery', function() { + const cleanup = []; + afterEach(async function() { + for (const fn of cleanup.splice(0).reverse()) { + await fn(); + } + }); + + function connect() { + const url = new URL('./vfs_open_lock_recovery-worker.js', import.meta.url); + url.searchParams.set('build', build); + url.searchParams.set('unsafe', 'off'); + const worker = new Worker(url, { type: 'module' }); + cleanup.push(() => worker.terminate()); + return (filename) => new Promise((resolve, reject) => { + const bound = setTimeout(() => resolve({ ok: false, error: 'hung' }), OPEN_BOUND_MS); + worker.addEventListener('message', ({ data }) => { + clearTimeout(bound); + resolve(data); + }, { once: true }); + worker.addEventListener('error', event => reject(new Error(event.message)), { once: true }); + worker.postMessage({ filename }); + }); + } + + it('should open a database once the file it could not acquire is released', + async function() { + const filename = `lock-recovery-${Math.random().toString(36).slice(2)}`; + const root = await navigator.storage.getDirectory(); + cleanup.push(() => root.removeEntry(filename).catch(() => {})); + + const holder = createHolder(); + cleanup.push(() => holder.dispose()); + const taken = await holder.take(filename); + if (!taken.ok) { + pending(`cannot hold an exclusive handle: ${taken.error}`); + return; + } + + // Expected to fail: the file is held elsewhere. + const open = connect(); + expect((await open(filename)).ok).toBeFalse(); + + await holder.release(); + + // The file is free, so this must succeed. When the failed open kept + // the file's lock, this one waited for it forever. + const opened = await open(filename); + expect(opened.ok ? 'opened' : opened.error).toBe('opened'); + }); + }); +} From dd5a5c985e4c84c188b031255ba06b62a80a5cad Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Fri, 2 Oct 2026 19:45:27 +0000 Subject: [PATCH 3/4] Release the open lock in OPFSAdaptiveVFS's jClose. SQLite calls xClose after a failed xOpen, since wa-sqlite's glue sets pMethods whatever xOpen returns; jClose is where the open lock belongs. --- src/examples/OPFSAdaptiveVFS.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/examples/OPFSAdaptiveVFS.js b/src/examples/OPFSAdaptiveVFS.js index 1b3e79f9..a1e46213 100644 --- a/src/examples/OPFSAdaptiveVFS.js +++ b/src/examples/OPFSAdaptiveVFS.js @@ -125,8 +125,8 @@ export class OPFSAdaptiveVFS extends WebLocksMixin(FacadeVFS) { } catch (e) { this.lastError = e; - // SQLite does not call xClose after a failed xOpen, so release here - // whatever this open acquired before it failed. + // Release at once what this open acquired, rather than leave it to + // xClose. const file = this.mapIdToFile.get(fileId); file?.openLockReleaser?.(); file?.handleRequestChannel?.close(); @@ -191,6 +191,9 @@ export class OPFSAdaptiveVFS extends WebLocksMixin(FacadeVFS) { this.mapIdToFile.delete(fileId); await file?.accessHandle?.close(); + // Release the open lock if no read came to release it. + file?.openLockReleaser?.(); + if (file?.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) { const [directoryHandle, name] = await getPathComponents(file.pathname, false); await directoryHandle.removeEntry(name, { recursive: false }); From 87ed5aaf672e29e340c47d76dccca395e8f0143c Mon Sep 17 00:00:00 2001 From: Alexandre LEGOUT Date: Sat, 3 Oct 2026 06:24:05 +0000 Subject: [PATCH 4/4] Release the access-handle lock and close the channel in jClose. Left to a later request on the channel, the lock outlived the file and the channel stayed open. --- src/examples/OPFSAdaptiveVFS.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/examples/OPFSAdaptiveVFS.js b/src/examples/OPFSAdaptiveVFS.js index a1e46213..07405d7e 100644 --- a/src/examples/OPFSAdaptiveVFS.js +++ b/src/examples/OPFSAdaptiveVFS.js @@ -191,8 +191,10 @@ export class OPFSAdaptiveVFS extends WebLocksMixin(FacadeVFS) { this.mapIdToFile.delete(fileId); await file?.accessHandle?.close(); - // Release the open lock if no read came to release it. + // Release what the open and the last transaction may still hold. file?.openLockReleaser?.(); + file?.handleLockReleaser?.(); + file?.handleRequestChannel?.close(); if (file?.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) { const [directoryHandle, name] = await getPathComponents(file.pathname, false);