tursodatabase / tursodatabase/libsql-js
close() does not release a connection that prepared a statement, exhausting file descriptors
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 335
- Forks
- 48
- Avg merge
- 3d 2h
- Merged PRs (30d)
- 3
Description
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
libsql0.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()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, 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.
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 by reproducing the descriptor growth with the supplied prepare().run() and close() loop under a small ulimit. Trace the database close path and the transaction lifecycle mentioned in Sqlite3Client.close(), executeStmt, and Sqlite3Transaction.close(). Done means prepared connections and transactions release their descriptors, with repeated cycles completing without SQLITE_CANTOPEN.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs, sqlite
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100