tursodatabase / tursodatabase/libsql-js

Database.close() does not finalize prepared statements — WAL file lock held until GC

Open
#228 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
335
Forks
48
Avg merge
3d 2h
Merged PRs (30d)
3

Description

Environment
  • libsql 0.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
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_mode WAL → 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::Database handle itself.
  • #214 (open) — statement lifecycle tracking via WeakRefs so close() closes all associated statements — looks like it fixes exactly this. 🎉

Two questions:

  1. Is there anything blocking #214 from landing?
  2. 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.