Summary
Calling connection.close() while async work is active or queued currently closes/erases the native connection first and then deletes the JavaScript database queue even when that queue is busy.
That can:
- race
sqlite3_close_v2() / dbMap.erase() against a background Promise::async using the same connection;
- leave queued operation promises pending forever;
- throw from queue cleanup after the queue has been deleted, producing an unhandled rejection; and
- turn a transaction failure into a secondary rollback/closed-database error.
This is distinct from hot-reload/runtime teardown in #32: this report concerns the public NitroSQLiteConnection.close() lifecycle and its normal async-operation queue.
Found by auditing main at ad8b835ba0f44a207649ecc2953820d39e4e8639 (package version 9.7.0).
Evidence
JavaScript lifecycle ordering
close() calls native first and then calls closeDatabaseQueue():
closeDatabaseQueue() only warns when work exists and deletes the queue anyway:
Every queued operation's finally calls startOperationAsync(dbName). That immediately calls getDatabaseQueue(dbName), which throws once closeDatabaseQueue() has removed the entry:
Any operations still in the deleted queue are neither started nor rejected, so their returned promises have no settlement path.
Native lifecycle ordering
Native close() immediately calls sqliteCloseDb():
sqliteCloseDb() calls sqlite3_close_v2(), ignores its result, and erases the pointer from the global dbMap:
At the same time, ordinary executeAsync(), async batches, and async file loading can run on background Promise::async work:
The global dbMap and the sqlite3* lifetime are not guarded here. This is especially risky on the default iOS/visionOS build, which compiles SQLite with SQLITE_THREADSAFE=0: RNNitroSQLite.podspec#L9-L43.
Deterministic queue-lifecycle reproduction outline
Pause a transaction callback, queue a batch behind it, and close before releasing the transaction:
const db = open({ name: 'close-race.sqlite' })
db.execute('CREATE TABLE events (name TEXT NOT NULL)')
let transactionStarted!: () => void
const started = new Promise<void>((resolve) => {
transactionStarted = resolve
})
let releaseTransaction!: () => void
const release = new Promise<void>((resolve) => {
releaseTransaction = resolve
})
const activeTransaction = db.transaction(async (tx) => {
transactionStarted()
await release
await tx.executeAsync("INSERT INTO events(name) VALUES ('transaction')")
})
await started
// This enters DatabaseQueue behind the active transaction.
const queuedBatch = db.executeBatchAsync([
{ query: "INSERT INTO events(name) VALUES ('queued')" },
])
db.close()
releaseTransaction()
const timeout = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 1_000)
})
const batchOutcome = await Promise.race([
queuedBatch.then(() => 'resolved' as const, () => 'rejected' as const),
timeout,
])
// Current risk: "timeout"; the deleted queue no longer has a path to start or reject it.
// Also monitor unhandled rejections from the active operation's finally block.
console.log(batchOutcome)
A native-race stress variant should start many ordinary executeAsync() calls and call close() immediately, then assert that every promise settles and the process does not crash.
Consequences
- Callers cannot safely know when it is legal to close or reopen a database.
- Queued promises can remain pending indefinitely, hanging shutdown, sign-out, database replacement, or tests.
- Cleanup can cause unhandled promise rejections.
- A background task may access a connection concurrently with its closure/removal.
sqlite3_close_v2() can create a zombie connection while statements remain; erasing the only tracked pointer makes lifecycle behavior opaque.
- A transaction's intended error can be replaced by the rollback failure caused by the already-closed connection.
Proposed direction
Define an explicit close state and settlement contract per connection. Reasonable designs include:
- an async close that stops accepting work, drains or cancels queued/in-flight operations, and closes native state only after they settle;
- a synchronous close that refuses with a deterministic busy error without mutating state; or
- separate
close() and closeAsync() behavior with clearly documented guarantees.
Whichever API is selected, the native connection registry and background work need coordinated lifetime ownership. Queue removal should not happen while an operation's finally still depends on that queue, and every accepted operation must resolve or reject.
The same lifecycle contract should be considered for delete(), runtime teardown, and reopening a database with the same name.
Acceptance criteria
Related
Summary
Calling
connection.close()while async work is active or queued currently closes/erases the native connection first and then deletes the JavaScript database queue even when that queue is busy.That can:
sqlite3_close_v2()/dbMap.erase()against a backgroundPromise::asyncusing the same connection;This is distinct from hot-reload/runtime teardown in #32: this report concerns the public
NitroSQLiteConnection.close()lifecycle and its normal async-operation queue.Found by auditing
mainatad8b835ba0f44a207649ecc2953820d39e4e8639(package version 9.7.0).Evidence
JavaScript lifecycle ordering
close()calls native first and then callscloseDatabaseQueue():closeDatabaseQueue()only warns when work exists and deletes the queue anyway:Every queued operation's
finallycallsstartOperationAsync(dbName). That immediately callsgetDatabaseQueue(dbName), which throws oncecloseDatabaseQueue()has removed the entry:Any operations still in the deleted queue are neither started nor rejected, so their returned promises have no settlement path.
Native lifecycle ordering
Native
close()immediately callssqliteCloseDb():sqliteCloseDb()callssqlite3_close_v2(), ignores its result, and erases the pointer from the globaldbMap:At the same time, ordinary
executeAsync(), async batches, and async file loading can run on backgroundPromise::asyncwork:The global
dbMapand thesqlite3*lifetime are not guarded here. This is especially risky on the default iOS/visionOS build, which compiles SQLite withSQLITE_THREADSAFE=0: RNNitroSQLite.podspec#L9-L43.Deterministic queue-lifecycle reproduction outline
Pause a transaction callback, queue a batch behind it, and close before releasing the transaction:
A native-race stress variant should start many ordinary
executeAsync()calls and callclose()immediately, then assert that every promise settles and the process does not crash.Consequences
sqlite3_close_v2()can create a zombie connection while statements remain; erasing the only tracked pointer makes lifecycle behavior opaque.Proposed direction
Define an explicit close state and settlement contract per connection. Reasonable designs include:
close()andcloseAsync()behavior with clearly documented guarantees.Whichever API is selected, the native connection registry and background work need coordinated lifetime ownership. Queue removal should not happen while an operation's
finallystill depends on that queue, and every accepted operation must resolve or reject.The same lifecycle contract should be considered for
delete(), runtime teardown, and reopening a database with the same name.Acceptance criteria
sqlite3*or race the native connection registry.sqlite3_close_v2()is handled consistently with the chosen lifecycle policy.executeAsync(), transaction, async batch, and async file import on iOS and Android.SQLITE_THREADSAFE=0build.Related