software-mansion / software-mansion/react-native-audio-api
SIGSEGV on JS thread's own event loop under rapid AudioBufferSourceNode + onEnded churn
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 839
- Forks
- 92
- Avg merge
- 4d 15h
- Merged PRs (30d)
- 27
Description
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
- Clone the repro repo,
npm install,npx react-native run-android - Tap "Create AudioContext"
- 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) - Watch
adb logcatforFatal 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the linked AudioApiLeakRepro and reproduce the 4000-iteration onended churn while watching adb logcat. Then trace AudioEventHandlerRegistry, ~EventCaller, AudioGraphManager's nodeDestructor_, and eventHandlers_.clear(), including the CallInvoker path. Done means the repro no longer produces the SIGSEGV under repeated runs and the underlying teardown path is understood.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, cpp, javascript, react-native
- Domain
- audio-video-rtc, mobile-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100