### 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: ```java @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: ```java 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.)