Skip to content

fix(android): stop resurrecting stopped recorders on stream disconnect#1089

Open
christian-apollo wants to merge 1 commit into
software-mansion:mainfrom
christian-apollo:fix/android-recorder-disconnect-races
Open

fix(android): stop resurrecting stopped recorders on stream disconnect#1089
christian-apollo wants to merge 1 commit into
software-mansion:mainfrom
christian-apollo:fix/android-recorder-disconnect-races

Conversation

@christian-apollo

Copy link
Copy Markdown
Contributor

Fixes #1088

Problem

On Android, a stopped AudioRecorder keeps its oboe input stream open (stop() only calls requestStop()), and onErrorAfterClose() unconditionally reopens and restarts the stream on ErrorDisconnected. Any audio route change — e.g. a Bluetooth headset connecting or disconnecting — therefore turns the microphone back on after stop(). Since the stream holds a shared_ptr to the recorder (shared_from_this()), the resurrected stream also keeps the recorder alive forever once JS drops it (reference cycle).

onErrorAfterClose additionally runs on oboe's detached error thread and mutates mStream_ (via cleanup()/openAudioStream()) with no synchronization against start()/stop() on the JS thread. On 0.12.x, where the callbacks were raw pointers, this race produced a production SIGSEGV in oboe::AudioStream::fireDataCallback (full tombstone in #1088): the destructor lost the race, the error thread reopened a stream pointing at the freed recorder, and the next mic buffer crashed.

Changes

  • stop() fully closes and resets the stream, so a stopped recorder has no open stream — no callbacks can fire, no error-restart can resurrect it, and the recorder→stream→recorder reference cycle is broken. start() already reopens the stream on demand when mStream_ is null.
  • onErrorAfterClose():
    • takes the same callbackMutex_/fileWriterMutex_/adapterNodeMutex_ locks as start()/stop(), serializing all mStream_ mutation;
    • bails out when the recorder is idle, so stopped recorders stay stopped;
    • bails out when the erroring stream is no longer mStream_ — the detached error thread can arrive late, after stop()/start() already replaced the stream, and previously would tear down the healthy new stream (without re-running prepare()).
  • isRecording() null-checks mStream_, which the error thread can reset concurrently; pause()/resume() take the recorder locks for the same reason (resume() also null-checks before mStream_->start(0)).

Deadlock safety

Closing the stream while holding the recorder mutexes is safe:

  • onAudioReady only ever uses Locker::tryLock on these mutexes, so the data callback never blocks on them and close()'s wait for in-flight callbacks is bounded.
  • oboe error-callback threads are detached (AudioStreamAAudio::internalErrorCallback, oboe 1.9.3), so close() never joins a thread that might be blocked on these locks; a blocked error thread simply proceeds afterwards and bails on the idle/stale-stream checks.

Testing

  • Compiled for arm64-v8a and running in production as a patch-package patch on 0.12.2 (the same fix plus destructor locking, which main no longer needs thanks to shared_from_this()).
  • Stopped-recorder + Bluetooth route-change no longer restarts the mic; repeated route changes during active recording recover as before via the (now guarded) reopen path.

🤖 Generated with Claude Code

A stopped AudioRecorder kept its oboe input stream open (stop() only
called requestStop()), leaving the data/error callbacks registered.
Any later audio route change (e.g. a Bluetooth headset connecting or
disconnecting) fired onErrorAfterClose(ErrorDisconnected), which
unconditionally reopened and restarted the stream - turning the mic
back on after stop(). Since the stream holds a shared_ptr to the
recorder, the resurrected stream also kept the recorder alive forever
once JS dropped it. On 0.12.x (raw callback pointers) the same race
produced a hard SIGSEGV in oboe::AudioStream::fireDataCallback when
the restarted stream fired into a destroyed recorder.

- stop() now fully closes and resets the stream so a stopped recorder
  has no open stream; start() already reopens it on demand.
- onErrorAfterClose() takes the same locks as start()/stop() (it runs
  on oboe's detached error thread while they mutate mStream_ on the JS
  thread), bails out when the recorder is idle, and bails out when the
  error belongs to a stream that stop()/start() already replaced, so a
  late error thread cannot tear down a healthy new stream.
- isRecording() null-checks mStream_, which the error thread can reset
  concurrently; pause()/resume() take the recorder locks for the same
  reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +562 to +564
if (isIdle()) {
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (isIdle()) {
return;
}
if (isIdle() || isPaused()) {
return;
}

Comment on lines +550 to +552
if (error != oboe::Result::ErrorDisconnected) {
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be good idea to also dispatch other RECORDER_ERROR events here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: stopped AudioRecorder is resurrected by onErrorAfterClose on audio route change (mic stays hot; SIGSEGV in oboe fireDataCallback on 0.12.x)

2 participants