Skip to content

SIGSEGV on JS thread's own event loop under rapid AudioBufferSourceNode + onEnded churn #1285

Description

@WentTheFox

Description

A native SIGSEGV (fault addr 0x0) crashes the whole app on the JS thread itself (mqt_v_js), reproducible under rapid AudioBufferSourceNode creation/teardown churn where each node has a real onEnded listener attached. The crash happens inside the JS thread's own event loop / microtask checkpoint machinery - the backtrace shows no react-native-audio-api frames at all, which suggests the actual corruption happens earlier and the crash only surfaces later when Hermes touches the (already-corrupted) heap during its own bookkeeping.

Expected: no crash, regardless of how quickly source nodes are churned.

Crash signature:

Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid <N> (mqt_v_js), pid <N> (com.audioapileakrepro)

Backtrace (trimmed, full untrimmed logcat available on request):

#00-#18  libhermesvm.so (various offsets, no exported symbols)
#19  facebook::react::RuntimeScheduler_Modern::performMicrotaskCheckpoint(facebook::jsi::Runtime&)+140
#20  facebook::react::RuntimeScheduler_Modern::runEventLoopTick(facebook::jsi::Runtime&, facebook::react::Task&)+220
#21  facebook::react::RuntimeScheduler_Modern::runEventLoop(facebook::jsi::Runtime&)+140
#22-#40  libreactnative.so (various offsets)
#41  facebook::jni::...JNativeRunnable::run()...
#42  facebook::jni::...FunctionWrapper...call(...)
#43  art_jni_trampoline

i.e. the crash surfaces while the JS thread is running its own RuntimeScheduler event loop, processing a runnable that was posted onto it via JNI (the CallInvoker/invokeAsync path).

Also seen in production, not just synthetic churn: the same signature (SIGSEGV, mqt_v_js, fault addr 0x0) has occurred intermittently (~5 times across a multi-hour session) in a separate real app doing ordinary rapid track-switching during audio playback (React Native 0.85.0), so this isn't purely a synthetic-stress-test artifact - though the synthetic repro below is far more reliable for actually catching it.

A ruled-out hypothesis, in case it saves someone time: given the crash's relationship to rapid source-node churn with an attached event listener, I suspected AudioEventHandlerRegistry::unregisterHandler() releasing its last shared_ptr<jsi::Function> reference off the JS thread - it's called from ~EventCaller(), which runs on whatever thread destroys the owning node, and AudioGraphManager's nodeDestructor_ (AudioDestructor) tears nodes down on a dedicated background thread rather than the JS thread. I patched unregisterHandler() to extract the handler under the existing lock and post its actual release onto the JS thread via callInvoker_->invokeAsync(...) instead of letting it destruct in place. This did not fix the crash - rebuilt with that patch applied (confirmed via .so timestamps postdating the source edit), reran the identical repro, and got the same signature/backtrace shape. So either the corruption comes from somewhere else entirely, or there's another cross-thread release of a JSI object I haven't found (e.g. AudioEventHandlerRegistry's own destructor eventHandlers_.clear(), or something outside this file).

I did create and verify the MRE below before filing this (built and ran it, reproduced the crash on both the unpatched library and with the ruled-out patch applied).

Steps to reproduce

  1. Clone the repro repo, npm install, npx react-native run-android
  2. Tap "Create AudioContext"
  3. Tap "Run 4000 onended churn (SIGSEGV repro)" (or the "crossfade-style overlap churn" button for a variant closer to a real crossfade - two distinct AudioBuffers, genuine node overlap instead of strict stop-then-create, background GC pressure)
  4. Watch adb logcat for Fatal signal 11 - it's a race, not deterministic, so it may take a few runs (observed anywhere from the first run to a handful of runs) to land

Each iteration: creates a fresh AudioBufferSourceNode + GainNode pair wrapping an AudioBuffer (silent, prebuilt once), registers a real source.onEnded = () => {} listener, starts it, waits a short interval, then stop()s and disconnect()s the previous pair.

Snack or a link to a repository

https://github.com/WentTheFox/AudioApiLeakRepro

(A separate, smaller repo originally built for #1263 - this is a distinct bug from that one, unrelated to the AudioBuffer copy-caching fix in #1281/#1283.)

React Native Audio API version

0.13.3

React Native version

0.87.0 (repro app) / 0.85.0 (the separate production app mentioned above)

Platforms

Android

JavaScript runtime

Hermes

Workflow

React Native

Architecture

Fabric (New Architecture)

Build type

Debug app & dev bundle

Device

Real device

Device model

Samsung Galaxy S24+ (SM-S926B), Android 16

Acknowledgements

Yes

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

    bugSomething isn't working as expected or produces unexpected errors

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions