Skip to content

NPE in ConnectedControllersManager.updateLastSentTimelineAndTracks when a controller is released during onPlayerInfoChanged (1.11.0 regression) #3375

Description

@Bwaim

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.

  1. Connect a MediaController (or MediaBrowser) to the session and register a Player.Listener on it.
  2. 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.
  3. 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.
  4. 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.)

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