From f2f7b8db3be0add3692a3e369b27a0762740965b Mon Sep 17 00:00:00 2001 From: Mickael Lecoq Date: Thu, 20 Aug 2026 13:56:12 +0200 Subject: [PATCH 1/3] fix: don't touch a closed connection when unsubscribing a reactive query --- cpp/DBHostObject.cpp | 38 +++++++++++++++++++++++++++++++------- cpp/DBHostObject.hpp | 6 +++++- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index 9cbc460f..c4b7df14 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -155,6 +155,10 @@ void DBHostObject::on_update(const std::string &table, } void DBHostObject::auto_register_update_hook() { + if (invalidated || db == nullptr) { + return; + } + if (update_hook_callback == nullptr && reactive_queries.empty() && is_update_hook_registered) { opsqlite_deregister_update_hook(db); @@ -171,6 +175,15 @@ void DBHostObject::auto_register_update_hook() { } #endif +void DBHostObject::release_hooks() { + reactive_queries.clear(); + pending_reactive_queries.clear(); + update_hook_callback = nullptr; + commit_hook_callback = nullptr; + rollback_hook_callback = nullptr; + is_update_hook_registered = false; +} + // _____ _ _ // / ____| | | | | // | | ___ _ __ ___| |_ _ __ _ _ ___| |_ ___ _ __ @@ -324,6 +337,7 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { // Without this, a queued/running execute() on the thread pool may // dereference the freed sqlite3* pointer → heap corruption / SIGABRT. thread_pool->wait_finished(); + release_hooks(); #ifdef OP_SQLITE_USE_LIBSQL opsqlite_libsql_close(db); db = {}; @@ -377,10 +391,13 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { "for remote-only databases"); } + release_hooks(); #ifdef OP_SQLITE_USE_LIBSQL opsqlite_libsql_remove(db, delete_db_name, base_path); #else - opsqlite_remove(db, delete_db_name, base_path); + auto *closing_db = db; + db = nullptr; + opsqlite_remove(closing_db, delete_db_name, base_path); #endif return {}; @@ -684,13 +701,19 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { auto_register_update_hook(); - auto unsubscribe = HFN2(this, reactiveQuery) { - auto it = std::find(reactive_queries.begin(), reactive_queries.end(), - reactiveQuery); - if (it != reactive_queries.end()) { - reactive_queries.erase(it); + auto weak_self = weak_from_this(); + + auto unsubscribe = HFN2(weak_self, reactiveQuery) { + auto self = weak_self.lock(); + if (self == nullptr) { + return {}; + } + auto it = std::find(self->reactive_queries.begin(), + self->reactive_queries.end(), reactiveQuery); + if (it != self->reactive_queries.end()) { + self->reactive_queries.erase(it); } - auto_register_update_hook(); + self->auto_register_update_hook(); return {}; }); @@ -802,6 +825,7 @@ void DBHostObject::invalidate() { // Drain in-flight thread pool work before closing the db handle. thread_pool->wait_finished(); + release_hooks(); #ifdef OP_SQLITE_USE_LIBSQL opsqlite_libsql_close(db); diff --git a/cpp/DBHostObject.hpp b/cpp/DBHostObject.hpp index 81ba8281..847fd363 100644 --- a/cpp/DBHostObject.hpp +++ b/cpp/DBHostObject.hpp @@ -14,6 +14,7 @@ #include #endif #endif +#include #include #include @@ -41,7 +42,9 @@ struct ReactiveQuery { std::shared_ptr callback; }; -class JSI_EXPORT DBHostObject : public jsi::HostObject { +class JSI_EXPORT DBHostObject + : public jsi::HostObject, + public std::enable_shared_from_this { public: // Normal constructor shared between all backends DBHostObject(jsi::Runtime &rt, std::string &base_path, std::string &db_name, @@ -82,6 +85,7 @@ class JSI_EXPORT DBHostObject : public jsi::HostObject { private: std::set> pending_reactive_queries; void auto_register_update_hook(); + void release_hooks(); void create_jsi_functions(jsi::Runtime &rt); void flush_pending_reactive_queries(const std::shared_ptr &resolve); From 33941959d0d66d003ba4f57f1b5ce48be8357d01 Mon Sep 17 00:00:00 2001 From: Mickael Lecoq Date: Thu, 20 Aug 2026 14:23:31 +0200 Subject: [PATCH 2/3] add test --- example/src/tests/reactive.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/example/src/tests/reactive.ts b/example/src/tests/reactive.ts index e44b10f6..e6488a09 100644 --- a/example/src/tests/reactive.ts +++ b/example/src/tests/reactive.ts @@ -337,6 +337,20 @@ describe("Reactive queries", () => { nickname: "Johnny", }); + unsubscribe(); + }); + + it("Unsubscribing after the database is closed does not crash", async () => { + const unsubscribe = db.reactiveExecute({ + query: "SELECT * FROM User;", + arguments: [], + fireOn: [{ table: "User" }], + callback: () => {}, + }); + + db.close(); + + unsubscribe(); }); }); From 5acb90ffd7fb41278bc297ff3ab42eb7486965b2 Mon Sep 17 00:00:00 2001 From: Mickael Lecoq Date: Thu, 20 Aug 2026 16:00:48 +0200 Subject: [PATCH 3/3] Throw instead of crashing when the database is used after close --- cpp/DBHostObject.cpp | 45 ++++++++++++++++++++++++++++++++++++ cpp/DBHostObject.hpp | 1 + example/src/tests/dbsetup.ts | 29 +++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index c4b7df14..74722437 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -175,6 +175,13 @@ void DBHostObject::auto_register_update_hook() { } #endif +void DBHostObject::throw_if_closed(const char *function_name) const { + if (invalidated) { + throw std::runtime_error(std::string("[op-sqlite][") + function_name + + "] database is closed"); + } +} + void DBHostObject::release_hooks() { reactive_queries.clear(); pending_reactive_queries.clear(); @@ -269,6 +276,8 @@ DBHostObject::DBHostObject(jsi::Runtime &rt, std::string &base_path, void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { function_map["attach"] = HFN(this) { + throw_if_closed("attach"); + std::string secondary_db_path = std::string(base_path); auto obj_params = args[0].asObject(rt); @@ -307,6 +316,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["detach"] = HFN(this) { + throw_if_closed("detach"); + if (!args[0].isString()) { throw std::runtime_error("[op-sqlite] alias must be a strings"); } @@ -404,6 +415,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["executeRaw"] = HFN(this) { + throw_if_closed("executeRaw"); + const std::string query = args[0].asString(rt).utf8(rt); const std::vector params = count == 2 && args[1].isObject() ? to_variant_vec(rt, args[1]) @@ -431,6 +444,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["executeSync"] = HFN(this) { + throw_if_closed("executeSync"); + std::string query = args[0].asString(rt).utf8(rt); std::vector params; @@ -447,6 +462,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["executeRawSync"] = HFN(this) { + throw_if_closed("executeRawSync"); + const std::string query = args[0].asString(rt).utf8(rt); std::vector params = count == 2 && args[1].isObject() ? to_variant_vec(rt, args[1]) @@ -464,6 +481,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["execute"] = HFN(this) { + throw_if_closed("execute"); + const std::string query = args[0].asString(rt).utf8(rt); std::vector params = count == 2 && args[1].isObject() ? to_variant_vec(rt, args[1]) @@ -486,6 +505,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["executeWithHostObjects"] = HFN(this) { + throw_if_closed("executeWithHostObjects"); + const std::string query = args[0].asString(rt).utf8(rt); std::vector params = count == 2 && args[1].isObject() ? to_variant_vec(rt, args[1]) @@ -518,6 +539,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["executeBatch"] = HFN(this) { + throw_if_closed("executeBatch"); + if (count < 1) { throw std::runtime_error( "[op-sqlite][executeAsyncBatch] Incorrect parameter count"); @@ -557,6 +580,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { #if defined(OP_SQLITE_USE_LIBSQL) || defined(OP_SQLITE_USE_TURSO) function_map["sync"] = HFN(this) { + throw_if_closed("sync"); + #ifdef OP_SQLITE_USE_LIBSQL opsqlite_libsql_sync(db); #else @@ -568,12 +593,16 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { #ifdef OP_SQLITE_USE_LIBSQL function_map["setReservedBytes"] = HFN(this) { + throw_if_closed("setReservedBytes"); + auto reserved_bytes = static_cast(args[0].asNumber()); opsqlite_libsql_set_reserved_bytes(db, reserved_bytes); return {}; }); function_map["getReservedBytes"] = HFN(this) { + throw_if_closed("getReservedBytes"); + return {opsqlite_libsql_get_reserved_bytes(db)}; }); #endif @@ -582,6 +611,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { #if !defined(OP_SQLITE_USE_LIBSQL) && !defined(OP_SQLITE_USE_TURSO) function_map["loadFile"] = HFN(this) { + throw_if_closed("loadFile"); + if (count < 1) { throw std::runtime_error( "[op-sqlite][loadFile] Incorrect parameter count"); @@ -602,6 +633,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["updateHook"] = HFN(this) { + throw_if_closed("updateHook"); + auto callback = std::make_shared(rt, args[0]); if (callback->isUndefined() || callback->isNull()) { @@ -615,6 +648,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["commitHook"] = HFN(this) { + throw_if_closed("commitHook"); + if (count < 1) { throw std::runtime_error("[op-sqlite][commitHook] callback needed"); } @@ -631,6 +666,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["rollbackHook"] = HFN(this) { + throw_if_closed("rollbackHook"); + if (count < 1) { throw std::runtime_error("[op-sqlite][rollbackHook] callback needed"); } @@ -648,6 +685,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["loadExtension"] = HFN(this) { + throw_if_closed("loadExtension"); + auto path = args[0].asString(rt).utf8(rt); std::string entry_point; if (count > 1 && args[1].isString()) { @@ -659,6 +698,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["reactiveExecute"] = HFN(this) { + throw_if_closed("reactiveExecute"); + auto query = args[0].asObject(rt); const std::string query_str = @@ -722,6 +763,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { #endif function_map["prepareStatement"] = HFN(this) { + throw_if_closed("prepareStatement"); + auto query = args[0].asString(rt).utf8(rt); #ifdef OP_SQLITE_USE_LIBSQL libsql_stmt_t statement = opsqlite_libsql_prepare_statement(db, query); @@ -760,6 +803,8 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { }); function_map["flushPendingReactiveQueries"] = HFN(this) { + throw_if_closed("flushPendingReactiveQueries"); + auto promiseCtr = rt.global().getPropertyAsFunction(rt, "Promise"); auto promise = promiseCtr.callAsConstructor(rt, HFN(this) { auto resolve = std::make_shared(rt, args[0]); diff --git a/cpp/DBHostObject.hpp b/cpp/DBHostObject.hpp index 847fd363..4e476a3e 100644 --- a/cpp/DBHostObject.hpp +++ b/cpp/DBHostObject.hpp @@ -86,6 +86,7 @@ class JSI_EXPORT DBHostObject std::set> pending_reactive_queries; void auto_register_update_hook(); void release_hooks(); + void throw_if_closed(const char *function_name) const; void create_jsi_functions(jsi::Runtime &rt); void flush_pending_reactive_queries(const std::shared_ptr &resolve); diff --git a/example/src/tests/dbsetup.ts b/example/src/tests/dbsetup.ts index 287a7a07..353016e4 100644 --- a/example/src/tests/dbsetup.ts +++ b/example/src/tests/dbsetup.ts @@ -123,6 +123,35 @@ describe("DB setup tests", () => { } }); + it("Throws instead of crashing when the database is used after close", () => { + const db = open({ + name: "closedDbGuard.sqlite", + }); + + db.close(); + + const callsOnClosedDb = [ + () => db.executeSync("SELECT 1;"), + () => db.prepareStatement("SELECT 1;"), + () => db.updateHook(() => {}), + () => db.commitHook(() => {}), + () => db.rollbackHook(() => {}), + () => db.attach({ secondaryDbFileName: "other", alias: "other" }), + ]; + + for (const call of callsOnClosedDb) { + let error: unknown = null; + try { + call(); + } catch (e) { + error = e; + } + expect(!!error).toEqual(true); + } + + db.delete(); + }); + it("Should delete db", async () => { const db = open({ name: "deleteTest",