Skip to content

close() does not release a connection that prepared a statement, exhausting file descriptors #230

Description

@diegopeixoto

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:

  1. Sqlite3Client.close() calls this.#db.close(), which by the above does not release. Closing a client therefore does not reclaim its descriptors.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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