You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
libsql0.5.29 (also reproduces through @libsql/client 0.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
constDatabase=require('libsql');// 1. Open, prepare any statement, close.{constdb=newDatabase('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 worksdb.close();}// 2. A second connection should now be the only one and able to leave WAL mode.constdb2=newDatabase('test.db');db2.exec('PRAGMA busy_timeout = 500');db2.exec('PRAGMA journal_mode = DELETE');// throws: database is locked
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:
@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.
Environment
libsql0.5.29 (also reproduces through@libsql/client0.17.4, which wraps this package for local files)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 afterclose()until GC happens to run finalizers. Lock release afterclose()is effectively nondeterministic.A connection that only ever used
exec()releases its lock onclose()as expected, which isolates the pin to prepared statements.Repro
Observed behavior (
node --expose-gc, probing repeatedly):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 afterclose().Impact
Any operation that needs exclusive access to the file after closing a connection is broken in-process:
journal_modeWAL → DELETE (clean shutdown)VACUUM INTO+ rename) safelyclose()(the Windows EBUSY from Drop libsql::Database on close() to release file locks #213)@libsql/clientinherits this: it prepares a statement for everyexecute()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
libsql::Databasehandle itself.close()closes all associated statements — looks like it fixes exactly this. 🎉Two questions:
Happy to test a build against our reproduction.