Skip to content

fix: Android recorderController.stop() never completes#486

Open
rubenvde wants to merge 2 commits into
SimformSolutionsPvtLtd:mainfrom
rubenvde:main
Open

fix: Android recorderController.stop() never completes#486
rubenvde wants to merge 2 commits into
SimformSolutionsPvtLtd:mainfrom
rubenvde:main

Conversation

@rubenvde

@rubenvde rubenvde commented Apr 3, 2026

Copy link
Copy Markdown

Description

Fixes a hang where await recorderController.stop() on Android never completes, especially for recordings >30 seconds. This was introduced in the 2.0.x rewrite that replaced MediaRecorder with AudioRecord +
MediaCodec.

Root causes fixed:

  1. Deadlock in stopEncoder()stopEncoder() is called from onOutputBufferAvailable on the handler thread, but it called handlerThread.join(), waiting for itself to finish. Removed the join() and
    use quitSafely() only.

  2. Race condition in signalToStop() — EOS buffer queuing now runs on the handler thread via handler.post {}, avoiding races with onInputBufferAvailable that caused "MediaCodec discarded an unknown
    buffer" errors.

  3. release() called before async completion — For the non-WAV path, release() was called synchronously after signalToStop(), before the completion callback fired. Moved release() into the completion
    callback.

  4. result.success() called from wrong threadsendRecordingResult() now posts to the main looper, since Flutter's MethodChannel.Result must be called on the platform thread.

  5. completionCallback swallowed on error — Moved completionCallback?.invoke() to a finally block in stopEncoder() so the Flutter result is always resolved.

  6. Missing handler in setCallback()mediaCodec.setCallback() now passes the handler so callbacks run on the intended HandlerThread.

Checklist

  • The title of my PR starts with a [Conventional Commit] prefix (fix:, feat:, docs: etc).
  • I have followed the [Contributor Guide] when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • I have updated/added relevant examples in examples or docs.

There's no test infrastructure in the project and the bug is a native Android threading issue that's difficult to unit test from Dart

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Related Issues

#470

rubenvde added 2 commits April 3, 2026 10:19
Prevent encoder hang and ensure proper cleanup when stopping recordings. Add queueEosBuffer helper and proactively queue EOS in signalToStop if an input buffer is available, run MediaCodec callback on the handler, move completionCallback to finally, call release() after sending results or on error, and post result.success on the main looper for thread-safety.
Post the EOS check to the handler thread so EOS queuing runs on the same thread as onInputBufferAvailable, preventing race conditions when an input buffer is already available. Add an early return if the encoder is stopped. Move handlerThread.quitSafely() into the finally block and remove join() to avoid deadlocking when stopEncoder is invoked from callbacks; ensure completionCallback is invoked before quitting the thread.
@yuanhoujun

Copy link
Copy Markdown
Contributor

Same issue!

_playerControler?.dispose();

@DorraY

DorraY commented Apr 13, 2026

Copy link
Copy Markdown

This does not work for me it actually crashes the app when I call stop().

// Quit the handler thread after invoking the callback.
// Don't call join() -- stopEncoder() is called from callbacks
// running on this same thread, so joining would deadlock.
handlerThread.quitSafely()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 mediaCodec.release() runs before quitSafely(), but quitSafely() still drains messages already queued on this handler before the thread quits. If an onInputBufferAvailable / feedEncoder message is already pending, it runs after release and calls getInputBuffer() on a released codec → IllegalStateException on the handler thread (uncaught — feedEncoder() has no try/catch). Consider handler.removeCallbacksAndMessages(null) before releasing the codec, or guarding the codec calls in feedEncoder. Low-probability, but the unguarded path exists.

// both threads try to queue EOS with the same buffer index.
handler.post {
if (isEncoderStopped) return@post
if (currentInputBufferIndex >= 0 && inputQueue.isEmpty()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 currentInputBufferIndex, isEncodingComplete and totalBytesEncoded (declared at lines 58–76) are accessed from three threads: the recording thread (queueInputBuffer / feedEncoder), this handler thread, and the caller thread of signalToStop. The handler.post here adds a happens-before barrier for this path (good), but onInputBufferAvailable reads isEncodingComplete directly from the framework with no barrier, and queueInputBuffer reads currentInputBufferIndex on the recording thread unsynchronized. Recommend marking these fields @Volatile to make cross-thread visibility explicit.

hashMap[Constants.resultFilePath] = recorderSettings?.path
hashMap[Constants.resultDuration] = duration
result.success(hashMap)
Handler(Looper.getMainLooper()).post {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Minor: sendBytesToFlutter runs on every audio chunk (hot path), and both it and sendRecordingResult allocate a new Handler per call. Hoist a single private val mainHandler = Handler(Looper.getMainLooper()) and reuse it.

// Post the EOS check to the handler thread so it runs on the same
// thread as onInputBufferAvailable, avoiding race conditions where
// both threads try to queue EOS with the same buffer index.
handler.post {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Flagging a dependency (no change strictly required): EOS is only queued here when currentInputBufferIndex >= 0 && inputQueue.isEmpty(). Otherwise it relies on a later onInputBufferAvailable to drain the queue and queue EOS. In the normal stop flow that holds (AudioRecord is stopped, no new input, queue drains). But if an input buffer is never returned, stopEncodercompletionCallback never fires and the Flutter stop() future hangs again — the original bug. Worth a comment/assertion documenting the assumption.

@lavigarg-simform

Copy link
Copy Markdown

Thanks for the fix, @rubenvde! This solves the stop()-never-completing hang nicely.

I've left a few comments on the diff, a couple worth addressing before merge, plus two minor cleanups. Could you take a look and resolve them? Happy to discuss any of them. Thanks again!

@lavigarg-simform

Copy link
Copy Markdown

Same issue!

_playerControler?.dispose();

Hi @yuanhoujun! Thanks for chiming in. Just a heads-up: this PR is about the recorder- RecorderController.stop() never completing. Your snippet uses PlayerController.dispose(), which is a different subsystem (audio playback), so this fix won't address it.
Could you please check if PR #495 solves your issue? Also worth checking issues #477, #478.
If none of the issues resonate with your bug, could you open a separate issue for the player-dispose problem with a stack trace and repro steps?

@lavigarg-simform lavigarg-simform added the waiting-for-response Waiting for someone to respond. label Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-response Waiting for someone to respond.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android : await recorderController.stop(); sometimes never completes

5 participants