Summary
A connection that has prepared a statement is not released by db.close(). The file descriptors stay open for the lifetime of the process, and the garbage collector does not reclaim them, so a program that opens and closes connections in a loop exhausts its descriptor limit and every subsequent new Database() fails.
A connection that only ever used exec() closes correctly, which is what pins it to prepare().
This reaches @libsql/client users indirectly: its executeStmt always goes through db.prepare(), so every client connection is affected, and Sqlite3Client.close() cannot release one.
Reproduction
libsql@0.5.29, no other dependencies:
import Database from 'libsql';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const file = join(mkdtempSync(join(tmpdir(), 'libsql-fd-')), 't.db');
const setup = new Database(file);
setup.exec('PRAGMA journal_mode = WAL');
setup.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
setup.close();
const run = (label, body) => {
let n = 0;
try {
for (; n < 1200; n++) body();
console.log(`${label} — completed ${n}`);
} catch (e) {
console.log(`${label} — threw after ${n}: ${String(e.message).split('\n')[0]}`);
}
};
run('exec() only, then close() ', () => {
const d = new Database(file);
d.exec("INSERT INTO t (v) VALUES ('x')");
d.close();
});
run('prepare().run(), then close() ', () => {
const d = new Database(file);
d.prepare('INSERT INTO t (v) VALUES (?)').run(['x']);
d.close();
});
Run with a small descriptor limit so exhaustion is reached quickly:
$ ulimit -n 512; node repro.mjs
exec() only, then close() — completed 1200
prepare().run(), then close() — threw after 247: ConnectionFailed("Unable to open connection to local database …: 14")
247 × 2 descriptors ≈ the 512 limit. SQLite error 14 is SQLITE_CANTOPEN.
A prepared SELECT behaves the same way, failing with unable to open database file after 246 cycles.
Descriptor growth
Without a limit, the growth is linear and never recovers. Counting open handles on the database file across 200 open/close cycles, each doing one prepare().run():
| journal mode |
descriptors per cycle |
| WAL |
+2.00 |
| rollback journal |
+1.00 |
The timeout option makes no difference, and no transaction is involved — a single prepared statement is enough.
Environment
libsql 0.5.29 (latest at the time of writing)
- macOS 15.5, arm64
- Node v24.16.0 — identical results under Bun 1.3.3
Why this matters downstream
@libsql/client's local client runs every statement through db.prepare() in executeStmt, so this affects any application using it against a file: URL. Two consequences we hit in a self-hosted app:
Sqlite3Client.close() calls this.#db.close(), which by the above does not release. Closing a client therefore does not reclaim its descriptors.
Sqlite3Client.transaction() hands its connection to the transaction object and sets this.#db = null, and nothing ever closes that handle — Sqlite3Transaction.close() only issues a ROLLBACK. Every transaction permanently costs two descriptors.
Measured through drizzle-orm on @libsql/client@0.17.4, 300 db.transaction() calls held 602 descriptors open, and under ulimit -n 512 the process died on transaction 247. In a container with a 1024 descriptor limit, an application doing a few hundred transactions stops being able to open its own database, and every query fails until it is restarted.
I am happy to test a fix against our workload if that is useful.
Summary
A connection that has prepared a statement is not released by
db.close(). The file descriptors stay open for the lifetime of the process, and the garbage collector does not reclaim them, so a program that opens and closes connections in a loop exhausts its descriptor limit and every subsequentnew Database()fails.A connection that only ever used
exec()closes correctly, which is what pins it toprepare().This reaches
@libsql/clientusers indirectly: itsexecuteStmtalways goes throughdb.prepare(), so every client connection is affected, andSqlite3Client.close()cannot release one.Reproduction
libsql@0.5.29, no other dependencies:Run with a small descriptor limit so exhaustion is reached quickly:
247 × 2 descriptors ≈ the 512 limit. SQLite error 14 is
SQLITE_CANTOPEN.A prepared
SELECTbehaves the same way, failing withunable to open database fileafter 246 cycles.Descriptor growth
Without a limit, the growth is linear and never recovers. Counting open handles on the database file across 200 open/close cycles, each doing one
prepare().run():The
timeoutoption makes no difference, and no transaction is involved — a single prepared statement is enough.Environment
libsql0.5.29 (latest at the time of writing)Why this matters downstream
@libsql/client's local client runs every statement throughdb.prepare()inexecuteStmt, so this affects any application using it against afile:URL. Two consequences we hit in a self-hosted app:Sqlite3Client.close()callsthis.#db.close(), which by the above does not release. Closing a client therefore does not reclaim its descriptors.Sqlite3Client.transaction()hands its connection to the transaction object and setsthis.#db = null, and nothing ever closes that handle —Sqlite3Transaction.close()only issues aROLLBACK. Every transaction permanently costs two descriptors.Measured through drizzle-orm on
@libsql/client@0.17.4, 300db.transaction()calls held 602 descriptors open, and underulimit -n 512the process died on transaction 247. In a container with a 1024 descriptor limit, an application doing a few hundred transactions stops being able to open its own database, and every query fails until it is restarted.I am happy to test a fix against our workload if that is useful.