Skip to content

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

Description

@abhiaiyer91

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:

@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

Two questions:

  1. Is there anything blocking Add statement lifecycle management and explicit close support #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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions