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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions