Summary
On Android, once the Oboe stream dies in a way that AudioPlayer::onErrorAfterClose does not recover from, AudioContext.resume() fails permanently for the rest of the process. The context reports state === "suspended", every resume() call rejects with "Failed to resume audio context.", and nothing in the library ever rebuilds the stream. The app is silent until the user force-closes it.
We are seeing this in production (game built on react-native-audio-api, buffer-source SFX + music): ~20 Android users in a 14-day window with hundreds to thousands of consecutive resume failures each (worst case ~5,000 in a single stretch), across Pixel and Samsung devices.
Environment
react-native-audio-api: 0.13.2 (the wedge logic is unchanged on current main)
- React Native: 0.83.2 (Fabric)
- Android (production builds); iOS unaffected
Root cause
- The only stream-death recovery is
AudioPlayer::onErrorAfterClose (android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp), and it is gated to oboe::Result::ErrorDisconnected:
void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result error) {
if (error != oboe::Result::ErrorDisconnected || driverMutex_ == nullptr) {
return;
}
...
Any other terminal error (ErrorNoService after an audioserver restart, ErrorTimeout, etc.) returns early, leaving mStream_ pointing at a stream Oboe has already closed. The same dead end is reached when the reopen inside the ErrorDisconnected path itself fails (there is no retry).
-
BaseAudioContext::getState() reports SUSPENDED whenever the driver is not running, so the JS side correctly sees "suspended" and calls resume().
-
AudioContext::resume() (common/cpp/audioapi/core/AudioContext.cpp) then has no path that can succeed:
if (isInitialized_.load(std::memory_order_acquire) && audioPlayer_->resume()) {
// requestStart() on the closed stream fails -> false
...
}
return tryStartDriver();
audioPlayer_->resume() calls requestStart() on the closed stream and fails. Then tryStartDriver() immediately returns false because isInitialized_ is still true:
bool AudioContext::tryStartDriver() {
...
if (isInitialized_.load(std::memory_order_acquire)) {
return false; // <- permanent: nothing ever resets isInitialized_ or reopens the stream
}
So after any unrecovered stream death: resume() fails forever, and there is no API the app can use to recover short of tearing down the whole AudioContext and rebuilding its graph.
Repro
Hard to trigger on demand, but killing the audio server while the app is in a suspended/idle-audio state reproduces the class of failure:
- Play some audio through an
AudioContext, let the context become suspended.
adb shell killall audioserver (or trigger any stream teardown that surfaces as a non-ErrorDisconnected terminal error).
- Call
ctx.resume() on the next play. It rejects, and keeps rejecting forever.
Suggested fix
In AudioContext::resume(), when the driver is initialized but audioPlayer_->resume() fails, rebuild the stream instead of falling into the tryStartDriver() dead end. We are running this patch in production via patch-package:
if (isInitialized_.load(std::memory_order_acquire) && audioPlayer_->resume()) {
setState(ContextState::RUNNING);
return true;
}
#ifdef ANDROID
// An initialized driver whose stream can no longer start (audioserver
// restart, route change missed by onErrorAfterClose's ErrorDisconnected-only
// recovery) previously wedged forever: resume() failed and tryStartDriver()
// early-returned false on isInitialized_. Rebuild the stream so the next
// resume() heals the context instead.
if (isInitialized_.load(std::memory_order_acquire)) {
audioPlayer_->cleanup();
if (!audioPlayer_->openAudioStream()) {
return false;
}
isInitialized_.store(false, std::memory_order_release);
}
#endif
return tryStartDriver();
}
This makes resume() self-healing: if the rebuild fails (audio HW genuinely unavailable at that moment), the next resume() retries it. Widening onErrorAfterClose to recover from all terminal errors would also help, but the resume()-side rebuild covers the failed-reopen case too, and it puts recovery on a code path the app is already calling whenever audio is needed.
Happy to open a PR with the change if you'd take it.
Summary
On Android, once the Oboe stream dies in a way that
AudioPlayer::onErrorAfterClosedoes not recover from,AudioContext.resume()fails permanently for the rest of the process. The context reportsstate === "suspended", everyresume()call rejects with"Failed to resume audio context.", and nothing in the library ever rebuilds the stream. The app is silent until the user force-closes it.We are seeing this in production (game built on
react-native-audio-api, buffer-source SFX + music): ~20 Android users in a 14-day window with hundreds to thousands of consecutive resume failures each (worst case ~5,000 in a single stretch), across Pixel and Samsung devices.Environment
react-native-audio-api: 0.13.2 (the wedge logic is unchanged on currentmain)Root cause
AudioPlayer::onErrorAfterClose(android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp), and it is gated tooboe::Result::ErrorDisconnected:Any other terminal error (
ErrorNoServiceafter an audioserver restart,ErrorTimeout, etc.) returns early, leavingmStream_pointing at a stream Oboe has already closed. The same dead end is reached when the reopen inside theErrorDisconnectedpath itself fails (there is no retry).BaseAudioContext::getState()reportsSUSPENDEDwhenever the driver is not running, so the JS side correctly sees"suspended"and callsresume().AudioContext::resume()(common/cpp/audioapi/core/AudioContext.cpp) then has no path that can succeed:audioPlayer_->resume()callsrequestStart()on the closed stream and fails. ThentryStartDriver()immediately returnsfalsebecauseisInitialized_is stilltrue:So after any unrecovered stream death:
resume()fails forever, and there is no API the app can use to recover short of tearing down the wholeAudioContextand rebuilding its graph.Repro
Hard to trigger on demand, but killing the audio server while the app is in a suspended/idle-audio state reproduces the class of failure:
AudioContext, let the context become suspended.adb shell killall audioserver(or trigger any stream teardown that surfaces as a non-ErrorDisconnectedterminal error).ctx.resume()on the next play. It rejects, and keeps rejecting forever.Suggested fix
In
AudioContext::resume(), when the driver is initialized butaudioPlayer_->resume()fails, rebuild the stream instead of falling into thetryStartDriver()dead end. We are running this patch in production via patch-package:This makes
resume()self-healing: if the rebuild fails (audio HW genuinely unavailable at that moment), the nextresume()retries it. WideningonErrorAfterCloseto recover from all terminal errors would also help, but theresume()-side rebuild covers the failed-reopen case too, and it puts recovery on a code path the app is already calling whenever audio is needed.Happy to open a PR with the change if you'd take it.