Skip to content

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

Closed

Description

@matzuk

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 67de7b2, 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 == null → prepareChildSource(). 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.releaseSource → PreloadMediaSource.releaseSourceInternal() → !isUsedByPlayer() and !preloadCalled → timeline = 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.preload → HlsMediaPeriod.prepare → checkNotNull(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 b52dec8) 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

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

Activity

  1. tianyif commented on Oct 1, 2026

    @tianyif
    Contributor

    Hi @matzuk,

    I just submitted a fix to this issue. Without a preload() called beforehand, the preload lambda shouldn't be posted to preloadHandler at step 3. Thanks for the thorough analysis!

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions