Skip to content

OPFSAdaptiveVFS: release the open lock when an open fails - #369

Merged
rhashimoto merged 5 commits into
rhashimoto:masterfrom
lalexdotcom:fix/adaptive-open-lock-leak
Oct 3, 2026
Merged

rhashimoto merged 5 commits into
rhashimoto:masterfrom
lalexdotcom:fix/adaptive-open-lock-leak

Conversation

@lalexdotcom

@lalexdotcom lalexdotcom commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

As mentioned in #365.

What happens

Without readwrite-unsafe access handles, as on Firefox, jOpen takes the file's Web Lock and then its access handle, and keeps the lock until the first jRead. 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 - jOpen returns SQLITE_CANTOPEN with the lock still held and the request channel open. SQLite then calls xClose on the file, but jClose only closes the access handle, so nothing releases the lock.

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. navigator.locks.query() shows the lock (OPFS:/<file>) held after the failed open.

The change

jClose releases the open lock and the access-handle lock if the file still holds them, and closes the request channel. The catch in jOpen also releases the open lock, closes the channel and removes the file from mapIdToFile, so a failed open leaves nothing for jClose to clean up. Nothing changes where readwrite-unsafe handles exist, since no lock is taken there. On success, the open lock is still released by the first read.

Test

test/vfs_open_lock_recovery.js with a worker of its own, wired into test/OPFSAdaptiveVFS.test.js. A worker holds an exclusive handle on the database file, so the open fails; the holder lets go, and the same worker must then open the database. The reopen is bounded at 5 s and reported as hung. The test worker removes FileSystemSyncAccessHandle's mode before loading the VFS, so Chromium takes the path of engines without readwrite-unsafe. It is skipped where the engine grants a second handle on the same file.

yarn web-test-runner test/OPFSAdaptiveVFS.test.js

On master: Expected 'hung' to be 'opened'. on asyncify and jspi. With this change the file's 70 tests pass, 3 runs of 3. The same scenario on Firefox itself (outside the suite, which runs Chromium) hangs on master and opens with this change.

It reuses the holder that vfs_handle_recovery.js exports since #367.

The whole suite on this branch, with master merged in: 6092 passed, 0 failed.

Checklist

  • I grant to recipients of this Project distribution a perpetual,
    non-exclusive, royalty-free, irrevocable copyright license to reproduce, prepare
    derivative works of, publicly display, sublicense, and distribute this
    Contribution and such derivative works.
  • I certify that I am legally entitled to grant this license, and that this
    Contribution contains no content requiring a license from any third party.

lalexdotcom and others added 2 commits September 28, 2026 19:04
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@rhashimoto

Copy link
Copy Markdown
Owner

SQLite does not call xClose after a failed xOpen, so nothing releases them.

Hmm. When I patch my local OPFSAdaptiveVFS:

  • Set the log property to console.log.
  • Insert return VFS.SQLITE_CANTOPEN as the first line of jOpen().

When I run the demo (http://localhost:8000/demo/?build=jspi&config=OPFSAdaptiveVFS&reset) the console shows a call to jClose():

11:46:37.464 demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:186 clearing OPFS and IndexedDB
11:46:37.600 FacadeVFS.js:254 jFullPathname hello 65
11:46:37.601 FacadeVFS.js:214 jOpen hello 595528 0x106
11:46:37.601 FacadeVFS.js:275 jClose 595528
11:46:37.601 FacadeVFS.js:266 jGetLastError 0
11:46:37.602 demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:170 SQLiteError: unable to open database file
    at Object.open_v2 (sqlite-api.js:524:25)
    at async demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:102:14
(anonymous) @ demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:170
Promise.catch
(anonymous) @ demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:169

And the stack trace from jClose() shows it is being called from sqlite3_open_v2():

jClose (OPFSAdaptiveVFS.js:184)
xClose (FacadeVFS.js:276)
(anonymous) (wa-sqlite-jspi.mjs:9)
handleAsync (wa-sqlite-jspi.mjs:9)
adapters_support (wa-sqlite-jspi.mjs:9)
_ippp_async (wa-sqlite-jspi.mjs:9)
$func1519 (wa-sqlite-jspi.wasm:0x77beb)
$func467 (wa-sqlite-jspi.wasm:0x30200)
$func806 (wa-sqlite-jspi.wasm:0x466f2)
$sqlite3_open_v2 (wa-sqlite-jspi.wasm:0x7c1b0)
await in $sqlite3_open_v2
ret.<computed> (wa-sqlite-jspi.mjs:9)
Module._sqlite3_open_v2 (wa-sqlite-jspi.mjs:9)
ccall (wa-sqlite-jspi.mjs:9)
(anonymous) (wa-sqlite-jspi.mjs:9)
(anonymous) (sqlite-api.js:515)
retry (sqlite-api.js:926)
(anonymous) (sqlite-api.js:515)
(anonymous) (demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:102)
Promise.then
(anonymous) (demo-worker.js?build=jspi&config=OPFSAdaptiveVFS&reset:80)

If this is a representative sequence then I think the primary problem is that jClose() doesn't conditionally call openLockReleaser. The current change to jOpen() would still be a good idea in addition to that.

SQLite calls xClose after a failed xOpen, since wa-sqlite's glue sets
pMethods whatever xOpen returns; jClose is where the open lock belongs.
@lalexdotcom

Copy link
Copy Markdown
Contributor Author

Thanks for checking. You're right, and that sentence was wrong: libvfs_xOpen() sets pMethods whatever the JavaScript xOpen returns, so SQLite's cleanup does call xClose. Tracing it on Chromium and Firefox and on both builds shows the same thing: jClose comes right after the failed open. The lock stays held because jClose never calls openLockReleaser.

I've moved the main fix there, so jClose now releases the open lock. The jOpen cleanup stays, with its comment corrected. I didn't make jClose close the request channel. Doing that without also releasing the access-handle lock makes the next open in another context wait, because a later request on that channel is what releases that lock.

I merged master into the branch and updated the description. The test fails on master's VFS and passes 3 runs of 3; the whole suite has 6092 passed, 0 failed.

@rhashimoto

Copy link
Copy Markdown
Owner

I didn't make jClose close the request channel. Doing that without also releasing the access-handle lock makes the next open in another context wait, because a later request on that channel is what releases that lock.

Is there a reason not to release the access handle lock (if it exists) and close the channel?

Left to a later request on the channel, the lock outlived the file and
the channel stayed open.
@lalexdotcom

Copy link
Copy Markdown
Contributor Author

No reason beyond keeping the change small. Done: jClose now releases the access-handle lock if it holds it and closes the channel, after closing the handle. I had tried that variant alongside the others on Chromium and Firefox, and it passed every scenario: open and close with or without a statement, then reopen in the same worker or another. It also stops leaving a channel open and a lock held after each close until someone asks for it.

The test passes 3 runs of 3; whole suite 6092 passed, 0 failed.

@rhashimoto

Copy link
Copy Markdown
Owner

Great, thanks! LGTM.

@rhashimoto
rhashimoto merged commit d7e7d6b into rhashimoto:master Oct 3, 2026
1 check passed
@lalexdotcom
lalexdotcom deleted the fix/adaptive-open-lock-leak branch October 3, 2026 19:52
simolus3 pushed a commit to powersync-ja/wa-sqlite that referenced this pull request Oct 5, 2026
…#369)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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.

* 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.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants