From f4f3542d5420b13f5ba2d490d3ed8455251d3075 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Wed, 12 Aug 2026 18:53:07 -0400 Subject: [PATCH 1/8] Patches around async work --- cpp/OPThreadPool.cpp | 4 ++++ cpp/utils.cpp | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cpp/OPThreadPool.cpp b/cpp/OPThreadPool.cpp index e9b06857..b06c00b3 100644 --- a/cpp/OPThreadPool.cpp +++ b/cpp/OPThreadPool.cpp @@ -77,6 +77,10 @@ void ThreadPool::doWork() { ++busy; } task(); + // Release the task (and everything it captured, e.g. JSI values) before + // signalling idle, so waitFinished()/close() can't observe busy == 0 + // while task-owned resources are still pending destruction. + task = nullptr; { std::lock_guard g(workQueueMutex); --busy; diff --git a/cpp/utils.cpp b/cpp/utils.cpp index 1f2ad590..10acdbc8 100644 --- a/cpp/utils.cpp +++ b/cpp/utils.cpp @@ -383,8 +383,7 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, auto resolve = std::make_shared(rt, args[0]); auto reject = std::make_shared(rt, args[1]); - auto task = [lambda = lambda, thread_pool, - resolve_callback = resolve_callback, + auto task = [lambda = lambda, resolve_callback = resolve_callback, resolve = std::move(resolve), reject = std::move(reject)]() { try { std::any result = lambda(); @@ -394,7 +393,7 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, } opsqlite::invoker->invokeAsync( - [result = std::move(result), resolve = resolve, + [result = std::move(result), resolve = resolve, reject = reject, resolve_callback = resolve_callback](jsi::Runtime &rt) { auto jsi_result = resolve_callback(rt, result); resolve->asObject(rt).asFunction(rt).call(rt, jsi_result); From 2c0fccf786fca3fd68b515555ba299e0e0315a45 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Wed, 12 Aug 2026 19:19:10 -0400 Subject: [PATCH 2/8] Properly capture resolve --- cpp/utils.cpp | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/cpp/utils.cpp b/cpp/utils.cpp index 10acdbc8..e442625e 100644 --- a/cpp/utils.cpp +++ b/cpp/utils.cpp @@ -170,8 +170,8 @@ std::vector to_variant_vec(jsi::Runtime &rt, jsi::Value const &xs) { res.reserve(arg_length); for (size_t ii = 0; ii < arg_length; ii++) { - res.emplace_back(to_variant(rt, values.getValueAtIndex(rt, ii))); - } + res.emplace_back(to_variant(rt, values.getValueAtIndex(rt, ii))); + } return res; } @@ -392,6 +392,8 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, return; } + // reject is also captured in the invokeAsync lambda + // so it can be safely disposed on the JS thread opsqlite::invoker->invokeAsync( [result = std::move(result), resolve = resolve, reject = reject, resolve_callback = resolve_callback](jsi::Runtime &rt) { @@ -403,23 +405,30 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, // runtime_error to the generic exception We have to // explicitly catch it // https://github.com/facebook/react-native/issues/48027 + // + // resolve is also captured in the invokeAsync lambda + // so it can be safely disposed on the JS thread auto what = e.what(); - opsqlite::invoker->invokeAsync( - [what = std::string(what), reject = reject](jsi::Runtime &rt) { - auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); - auto error = errorCtr.callAsConstructor( - rt, jsi::String::createFromAscii(rt, what)); - reject->asObject(rt).asFunction(rt).call(rt, error); - }); + opsqlite::invoker->invokeAsync([what = std::string(what), + resolve = resolve, + reject = reject](jsi::Runtime &rt) { + auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); + auto error = errorCtr.callAsConstructor( + rt, jsi::String::createFromAscii(rt, what)); + reject->asObject(rt).asFunction(rt).call(rt, error); + }); } catch (std::exception &exc) { auto what = exc.what(); - opsqlite::invoker->invokeAsync( - [what = std::string(what), reject = reject](jsi::Runtime &rt) { - auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); - auto error = errorCtr.callAsConstructor( - rt, jsi::String::createFromAscii(rt, what)); - reject->asObject(rt).asFunction(rt).call(rt, error); - }); + // resolve is also captured in the invokeAsync lambda + // so it can be safely disposed on the JS thread + opsqlite::invoker->invokeAsync([what = std::string(what), + resolve = resolve, + reject = reject](jsi::Runtime &rt) { + auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); + auto error = errorCtr.callAsConstructor( + rt, jsi::String::createFromAscii(rt, what)); + reject->asObject(rt).asFunction(rt).call(rt, error); + }); } }; From 07fc36a5bbf99d10486b3f586dc4aea1be3c662d Mon Sep 17 00:00:00 2001 From: Babs Craig Date: Thu, 13 Aug 2026 00:36:38 +0100 Subject: [PATCH 3/8] Bind hooks and reactive flush to their runtime generation During a bridgeless reload two JS runtime generations overlap: the outgoing one is invalidated while the incoming one is already installing. The process globals invoker and invalidated cannot describe that, so a database created by the outgoing generation would post its hook and reactive-query callbacks into the runtime that replaced it, and then call asFunction() on a jsi::Value owned by the dead one. Each generation now gets its own liveness flag, and DBHostObject binds both the invoker and that flag at construction, on the JS thread. The members shadow the globals inside member functions, so every invokeAsync site is fixed without touching each call site. --- cpp/DBHostObject.cpp | 19 +++++++++++++++++++ cpp/DBHostObject.hpp | 12 ++++++++++++ cpp/OPSqlite.cpp | 9 +++++++++ cpp/types.hpp | 13 +++++++++++++ 4 files changed, 53 insertions(+) diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index b3fbcc37..285ea9d1 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -20,6 +20,9 @@ namespace react = facebook::react; #ifdef OP_SQLITE_USE_LIBSQL void DBHostObject::flush_pending_reactive_queries( const std::shared_ptr &resolve) { + if (alive != nullptr && !alive->load()) { + return; + } invoker->invokeAsync([resolve](jsi::Runtime &rt) { resolve->asObject(rt).asFunction(rt).call(rt, {}); }); @@ -33,6 +36,9 @@ std::string turso_remote_db_name(const std::string &url) { void DBHostObject::flush_pending_reactive_queries( const std::shared_ptr &resolve) { + if (alive != nullptr && !alive->load()) { + return; + } invoker->invokeAsync([resolve](jsi::Runtime &rt) { resolve->asObject(rt).asFunction(rt).call(rt, {}); }); @@ -40,6 +46,9 @@ void DBHostObject::flush_pending_reactive_queries( #else void DBHostObject::flush_pending_reactive_queries( const std::shared_ptr &resolve) { + if (alive != nullptr && !alive->load()) { + return; + } for (const auto &query_ptr : pending_reactive_queries) { auto query = query_ptr.get(); @@ -67,12 +76,18 @@ void DBHostObject::flush_pending_reactive_queries( } void DBHostObject::on_commit() { + if (alive != nullptr && !alive->load()) { + return; + } invoker->invokeAsync([this](jsi::Runtime &rt) { commit_hook_callback->asObject(rt).asFunction(rt).call(rt); }); } void DBHostObject::on_rollback() { + if (alive != nullptr && !alive->load()) { + return; + } invoker->invokeAsync([this](jsi::Runtime &rt) { rollback_hook_callback->asObject(rt).asFunction(rt).call(rt); }); @@ -80,6 +95,10 @@ void DBHostObject::on_rollback() { void DBHostObject::on_update(const std::string &table, const std::string &operation, long long row_id) { + if (alive != nullptr && !alive->load()) { + return; + } + if (update_hook_callback != nullptr) { invoker->invokeAsync([callback = update_hook_callback, table, operation, row_id](jsi::Runtime &rt) { diff --git a/cpp/DBHostObject.hpp b/cpp/DBHostObject.hpp index 19b1180d..81ba8281 100644 --- a/cpp/DBHostObject.hpp +++ b/cpp/DBHostObject.hpp @@ -87,6 +87,18 @@ class JSI_EXPORT DBHostObject : public jsi::HostObject { std::unordered_map function_map; std::string base_path; + // Bound at construction, on the JS thread, to the generation that created + // this database. + // + // NOTE: these deliberately shadow the process-global opsqlite::invoker and + // opsqlite::generation_alive inside every member function, which is what + // fixes the update/commit/rollback hooks and flush_pending_reactive_queries + // without touching each call site. Reading the globals at callback time + // instead lets a database belonging to a torn-down runtime post work into the + // runtime that replaced it, and then call asFunction() on a jsi::Value owned + // by the dead one. + std::shared_ptr invoker = opsqlite::invoker; + std::shared_ptr> alive = opsqlite::generation_alive; std::shared_ptr thread_pool; std::string db_name; std::string delete_db_name; diff --git a/cpp/OPSqlite.cpp b/cpp/OPSqlite.cpp index 31401f2a..2e2a52e8 100644 --- a/cpp/OPSqlite.cpp +++ b/cpp/OPSqlite.cpp @@ -27,6 +27,7 @@ std::string _sqlite_vec_path; std::vector> dbs; bool invalidated = false; std::shared_ptr invoker; +std::shared_ptr> generation_alive; // React native will try to clean the module on JS context invalidation // (CodePush/Hot Reload) The clearState function is called @@ -34,6 +35,13 @@ void invalidate() { // Global flag used by the threads to stop work invalidated = true; + // Mark THIS generation dead. Work queued by it holds a copy of the flag, so + // it drops its completions instead of resolving into a runtime that is being + // torn down. + if (generation_alive != nullptr) { + generation_alive->store(false); + } + for (const auto &db : dbs) { db->invalidate(); } @@ -53,6 +61,7 @@ void install(jsi::Runtime &rt, _sqlite_vec_path = std::string(sqlite_vec_path); opsqlite::invoker = _invoker; opsqlite::invalidated = false; + opsqlite::generation_alive = std::make_shared>(true); auto open = HFN0 { jsi::Object options = args[0].asObject(rt); diff --git a/cpp/types.hpp b/cpp/types.hpp index 6c2ff2df..5844e615 100644 --- a/cpp/types.hpp +++ b/cpp/types.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -12,6 +13,18 @@ namespace opsqlite { extern std::shared_ptr invoker; extern bool invalidated; +// Liveness of the current JS runtime generation. Replaced by install() and +// cleared by invalidate(), so each generation gets its own flag rather than +// sharing the process-global `invalidated` bool. +// +// Whoever queues work copies the shared_ptr when the work is created, so it +// always observes ITS OWN generation's liveness. Checking a process-global +// instead is wrong in both directions during a bridgeless reload, where two +// generations overlap: an outgoing generation clearing it would suppress the +// incoming generation's callbacks, and an incoming generation setting it would +// re-enable the outgoing generation's. +extern std::shared_ptr> generation_alive; + struct ArrayBuffer { std::shared_ptr data; size_t size; From f03ae7a2686d1d90074b3f5c432e9c4e3dbe94f7 Mon Sep 17 00:00:00 2001 From: Babs Craig Date: Thu, 13 Aug 2026 00:37:15 +0100 Subject: [PATCH 4/8] Bind promisify tasks to their runtime generation Same problem as the hooks, on the thread pool. A task read opsqlite::invoker when it completed rather than when it was queued, so a query started by the outgoing runtime could resolve into the incoming one and call asFunction() on a jsi::Value that belongs to the dead runtime. The invoker and the liveness flag are now captured on the JS thread while the promise is constructed, and the early return checks this generation rather than the process-global invalidated, which is set by whichever generation tore down last. Note the early return still drops the last references on the pool thread, so ~jsi::Value runs there. That is pre-existing, and the sqlite3_interrupt change that follows makes it very hard to reach, but it is not airtight without either leaking the values or handing them to the JS thread. --- cpp/utils.cpp | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/cpp/utils.cpp b/cpp/utils.cpp index e442625e..0bf6d2d9 100644 --- a/cpp/utils.cpp +++ b/cpp/utils.cpp @@ -383,18 +383,34 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, auto resolve = std::make_shared(rt, args[0]); auto reject = std::make_shared(rt, args[1]); + // Bind this generation's invoker and liveness flag here, on the JS thread, + // while the promise is being constructed. Reading the process globals from + // the worker instead lets a task queued by a torn-down runtime post into the + // runtime that replaced it, and then call asFunction() on a jsi::Value that + // belongs to the dead one. + auto invoker = opsqlite::invoker; + auto alive = opsqlite::generation_alive; + auto task = [lambda = lambda, resolve_callback = resolve_callback, - resolve = std::move(resolve), reject = std::move(reject)]() { + resolve = std::move(resolve), reject = std::move(reject), + invoker, alive]() { + if (invoker == nullptr) { + return; + } + try { std::any result = lambda(); - if (opsqlite::invalidated) { + // This generation is gone. Posting now would schedule onto a runtime + // that is being torn down, where asFunction() sees an already + // invalidated PointerValue. + if (alive != nullptr && !alive->load()) { return; } // reject is also captured in the invokeAsync lambda // so it can be safely disposed on the JS thread - opsqlite::invoker->invokeAsync( + invoker->invokeAsync( [result = std::move(result), resolve = resolve, reject = reject, resolve_callback = resolve_callback](jsi::Runtime &rt) { auto jsi_result = resolve_callback(rt, result); @@ -409,9 +425,11 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, // resolve is also captured in the invokeAsync lambda // so it can be safely disposed on the JS thread auto what = e.what(); - opsqlite::invoker->invokeAsync([what = std::string(what), - resolve = resolve, - reject = reject](jsi::Runtime &rt) { + if (alive != nullptr && !alive->load()) { + return; + } + invoker->invokeAsync([what = std::string(what), resolve = resolve, + reject = reject](jsi::Runtime &rt) { auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); auto error = errorCtr.callAsConstructor( rt, jsi::String::createFromAscii(rt, what)); @@ -419,11 +437,13 @@ promisify(jsi::Runtime &rt, std::shared_ptr thread_pool, }); } catch (std::exception &exc) { auto what = exc.what(); + if (alive != nullptr && !alive->load()) { + return; + } // resolve is also captured in the invokeAsync lambda // so it can be safely disposed on the JS thread - opsqlite::invoker->invokeAsync([what = std::string(what), - resolve = resolve, - reject = reject](jsi::Runtime &rt) { + invoker->invokeAsync([what = std::string(what), resolve = resolve, + reject = reject](jsi::Runtime &rt) { auto errorCtr = rt.global().getPropertyAsFunction(rt, "Error"); auto error = errorCtr.callAsConstructor( rt, jsi::String::createFromAscii(rt, what)); From f700f82d43128b7c86187611428a455945f3e588 Mon Sep 17 00:00:00 2001 From: Babs Craig Date: Thu, 13 Aug 2026 00:37:28 +0100 Subject: [PATCH 5/8] Interrupt in-flight queries before draining on invalidate invalidate() waits on the thread pool but, unlike close() and delete(), never asks SQLite to stop. A query inside sqlite3_step therefore runs to completion while the JS runtime is being torn down. React Native gives module invalidation ten seconds before it destroys the runtime regardless, so an uninterrupted drain can lose that race and leave a worker touching state the runtime owned. This is the same three lines close() already runs, moved to the one teardown path that lacked them. --- cpp/DBHostObject.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index 285ea9d1..c2b2f631 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -787,6 +787,18 @@ void DBHostObject::invalidate() { } invalidated = true; + + // Abort whatever is currently inside sqlite3_step so the drain below can + // actually finish. Parity with the close and delete host functions, which + // already do this. Without it a long running query holds the pool past React + // Native's module invalidation budget, after which the runtime is destroyed + // anyway and the drain has bought nothing. +#if !defined(OP_SQLITE_USE_LIBSQL) && !defined(OP_SQLITE_USE_TURSO) + if (db != nullptr) { + sqlite3_interrupt(db); + } +#endif + // Drain in-flight thread pool work before closing the db handle. // restartPool() joins threads (waiting for the current task) but then // needlessly re-creates the pool. waitFinished() is sufficient: it From fc3ad87963d55d7fd748956225582b6ecce67121 Mon Sep 17 00:00:00 2001 From: Babs Craig Date: Thu, 13 Aug 2026 00:38:00 +0100 Subject: [PATCH 6/8] Guard the database registry with a mutex open() runs on the JS thread and invalidate() runs on the TurboModule queue. During a bridgeless reload those belong to different generations and overlap, because the new instance is constructed before the old one finishes invalidating, so both can reach the dbs vector at once. Iterating it while another thread emplaces can walk a reallocated buffer. invalidate() now swaps the vector out under the lock and works on its own copy, which also keeps the lock off the invalidate() calls themselves. --- cpp/OPSqlite.cpp | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/cpp/OPSqlite.cpp b/cpp/OPSqlite.cpp index 2e2a52e8..79f228f2 100644 --- a/cpp/OPSqlite.cpp +++ b/cpp/OPSqlite.cpp @@ -12,6 +12,7 @@ #include "utils.hpp" #include #include +#include #include #include #include @@ -25,6 +26,10 @@ std::string _base_path; std::string _crsqlite_path; std::string _sqlite_vec_path; std::vector> dbs; +// Guards `dbs`. Two JS runtime generations overlap during a bridgeless reload, +// so open() and invalidate() can touch this vector from different threads at +// the same time. +std::mutex dbs_mutex; bool invalidated = false; std::shared_ptr invoker; std::shared_ptr> generation_alive; @@ -42,13 +47,21 @@ void invalidate() { generation_alive->store(false); } - for (const auto &db : dbs) { - db->invalidate(); + // Take ownership of the registry under the lock before touching it. This runs + // on the outgoing generation's TurboModule queue, while the incoming + // generation's open() may already be emplacing into `dbs` on its own JS + // thread: RCTHost constructs the new RCTInstance without waiting for the old + // one to finish invalidating. Iterating the vector directly can therefore run + // off a reallocated buffer. + std::vector> closing; + { + std::lock_guard g(dbs_mutex); + closing.swap(dbs); } - // Clear our existing vector of shared pointers so they can be garbage - // collected - dbs.clear(); + for (const auto &db : closing) { + db->invalidate(); + } } void install(jsi::Runtime &rt, @@ -101,7 +114,10 @@ void install(jsi::Runtime &rt, std::shared_ptr db = std::make_shared( rt, path, name, path, readOnly, failOnCreate, encryption_key); - dbs.emplace_back(db); + { + std::lock_guard g(dbs_mutex); + dbs.emplace_back(db); + } return jsi::Object::createFromHostObject(rt, db); }); @@ -155,7 +171,10 @@ void install(jsi::Runtime &rt, std::make_shared(rt, url, auth_token, path); #endif - dbs.emplace_back(db); + { + std::lock_guard g(dbs_mutex); + dbs.emplace_back(db); + } return jsi::Object::createFromHostObject(rt, db); }); @@ -217,7 +236,10 @@ void install(jsi::Runtime &rt, rt, name, path, url, auth_token, remote_encryption_key); #endif - dbs.emplace_back(db); + { + std::lock_guard g(dbs_mutex); + dbs.emplace_back(db); + } return jsi::Object::createFromHostObject(rt, db); }); From fd5be806a7a5cbc9ec8d1c7157464b7575882f2f Mon Sep 17 00:00:00 2001 From: Babs Craig Date: Thu, 13 Aug 2026 00:38:20 +0100 Subject: [PATCH 7/8] Wake all thread pool waiters and make done atomic waitFinished() and doWork() wait on the same condition variable, so notify_one() can wake a worker when the waiter needed the wakeup, or the other way round, and leave the other one asleep until the next event. With a single pool thread that is easy to hit at teardown, where waitFinished() is the only waiter that matters. done is read in the doWork() loop condition outside the mutex while the destructor writes it, which is a data race; make it atomic. --- cpp/OPThreadPool.cpp | 8 +++++--- cpp/OPThreadPool.hpp | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cpp/OPThreadPool.cpp b/cpp/OPThreadPool.cpp index b06c00b3..3e56c64d 100644 --- a/cpp/OPThreadPool.cpp +++ b/cpp/OPThreadPool.cpp @@ -48,8 +48,10 @@ void ThreadPool::queueWork(const std::function &task) { // Push the request to the queue workQueue.push(task); - // Notify one thread that there are requests to process - workQueueConditionVariable.notify_one(); + // Wake every waiter. waitFinished() and doWork() share this condition + // variable, so notify_one() can hand the wakeup to the wrong one and leave + // the other asleep. + workQueueConditionVariable.notify_all(); } // Function used by the threads to grab work from the queue @@ -85,7 +87,7 @@ void ThreadPool::doWork() { std::lock_guard g(workQueueMutex); --busy; } - workQueueConditionVariable.notify_one(); + workQueueConditionVariable.notify_all(); } } diff --git a/cpp/OPThreadPool.hpp b/cpp/OPThreadPool.hpp index 9405681d..6cadfea3 100644 --- a/cpp/OPThreadPool.hpp +++ b/cpp/OPThreadPool.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -34,8 +35,9 @@ class ThreadPool { std::queue> workQueue; // This will be set to true when the thread pool is shutting down. This - // tells the threads to stop looping and finish - bool done; + // tells the threads to stop looping and finish. + // Atomic because doWork() reads it in `while (!done)` outside the mutex. + std::atomic done; // Function used by the threads to grab work from the queue void doWork(); From ca3dc3af969de0016b6cf47a3fd05e218ac52a0e Mon Sep 17 00:00:00 2001 From: Babs Craig Date: Thu, 13 Aug 2026 00:38:56 +0100 Subject: [PATCH 8/8] Never let a pool thread join itself If the last shared_ptr to a ThreadPool is released on one of its own workers, the destructor tries to join the running thread and std::thread::join throws "Resource deadlock avoided", which aborts. Dropping the pool capture from the promisify task removes the known way to get there, so this is a backstop for any future owner that ends up released on a worker. --- cpp/OPThreadPool.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cpp/OPThreadPool.cpp b/cpp/OPThreadPool.cpp index 3e56c64d..d44960c1 100644 --- a/cpp/OPThreadPool.cpp +++ b/cpp/OPThreadPool.cpp @@ -31,6 +31,16 @@ ThreadPool::~ThreadPool() { workQueueConditionVariable.notify_all(); for (auto &thread : threads) { + // Never join ourselves. If the pool's last owner is released on one of its + // own workers, join() throws std::system_error ("thread::join failed: + // Resource deadlock avoided") and takes the process with it. Not capturing + // the pool in the promisify task should make this unreachable; this is a + // backstop, not the fix. + if (thread.get_id() == std::this_thread::get_id()) { + thread.detach(); + continue; + } + if (thread.joinable()) { thread.join(); }