software-mansion / software-mansion/react-native-audio-api

[Android] AudioContext.resume() fails permanently after the Oboe stream dies with a non-Disconnected error (audio silent for the rest of the session)

Open
#1,230 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

android bug
Dominant language
C++
Stars
839
Forks
92
Avg merge
4d 15h
Merged PRs (30d)
27

Description

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

  1. 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).

  1. BaseAudioContext::getState() reports SUSPENDED whenever the driver is not running, so the JS side correctly sees "suspended" and calls resume().

  2. 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:

  1. Play some audio through an AudioContext, let the context become suspended.
  2. adb shell killall audioserver (or trigger any stream teardown that surfaces as a non-ErrorDisconnected terminal error).
  3. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in common/cpp/audioapi/core/AudioContext.cpp, then inspect android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp and the existing resume and stream-recovery paths. Reproduce with the documented audioserver kill if possible, and verify that a failed stream resume rebuilds the stream so a later resume can recover rather than failing permanently.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, cpp, react-native
Domain
audio-video-rtc, mobile-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.