Skip to content

[v3.0.1] Random SIGABRT in PVROCL watchdog thread on PowerVR devices — GPU delegate lifecycle issue #186

Description

@jslok

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

  1. Use loadTensorflowModel(modelSource, ['android-gpu']) on a PVR device.
  2. Drop the last JS reference to the model (so the HybridObject destructor runs) — e.g. reassign the cached singleton to null.
  3. 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

  1. Accept the delegate-lifecycle patch (I can PR).
  2. Decide how thread-confinement should be integrated and open it as a tracked work item.
  3. Until then, consider documenting the PowerVR limitation in the README.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions