PreloadMediaSource: stale onChildSourceInfoRefreshed callback calls createPeriod on a released child source (NPE in BaseMediaSource.getPlayerId)
@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:
- Player:
PreloadMediaSource.prepareSource(...)→prepareSourceInternal()→timeline == null→prepareChildSource(). The child (HlsMediaSource) is prepared by the player;preloadCalledis stillfalse. - The app enqueues the player's release of the source (
stop()/setMediaItems(...)). The message sits in the queue. - The child's playlist arrives →
onChildSourceInfoRefreshed(newTimeline)→this.timeline = newTimeline,refreshSourceInfo(...), and the preload lambda is posted topreloadHandler— queued behind the release from step 2. - The release runs:
BaseMediaSource.releaseSource→PreloadMediaSource.releaseSourceInternal()→!isUsedByPlayer()and!preloadCalled→timeline = null; prepareChildSourceCalled = false; super.releaseSourceInternal(). The child is released and itsplayerIdnulled. The lambda from step 3 is not removed — onlyreleasePreloadMediaSource()/stopPreloading()clear the handler, and neither is on this path. - On the application thread,
DefaultPreloadManagerselects this source as current again (e.g.invalidate()after the current index moved so the item is now at rank ±1, ormaybeAdvanceToNextMediaSourceHolder()) with a target status aboveSTAGE_SOURCE_PREPARED, and postspreload(). - The lambda from step 3 runs before that
preload():isUsedByPlayer()is false,onSourcePreparedNotifiedis 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 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 bugreportto 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.