Version
Media3 1.11.0
More version details
Regression introduced in 1.11.0. Still present on main (checked at 37b9edf04c8f). Not reproducible on 1.10.1, because the crashing call did not exist there.
Devices that reproduce the issue
- Emulator
sdk_gphone64_arm64 running Android 16 (API 36)
- Emulator
sdk_gphone64_x86_64 running Android 12
Not device-specific — this is a session-side logic race, not a hardware/codec issue.
Devices that do not reproduce the issue
None known.
Reproducible in the demo app?
Not tested
The demo app does not release its controller from inside a player callback, so it would not hit this out of the box. The trigger is described below and is app-code-independent.
Reproduction steps
Preconditions: a MediaSessionService (or MediaLibraryService) hosted in the same process as the MediaController/MediaBrowser connected to it, i.e. the ordinary single-process setup. This matters because MediaControllerStub.dispatchControllerTaskOnHandler uses Util.postOrRun, which runs the callback inline when it is already on the controller's application thread — so app listener code executes synchronously inside the session's dispatch loop.
- Connect a
MediaController (or MediaBrowser) to the session and register a Player.Listener on it.
- From inside one of those listener callbacks — e.g.
onPlaybackStateChanged — call MediaController.release(). This can easily be indirect: in our case the listener publishes into a StateFlow, and a collector on Dispatchers.Main.immediate resumes inline and releases the controller from there.
- Cause a player state change on the session's player so that
MediaSessionImpl dispatches onPlayerInfoChanged. Any state change works; we used player.stop() (delivered via a KEYCODE_MEDIA_STOP media button), which yields STATE_IDLE.
- The process dies with the stack trace below.
This reproduces 100% of the time for us once step 2's release path is reachable.
Expected result
Releasing a controller from within a player callback does not crash. ConnectedControllersManager already tolerates a controller disappearing everywhere else — every other accessor in the class is written defensively as:
@Nullable ConnectedControllerRecord<T> record = controllerRecords.get(controllerInfo);
if (record != null) { ... }
Actual result
java.lang.NullPointerException
at com.google.common.base.Preconditions.checkNotNull(Preconditions.java:904)
at androidx.media3.session.ConnectedControllersManager.updateLastSentTimelineAndTracks(ConnectedControllersManager.java:339)
at androidx.media3.session.MediaSessionImpl.dispatchOnPlayerInfoChanged(MediaSessionImpl.java:875)
at androidx.media3.session.MediaSessionImpl.access$1200(MediaSessionImpl.java:108)
at androidx.media3.session.MediaSessionImpl$PlayerInfoChangedHandler.handleMessage(MediaSessionImpl.java:2397)
at android.os.Handler.dispatchMessage(Handler.java:110)
at android.os.Looper.loopOnce(Looper.java:248)
at android.os.Looper.loop(Looper.java:338)
at android.app.ActivityThread.main(ActivityThread.java:9067)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:593)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:932)
Analysis
dispatchOnPlayerInfoChanged checks that the controller is connected at the top of each loop iteration, then invokes the controller callback, then writes per-controller state back at the end:
if (!controllersManager.isConnected(controller)) {
continue;
}
...
checkNotNull(controller.getControllerCb()).onPlayerInfoChanged(...); // app code runs here
controllersManager.updateLastSentTimelineAndTracks(controller, ...); // record may be gone now
For an in-process controller, onPlayerInfoChanged runs app code synchronously (see Util.postOrRun above). If that app code releases the controller, the removal is also synchronous: MediaSessionStub.release → postOrRunOnApplicationHandler (inline, same thread) → ConnectedControllersManager.removeController, which does controllerRecords.remove(controllerInfo) immediately. Control then returns into the loop, and updateLastSentTimelineAndTracks fails its checkNotNull.
Because the class is documented as main-thread-confined in 1.11.0 ("All methods must be called on the application thread associated with the player", replacing the previous synchronized design), the app-visible callback is the only place the record can vanish between the isConnected check and the write-back — which makes this reentrancy, not a thread race.
Both updateLastSentTimelineAndTracks and the two getLastSent* accessors added in 1.11.0 use checkNotNull, unlike the rest of the class. getLastSentTimeline/getLastSentTracks are called immediately after the isConnected check so they are safe in this particular flow, but they look equally exposed to any future caller.
Suggested fix
Either make the three lastSent* methods null-tolerant like their neighbours, or re-check isConnected(controller) before the write-back in dispatchOnPlayerInfoChanged. For reference, the relevant 1.11.0 release note is "Fix an out-of-bounds timeline merge crash by tracking state consistency per-controller on the session side".
Media
Not applicable — the crash does not depend on the media being played. It is triggered by a player state change plus a controller release, and the stack contains no extractor/renderer frames. (We happened to hit it with HLS audio, but any playback works.)
Version
Media3 1.11.0
More version details
Regression introduced in 1.11.0. Still present on
main(checked at37b9edf04c8f). Not reproducible on 1.10.1, because the crashing call did not exist there.Devices that reproduce the issue
sdk_gphone64_arm64running Android 16 (API 36)sdk_gphone64_x86_64running Android 12Not device-specific — this is a session-side logic race, not a hardware/codec issue.
Devices that do not reproduce the issue
None known.
Reproducible in the demo app?
Not tested
The demo app does not release its controller from inside a player callback, so it would not hit this out of the box. The trigger is described below and is app-code-independent.
Reproduction steps
Preconditions: a
MediaSessionService(orMediaLibraryService) hosted in the same process as theMediaController/MediaBrowserconnected to it, i.e. the ordinary single-process setup. This matters becauseMediaControllerStub.dispatchControllerTaskOnHandlerusesUtil.postOrRun, which runs the callback inline when it is already on the controller's application thread — so app listener code executes synchronously inside the session's dispatch loop.MediaController(orMediaBrowser) to the session and register aPlayer.Listeneron it.onPlaybackStateChanged— callMediaController.release(). This can easily be indirect: in our case the listener publishes into aStateFlow, and a collector onDispatchers.Main.immediateresumes inline and releases the controller from there.MediaSessionImpldispatchesonPlayerInfoChanged. Any state change works; we usedplayer.stop()(delivered via aKEYCODE_MEDIA_STOPmedia button), which yieldsSTATE_IDLE.This reproduces 100% of the time for us once step 2's release path is reachable.
Expected result
Releasing a controller from within a player callback does not crash.
ConnectedControllersManageralready tolerates a controller disappearing everywhere else — every other accessor in the class is written defensively as:Actual result
Analysis
dispatchOnPlayerInfoChangedchecks that the controller is connected at the top of each loop iteration, then invokes the controller callback, then writes per-controller state back at the end:For an in-process controller,
onPlayerInfoChangedruns app code synchronously (seeUtil.postOrRunabove). If that app code releases the controller, the removal is also synchronous:MediaSessionStub.release→postOrRunOnApplicationHandler(inline, same thread) →ConnectedControllersManager.removeController, which doescontrollerRecords.remove(controllerInfo)immediately. Control then returns into the loop, andupdateLastSentTimelineAndTracksfails itscheckNotNull.Because the class is documented as main-thread-confined in 1.11.0 ("All methods must be called on the application thread associated with the player", replacing the previous
synchronizeddesign), the app-visible callback is the only place the record can vanish between theisConnectedcheck and the write-back — which makes this reentrancy, not a thread race.Both
updateLastSentTimelineAndTracksand the twogetLastSent*accessors added in 1.11.0 usecheckNotNull, unlike the rest of the class.getLastSentTimeline/getLastSentTracksare called immediately after theisConnectedcheck so they are safe in this particular flow, but they look equally exposed to any future caller.Suggested fix
Either make the three
lastSent*methods null-tolerant like their neighbours, or re-checkisConnected(controller)before the write-back indispatchOnPlayerInfoChanged. For reference, the relevant 1.11.0 release note is "Fix an out-of-bounds timeline merge crash by tracking state consistency per-controller on the session side".Media
Not applicable — the crash does not depend on the media being played. It is triggered by a player state change plus a controller release, and the stack contains no extractor/renderer frames. (We happened to hit it with HLS audio, but any playback works.)