androidx / androidx/media

PreloadMediaSource: stale onChildSourceInfoRefreshed callback calls createPeriod on a released child source (NPE in BaseMediaSource.getPlayerId)

Open
#3,408 0 comments 0 reactions 1 assignee View on GitHub

@tianyif is already working on this.

Since Sep 9, 2026.

Dominant language
Java
Stars
3k
Forks
955
Avg merge
12d 14h
Merged PRs (30d)
2

Description

Version

Media3 1.10.1

More version details

Seen in production on 1.9.2 and 1.10.1. The relevant code (PreloadMediaSource.onChildSourceInfoRefreshed and PreloadMediaSource.releaseSourceInternal) is unchanged in 1.11.0 and on main at 67de7b2364522a9e3c69b7534ffcff0ed872fbe3, so it should reproduce there too. On 1.8.0 the same path throws IllegalStateException instead, because BaseMediaSource.getPlayerId() used Assertions.checkStateNotNull at the time.

Devices that reproduce the issue

Not device-specific: all Android versions (API 30–36) and vendors, in a large production app that uses DefaultPreloadManager for a vertical video feed (roughly 1,500 crashes/day).

Devices that do not reproduce the issue

None known.

Reproducible in the demo app?

Not attempted. It is a timing-dependent ordering of messages on the shared preload/playback looper (exact order below); it can be reproduced deterministically in a PreloadMediaSourceTest by driving that order.

Reproduction steps

Setup: an ExoPlayer built via DefaultPreloadManager.Builder.buildExoPlayer (so it shares the preload looper) is given the PreloadMediaSource returned by DefaultPreloadManager.getMediaSource(item) before the manager has called preload() on that source. This happens routinely: e.g. the currently-playing item's target status is PRELOAD_STATUS_NOT_PRELOADED, or the user scrolls faster than the one-at-a-time preloader.

Message order on the preload/playback looper:

  1. Player: PreloadMediaSource.prepareSource(...)prepareSourceInternal()timeline == nullprepareChildSource(). The child (HlsMediaSource) is prepared by the player; preloadCalled is still false.
  2. The app enqueues the player's release of the source (stop() / setMediaItems(...)). The message sits in the queue.
  3. The child's playlist arrives → onChildSourceInfoRefreshed(newTimeline)this.timeline = newTimeline, refreshSourceInfo(...), and the preload lambda is posted to preloadHandler — queued behind the release from step 2.
  4. The release runs: BaseMediaSource.releaseSourcePreloadMediaSource.releaseSourceInternal()!isUsedByPlayer() and !preloadCalledtimeline = null; prepareChildSourceCalled = false; super.releaseSourceInternal(). The child is released and its playerId nulled. The lambda from step 3 is not removed — only releasePreloadMediaSource() / stopPreloading() clear the handler, and neither is on this path.
  5. On the application thread, DefaultPreloadManager selects this source as current again (e.g. invalidate() after the current index moved so the item is now at rank ±1, or maybeAdvanceToNextMediaSourceHolder()) with a target status above STAGE_SOURCE_PREPARED, and posts preload().
  6. The lambda from step 3 runs before that preload(): isUsedByPlayer() is false, onSourcePreparedNotified is false, preloadControl.onSourcePrepared(this) returns true because the source is current → createPeriod(...)mediaSource.createPeriod(...) on the released child → BaseMediaSource.getPlayerId()checkNotNull(playerId) → NPE.

If the preload() from step 5 happens to run before the lambda, the child is re-prepared, but the lambda still proceeds with its stale newTimeline: createPeriod succeeds, then PreloadMediaPeriod.preloadHlsMediaPeriod.preparecheckNotNull(playlistTracker.getMultivariantPlaylist()) fails because the tracker was just restarted (third stack below).

Expected result

A queued preload step whose child source has since been released is dropped or ignored. No crash.

Actual result

Fatal NullPointerException on the preload/playback HandlerThread. Three stacks, one root cause (line numbers are 1.10.1):

