diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 99089a2afc..4d947e27cf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -143,6 +143,8 @@ public interface ExoPlayer extends Player { private boolean useLazyPreparation; private boolean buildCalled; + private long releaseTimeoutMs; + /** * Creates a builder with a list of {@link Renderer Renderers}. * @@ -211,6 +213,21 @@ public interface ExoPlayer extends Player { this.clock = clock; } + /** + * Set a limit on the time a call to {@link ExoPlayer#release()} can spend. If a call to {@link + * ExoPlayer#release()} takes more than {@code timeoutMs} milliseconds to complete, the player + * will raise an error via {@link Player.EventListener#onPlayerError}. + * + *

This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the player is used. + * + * @param timeoutMs The time limit in milliseconds, or 0 for no limit. + */ + public Builder experimental_setReleaseTimeoutMs(long timeoutMs) { + releaseTimeoutMs = timeoutMs; + return this; + } + /** * Sets the {@link TrackSelector} that will be used by the player. * @@ -317,8 +334,14 @@ public interface ExoPlayer extends Player { public ExoPlayer build() { Assertions.checkState(!buildCalled); buildCalled = true; - return new ExoPlayerImpl( - renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + ExoPlayerImpl player = + new ExoPlayerImpl(renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + + if (releaseTimeoutMs > 0) { + player.experimental_setReleaseTimeoutMs(releaseTimeoutMs); + } + + return player; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 97658d2906..e79a61da9a 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -36,6 +36,7 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.util.ArrayDeque; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeoutException; /** * An {@link ExoPlayer} implementation. Instances can be obtained from {@link ExoPlayer.Builder}. @@ -144,6 +145,20 @@ import java.util.concurrent.CopyOnWriteArrayList; internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); } + /** + * Set a limit on the time a call to {@link #release()} can spend. If a call to {@link #release()} + * takes more than {@code timeoutMs} milliseconds to complete, the player will raise an error via + * {@link Player.EventListener#onPlayerError}. + * + *

This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the player is used. + * + * @param timeoutMs The time limit in milliseconds, or 0 for no limit. + */ + public void experimental_setReleaseTimeoutMs(long timeoutMs) { + internalPlayer.experimental_setReleaseTimeoutMs(timeoutMs); + } + @Override @Nullable public AudioComponent getAudioComponent() { @@ -441,7 +456,13 @@ import java.util.concurrent.CopyOnWriteArrayList; + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + ExoPlayerLibraryInfo.registeredModules() + "]"); mediaSource = null; - internalPlayer.release(); + if (!internalPlayer.release()) { + notifyListeners( + listener -> + listener.onPlayerError( + ExoPlaybackException.createForUnexpected( + new RuntimeException(new TimeoutException("Player release timed out."))))); + } eventHandler.removeCallbacksAndMessages(null); playbackInfo = getResetPlaybackInfo( diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 4c25c180f4..6861c6a593 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -123,6 +123,8 @@ import java.util.concurrent.atomic.AtomicBoolean; private int nextPendingMessageIndex; private boolean deliverPendingMessageAtStartPositionRequired; + private long releaseTimeoutMs; + public ExoPlayerImplInternal( Renderer[] renderers, TrackSelector trackSelector, @@ -174,6 +176,10 @@ import java.util.concurrent.atomic.AtomicBoolean; deliverPendingMessageAtStartPositionRequired = true; } + public void experimental_setReleaseTimeoutMs(long releaseTimeoutMs) { + this.releaseTimeoutMs = releaseTimeoutMs; + } + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { handler .obtainMessage(MSG_PREPARE, resetPosition ? 1 : 0, resetState ? 1 : 0, mediaSource) @@ -246,23 +252,23 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - public synchronized void release() { + public synchronized boolean release() { if (released || !internalPlaybackThread.isAlive()) { - return; + return true; } + handler.sendEmptyMessage(MSG_RELEASE); - boolean wasInterrupted = false; - while (!released) { - try { - wait(); - } catch (InterruptedException e) { - wasInterrupted = true; + try { + if (releaseTimeoutMs > 0) { + waitUntilReleased(releaseTimeoutMs); + } else { + waitUntilReleased(); } - } - if (wasInterrupted) { - // Restore the interrupted status. + } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + + return released; } public Looper getPlaybackLooper() { @@ -411,6 +417,63 @@ import java.util.concurrent.atomic.AtomicBoolean; // Private methods. + /** + * Blocks the current thread until {@link #releaseInternal()} is executed on the playback Thread. + * + *

If the current thread is interrupted while waiting for {@link #releaseInternal()} to + * complete, this method will delay throwing the {@link InterruptedException} to ensure that the + * underlying resources have been released, and will an {@link InterruptedException} after + * {@link #releaseInternal()} is complete. + * + * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for + * {{@link #releaseInternal()}} to complete. + */ + private synchronized void waitUntilReleased() throws InterruptedException { + InterruptedException interruptedException = null; + while (!released) { + try { + wait(); + } catch (InterruptedException e) { + interruptedException = e; + } + } + + if (interruptedException != null) { + throw interruptedException; + } + } + + /** + * Blocks the current thread until {@link #releaseInternal()} is performed on the playback Thread + * or the specified amount of time has elapsed. + * + *

If the current thread is interrupted while waiting for {@link #releaseInternal()} to + * complete, this method will delay throwing the {@link InterruptedException} to ensure that the + * underlying resources have been released or the operation timed out, and will throw an {@link + * InterruptedException} afterwards. + * + * @param timeoutMs the time in milliseconds to wait for {@link #releaseInternal()} to complete. + * @throws {@link InterruptedException} if the current Thread was interrupted while waiting for + * {{@link #releaseInternal()}} to complete. + */ + private synchronized void waitUntilReleased(long timeoutMs) throws InterruptedException { + long deadlineMs = clock.elapsedRealtime() + timeoutMs; + long remainingMs = timeoutMs; + InterruptedException interruptedException = null; + while (!released && remainingMs > 0) { + try { + wait(remainingMs); + } catch (InterruptedException e) { + interruptedException = e; + } + remainingMs = deadlineMs - clock.elapsedRealtime(); + } + + if (interruptedException != null) { + throw interruptedException; + } + } + private void setState(int state) { if (playbackInfo.playbackState != state) { playbackInfo = playbackInfo.copyWithPlaybackState(state); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java index 49309181a0..e7ade7b68b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlayerMessage.java @@ -17,7 +17,10 @@ package com.google.android.exoplayer2; import android.os.Handler; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Clock; +import java.util.concurrent.TimeoutException; /** * Defines a player message which can be sent with a {@link Sender} and received by a {@link @@ -285,6 +288,28 @@ public final class PlayerMessage { return isDelivered; } + /** + * Blocks until after the message has been delivered or the player is no longer able to deliver + * the message or the specified waiting time elapses. + * + *

Note that this method can't be called if the current thread is the same thread used by the + * message handler set with {@link #setHandler(Handler)} as it would cause a deadlock. + * + * @param timeoutMs the maximum time to wait in milliseconds. + * @return Whether the message was delivered successfully. + * @throws IllegalStateException If this method is called before {@link #send()}. + * @throws IllegalStateException If this method is called on the same thread used by the message + * handler set with {@link #setHandler(Handler)}. + * @throws TimeoutException If the waiting time elapsed and this message has not been delivered + * and the player is still able to deliver the message. + * @throws InterruptedException If the current thread is interrupted while waiting for the message + * to be delivered. + */ + public synchronized boolean experimental_blockUntilDelivered(long timeoutMs) + throws InterruptedException, TimeoutException { + return experimental_blockUntilDelivered(timeoutMs, Clock.DEFAULT); + } + /** * Marks the message as processed. Should only be called by a {@link Sender} and may be called * multiple times. @@ -298,4 +323,24 @@ public final class PlayerMessage { isProcessed = true; notifyAll(); } + + @VisibleForTesting() + /* package */ synchronized boolean experimental_blockUntilDelivered(long timeoutMs, Clock clock) + throws InterruptedException, TimeoutException { + Assertions.checkState(isSent); + Assertions.checkState(handler.getLooper().getThread() != Thread.currentThread()); + + long deadlineMs = clock.elapsedRealtime() + timeoutMs; + long remainingMs = timeoutMs; + while (!isProcessed && remainingMs > 0) { + wait(remainingMs); + remainingMs = deadlineMs - clock.elapsedRealtime(); + } + + if (!isProcessed) { + throw new TimeoutException("Message delivery timed out."); + } + + return isDelivered; + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java new file mode 100644 index 0000000000..a7ef451752 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/PlayerMessageTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.os.Handler; +import android.os.HandlerThread; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.util.Clock; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; + +/** Unit test for {@link PlayerMessage}. */ +@RunWith(AndroidJUnit4.class) +public class PlayerMessageTest { + + private static final long TIMEOUT_MS = 10; + + @Mock Clock clock; + private HandlerThread handlerThread; + private PlayerMessage message; + + @Before + public void setUp() { + initMocks(this); + PlayerMessage.Sender sender = (message) -> {}; + PlayerMessage.Target target = (messageType, payload) -> {}; + handlerThread = new HandlerThread("TestHandlerThread"); + handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); + message = + new PlayerMessage(sender, target, Timeline.EMPTY, /* defaultWindowIndex= */ 0, handler); + } + + @After + public void tearDown() { + handlerThread.quit(); + } + + @Test + public void experimental_blockUntilDelivered_timesOut() throws Exception { + when(clock.elapsedRealtime()).thenReturn(0L).thenReturn(TIMEOUT_MS * 2); + + try { + message.send().experimental_blockUntilDelivered(TIMEOUT_MS, clock); + fail(); + } catch (TimeoutException expected) { + } + + // Ensure experimental_blockUntilDelivered() entered the blocking loop + verify(clock, Mockito.times(2)).elapsedRealtime(); + } + + @Test + public void experimental_blockUntilDelivered_onAlreadyProcessed_succeeds() throws Exception { + when(clock.elapsedRealtime()).thenReturn(0L); + + message.send().markAsProcessed(/* isDelivered= */ true); + + assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + } + + @Test + public void experimental_blockUntilDelivered_markAsProcessedWhileBlocked_succeeds() + throws Exception { + message.send(); + + // Use a separate Thread to mark the message as processed. + CountDownLatch prepareLatch = new CountDownLatch(1); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + Future future = + executorService.submit( + () -> { + prepareLatch.await(); + message.markAsProcessed(true); + return true; + }); + + when(clock.elapsedRealtime()) + .thenReturn(0L) + .then( + (invocation) -> { + // Signal the background thread to call PlayerMessage#markAsProcessed. + prepareLatch.countDown(); + return TIMEOUT_MS - 1; + }); + + try { + assertThat(message.experimental_blockUntilDelivered(TIMEOUT_MS, clock)).isTrue(); + // Ensure experimental_blockUntilDelivered() entered the blocking loop. + verify(clock, Mockito.atLeast(2)).elapsedRealtime(); + future.get(1, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + } +}