What happened?
After upgrading @op-engineering/op-sqlite from 18.1.3 to 18.1.4, an Expo / React Native iOS development build crashes with EXC_BAD_ACCESS (SIGSEGV) when the Hermes runtime is replaced during a Metro reload.
The database has already been opened and queried before the reload. The crash happens while the React Native JavaScript thread exits and C++ thread-local storage is finalized.
This appears to be a regression introduced by commit 32f2f185059060e3244bcc39957e7a5746ab3ac9, which added a per-runtime thread_local std::unordered_map<jsi::Runtime *, ResultPropNames> holding three jsi::PropNameID values.
Versions
- op-sqlite: 18.1.4, currently the latest published version
- last known good: 18.1.3
- React Native: 0.86.2
- Expo: 57.0.16
- JavaScript engine: Hermes
- architecture: New Architecture / bridgeless development build
- device: iPhone 16 Simulator, iOS 18.6
- host: macOS 26.6, Apple Silicon
Reproducible example
The application repository is not public, but the failure is deterministic with this runtime sequence:
- Install op-sqlite 18.1.4 in a Hermes New Architecture iOS app.
- Open a database.
- Execute at least one statement that creates an execute result, so
result_prop_names(rt) populates its thread-local cache.
- Trigger a Metro reload or otherwise replace the React Native runtime.
- Allow the old
com.facebook.react.runtime.JavaScript thread to exit.
Observed result: the app terminates with a null-pointer EXC_BAD_ACCESS while destroying the cached jsi::PropNameID values during thread-local finalization.
Expected result: the old runtime and JavaScript thread tear down without a native crash.
Symbolicated crash excerpt
Triggered by Thread: 5 com.facebook.react.runtime.JavaScript
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000000
0 facebook::jsi::Pointer::~Pointer() jsi.h:833
1 facebook::jsi::PropNameID::~PropNameID() jsi.h:848
2 facebook::jsi::PropNameID::~PropNameID() jsi.h:848
3 opsqlite::(anonymous namespace)::ResultPropNames::~ResultPropNames()
OPUtils.cpp:30
...
12 std::__1::unordered_map<facebook::jsi::Runtime *,
opsqlite::ResultPropNames>::~unordered_map()
14 dyld::ThreadLocalVariables::finalizeList(void *)
16 _pthread_tsd_cleanup
17 _pthread_exit
The full symbolicated stack shows destruction of the thread-local unordered_map after the JSI runtime lifetime has ended.
Suspected cause
jsi::PropNameID is runtime-scoped. The cache owns those handles in thread-local storage:
ResultPropNames &result_prop_names(jsi::Runtime &rt) {
static thread_local std::unordered_map<jsi::Runtime *, ResultPropNames> cache;
// ...
}
The map key distinguishes overlapping runtime generations, but no cache entry is erased before the corresponding runtime is destroyed. When thread-local storage later finalizes, each PropNameID destructor can touch a runtime that is already gone. The null address and teardown-only stack are consistent with that lifetime mismatch.
A fix likely needs explicit cache cleanup tied to runtime invalidation, or a cache representation that does not own runtime-scoped JSI handles beyond the runtime lifetime.
Regression confirmation and mitigation
- 18.1.4: reproducible native crash during runtime teardown.
- Exact pin to 18.1.3 followed by a fresh native rebuild: the same database initialization and repeated runtime-reload flow survived four consecutive reloads without a crash.
- No local package patch is being used.
Please let me know if a standalone public reproduction is still required after reviewing the symbolicated lifetime path; I can help reduce the sequence further.
What happened?
After upgrading
@op-engineering/op-sqlitefrom 18.1.3 to 18.1.4, an Expo / React Native iOS development build crashes withEXC_BAD_ACCESS (SIGSEGV)when the Hermes runtime is replaced during a Metro reload.The database has already been opened and queried before the reload. The crash happens while the React Native JavaScript thread exits and C++ thread-local storage is finalized.
This appears to be a regression introduced by commit 32f2f185059060e3244bcc39957e7a5746ab3ac9, which added a per-runtime
thread_local std::unordered_map<jsi::Runtime *, ResultPropNames>holding threejsi::PropNameIDvalues.Versions
Reproducible example
The application repository is not public, but the failure is deterministic with this runtime sequence:
result_prop_names(rt)populates its thread-local cache.com.facebook.react.runtime.JavaScriptthread to exit.Observed result: the app terminates with a null-pointer
EXC_BAD_ACCESSwhile destroying the cachedjsi::PropNameIDvalues during thread-local finalization.Expected result: the old runtime and JavaScript thread tear down without a native crash.
Symbolicated crash excerpt
The full symbolicated stack shows destruction of the thread-local
unordered_mapafter the JSI runtime lifetime has ended.Suspected cause
jsi::PropNameIDis runtime-scoped. The cache owns those handles in thread-local storage:The map key distinguishes overlapping runtime generations, but no cache entry is erased before the corresponding runtime is destroyed. When thread-local storage later finalizes, each
PropNameIDdestructor can touch a runtime that is already gone. The null address and teardown-only stack are consistent with that lifetime mismatch.A fix likely needs explicit cache cleanup tied to runtime invalidation, or a cache representation that does not own runtime-scoped JSI handles beyond the runtime lifetime.
Regression confirmation and mitigation
Please let me know if a standalone public reproduction is still required after reviewing the symbolicated lifetime path; I can help reduce the sequence further.