java.lang.NullPointerException
    at com.google.common.base.Preconditions.checkNotNull(Preconditions.java:904)
    at androidx.media3.exoplayer.source.BaseMediaSource.getPlayerId(BaseMediaSource.java:189)
    at androidx.media3.exoplayer.hls.HlsMediaSource.createPeriod(HlsMediaSource.java:575)
    at androidx.media3.exoplayer.source.preload.PreloadMediaSource.createPeriod(PreloadMediaSource.java:398)
    at androidx.media3.exoplayer.source.preload.PreloadMediaSource.lambda$onChildSourceInfoRefreshed$2(PreloadMediaSource.java:374)
    at android.os.Handler.handleCallback(Handler.java:958)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loopOnce(Looper.java:230)
    at android.os.Looper.loop(Looper.java:319)
    at android.os.HandlerThread.run(HandlerThread.java:67)

Same path for progressive media:

    at com.google.common.base.Preconditions.checkNotNull(Preconditions.java:904)
    at androidx.media3.exoplayer.source.BaseMediaSource.getPlayerId(BaseMediaSource.java:189)
    at androidx.media3.exoplayer.source.ProgressiveMediaSource.createPeriod(ProgressiveMediaSource.java:425)
    at androidx.media3.exoplayer.source.preload.PreloadMediaSource.createPeriod(PreloadMediaSource.java:398)
    at androidx.media3.exoplayer.source.preload.PreloadMediaSource.lambda$onChildSourceInfoRefreshed$2(PreloadMediaSource.java:374)

The "preload() won the race" variant:

    at com.google.common.base.Preconditions.checkNotNull(Preconditions.java:904)
    at androidx.media3.exoplayer.hls.HlsMediaPeriod.buildAndPrepareSampleStreamWrappers(HlsMediaPeriod.java:539)
    at androidx.media3.exoplayer.hls.HlsMediaPeriod.prepare(HlsMediaPeriod.java:195)
    at androidx.media3.exoplayer.source.preload.PreloadMediaPeriod.prepareInternal(PreloadMediaPeriod.java:78)
    at androidx.media3.exoplayer.source.preload.PreloadMediaPeriod.preload(PreloadMediaPeriod.java:60)
    at androidx.media3.exoplayer.source.preload.PreloadMediaSource.lambda$onChildSourceInfoRefreshed$2(PreloadMediaSource.java)

Related: #3168 / #3201 (fixed in 1.10.1 by b52dec8ff293a0e6c64a5df9ef7cf38b97679e24) were the analogous stale-callback NPE in PreloadMediaPeriodCallback. The onChildSourceInfoRefreshed lambda has the same shape but did not get a guard.

Proposed fix

Two hunks in PreloadMediaSource: bail out of the posted lambda if the child was released (and possibly re-prepared) since it was posted, and clear queued preload work when the source is fully released.

@@ -358,6 +358,11 @@
           if (isUsedByPlayer() || onSourcePreparedNotified) {
             return;
           }
+          if (timeline != newTimeline) {
+            // The child source was released (and possibly re-prepared) after this event was
+            // posted, so its player id and playlist state no longer match this timeline.
+            return;
+          }
           onSourcePreparedNotified = true;
           if (!preloadControl.onSourcePrepared(this)) {
             stopPreloading();
@@ -434,6 +439,8 @@
         setPlayerId(PlayerId.PRELOAD);
         maybeSetPlayerIdForAllocator();
       } else {
+        // Drop any preload step still queued against the child source being released below.
+        stopPreloading();
         timeline = null;
         prepareChildSourceCalled = false;
         super.releaseSourceInternal();

timeline != newTimeline is only false while the captured timeline is still the live one: after releaseSourceInternal nulls it the lambda bails; after a release plus re-preload(), the re-prepared child produces a new Timeline before any new lambda is posted, so the stale lambda bails and the fresh one proceeds. Happy to send this as a PR with a PreloadMediaSourceTest case driving the order above.

Media

Not applicable — any HLS or progressive media; the crash depends only on message ordering.

Bug Report
  • You will email the zip file produced by adb bugreport to android-media-github@google.com after filing this issue.

The failure is fully explained by the code path above; a bug report can be provided on request.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.