Summary
On Android devices with a PowerVR GPU (Pixel Tablet / google/frankel, Android 16), react-native-fast-tflite@3.0.1 with ['android-gpu'] causes a random fatal SIGABRT that surfaces as an app crash for end users. The crash happens asynchronously on a PVR-driver-owned thread after the model is destroyed, with no app frames and no TFLite frames on the stack — just the vendor driver tearing itself down incorrectly.
The same app logic running on react-native-nitro-tflite@0.1.1 (a separate, unrelated library by a different maintainer — also built on nitro-modules, wrapping the same TFLite C API, same TfLiteGpuDelegateOptionsV2Default + TfLiteGpuDelegateV2Create/Delete) does not crash on the exact same device and usage pattern. Referencing it only as a working comparison point.
This is user-visible: the app terminates without warning, typically seconds after the camera screen is opened/closed.
Environment
- Device: Pixel 10 (
google/frankel), Android 16, build CP1A.260405.005
- GPU: PowerVR (
libPVROCL.so, libsrv_um.so)
react-native-fast-tflite: 3.0.1
react-native-nitro-modules: latest at time of 3.0.1 release
- RN: 0.84, Hermes
- Delegate:
['android-gpu']
Crash signature (reproduced 4× in a row, backtrace byte-identical)
pid: <redacted>, tid: <redacted>, name: CDM Watchd(dwt) >>> <app> <<<
signal 6 (SIGABRT), code -1 (SI_QUEUE)
Abort message: 'Scudo ERROR: invalid chunk state when deallocating address 0x...'
backtrace:
#00 pc abort+160 libc.so
#01 pc scudo::die()+12 libc.so
#02 pc scudo::reportRawError(char const*)+32 libc.so
#03 pc scudo::ScopedErrorReport::~ScopedErrorReport()+16 libc.so
#04 pc scudo::reportInvalidChunkState(...)+120 libc.so
#05 pc scudo::Allocator<...>::deallocate(...)+292 libc.so
#06 pc OCL_CommandListRemoveLockless+96 libPVROCL.so
#07 pc OCL_CDMWatchdog+620 libPVROCL.so
#08 pc DeferredWorker+248 libsrv_um.so
#09 pc __pthread_start+180 libc.so
#10 pc __start_thread+68 libc.so
No frames from libNitroTflite.so, libtensorflowlite_gpu_jni.so, or app code. The PVR CDM watchdog thread is freeing a chunk whose Scudo header is already invalid — a double-free inside the driver's own bookkeeping.
Repro
- Use
loadTensorflowModel(modelSource, ['android-gpu']) on a PVR device.
- Drop the last JS reference to the model (so the HybridObject destructor runs) — e.g. reassign the cached singleton to
null.
- Watch
adb logcat -b crash -d for a few seconds to a minute. Fires intermittently; higher reload frequency ⇒ higher trip rate.
In our app we reload the model on scanner-mode switches (iOS CoreML thread-affinity workaround), which makes the crash reproduce within ~1 minute of normal use.
Probable cause — cross-thread delegate destruction
loadTensorflowModel resolves on the JS thread, so TfLiteGpuDelegateV2Create runs on the JS thread.
HybridTfliteModel's destructor runs whenever Hermes' JSI finalizer reaps the reference — a different thread. That's where TfLiteGpuDelegateV2Delete now runs (after the delegate-lifecycle fix discussed below).
- PowerVR's OpenCL driver binds the CDM watchdog and per-thread CL command queues to the context-creator thread. Releasing the context from a different thread leaves the watchdog with stale pointers; when it fires its next iteration it double-frees driver-internal memory.
Secondary finding — GPU delegate was never being deleted at all in v3.0.1
While investigating, I found that HybridTfliteModel::~HybridTfliteModel() in cpp/HybridTfliteModel.cpp only calls TfLiteInterpreterDelete. The TfLiteDelegate* returned by getAndroidGPUDelegate() is captured in a local in HybridTfliteModule::createModel and discarded. TfLiteGpuDelegateV2Delete (and TfLiteNnapiDelegateDelete, TfLiteCoreMlDelegateDelete) are not referenced anywhere in the v3.0.1 source — I confirmed this by running llvm-strings on a pre-patch libNitroTflite.so (symbols absent) vs a patched build (symbols present).
Per the TFLite C API contract, TfLiteInterpreterDelete does not free delegates installed via TfLiteInterpreterOptionsAddDelegate — the caller owns their lifetime. So in v3.0.1, every ['android-gpu'] model leaks its delegate and OpenCL context forever. This leak appears to mask the PVR double-free on most devices (the watchdog keeps polling live-but-leaked memory), but on other drivers / with GC pressure, this is independently a problem worth fixing.
Minimal delegate-lifecycle fix (compiles cleanly against 3.0.1)
Three-file patch — stores a std::function<void()> per delegate so the destructor can run the matching delete:
cpp/HybridTfliteModel.hpp: add #include <functional>, extend constructor, add std::vector<std::function<void()>> _delegateDeleters field.
cpp/HybridTfliteModel.cpp: in the destructor, after TfLiteInterpreterDelete(_interpreter), iterate _delegateDeleters in reverse and invoke each.
cpp/HybridTfliteModule.cpp: for each TensorflowModelDelegate created, push the matching TfLiteGpuDelegateV2Delete / TfLiteNnapiDelegateDelete / TfLiteCoreMlDelegateDelete lambda into the deleter vector, then move-construct it into the HybridTfliteModel.
I have this working as a patch-package locally and am happy to open a PR if useful. Note: this patch alone does not fix the PVR crash — on PowerVR it actually makes the crash more reproducible, because now the previously-leaked delegate is correctly destroyed but on the wrong thread. The real fix is thread-confinement.
Suggested thread-confinement fix (larger)
Create a dedicated single-worker thread (or use an existing Nitro executor) and funnel TfLiteGpuDelegateV2Create, TfLiteInterpreterCreate, TfLiteInterpreterInvoke, and the destructors through it. Create and Delete then always run on the same OS thread, satisfying PVR's per-thread OpenCL invariant.
This is more invasive and needs design input from the maintainer on how best to integrate with the worklet-thread runSync path.
Impact
This is a user-visible random crash on an entire class of Android devices (Pixel Tablet and other PowerVR-based SoCs). Because no app frames are on the stack, neither Crashlytics nor Firebase can attribute it to a specific screen, and it cannot be caught from JS.
Workaround we're shipping: runtime GL_RENDERER check, fall back to CPU/NNAPI on PowerVR. But this is a platform-support regression.
Ask
- Accept the delegate-lifecycle patch (I can PR).
- Decide how thread-confinement should be integrated and open it as a tracked work item.
- Until then, consider documenting the PowerVR limitation in the README.
Summary
On Android devices with a PowerVR GPU (Pixel Tablet /
google/frankel, Android 16),react-native-fast-tflite@3.0.1with['android-gpu']causes a random fatal SIGABRT that surfaces as an app crash for end users. The crash happens asynchronously on a PVR-driver-owned thread after the model is destroyed, with no app frames and no TFLite frames on the stack — just the vendor driver tearing itself down incorrectly.The same app logic running on
react-native-nitro-tflite@0.1.1(a separate, unrelated library by a different maintainer — also built on nitro-modules, wrapping the same TFLite C API, sameTfLiteGpuDelegateOptionsV2Default+TfLiteGpuDelegateV2Create/Delete) does not crash on the exact same device and usage pattern. Referencing it only as a working comparison point.This is user-visible: the app terminates without warning, typically seconds after the camera screen is opened/closed.
Environment
google/frankel), Android 16, buildCP1A.260405.005libPVROCL.so,libsrv_um.so)react-native-fast-tflite:3.0.1react-native-nitro-modules: latest at time of 3.0.1 release['android-gpu']Crash signature (reproduced 4× in a row, backtrace byte-identical)
No frames from
libNitroTflite.so,libtensorflowlite_gpu_jni.so, or app code. The PVR CDM watchdog thread is freeing a chunk whose Scudo header is already invalid — a double-free inside the driver's own bookkeeping.Repro
loadTensorflowModel(modelSource, ['android-gpu'])on a PVR device.null.adb logcat -b crash -dfor a few seconds to a minute. Fires intermittently; higher reload frequency ⇒ higher trip rate.In our app we reload the model on scanner-mode switches (iOS CoreML thread-affinity workaround), which makes the crash reproduce within ~1 minute of normal use.
Probable cause — cross-thread delegate destruction
loadTensorflowModelresolves on the JS thread, soTfLiteGpuDelegateV2Createruns on the JS thread.HybridTfliteModel's destructor runs whenever Hermes' JSI finalizer reaps the reference — a different thread. That's whereTfLiteGpuDelegateV2Deletenow runs (after the delegate-lifecycle fix discussed below).Secondary finding — GPU delegate was never being deleted at all in v3.0.1
While investigating, I found that
HybridTfliteModel::~HybridTfliteModel()incpp/HybridTfliteModel.cpponly callsTfLiteInterpreterDelete. TheTfLiteDelegate*returned bygetAndroidGPUDelegate()is captured in a local inHybridTfliteModule::createModeland discarded.TfLiteGpuDelegateV2Delete(andTfLiteNnapiDelegateDelete,TfLiteCoreMlDelegateDelete) are not referenced anywhere in the v3.0.1 source — I confirmed this by runningllvm-stringson a pre-patchlibNitroTflite.so(symbols absent) vs a patched build (symbols present).Per the TFLite C API contract,
TfLiteInterpreterDeletedoes not free delegates installed viaTfLiteInterpreterOptionsAddDelegate— the caller owns their lifetime. So in v3.0.1, every['android-gpu']model leaks its delegate and OpenCL context forever. This leak appears to mask the PVR double-free on most devices (the watchdog keeps polling live-but-leaked memory), but on other drivers / with GC pressure, this is independently a problem worth fixing.Minimal delegate-lifecycle fix (compiles cleanly against 3.0.1)
Three-file patch — stores a
std::function<void()>per delegate so the destructor can run the matching delete:cpp/HybridTfliteModel.hpp: add#include <functional>, extend constructor, addstd::vector<std::function<void()>> _delegateDeletersfield.cpp/HybridTfliteModel.cpp: in the destructor, afterTfLiteInterpreterDelete(_interpreter), iterate_delegateDeletersin reverse and invoke each.cpp/HybridTfliteModule.cpp: for eachTensorflowModelDelegatecreated, push the matchingTfLiteGpuDelegateV2Delete/TfLiteNnapiDelegateDelete/TfLiteCoreMlDelegateDeletelambda into the deleter vector, then move-construct it into theHybridTfliteModel.I have this working as a
patch-packagelocally and am happy to open a PR if useful. Note: this patch alone does not fix the PVR crash — on PowerVR it actually makes the crash more reproducible, because now the previously-leaked delegate is correctly destroyed but on the wrong thread. The real fix is thread-confinement.Suggested thread-confinement fix (larger)
Create a dedicated single-worker thread (or use an existing Nitro executor) and funnel
TfLiteGpuDelegateV2Create,TfLiteInterpreterCreate,TfLiteInterpreterInvoke, and the destructors through it. Create and Delete then always run on the same OS thread, satisfying PVR's per-thread OpenCL invariant.This is more invasive and needs design input from the maintainer on how best to integrate with the worklet-thread
runSyncpath.Impact
This is a user-visible random crash on an entire class of Android devices (Pixel Tablet and other PowerVR-based SoCs). Because no app frames are on the stack, neither Crashlytics nor Firebase can attribute it to a specific screen, and it cannot be caught from JS.
Workaround we're shipping: runtime
GL_RENDERERcheck, fall back to CPU/NNAPI on PowerVR. But this is a platform-support regression.Ask