tursodatabase / tursodatabase/libsql-js
Database.close() does not finalize prepared statements — WAL file lock held until GC
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 335
- Forks
- 48
- Avg merge
- 3d 2h
- Merged PRs (30d)
- 3
Description
Environment
libsql0.5.29 (also reproduces through@libsql/client0.17.4, which wraps this package for local files)- Node v22.22.2, macOS arm64 (also observed on Linux)
Summary
Database.close() does not finalize outstanding prepared statements. If a connection ever prepared a statement, its underlying connection — and therefore its WAL shared-memory lock on the database file — stays alive after close() until GC happens to run finalizers. Lock release after close() is effectively nondeterministic.
A connection that only ever used exec() releases its lock on close() as expected, which isolates the pin to prepared statements.
Repro
const Database = require('libsql');
// 1. Open, prepare any statement, close.
{
const db = new Database('test.db');
db.exec('PRAGMA journal_mode = WAL');
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)');
db.prepare('SELECT 1').get(); // <-- remove this line and everything works
db.close();
}
// 2. A second connection should now be the only one and able to leave WAL mode.
const db2 = new Database('test.db');
db2.exec('PRAGMA busy_timeout = 500');
db2.exec('PRAGMA journal_mode = DELETE'); // throws: database is locked
Observed behavior (node --expose-gc, probing repeatedly):
FAIL (right after close()): database is locked
FAIL (gc attempt 1 + 200ms): database is locked
OK (gc attempt 2 + 200ms)
So even one explicit GC pass isn't reliably enough — the statement handles are only collected on a later cycle. Without --expose-gc (i.e. any production process) there is no way to force release.
If the db.prepare('SELECT 1').get() line is removed (exec-only connection), the second connection acquires exclusive access immediately after close().
Impact
Any operation that needs exclusive access to the file after closing a connection is broken in-process:
- switching
journal_modeWAL → DELETE (clean shutdown) - compacting/swapping the database file (
VACUUM INTO+ rename) safely - deleting the file right after
close()(the Windows EBUSY from #213)
@libsql/client inherits this: it prepares a statement for every execute() and never finalizes, so every closed local client leaves a GC-pinned lock behind.
We currently work around it by probing exclusivity with a separate exec-only connection and verifying the outcome from the SQLite file header bytes, which works but shouldn't be necessary.
Related work
- #213 (merged) fixed the analogous problem for the
libsql::Databasehandle itself. - #214 (open) — statement lifecycle tracking via WeakRefs so
close()closes all associated statements — looks like it fixes exactly this. 🎉
Two questions:
- Is there anything blocking #214 from landing?
- Since 0.6.0 is still in pre-release, would you consider backporting the statement-finalization fix to a 0.5.x patch release?
Happy to test a build against our reproduction.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with related issue #214, which describes statement lifecycle tracking via WeakRefs, and reproduce the WAL-lock failure from this issue with the shown Node script. Done means Database.close() releases prepared-statement locks deterministically so the second connection can switch the database from WAL to DELETE without waiting for GC.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs, sqlite
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100