fix(android): stop resurrecting stopped recorders on stream disconnect#1089
Open
christian-apollo wants to merge 1 commit into
Open
Conversation
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>
mdydek
requested changes
Jun 8, 2026
Comment on lines
+562
to
+564
| if (isIdle()) { | ||
| return; | ||
| } |
Collaborator
There was a problem hiding this comment.
Suggested change
| if (isIdle()) { | |
| return; | |
| } | |
| if (isIdle() || isPaused()) { | |
| return; | |
| } |
Comment on lines
+550
to
+552
| if (error != oboe::Result::ErrorDisconnected) { | ||
| return; | ||
| } |
Collaborator
There was a problem hiding this comment.
it would be good idea to also dispatch other RECORDER_ERROR events here
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1088
Problem
On Android, a stopped
AudioRecorderkeeps its oboe input stream open (stop()only callsrequestStop()), andonErrorAfterClose()unconditionally reopens and restarts the stream onErrorDisconnected. Any audio route change — e.g. a Bluetooth headset connecting or disconnecting — therefore turns the microphone back on afterstop(). Since the stream holds ashared_ptrto the recorder (shared_from_this()), the resurrected stream also keeps the recorder alive forever once JS drops it (reference cycle).onErrorAfterCloseadditionally runs on oboe's detached error thread and mutatesmStream_(viacleanup()/openAudioStream()) with no synchronization againststart()/stop()on the JS thread. On 0.12.x, where the callbacks were raw pointers, this race produced a production SIGSEGV inoboe::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 whenmStream_is null.onErrorAfterClose():callbackMutex_/fileWriterMutex_/adapterNodeMutex_locks asstart()/stop(), serializing allmStream_mutation;mStream_— the detached error thread can arrive late, afterstop()/start()already replaced the stream, and previously would tear down the healthy new stream (without re-runningprepare()).isRecording()null-checksmStream_, which the error thread can reset concurrently;pause()/resume()take the recorder locks for the same reason (resume()also null-checks beforemStream_->start(0)).Deadlock safety
Closing the stream while holding the recorder mutexes is safe:
onAudioReadyonly ever usesLocker::tryLockon these mutexes, so the data callback never blocks on them andclose()'s wait for in-flight callbacks is bounded.AudioStreamAAudio::internalErrorCallback, oboe 1.9.3), soclose()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
mainno longer needs thanks toshared_from_this()).🤖 Generated with Claude Code