Wait for frame delivery to stop in Android codec release

release() of AndroidVideoDecoder and HardwareVideoEncoder now returns
only after the output thread has stopped delivering frames. Only the
release of the MediaCodec itself remains subject to a timeout. Each
output thread now releases the codec instance it was created for.

The callbacks that the native video decoder and encoder wrappers pass to
the Java codecs are now invalidated on release, reconfiguration and
destruction. Frames delivered after invalidation are dropped.

Bug: chromium:558966896
Change-Id: I300664aad56bbc9e98bdbde72a54c7a641a51ccd
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/506620
Reviewed-by: ZoƩ Lepaul <zlep@webrtc.org>
Commit-Queue: Tomas Gunnarsson <tommi@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#48770}
diff --git a/sdk/android/BUILD.gn b/sdk/android/BUILD.gn
index e8d59ff..d184998 100644
--- a/sdk/android/BUILD.gn
+++ b/sdk/android/BUILD.gn
@@ -1984,12 +1984,15 @@
       "tests/src/org/webrtc/RefCountDelegateTest.java",
       "tests/src/org/webrtc/RenderSynchronizerTest.java",
       "tests/src/org/webrtc/ScalingSettingsTest.java",
+      "tests/src/org/webrtc/VideoDecoderWrapperTest.java",
+      "tests/src/org/webrtc/VideoEncoderWrapperTest.java",
       "tests/src/org/webrtc/audio/LowLatencyAudioBufferManagerTest.java",
     ]
 
     deps = [
       ":base_java",
       ":camera_java",
+      ":generated_video_jni_java",
       ":hwcodecs_java",
       ":java_audio_device_module_java",
       ":libjingle_peerconnection_java",
diff --git a/sdk/android/src/java/org/webrtc/AndroidVideoDecoder.java b/sdk/android/src/java/org/webrtc/AndroidVideoDecoder.java
index efd11db..3dc3fef 100644
--- a/sdk/android/src/java/org/webrtc/AndroidVideoDecoder.java
+++ b/sdk/android/src/java/org/webrtc/AndroidVideoDecoder.java
@@ -19,6 +19,7 @@
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.concurrent.BlockingDeque;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.LinkedBlockingDeque;
 import java.util.concurrent.TimeUnit;
 import org.webrtc.ThreadUtils.ThreadChecker;
@@ -65,6 +66,10 @@
   // thread and is immutable while the codec is running.
   @Nullable private Thread outputThread;
 
+  // Counted down by the output thread once it has stopped delivering frames, i.e. before it starts
+  // releasing the MediaCodec. Set on the decoder thread together with `outputThread`.
+  @Nullable private CountDownLatch outputThreadDeliveryStopped;
+
   // Checker that ensures work is run on the output thread.
   private ThreadChecker outputThreadChecker;
 
@@ -186,7 +191,8 @@
       return VideoCodecStatus.FALLBACK_SOFTWARE;
     }
     running = true;
-    outputThread = createOutputThread();
+    outputThreadDeliveryStopped = new CountDownLatch(1);
+    outputThread = createOutputThread(codec, outputThreadDeliveryStopped);
     outputThread.start();
 
     Logging.d(TAG, "initDecodeInternal done");
@@ -315,6 +321,13 @@
     try {
       // The outputThread actually stops and releases the codec once running is false.
       running = false;
+      // release() returns only after frame delivery has stopped. Only the release of the
+      // MediaCodec, which occasionally hangs, is subject to a timeout.
+      if (!ThreadUtils.awaitUninterruptibly(
+              outputThreadDeliveryStopped, MEDIA_CODEC_RELEASE_TIMEOUT_MS)) {
+        Logging.e(TAG, "Output thread slow to stop delivering frames", new RuntimeException());
+        ThreadUtils.awaitUninterruptibly(outputThreadDeliveryStopped);
+      }
       if (!ThreadUtils.joinUninterruptibly(outputThread, MEDIA_CODEC_RELEASE_TIMEOUT_MS)) {
         // Log an exception to capture the stack trace and turn it into a TIMEOUT error.
         Logging.e(TAG, "Media decoder release timeout", new RuntimeException());
@@ -330,6 +343,7 @@
     } finally {
       codec = null;
       outputThread = null;
+      outputThreadDeliveryStopped = null;
       frameInfos.clear();
     }
     return VideoCodecStatus.OK;
@@ -344,15 +358,23 @@
     return initDecodeInternal(newWidth, newHeight);
   }
 
