Skip to content

fix: don't touch a closed connection when unsubscribing a reactive query - #442

Merged
ospfranco merged 3 commits into
OP-Engineering:mainfrom
mlecoq:fix_unsubscribeReactiveQueries
Aug 20, 2026
Merged

fix: don't touch a closed connection when unsubscribing a reactive query#442
ospfranco merged 3 commits into
OP-Engineering:mainfrom
mlecoq:fix_unsubscribeReactiveQueries

Conversation

@mlecoq

@mlecoq mlecoq commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Motivation

reactiveExecute returns an unsubscribe host function that captures
DBHostObject* raw (HFN2(this, reactiveQuery)). Host functions don't retain
the host object, so JS can hold that unsubscribe after the DB it came from
is gone — a React effect cleanup outliving the database is the everyday case.
~DBHostObject() closes the connection and nulls the sqlite3*; the next
unsubscribe() reaches auto_register_update_hook(), which calls
sqlite3_update_hook(db, …) with no check on db. That starts with
sqlite3_mutex_enter(db->mutex), and mutex is at offset 0x18 in
struct sqlite3, so the process dies reading 0x18: EXC_BAD_ACCESS / KERN_INVALID_ADDRESS at 0x18 on iOS, SIGSEGV on Android. We see this in
production on iOS 18.

The same call path is reachable without waiting for a GC: close() nulls the
handle but leaves the query registered, so a later unsubscribe() calls
opsqlite_deregister_update_hook(nullptr). The added test does exactly that and
crashes without this change.

What this changes

  • auto_register_update_hook() returns early when the connection is gone
    (invalidated || db == nullptr).
  • unsubscribe captures weak_from_this() instead of this, so unsubscribing
    after the database is destroyed is a no-op rather than a use-after-free — the
    half a null check can't cover, since freed-and-reused memory isn't null. Every
    DBHostObject is created with std::make_shared, so live databases are
    unaffected.
  • close/delete/invalidate drop the hook state, releasing the JS callback
    jsi::Values at close time instead of at finalization.
  • delete() gives up the handle before opsqlite_remove(), which closes the
    connection; it previously left a dangling pointer, including when
    opsqlite_remove throws on a missing file.

@ospfranco

Copy link
Copy Markdown
Contributor

Awesome, thanks for the fix

@ospfranco

Copy link
Copy Markdown
Contributor

One finding

The same class of bug the PR fixes for updateHook (via the new auto_register_update_hook guard) still exists, unfixed, for commitHook/rollbackHook. Could you also implement it there so that the fix is complete?

@mlecoq

mlecoq commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Done — you were right: db.close(); db.commitHook(cb) hits sqlite3_commit_hook(nullptr, …), same null read at 0x18.
Rather than patch the two hooks, I added a shared throw_if_closed() (throwing "database is closed", like interrupt already does) to all 19 host functions that touch the handle

@ospfranco
ospfranco merged commit c2446dd into OP-Engineering:main Aug 20, 2026
10 checks passed
@mlecoq
mlecoq deleted the fix_unsubscribeReactiveQueries branch August 20, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants