Skip to content

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

Description

@otang

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.

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