-  private Thread createOutputThread() {
+  // `threadCodec` is the codec that the output thread releases when done. It is passed explicitly
+  // since the `codec` member may be reset (or reassigned) while the release is still in progress.
+  private Thread createOutputThread(
+      final MediaCodecWrapper threadCodec, final CountDownLatch deliveryStopped) {
     return new Thread("AndroidVideoDecoder.outputThread") {
       @Override
       public void run() {
         outputThreadChecker = new ThreadChecker();
-        while (running) {
-          deliverDecodedFrame();
+        try {
+          while (running) {
+            deliverDecodedFrame();
+          }
+        } finally {
+          // No more frames will be delivered from this thread.
+          deliveryStopped.countDown();
         }
-        releaseCodecOnOutputThread();
+        releaseCodecOnOutputThread(threadCodec);
       }
     };
   }
@@ -615,16 +637,16 @@
     }
   }
 
-  private void releaseCodecOnOutputThread() {
+  private void releaseCodecOnOutputThread(MediaCodecWrapper threadCodec) {
     outputThreadChecker.checkIsOnValidThread();
     Logging.d(TAG, "Releasing MediaCodec on output thread");
     try {
-      codec.stop();
+      threadCodec.stop();
     } catch (Exception e) {
       Logging.e(TAG, "Media decoder stop failed", e);
     }
     try {
-      codec.release();
+      threadCodec.release();
     } catch (Exception e) {
       Logging.e(TAG, "Media decoder release failed", e);
       // Propagate exceptions caught during release back to the main thread.
diff --git a/sdk/android/src/java/org/webrtc/HardwareVideoEncoder.java b/sdk/android/src/java/org/webrtc/HardwareVideoEncoder.java
index 7c6b1a7..9664020 100644
--- a/sdk/android/src/java/org/webrtc/HardwareVideoEncoder.java
+++ b/sdk/android/src/java/org/webrtc/HardwareVideoEncoder.java
@@ -27,6 +27,7 @@
 import java.nio.ByteBuffer;
 import java.util.Map;
 import java.util.concurrent.BlockingDeque;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.LinkedBlockingDeque;
 import java.util.concurrent.TimeUnit;
 import org.webrtc.ThreadUtils.ThreadChecker;
@@ -128,6 +129,9 @@
   @Nullable private MediaCodecWrapper codec;
   // Thread that delivers encoded frames to the user callback.
   @Nullable private Thread outputThread;
+  // Counted down by the output thread once it has stopped delivering frames, i.e. before it starts
+  // releasing the MediaCodec. Set on the encoder thread together with `outputThread`.
+  @Nullable private CountDownLatch outputThreadDeliveryStopped;
 
   // EGL base wrapping the shared texture context.  Holds hooks to both the shared context and the
   // input surface.  Making this base current allows textures from the context to be drawn onto the
@@ -300,7 +304,8 @@
 
     running = true;
     outputThreadChecker.detachThread();
-    outputThread = createOutputThread();
+    outputThreadDeliveryStopped = new CountDownLatch(1);
+    outputThread = createOutputThread(codec, outputThreadDeliveryStopped);
     outputThread.start();
 
     return VideoCodecStatus.OK;
@@ -316,6 +321,13 @@
     } else {
       // The outputThread actually stops and releases the codec once running is false.
       running = false;
+      // release() returns only after frame delivery has stopped. Only the release of the
+      // MediaCodec, which occasionally hangs, is subject to a timeout.
+      if (!ThreadUtils.awaitUninterruptibly(
+              outputThreadDeliveryStopped, MEDIA_CODEC_RELEASE_TIMEOUT_MS)) {
+        Logging.e(TAG, "Output thread slow to stop delivering frames", new RuntimeException());
+        ThreadUtils.awaitUninterruptibly(outputThreadDeliveryStopped);
+      }
       if (!ThreadUtils.joinUninterruptibly(outputThread, MEDIA_CODEC_RELEASE_TIMEOUT_MS)) {
         Logging.e(TAG, "Media encoder release timeout");
         returnValue = VideoCodecStatus.TIMEOUT;
@@ -342,6 +354,7 @@
 
     codec = null;
     outputThread = null;
+    outputThreadDeliveryStopped = null;
 
     // Allow changing thread after release.
     encodeThreadChecker.detachThread();
@@ -561,14 +574,22 @@
     lastKeyFrameNs = presentationTimestampNs;
   }
 
-  private Thread createOutputThread() {
+  // `threadCodec` is the codec that the output thread releases when done. It is passed explicitly
+  // since the `codec` member may be reset (or reassigned) while the release is still in progress.
+  private Thread createOutputThread(
+      final MediaCodecWrapper threadCodec, final CountDownLatch deliveryStopped) {
     return new Thread() {
       @Override
       public void run() {
-        while (running) {
-          deliverEncodedImage();
+        try {
+          while (running) {
+            deliverEncodedImage();
+          }
+        } finally {
+          // No more frames will be delivered from this thread.
+          deliveryStopped.countDown();
         }
-        releaseCodecOnOutputThread();
+        releaseCodecOnOutputThread(threadCodec);
       }
     };
   }
@@ -670,17 +691,17 @@
     }
   }
 
-  private void releaseCodecOnOutputThread() {
+  private void releaseCodecOnOutputThread(MediaCodecWrapper threadCodec) {
     outputThreadChecker.checkIsOnValidThread();
     Logging.d(TAG, "Releasing MediaCodec on output thread");
     outputBuffersBusyCount.waitForZero();
     try {
-      codec.stop();
+      threadCodec.stop();
     } catch (Exception e) {
       Logging.e(TAG, "Media encoder stop failed", e);
     }
     try {
-      codec.release();
+      threadCodec.release();
     } catch (Exception e) {
       Logging.e(TAG, "Media encoder release failed", e);
       // Propagate exceptions caught during release back to the main thread.
diff --git a/sdk/android/src/java/org/webrtc/VideoDecoderWrapper.java b/sdk/android/src/java/org/webrtc/VideoDecoderWrapper.java
index d7b4044..edaa2ff 100644
--- a/sdk/android/src/java/org/webrtc/VideoDecoderWrapper.java
+++ b/sdk/android/src/java/org/webrtc/VideoDecoderWrapper.java
@@ -16,10 +16,36 @@
  * This class contains the Java glue code for JNI generation of VideoDecoder.
  */
 class VideoDecoderWrapper {
+  /**
+   * Forwards decoded frames to the native VideoDecoderWrapper until the callback is invalidated.
+   * Frames delivered after that are dropped.
+   */
+  static class NativeDecoderCallback implements VideoDecoder.Callback {
+    private final NativeLifecycleLock lifecycleLock;
+
+    NativeDecoderCallback(long nativeDecoder) {
+      lifecycleLock = new NativeLifecycleLock("VideoDecoderWrapper", nativeDecoder);
+    }
+
+    @Override
+    public void onDecodedFrame(VideoFrame frame, Integer decodeTimeMs, Integer qp) {
+      lifecycleLock.runIfAlive(
+          (long nativeDecoder)
+              -> VideoDecoderWrapperJni.get().onDecodedFrame(
+                  nativeDecoder, frame, decodeTimeMs, qp));
+    }
+
+    // Called by the native VideoDecoderWrapper. Blocks until an ongoing onDecodedFrame() call, if
+    // any, has returned. Frames delivered after this call are dropped.
+    @CalledByNative
+    void invalidate() {
+      lifecycleLock.dispose((long nativeDecoder) -> {});
+    }
+  }
+
   @CalledByNative
-  static VideoDecoder.Callback createDecoderCallback(final long nativeDecoder) {
-    return (VideoFrame frame, Integer decodeTimeMs, Integer qp) ->
-        VideoDecoderWrapperJni.get().onDecodedFrame(nativeDecoder, frame, decodeTimeMs, qp);
+  static NativeDecoderCallback createDecoderCallback(final long nativeDecoder) {
+    return new NativeDecoderCallback(nativeDecoder);
   }
 
   @NativeMethods
diff --git a/sdk/android/src/java/org/webrtc/VideoEncoderWrapper.java b/sdk/android/src/java/org/webrtc/VideoEncoderWrapper.java
index beadd8c..5e7c66c 100644
--- a/sdk/android/src/java/org/webrtc/VideoEncoderWrapper.java
+++ b/sdk/android/src/java/org/webrtc/VideoEncoderWrapper.java
@@ -35,10 +35,35 @@
     return scalingSettings.high;
   }
 
+  /**
+   * Forwards encoded frames to the native VideoEncoderWrapper until the callback is invalidated.
+   * Frames delivered after that are dropped.
+   */
+  static class NativeEncoderCallback implements VideoEncoder.Callback {
+    private final NativeLifecycleLock lifecycleLock;
+
+    NativeEncoderCallback(long nativeEncoder) {
+      lifecycleLock = new NativeLifecycleLock("VideoEncoderWrapper", nativeEncoder);
+    }
+
+    @Override
+    public void onEncodedFrame(EncodedImage frame, VideoEncoder.CodecSpecificInfo info) {
+      lifecycleLock.runIfAlive(
+          (long nativeEncoder) ->
+              VideoEncoderWrapperJni.get().onEncodedFrame(nativeEncoder, frame));
+    }
+
+    // Called by the native VideoEncoderWrapper. Blocks until an ongoing onEncodedFrame() call, if
+    // any, has returned. Frames delivered after this call are dropped.
+    @CalledByNative
+    void invalidate() {
+      lifecycleLock.dispose((long nativeEncoder) -> {});
+    }
+  }
+
   @CalledByNative
-  static VideoEncoder.Callback createEncoderCallback(final long nativeEncoder) {
-    return (EncodedImage frame, VideoEncoder.CodecSpecificInfo info) ->
-               VideoEncoderWrapperJni.get().onEncodedFrame(nativeEncoder, frame);
+  static NativeEncoderCallback createEncoderCallback(final long nativeEncoder) {
+    return new NativeEncoderCallback(nativeEncoder);
   }
 
   @NativeMethods
diff --git a/sdk/android/src/jni/video_decoder_wrapper.cc b/sdk/android/src/jni/video_decoder_wrapper.cc
index 30ed742..1a16d71 100644
--- a/sdk/android/src/jni/video_decoder_wrapper.cc
+++ b/sdk/android/src/jni/video_decoder_wrapper.cc
@@ -39,6 +39,7 @@
 #include "sdk/android/src/jni/jvm.h"
 #include "sdk/android/src/jni/video_codec_status.h"
 #include "sdk/android/src/jni/video_frame.h"
+#include "third_party/jni_zero/jni_zero.h"
 
 namespace webrtc {
 namespace jni {
@@ -68,7 +69,9 @@
   decoder_thread_checker_.Detach();
 }
 
-VideoDecoderWrapper::~VideoDecoderWrapper() = default;
+VideoDecoderWrapper::~VideoDecoderWrapper() {
+  InvalidateCallback(AttachCurrentThreadIfNeeded());
+}
 
 bool VideoDecoderWrapper::Configure(const Settings& settings) {
   RTC_DCHECK_RUN_ON(&decoder_thread_checker_);
@@ -83,9 +86,12 @@
       Java_Settings_Constructor(jni, decoder_settings_.number_of_cores(),
                                 resolution.Width(), resolution.Height());
 
+  // Replace the callback of a previous initDecode() call, if any.
+  InvalidateCallback(jni);
   ScopedJavaLocalRef<jobject> callback =
       Java_VideoDecoderWrapper_createDecoderCallback(jni,
                                                      jlongFromPointer(this));
+  j_callback_.Reset(jni, callback);
 
   int32_t status = JavaToNativeVideoCodecStatus(
       jni, Java_VideoDecoder_initDecode(jni, decoder_, settings, callback));
@@ -149,6 +155,8 @@
   int32_t status = JavaToNativeVideoCodecStatus(
       jni, Java_VideoDecoder_release(jni, decoder_));
   RTC_LOG(LS_INFO) << "release: " << status;
+  // Frames delivered after release() are dropped.
+  InvalidateCallback(jni);
   {
     MutexLock lock(&frame_extra_infos_lock_);
     frame_extra_infos_.clear();
@@ -159,6 +167,14 @@
   return status;
 }
 
+void VideoDecoderWrapper::InvalidateCallback(JNIEnv* jni) {
+  if (j_callback_.is_null()) {
+    return;
+  }
+  Java_NativeDecoderCallback_invalidate(jni, j_callback_);
+  j_callback_.Reset();
+}
+
 const char* VideoDecoderWrapper::ImplementationName() const {
   return implementation_name_.c_str();
 }
diff --git a/sdk/android/src/jni/video_decoder_wrapper.h b/sdk/android/src/jni/video_decoder_wrapper.h
index e1a4fa7..54678b4 100644
--- a/sdk/android/src/jni/video_decoder_wrapper.h
+++ b/sdk/android/src/jni/video_decoder_wrapper.h
@@ -81,6 +81,11 @@
 
   bool ConfigureInternal(JNIEnv* jni) RTC_RUN_ON(decoder_thread_checker_);
 
+  // Invalidates the callback that was passed to the Java decoder, if any, so
+  // that frames that the Java decoder delivers later are dropped. Blocks until
+  // an ongoing OnDecodedFrame() call, if any, has returned.
+  void InvalidateCallback(JNIEnv* jni) RTC_RUN_ON(decoder_thread_checker_);
+
   // Takes Java VideoCodecStatus, handles it and returns WEBRTC_VIDEO_CODEC_*
   // status code.
   int32_t HandleReturnCode(JNIEnv* jni,
@@ -104,6 +109,10 @@
       RTC_GUARDED_BY(decoder_thread_checker_);
 
   bool initialized_ RTC_GUARDED_BY(decoder_thread_checker_);
+  // The callback passed to the Java decoder by the most recent initDecode()
+  // call.
+  ScopedJavaGlobalRef<jobject> j_callback_
+      RTC_GUARDED_BY(decoder_thread_checker_);
   H264BitstreamParser h264_bitstream_parser_
       RTC_GUARDED_BY(decoder_thread_checker_);
 #ifdef RTC_ENABLE_H265
diff --git a/sdk/android/src/jni/video_encoder_wrapper.cc b/sdk/android/src/jni/video_encoder_wrapper.cc
index d36a769..5c0778a 100644
--- a/sdk/android/src/jni/video_encoder_wrapper.cc
+++ b/sdk/android/src/jni/video_encoder_wrapper.cc
@@ -13,6 +13,7 @@
 #include <jni.h>
 
 #include <array>
+#include <cstddef>
 #include <cstdint>
 #include <memory>
 #include <optional>
@@ -49,6 +50,7 @@
 #include "sdk/android/src/jni/jvm.h"
 #include "sdk/android/src/jni/video_codec_status.h"
 #include "sdk/android/src/jni/video_frame.h"
+#include "third_party/jni_zero/jni_zero.h"
 
 namespace webrtc {
 namespace jni {
@@ -62,7 +64,9 @@
   // Fetch and update encoder info.
   UpdateEncoderInfo(jni);
 }
-VideoEncoderWrapper::~VideoEncoderWrapper() = default;
+VideoEncoderWrapper::~VideoEncoderWrapper() {
+  InvalidateCallback(AttachCurrentThreadIfNeeded());
+}
 
 int VideoEncoderWrapper::InitEncode(const VideoCodec* codec_settings,
                                     const Settings& settings) {
@@ -102,9 +106,12 @@
       static_cast<int>(codec_settings_.numberOfSimulcastStreams),
       automatic_resize_on, capabilities);
 
+  // Replace the callback of a previous initEncode() call, if any.
+  InvalidateCallback(jni);
   ScopedJavaLocalRef<jobject> callback =
       Java_VideoEncoderWrapper_createEncoderCallback(jni,
                                                      jlongFromPointer(this));
+  j_callback_.Reset(jni, callback);
 
   int32_t status = JavaToNativeVideoCodecStatus(
       jni, Java_VideoEncoder_initEncode(jni, encoder_, settings, callback));
@@ -153,6 +160,8 @@
   int32_t status = JavaToNativeVideoCodecStatus(
       jni, Java_VideoEncoder_release(jni, encoder_));
   RTC_LOG(LS_INFO) << "release: " << status;
+  // Frames delivered after release() are dropped.
+  InvalidateCallback(jni);
   {
     MutexLock lock(&frame_extra_infos_lock_);
     frame_extra_infos_.clear();
@@ -162,6 +171,14 @@
   return status;
 }
 
+void VideoEncoderWrapper::InvalidateCallback(JNIEnv* jni) {
+  if (j_callback_.is_null()) {
+    return;
+  }
+  Java_NativeEncoderCallback_invalidate(jni, j_callback_);
+  j_callback_.Reset();
+}
+
 int32_t VideoEncoderWrapper::Encode(
     const VideoFrame& frame,
     const std::vector<VideoFrameType>* frame_types) {
diff --git a/sdk/android/src/jni/video_encoder_wrapper.h b/sdk/android/src/jni/video_encoder_wrapper.h
index 91c9715..960ff3f 100644
--- a/sdk/android/src/jni/video_encoder_wrapper.h
+++ b/sdk/android/src/jni/video_encoder_wrapper.h
@@ -74,6 +74,11 @@
 
   int32_t InitEncodeInternal(JNIEnv* jni);
 
+  // Invalidates the callback that was passed to the Java encoder, if any, so
+  // that frames that the Java encoder delivers later are dropped. Blocks until
+  // an ongoing OnEncodedFrame() call, if any, has returned.
+  void InvalidateCallback(JNIEnv* jni);
+
   // Takes Java VideoCodecStatus, handles it and returns WEBRTC_VIDEO_CODEC_*
   // status code.
   int32_t HandleReturnCode(JNIEnv* jni,
@@ -102,6 +107,9 @@
 
   const ScopedJavaGlobalRef<jobject> encoder_;
   const ScopedJavaGlobalRef<jclass> int_array_class_;
+  // The callback passed to the Java encoder by the most recent initEncode()
+  // call.
+  ScopedJavaGlobalRef<jobject> j_callback_;
 
   // Modified both on the encoder thread and the callback thread.
   Mutex frame_extra_infos_lock_;
diff --git a/sdk/android/tests/src/org/webrtc/AndroidVideoDecoderTest.java b/sdk/android/tests/src/org/webrtc/AndroidVideoDecoderTest.java
index ea9ec62..5d6bc66 100644
--- a/sdk/android/tests/src/org/webrtc/AndroidVideoDecoderTest.java
+++ b/sdk/android/tests/src/org/webrtc/AndroidVideoDecoderTest.java
@@ -31,6 +31,8 @@
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -55,6 +57,8 @@
   private static final int COLOR_FORMAT = CodecCapabilities.COLOR_FormatYUV420Planar;
   private static final long POLL_DELAY_MS = 10;
   private static final long DELIVER_DECODED_IMAGE_DELAY_MS = 10;
+  // Time for which a test callback blocks frame delivery.
+  private static final long CALLBACK_BLOCK_TIME_MS = 5500;
 
   private static final byte[] ENCODED_TEST_DATA = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
 
@@ -77,6 +81,14 @@
       }
     }
 
+    // Like waitDeliverDecodedFrame() but does not wait for the delivery to complete.
+    public void triggerDeliverDecodedFrame() {
+      synchronized (deliverDecodedFrameLock) {
+        deliverDecodedFrameDone = false;
+        deliverDecodedFrameLock.notifyAll();
+      }
+    }
+
     @SuppressWarnings("WaitNotInLoop") // This method is called inside a loop.
     @Override
     protected void deliverDecodedFrame() {
@@ -261,6 +273,42 @@
   }
 
   @Test
+  public void testReleaseWaitsForOngoingFrameDelivery() throws InterruptedException {
+    // release() returns only after an ongoing frame delivery has completed.
+    final CountDownLatch callbackEntered = new CountDownLatch(1);
+    final AtomicBoolean callbackReturned = new AtomicBoolean();
+    VideoDecoder.Callback blockingCallback = (frame, decodeTimeMs, qp) -> {
+      callbackEntered.countDown();
+      try {
+        Thread.sleep(CALLBACK_BLOCK_TIME_MS);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+      callbackReturned.set(true);
+    };
+
+    // Set-up.
+    TestDecoder decoder = new TestDecoderBuilder().setUseSurface(/* useSurface = */ false).build();
+    decoder.initDecode(TEST_DECODER_SETTINGS, blockingCallback);
+    decoder.decode(createTestEncodedImage(),
+        new DecodeInfo(/* isMissingFrames= */ false, /* renderTimeMs= */ 0));
+    fakeMediaCodecWrapper.addOutputData(
+        CodecTestHelper.generateRandomData(
+            TEST_DECODER_SETTINGS.width * TEST_DECODER_SETTINGS.height * 3 / 2),
+        /* presentationTimestampUs= */ 0, /* flags= */ 0);
+    decoder.triggerDeliverDecodedFrame();
+    callbackEntered.await();
+
+    // Test.
+    VideoCodecStatus status = decoder.release();
+
+    // Verify.
+    assertThat(callbackReturned.get()).isTrue();
+    assertThat(status).isEqualTo(VideoCodecStatus.OK);
+    assertThat(fakeMediaCodecWrapper.getState()).isEqualTo(State.RELEASED);
+  }
+
+  @Test
   public void testDecodeQueuesData() {
     // Set-up.
     AndroidVideoDecoder decoder = new TestDecoderBuilder().build();
diff --git a/sdk/android/tests/src/org/webrtc/HardwareVideoEncoderTest.java b/sdk/android/tests/src/org/webrtc/HardwareVideoEncoderTest.java
index e080dee..026f953 100644
--- a/sdk/android/tests/src/org/webrtc/HardwareVideoEncoderTest.java
+++ b/sdk/android/tests/src/org/webrtc/HardwareVideoEncoderTest.java
@@ -41,6 +41,8 @@
 import java.nio.charset.Charset;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -78,6 +80,8 @@
       /* capabilities= */ new VideoEncoder.Capabilities(false /* lossNotification */));
   private static final long POLL_DELAY_MS = 10;
   private static final long DELIVER_ENCODED_IMAGE_DELAY_MS = 10;
+  // Time for which a test callback blocks frame delivery.
+  private static final long CALLBACK_BLOCK_TIME_MS = 5500;
   private static final EncodeInfo ENCODE_INFO_KEY_FRAME =
       new EncodeInfo(new FrameType[] {FrameType.VideoFrameKey});
   private static final EncodeInfo ENCODE_INFO_DELTA_FRAME =
@@ -108,6 +112,14 @@
       }
     }
 
+    // Like waitDeliverEncodedImage() but does not wait for the delivery to complete.
+    public void triggerDeliverEncodedImage() {
+      synchronized (deliverEncodedImageLock) {
+        deliverEncodedImageDone = false;
+        deliverEncodedImageLock.notifyAll();
+      }
+    }
+
     @SuppressWarnings("WaitNotInLoop") // This method is called inside a loop.
     @Override
     protected void deliverEncodedImage() {
@@ -225,6 +237,40 @@
   }
 
   @Test
+  public void testReleaseWaitsForOngoingFrameDelivery() throws InterruptedException {
+    // release() returns only after an ongoing frame delivery has completed.
+    final CountDownLatch callbackEntered = new CountDownLatch(1);
+    final AtomicBoolean callbackReturned = new AtomicBoolean();
+    VideoEncoder.Callback blockingCallback = (frame, info) -> {
+      callbackEntered.countDown();
+      try {
+        Thread.sleep(CALLBACK_BLOCK_TIME_MS);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+      callbackReturned.set(true);
+    };
+
+    // Set-up.
+    TestEncoder encoder = new TestEncoderBuilder().build();
+    encoder.initEncode(TEST_ENCODER_SETTINGS, blockingCallback);
+    encoder.encode(createTestVideoFrame(/* timestampNs= */ 42), ENCODE_INFO_KEY_FRAME);
+    fakeMediaCodecWrapper.addOutputData(CodecTestHelper.generateRandomData(100),
+        /* presentationTimestampUs= */ 0,
+        /* flags= */ BUFFER_FLAG_SYNC_FRAME);
+    encoder.triggerDeliverEncodedImage();
+    callbackEntered.await();
+
+    // Test.
+    VideoCodecStatus status = encoder.release();
+
+    // Verify.
+    assertThat(callbackReturned.get()).isTrue();
+    assertThat(status).isEqualTo(VideoCodecStatus.OK);
+    assertThat(fakeMediaCodecWrapper.getState()).isEqualTo(State.RELEASED);
+  }
+
+  @Test
   public void encodingStatistics_unsupported_disabled() throws InterruptedException {
     TestEncoder encoder = new TestEncoderBuilder().setIsEncodingStatisticsSupported(false).build();
 
diff --git a/sdk/android/tests/src/org/webrtc/VideoDecoderWrapperTest.java b/sdk/android/tests/src/org/webrtc/VideoDecoderWrapperTest.java
new file mode 100644
index 0000000..c416efe
--- /dev/null
+++ b/sdk/android/tests/src/org/webrtc/VideoDecoderWrapperTest.java
@@ -0,0 +1,106 @@
+/*
+ *  Copyright 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+package org.webrtc;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import androidx.test.runner.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(manifest = Config.NONE)
+public class VideoDecoderWrapperTest {
+  private static final long FAKE_NATIVE_DECODER = 0x12345678L;
+  private static final long TIMEOUT_MS = 5000;
+
+  @Mock private VideoDecoderWrapper.Natives mockNatives;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    VideoDecoderWrapperJni.setInstanceForTesting(mockNatives);
+  }
+
+  @After
+  public void tearDown() {
+    VideoDecoderWrapperJni.setInstanceForTesting(null);
+  }
+
+  @Test
+  public void testCallbackForwardsFramesToNative() {
+    VideoDecoderWrapper.NativeDecoderCallback callback =
+        VideoDecoderWrapper.createDecoderCallback(FAKE_NATIVE_DECODER);
+
+    callback.onDecodedFrame(/* frame= */ null, /* decodeTimeMs= */ 1, /* qp= */ 2);
+
+    verify(mockNatives).onDecodedFrame(eq(FAKE_NATIVE_DECODER), isNull(), eq(1), eq(2));
+  }
+
+  @Test
+  public void testCallbackDropsFramesAfterInvalidate() {
+    VideoDecoderWrapper.NativeDecoderCallback callback =
+        VideoDecoderWrapper.createDecoderCallback(FAKE_NATIVE_DECODER);
+
+    callback.invalidate();
+    callback.onDecodedFrame(/* frame= */ null, /* decodeTimeMs= */ 1, /* qp= */ 2);
+
+    verifyNoInteractions(mockNatives);
+  }
+
+  @Test
+  public void testInvalidateWaitsForOngoingCallback() throws InterruptedException {
+    final CountDownLatch nativeEntered = new CountDownLatch(1);
+    final CountDownLatch allowNativeReturn = new CountDownLatch(1);
+    doAnswer(invocation -> {
+      nativeEntered.countDown();
+      allowNativeReturn.await();
+      return null;
+    })
+        .when(mockNatives)
+        .onDecodedFrame(anyLong(), isNull(), eq(1), eq(2));
+    VideoDecoderWrapper.NativeDecoderCallback callback =
+        VideoDecoderWrapper.createDecoderCallback(FAKE_NATIVE_DECODER);
+
+    Thread deliveryThread = new Thread(
+        () -> callback.onDecodedFrame(/* frame= */ null, /* decodeTimeMs= */ 1, /* qp= */ 2));
+    deliveryThread.start();
+    assertThat(nativeEntered.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue();
+
+    final CountDownLatch invalidated = new CountDownLatch(1);
+    Thread invalidateThread = new Thread(() -> {
+      callback.invalidate();
+      invalidated.countDown();
+    });
+    invalidateThread.start();
+
+    // invalidate() must not return while the native callback is running.
+    assertThat(invalidated.await(100, TimeUnit.MILLISECONDS)).isFalse();
+
+    allowNativeReturn.countDown();
+    assertThat(invalidated.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue();
+    deliveryThread.join(TIMEOUT_MS);
+    invalidateThread.join(TIMEOUT_MS);
+  }
+}
diff --git a/sdk/android/tests/src/org/webrtc/VideoEncoderWrapperTest.java b/sdk/android/tests/src/org/webrtc/VideoEncoderWrapperTest.java
new file mode 100644
index 0000000..d0288d2
--- /dev/null
+++ b/sdk/android/tests/src/org/webrtc/VideoEncoderWrapperTest.java
@@ -0,0 +1,106 @@
+/*
+ *  Copyright 2026 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+package org.webrtc;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import androidx.test.runner.AndroidJUnit4;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(manifest = Config.NONE)
+public class VideoEncoderWrapperTest {
+  private static final long FAKE_NATIVE_ENCODER = 0x12345678L;
+  private static final long TIMEOUT_MS = 5000;
+
+  @Mock private VideoEncoderWrapper.Natives mockNatives;
+
+  @Before
+  public void setUp() {
+    MockitoAnnotations.initMocks(this);
+    VideoEncoderWrapperJni.setInstanceForTesting(mockNatives);
+  }
+
+  @After
+  public void tearDown() {
+    VideoEncoderWrapperJni.setInstanceForTesting(null);
+  }
+
+  @Test
+  public void testCallbackForwardsFramesToNative() {
+    VideoEncoderWrapper.NativeEncoderCallback callback =
+        VideoEncoderWrapper.createEncoderCallback(FAKE_NATIVE_ENCODER);
+
+    callback.onEncodedFrame(/* frame= */ null, new VideoEncoder.CodecSpecificInfo());
+
+    verify(mockNatives).onEncodedFrame(eq(FAKE_NATIVE_ENCODER), isNull());
+  }
+
+  @Test
+  public void testCallbackDropsFramesAfterInvalidate() {
+    VideoEncoderWrapper.NativeEncoderCallback callback =
+        VideoEncoderWrapper.createEncoderCallback(FAKE_NATIVE_ENCODER);
+
+    callback.invalidate();
+    callback.onEncodedFrame(/* frame= */ null, new VideoEncoder.CodecSpecificInfo());
+
+    verifyNoInteractions(mockNatives);
+  }
+
+  @Test
+  public void testInvalidateWaitsForOngoingCallback() throws InterruptedException {
+    final CountDownLatch nativeEntered = new CountDownLatch(1);
+    final CountDownLatch allowNativeReturn = new CountDownLatch(1);
+    doAnswer(invocation -> {
+      nativeEntered.countDown();
+      allowNativeReturn.await();
+      return null;
+    })
+        .when(mockNatives)
+        .onEncodedFrame(anyLong(), isNull());
+    VideoEncoderWrapper.NativeEncoderCallback callback =
+        VideoEncoderWrapper.createEncoderCallback(FAKE_NATIVE_ENCODER);
+
+    Thread deliveryThread = new Thread(
+        () -> callback.onEncodedFrame(/* frame= */ null, new VideoEncoder.CodecSpecificInfo()));
+    deliveryThread.start();
+    assertThat(nativeEntered.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue();
+
+    final CountDownLatch invalidated = new CountDownLatch(1);
+    Thread invalidateThread = new Thread(() -> {
+      callback.invalidate();
+      invalidated.countDown();
+    });
+    invalidateThread.start();
+
+    // invalidate() must not return while the native callback is running.
+    assertThat(invalidated.await(100, TimeUnit.MILLISECONDS)).isFalse();
+
+    allowNativeReturn.countDown();
+    assertThat(invalidated.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue();
+    deliveryThread.join(TIMEOUT_MS);
+    invalidateThread.join(TIMEOUT_MS);
+  }
+}