Skip to content

node:sqlite segfaults when db.close() is called from a user-defined function callback during query executionΒ #63180

@mceachen

Description

@mceachen

Version

all current versions (tested v22.22.2, v24.15.0, v25.9.0, and v26.1.0)

Platform

All platforms are impacted.

(but because you asked:)

$ uname -a
Linux swift 6.17.0-23-generic #23~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 14 16:11:48 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=24.04
DISTRIB_CODENAME=noble
DISTRIB_DESCRIPTION="Ubuntu 24.04.4 LTS"

Subsystem

sqlite

What steps will reproduce the bug?

There are two distinct crash classes, both triggered by db.close() from inside a user-defined function callback. The original .all() repro and the .run() repro below are verified to segfault on main; the other Class A variants are expected to segfault by code inspection β€” they all funnel through the same StatementExecutionHelper step sites.

Class A β€” Statement VM freed mid-step. The original report; affects every StatementSync execution path that runs sqlite3_step:

// .all() β€” segfaults
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)");
db.prepare("INSERT INTO t VALUES (1, 10)").run();
db.prepare("INSERT INTO t VALUES (2, 20)").run();
db.function("close_db", (x) => { try { db.close(); } catch {} return x; });
db.prepare("SELECT close_db(v) FROM t").all();
// .get() β€” same crash
db.prepare("SELECT close_db(v) FROM t").get();
// iterator β€” same crash
const iter = db.prepare("SELECT close_db(v) FROM t").iterate();
iter.next();
// SQL tag store .all() / .get() β€” same crash
const sql = db.createTagStore(4);
sql.all`SELECT close_db(v) FROM t`;

Class B β€” db->Connection() null deref after step. Distinct crash path, only surfaces in .run() (and the SQL-tag .run()), even after Class A is fixed by deferring finalize. After sqlite3_step returns, StatementExecutionHelper::Run reads sqlite3_last_insert_rowid(db->Connection()) and sqlite3_changes64(db->Connection()). The reentrant db.close() set db->connection_ = nullptr before step returned, and the bundled SQLite is built without SQLITE_ENABLE_API_ARMOR, so passing NULL to those calls is a null-pointer deref:

// .run() β€” segfaults via the connection-null path
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)");
db.function("close_db", (x) => { try { db.close(); } catch {} return x; });
db.prepare("INSERT INTO t SELECT close_db(1), close_db(2)").run();
// SQL tag store .run() β€” same crash
const sql = db.createTagStore(4);
sql.run`INSERT INTO t SELECT close_db(${1}), close_db(${2})`;

How often does it reproduce? Is there a required condition?

100%.

The user-function callback must call db.close() while the outer sqlite3_step is still on the call stack.

What is the expected behavior? Why is that the expected behavior?

Either a clean error ("database closed during query" / SQLITE_INTERRUPT-style) or graceful completion of the in-flight statement followed by close. Process must not crash.

What do you see instead?

Segmentation fault (core dumped), exit code 139 on linux

Additional information

(full disclosure: produced with assistance from claude)

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-bugIssues with confirmed bugs.sqliteIssues and PRs related to the SQLite subsystem.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions