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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions