Skip to content

ArrayIndexOutOfBoundsException in resolvePositionForPlaylistChange when a live SSAI stream is joined inside an ad and the default position moves past the ad period #3348

Description

@timrijckaert

Version

Media3 1.10.1

More version details

1.10.1

Devices that reproduce the issue

All
Any reproduced deterministically in a Robolectric unit test (inlined below) and on physical devices (Android 14) playing a live DASH stream with AWS MediaTailor server-side ad insertion via ServerSideAdInsertionMediaSource.

Devices that do not reproduce the issue

No response

Reproducible in the demo app?

Not tested

Reproduction steps

Scenario: a viewer tunes into a live DASH stream while an ad break is on air and the SSAI ad metadata is already known.

  1. Prepare a ServerSideAdInsertionMediaSource wrapping a live, dynamic, multi-period timeline. Mark one mid-window period as a single whole-period server-side ad group (ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState(state, 0, periodDurationUs, periodDurationUs)).
  2. Let the window default position fall inside that ad period. The player resolves the start position into the ad (isPlayingAd == true). Do not play any content.
  3. Refresh the content timeline (normal live manifest refresh) with the default position now past the ad period, inside a following ad-free period.

The refresh crashes the playback thread (stack from 1.10.1):

java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
  at androidx.media3.common.AdPlaybackState.getAdGroup(AdPlaybackState.java:1035)
  at androidx.media3.common.Timeline$Period.isServerSideInsertedAdGroup(Timeline.java:890)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.isIgnorableServerSideAdInsertionPeriodChange(ExoPlayerImplInternal.java:3798)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.resolvePositionForPlaylistChange(ExoPlayerImplInternal.java:3706)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed(ExoPlayerImplInternal.java:2497)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.mediaSourceListUpdateRequestedInternal(ExoPlayerImplInternal.java:1054)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:803)

Surfaced to the app as ExoPlaybackException: Unexpected runtime error, ending playback. Joining a live channel during an ad break is a very common user action, so the production crash volume is significant.

Self-contained Robolectric test — Media3 (media3-exoplayer, media3-test-utils, media3-test-utils-robolectric), Robolectric and JUnit only:

package be.persgroep.lfvp.libs.player.repro

import androidx.media3.common.AdPlaybackState
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil
import androidx.media3.test.utils.FakeMediaSource
import androidx.media3.test.utils.FakeTimeline
import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition
import androidx.media3.test.utils.TestExoPlayerBuilder
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.collect.ImmutableMap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

/**
 * Standalone reproduction of a Media3 1.8.0 crash, written against public Media3 + Robolectric
 * test APIs only (no project code) so it can be shared verbatim on the androidx/media tracker.
 *
 * Scenario (live DASH stream with server-side ad insertion, e.g. AWS MediaTailor):
 * 1. A viewer tunes in while an ad break is on air and its SSAI metadata is already known: the
 *    window default position falls inside a period marked as a server-side ad group, so the
 *    session starts playing the ad (`isPlayingAd == true`).
 * 2. A live manifest refresh moves the window default position past the ad period, into an
 *    ad-free period. No content has been played since join.
 *
 * Handling that refresh crashes the playback thread:
 *
 * ```
 * java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
 *   at androidx.media3.common.AdPlaybackState.getAdGroup
 *   at androidx.media3.common.Timeline$Period.isServerSideInsertedAdGroup
 *   at androidx.media3.exoplayer.ExoPlayerImplInternal.isIgnorableServerSideAdInsertionPeriodChange
 *   at androidx.media3.exoplayer.ExoPlayerImplInternal.resolvePositionForPlaylistChange
 *   at androidx.media3.exoplayer.ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed
 * ```
 */
@RunWith(AndroidJUnit4::class)
@Config(sdk = [33])
public class Media3LiveSsaiJoinCrashReproTest {

    private lateinit var player: ExoPlayer

    @After
    public fun tearDown() {
        player.release()
    }

    @Test
    public fun `manifest refresh while playing the ad the session joined in`() {
        // window: ├─ p0 content (0..20s) ─┼─ p1 ad (20..40s) ─┼─ p2 content (40..60s) ─┤
        player = TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build()
        val initialTimeline = liveTimeline(defaultPositionUs = 30_000_000)
        val content = FakeMediaSource(initialTimeline)
        val ssai = ServerSideAdInsertionMediaSource(content) { false }

        // 1. Join: p1 (20s..40s) is a known 20s server-side ad; the live default position (30s)
        //    falls inside it, so the session starts playing the ad.
        ssai.setAdPlaybackStates(adPlaybackStates(initialTimeline, adPeriodIndex = 1), initialTimeline)
        player.setMediaSource(ssai)
        player.prepare()
        TestPlayerRunHelper.advance(player).untilState(Player.STATE_READY)
        assertTrue(player.isPlayingAd)
        assertEquals(1, player.currentPeriodIndex)

        // 2. Live manifest refresh: the default position moved past the ad period (45s, in p2).
        content.setNewSourceInfo(liveTimeline(defaultPositionUs = 45_000_000))
        TestPlayerRunHelper.advance(player).untilPendingCommandsAreFullyHandled()

        // Asserting on the cause's stack trace puts the ArrayIndexOutOfBoundsException and its
        // full ExoPlayerImplInternal stack directly into the failure message.
        assertNull(player.playerError?.cause?.stackTraceToString())
    }

    /** FakeTimeline period uids are Pair(windowUid, periodIndex): stable across refreshes. */
    private fun liveTimeline(defaultPositionUs: Long): Timeline =
        FakeTimeline(
            TimelineWindowDefinition.Builder()
                .setUid("window")
                .setLive(true)
                .setDynamic(true)
                .setSeekable(true)
                .setPeriodCount(3)
                .setDurationUs(60_000_000)
                .setWindowStartTimeUs(1_720_000_000_000_000)
                .setWindowPositionInFirstPeriodUs(0)
                .setDefaultPositionUs(defaultPositionUs)
                .build(),
        )

    /** One state per period, keyed by period uid (`ServerSideAdInsertionTimeline` requires full coverage). */
    private fun adPlaybackStates(timeline: Timeline, adPeriodIndex: Int): ImmutableMap<Any, AdPlaybackState> {
        val builder = ImmutableMap.builder<Any, AdPlaybackState>()
        for (periodIndex in 0 until timeline.periodCount) {
            builder.put(
                timeline.getUidOfPeriod(periodIndex),
                if (periodIndex == adPeriodIndex) {
                    ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState(
                        AdPlaybackState(ADS_ID),
                        /* fromPositionUs = */ 0,
                        /* contentResumeOffsetUs = */ 20_000_000,
                        /* adDurationsUs... = */ 20_000_000,
                    )
                } else {
                    AdPlaybackState(ADS_ID)
                },
            )
        }
        return builder.build()
    }

    private companion object {
        const val ADS_ID = "repro-ads-id"
    }
}

Expected result

The refresh is applied and the session keeps playing the ad it joined in.

Actual result

ArrayIndexOutOfBoundsException on the playback thread; playback fails.

java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
  at androidx.media3.common.AdPlaybackState.getAdGroup(AdPlaybackState.java:1035)
  at androidx.media3.common.Timeline$Period.isServerSideInsertedAdGroup(Timeline.java:890)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.isIgnorableServerSideAdInsertionPeriodChange(ExoPlayerImplInternal.java:3798)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.resolvePositionForPlaylistChange(ExoPlayerImplInternal.java:3706)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.handleMediaSourceListInfoRefreshed(ExoPlayerImplInternal.java:2497)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.mediaSourceListUpdateRequestedInternal(ExoPlayerImplInternal.java:1054)
  at androidx.media3.exoplayer.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:803)

Media

Not applicable

Bug Report

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions