Skip to content

Fix OPFSCoopSyncVFS access handle leak on a failed acquisition - #350

Open
lalexdotcom wants to merge 2 commits into
rhashimoto:masterfrom
lalexdotcom:fix/coopsync-access-handle-leak
Open

lalexdotcom wants to merge 2 commits into
rhashimoto:masterfrom
lalexdotcom:fix/coopsync-access-handle-leak

Conversation

@lalexdotcom

Copy link
Copy Markdown

What happens

#requestAccessHandle acquires the access handles for the database file and its sidecars ('', -journal, -wal) in parallel with Promise.all. When one acquisition fails, Promise.all rejects immediately — while the others are still in flight.

The catch then calls #releaseAccessHandle, whose job is stated by its own comment: "Close any of the potentially opened access handles". But at that moment the in-flight acquisitions have not assigned anything yet, so it finds accessHandle still null on those persistent files and closes nothing. They complete a moment later and assign handles that nothing holds a reference to any more: the next attempt calls #createPersistentFile, which replaces the entries in persistentFiles.

So one access handle leaks per failed attempt — and it leaks on the sidecars, not on the file that actually failed. Once a single acquisition has failed, the VFS instance blocks itself: every later sqlite3_open_v2 on that database fails on -journal, held by the very instance asking for it. The failure outlives whatever caused the first one, for the life of the instance.

The first failure needs no bug to happen — another connection holding the file, or a worker terminated a moment ago whose handles the engine has not reclaimed yet. Failing then is correct. Never recovering is not.

Reproducing

A self-contained reproduction — Playwright, run from the root of a wa-sqlite checkout (npm i playwright, node coopsync-access-handle-leak.mjs 3), no build needed since dist/ is committed — does this:

  1. A worker takes an exclusive access handle on /demo (createSyncAccessHandle() with no mode).
  2. Another worker creates an OPFSCoopSyncVFS and opens demo. It fails — expected, and not the subject.
  3. The holder closes its handle, so the file is free.
  4. The same VFS instance opens demo again.
  5. A third worker asks which of demo, demo-journal, demo-wal can be acquired right now.

On master, 3 runs of 3:

run 1: open while held = failed | open after release = FAILED | still held afterwards: demo-journal, demo-wal
run 2: open while held = failed | open after release = FAILED | still held afterwards: demo-journal, demo-wal
run 3: open while held = failed | open after release = FAILED | still held afterwards: demo-journal, demo-wal

demo is free at step 5 — the holder released it — while demo-journal and demo-wal are not, and the only thing alive that can be holding them is the VFS of step 2. With this change, 3 runs of 3:

run 1: open while held = failed | open after release = OK | still held afterwards: none

The change

Promise.allSettled instead of Promise.all, then rethrow the first rejection. Every acquisition has finished by the time the failure is reported, so the cleanup in the catch sees the handles that succeeded and closes them.

Nothing else moves: same behaviour on success, the original error still propagates (failure.reason), same finally. Only the moment of the rejection changes.

Test

test/vfs_handle_recovery.js, wired into test/OPFSCoopSyncVFS.test.js. A worker holds an exclusive handle on the database file, the open fails, the holder releases, and the same VFS instance must then open it. It is skipped where the engine grants a second handle on the same file, since nothing can be held from another context there.

Against master it fails on the default and asyncify builds with Error: sqlite3_open_v2; with this change the file's 70 tests pass.

Scope

This does not make a blocked acquisition succeed — a file held elsewhere is a legitimate failure, and the caller is expected to retry. It makes that failure recoverable, which today it is not.

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 18, 2026 11:17
#requestAccessHandle acquires the database file and its sidecars in
parallel with Promise.all, which rejects as soon as one acquisition
does, while the others are still in flight. The catch then calls
#releaseAccessHandle, which finds those persistent files carrying no
access handle yet and closes nothing; the in-flight acquisitions
complete afterwards and assign handles that nothing will ever close.

So one access handle leaks per failed attempt, and it leaks on the
sidecars rather than on the file that failed — after which the VFS
blocks itself: every later open of the same database fails on -journal,
held by the very instance asking for it.

Observed on Chromium with a dead worker briefly holding the main file.
The file was free again 78 ms in, yet 25 successive opens over 2.5 s
all failed, every one of them on -journal.

Promise.allSettled lets every acquisition finish before the failure is
reported, so the cleanup in the catch below sees the handles that
succeeded and closes them — which is what its comment already says it
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A worker holds an exclusive access handle on the database file, so the
open fails - which is expected, and is what a connection elsewhere or a
worker whose handles the engine has not reclaimed yet would cause. The
holder then closes it and the same VFS instance opens the database
again, which has to succeed.

Against the previous behaviour the second open failed too, on a sidecar
file the instance was holding itself, and went on failing for the life
of the instance.

The holder runs in a worker of its own because createSyncAccessHandle
is not available on the main thread, and the test is skipped where the
engine grants a second handle on the same file, since nothing can be
held from another context there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant