Repository navigation
SIGSEGV on JS thread's own event loop under rapid AudioBufferSourceNode + onEnded churn #1285
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
Activity
WentTheFox commentedon Sep 11, 2026
This issue was filed with the help of Claude Code (Anthropic's AI coding assistant), which built and ran the MRE, captured the crash logs/backtraces, and drafted the report. The repro app, the crash finding, and the (ruled-out) unregisterHandler fix attempt were all verified live on-device before writing this up, per the template's request for AI-filed issues to create and verify the MRE first.
WentTheFox commentedon Sep 12, 2026
Some additional negative results from further digging tonight, since they meaningfully narrow the search even without a fix:
Ruled out: a generic
RuntimeScheduler_Modern/Hermes bug independent of this library. Added a control test to the repro app with the identical timing/interval/GC-pressure shape as the churn tests, but with zeroreact-native-audio-apicalls (noAudioContext, no nodes at all - just a tightawait delay(...)loop plus concurrent busy-work to force GC activity). Two full clean runs (6,000 iterations total) produced no crash. Soreact-native-audio-apigenuinely is necessary to trigger this, even though its frames never appear in the crash backtrace itself - the corruption must happen inside it somewhere, surfacing later.Ruled out:
setExternalMemoryPressureover-reporting.AudioBufferSourceNodeHostObject::setBuffer()reportsbufferHostObject->getSizeInBytes()as fresh external memory pressure on every newAudioBufferSourceNodeHostObject, even whengetOrCreateImmutableCopy()means the real backing memory is shared/cached rather than actually reallocated per node - with the repro's original 240s buffer (~84MB), that's a huge amount of reported-but-not-real pressure accumulating across thousands of churned nodes, which seemed like a very plausible way to push Hermes's GC heuristics into a bad state. Tested directly by shrinking the buffer to 5s (~1.76MB reported pressure per node, ~48x smaller) and reran the churn - crashed just as reliably, with the identical signature and backtrace. So the absolute pressure value isn't what's driving this either.Traced the onEnded wiring end-to-end (
AudioScheduledSourceNode.ts->AudioEventEmitter/AudioEventSubscription->AudioEventHandlerRegistryHostObject::addAudioEventListener/removeAudioEventListener) - confirmedAudioEventHandlerRegistry::registerHandler/unregisterHandlerare the only two places that ever touch theshared_ptr<jsi::Function>map, so there isn't another obvious cross-thread release in that specific code path I've missed.At this point I don't have a good way to narrow this further without either symbolicated Hermes internals (the prebuilt
libhermesvm.soshipped with this RN version is stripped -#00-#18of every capture are unresolved) or an ASan-instrumented build to catch the actual corrupting write directly. Both are a meaningfully bigger undertaking than tonight's investigation, so pausing here rather than continuing to guess - hopefully the ruled-out list above at least saves someone time.mdydek commentedon Sep 23, 2026
Ok, coming back with the explanation: the repro crashes with
which basically points to the fact that app crashed, because it used too much memory and heap has just ended. Looking at your code that stops the
AudioBufferSourceNodeyou are not clearing up the event, call the
current.source.onEnded = nullbefore disconnecting the source.Adding that line allows the app to finish the 4000 cycle without killing the app.
The reason for it is that underlying event captures the node, which makes the node without event unregistered undestructible. Thank you for pointing out this issue, we need to make up the mind internally, but it probably would be the good idea to unregister the event under the hood when calling
disconnecton the node, it would make sense.Clear onEnded before disconnecting a source node on Android
mdydek commentedon Oct 6, 2026
I see that it is solved on your side, closing.
WentTheFox commentedon Oct 6, 2026
Yeah, sorry, just forgot to follow it up here, I appreciate your help!