Skip to content

Closing a database with active or queued async work can race native access and strand promises #304

Description

@chrispader

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

  • Closing while work is active has documented deterministic behavior: drain, cancel, or reject-close-without-closing.
  • No background task can access an erased/closed sqlite3* or race the native connection registry.
  • Every operation accepted before close settles; queued operations cannot remain pending indefinitely.
  • Queue completion after close does not throw an unhandled rejection.
  • The result of sqlite3_close_v2() is handled consistently with the chosen lifecycle policy.
  • New work after closing begins is rejected deterministically.
  • Reopening the same database name is allowed only after teardown is complete.
  • Harness coverage includes close during ordinary executeAsync(), transaction, async batch, and async file import on iOS and Android.
  • Stress coverage validates no native crash and no promise timeout in Debug and Release; iOS should include the default SQLITE_THREADSAFE=0 build.

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions