Skip to content

Harden media button event sequencing - #5646

Open
joashrajin wants to merge 4 commits into
mainfrom
codex/serialize-media-button-events
Open

Harden media button event sequencing#5646
joashrajin wants to merge 4 commits into
mainfrom
codex/serialize-media-button-events

Conversation

@joashrajin

@joashrajin joashrajin commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Description

Media-button callbacks currently launch queue work onto a shared multi-threaded
scope. MediaEventQueue stores its tap jobs and counter in unsynchronized mutable
state, so concurrent callbacks can create multiple tap windows or observe events
out of order.

This PR is the first, independently tested part of the #5645 stack:

  • Protect tap-window state and the tap counter with a mutex without holding the
    mutex across the 600 ms disambiguation window.
  • Introduce a shared MediaButtonEventHandler that owns key-code mapping, error
    handling, and ordered event registration.
  • Register each event before returning to the framework callback, while keeping
    resolved media actions asynchronous on the supplied scope.
  • Preserve Pixel Buds NEXT/PREVIOUS followed by spurious PLAY suppression.
  • Add eight-thread contention coverage plus focused handler behavior tests.

The follow-up PR #5645 wires this processor into the Media3 and legacy session
callbacks and contains the user-facing explicit-play behavior. Keeping the
processor and its unit tests here leaves both PRs below the 500-line review
threshold.

Related to #5631

Testing Instructions

  1. Run:

    ./gradlew :modules:services:repositories:testDebugUnitTest \
      --tests '*MediaEventQueueTest*' \
      --tests '*MediaButtonEventHandlerTest*'
  2. Confirm all 23 focused tests pass.

  3. Run ./gradlew :modules:services:repositories:testDebugUnitTest and confirm
    all 860 tests pass.

  4. Run ./gradlew spotlessCheck.

No manual playback verification is needed for this preparatory PR because the
handler is integrated into the application callbacks in #5645.

On-device verification of the stacked integration (2026-08-07)

Installed GitHub's merge result c20537ff7c021b7431c0f33c332cb52084505338,
which combines this PR at 9d4471ea4cc870a22a2cba06457045fe4fa679b5
with #5645 at 361b8f64e5f0052d36751cf5d96ddfda00afb43f, on a physical
Samsung SM-G990E running Android 16. Both the legacy and Media3 session paths
passed:

  • A dedicated KEYCODE_MEDIA_PLAY resumed paused playback while the display
    remained dozing; the explicit-play handler ran in approximately 5–10 ms rather
    than after the 600 ms tap window.
  • A redundant play command while already playing remained a no-op.
  • Rapid repeated play commands started playback once and still resolved the
    configured double-tap action after the window.
  • NEXT/PREVIOUS followed immediately by the spurious PLAY event kept
    the configured skip action and suppressed the trailing play action.
  • No crash or ANR was recorded.

Playback was left paused, the Media3 beta flag was restored to its original
enabled state, and the display was returned to dozing. No Bluetooth audio
accessory was connected, so media keys were injected through Android's media-key
dispatcher with adb. This verifies the app/session and screen-off behavior,
but not a physical headset's Bluetooth transport behavior.

Screenshots or Screencast

Not applicable; there are no UI changes.

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md — not user-facing on its own
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml — no strings
  • Any jetpack compose components I added or changed are covered by compose previews — no UI
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics — no analytics changes

I have tested any UI changes...

Not applicable; there are no UI changes.

@joashrajin joashrajin added [Type] Bug Not functioning as intended. [Area] Playback Episode playback issue labels Jul 25, 2026
@joashrajin joashrajin added this to the 8.18 milestone Jul 25, 2026
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

@wpmobilebot wpmobilebot modified the milestones: 8.18, 8.19 Aug 3, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 8.18 has now entered code-freeze, so the milestone of this PR has been updated to 8.19.

@joashrajin
joashrajin marked this pull request as ready for review August 7, 2026 14:34
@joashrajin
joashrajin requested a review from a team as a code owner August 7, 2026 14:34
@joashrajin
joashrajin requested review from geekygecko and removed request for a team August 7, 2026 14:34
@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 5m 50s —— View job


Code Review: Harden media button event sequencing

  • Gather PR context and diff
  • Review MediaEventQueue.kt concurrency changes
  • Review MediaButtonEventHandler.kt
  • Review test coverage
  • Post review (6 inline comments)

The core change is sound: guarding singleTapJob/multiTapJob/counter with a Mutex while deliberately not holding it across the 600 ms window is the right shape, and the 8-thread contention test is a genuinely valuable addition to a class that previously had none. Splitting the processor out from the integration is also the right call for reviewability.

I found one correctness issue, one behavioural divergence that will matter in #5645, and a few robustness/clarity items. Nothing blocking, but items 1–3 are worth resolving before the follow-up builds on this contract.

Findings

1. Rollback cleanup is skipped on cancellation (MediaEventQueue.kt:45-55) — the cleanup calls stateMutex.withLock from inside catch (e: Exception). CancellationException is an Exception (java.util.concurrent.CancellationExceptionIllegalStateException), and a suspend call in a cancelled coroutine throws immediately, so singleTapJob is never cleared and the original exception is replaced by the cancellation. Needs withContext(NonCancellable).

2. Two layers own errors, and they disagree (MediaButtonEventHandler.kt:78-86) — handleImmediatePlay catches Exception before it can reach the queue, so the rollback at MediaEventQueue.kt:47-55 is unreachable from the only production caller. The two new test files assert opposite outcomes for "immediate callback throws": MediaEventQueueTest.kt:125 says the tap window is discarded, MediaButtonEventHandlerTest.kt:58 says it survives and resolves as DoubleTap. Only one is reachable, and nothing pins which layer decides — removing the seemingly-redundant inner try/catch later would silently flip user-visible behaviour. Also note the rollback path itself discards an already-registered double tap, since it rethrows instead of emitting the resolved event.

3. ACTION_UP handling diverges from both callbacks being replaced (MediaButtonEventHandler.kt:31-34) — handle() returns false, but Media3SessionCallback.kt:200-202 returns true and MediaSessionManager.kt:1262 defers to super. Since every ACTION_DOWN is followed by an ACTION_UP, returning false invites Media3's default handling on the second half of every press once #5645 wires this in. KEYCODE_MEDIA_PAUSE / SKIP_FORWARD / SKIP_BACKWARD and the LogBuffer.i keyCode breadcrumb also aren't covered by the handler, so the callbacks must keep intercepting those first.

4. The stated ordering guarantee is best-effort (MediaButtonEventHandler.kt:55-57) — UNDISPATCHED runs inline only until the first real suspension. Under actual contention (the case being hardened) the second caller suspends on Mutex.lock() and resumes on the scope's dispatcher, so registration happens after handle() returns. Worth softening the comment. Relatedly, UNDISPATCHED moves onImmediatePlay onto the framework callback thread (Media3's app looper) — please confirm in #5645 that the wired-up implementation is main-safe.

5. yield() as an async boundary is defeatable (MediaButtonEventHandler.kt:64-68) — it's a no-op on Dispatchers.Unconfined, which would let onMediaEvent run on the framework stack. Latent only, but scope.launch { onMediaEvent(...) } states the intent unambiguously.

6. Test hardening (MediaEventQueueTest.kt:137-159) — the contention test asserts only immediateTapCount == 1, so it would still pass if every event were lost; asserting results.filterNotNull() == listOf(MediaEvent.TripleTap) closes that. CyclicBarrier(8) on a pool of exactly 8 also couples the participant count to the pool size.

7. Thread-safety of counter is implicit (MediaEventQueue.kt:73-88) — a plain Int whose visibility relies on callers holding stateMutex; an AtomicInteger would make it self-evident and let event() drop the extra lock acquisition at line 57.

Things I liked

  • SingleTapJob is now created and published atomically under the lock — the "two tap windows" race in the description is genuinely closed.
  • The singleTapJob === newSingleTapJob identity check before clearing correctly avoids clobbering a newer window.
  • Pixel Buds NEXT/PREVIOUS → spurious PLAY suppression is preserved and directly covered (MediaEventQueueTest.kt, MediaButtonEventHandlerTest.kt:78).
  • CancellationException is rethrown rather than reported as an error at both catch sites.

Not verified

I could not run :modules:services:repositories:testDebugUnitTest or spotlessCheck in this environment (Gradle needs network/cache access I don't have here), so my reading of the test outcomes is from the source, not from a run. Line lengths and formatting look consistent with the 120-char rule. Please rely on CI for the actual gates.
codex/serialize-media-button-events

Comment on lines +78 to +86
private fun handleImmediatePlay() {
try {
onImmediatePlay()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
onError(e)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error ownership is duplicated here, and it makes the rollback in MediaEventQueue dead code.

Because handleImmediatePlay catches Exception itself, no exception ever reaches MediaEventQueue.handleSingleTapEvent, so the rollback block at MediaEventQueue.kt:47-55 is unreachable from the only production caller. (CancellationException is rethrown, but the queue's catch (e: Exception) would catch it anyway since java.util.concurrent.CancellationException extends IllegalStateException.)

The two test files now assert opposite behaviour for the same scenario:

  • MediaEventQueueTest.kt:125 — callback throws ⇒ window is discarded, the next tap resolves as a fresh SingleTap.
  • MediaButtonEventHandlerTest.kt:58 — callback throws ⇒ window survives, the second tap resolves as DoubleTap.

Both are defensible, but only one is reachable in the app, and there is no test pinning which layer owns the decision. If someone later removes this try/catch (it looks redundant — the caller already catches), the user-visible behaviour silently flips from "double tap still works" to "double tap is swallowed".

Suggest picking one owner: either drop this try/catch and keep the queue's rollback (accepting that a failed immediate play discards a concurrent double tap), or drop the rollback in the queue and document in consumeEvent's KDoc that onImmediateSingleTap must not throw. Either way, a comment explaining the choice would help.

Fix this →

Comment on lines +45 to +55
try {
onImmediateSingleTap?.invoke()
} catch (e: Exception) {
stateMutex.withLock {
if (singleTapJob === newSingleTapJob) {
singleTapJob = null
newSingleTapJob.cancel()
}
}
throw e
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this rollback block:

1. The cleanup is skipped exactly when it is most likely to be needed. stateMutex.withLock is a suspend call inside a catch (e: Exception), and CancellationException is an Exception (java.util.concurrent.CancellationExceptionIllegalStateException). If the coroutine has been cancelled, this withLock throws CancellationException immediately, so singleTapJob is never cleared, newSingleTapJob.cancel() never runs, and the original exception is replaced by the cancellation. Wrap the cleanup in withContext(NonCancellable), or catch a narrower type and let cancellation flow past.

2. A concurrent double tap is silently lost. If a second tap already ran incrementTaps() (counter = 2) while the first coroutine was inside onImmediateSingleTap, the second tap returned null and is relying on this coroutine to emit DoubleTap. Rolling back throws instead, so the user's double tap disappears. Rethrowing after cleanup means nobody emits the resolved event.

} catch (e: Exception) {
    withContext(NonCancellable) {
        stateMutex.withLock {
            if (singleTapJob === newSingleTapJob) {
                singleTapJob = null
                newSingleTapJob.cancel()
            }
        }
    }
    throw e
}

Fix this →

Comment on lines +55 to +57
// Register the event before returning to the framework callback. This preserves
// delivery order while the queue's timeout still resumes on the provided scope.
scope.launch(start = CoroutineStart.UNDISPATCHED) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering guarantee this comment claims is best-effort, not absolute — worth softening the wording so the next reader doesn't rely on it.

UNDISPATCHED runs the body on the caller's thread only until the first real suspension. consumeEvent immediately hits stateMutex.withLock, which takes the non-suspending fast path only when the mutex is uncontended. If two media-button events genuinely overlap (the case this PR is hardening against), the second one suspends on lock() and resumes on the scope's dispatcher — so registration happens after handle() has already returned true, which is exactly the ordering the comment says is preserved.

Second consideration: UNDISPATCHED also means onImmediatePlay executes synchronously on whatever thread onMediaButtonEvent was delivered on. For Media3 that's the app's main looper, so playbackManager.playPause(...) would now run inline on the main thread instead of on the callback scope's dispatcher as it does today. The KDoc says "must stay fast", but it would be good to confirm in #5645 that the wired-up onImmediatePlay is genuinely main-safe.

Comment on lines +64 to +68
if (outputEvent != null) {
// Output actions historically ran asynchronously on the callback scope.
yield()
onMediaEvent(outputEvent)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yield() is a fragile way to express "run this asynchronously": it is a no-op on a dispatcher whose isDispatchNeeded returns false (e.g. Dispatchers.Unconfined). With such a scope, and since multi-tap events return from consumeEvent without suspending, onMediaEvent would run inline on the framework callback stack — silently breaking the invariant in the KDoc.

Both current call sites use real dispatchers so this is latent rather than live, but scope.launch { onMediaEvent(outputEvent) } (or withContext(dispatcher)) states the intent directly and can't be defeated by the scope's dispatcher choice.

Comment on lines +31 to +34
fun handle(keyEvent: KeyEvent): Boolean {
if (keyEvent.action != KeyEvent.ACTION_DOWN) {
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning false for non-ACTION_DOWN events diverges from both callbacks this handler is meant to replace:

  • Media3SessionCallback.kt:200-202 returns true for a media key with action != ACTION_DOWN.
  • MediaSessionManager.kt:1262 falls through to super.onMediaButtonEvent(...).

Returning false tells Media3 the event was not consumed, so it applies its own default handling to the ACTION_UP half of every media-button press. Since every ACTION_DOWN is followed by an ACTION_UP, that risks a second action per press once #5645 wires this in (e.g. player.play()/pause() on top of the resolved tap action).

Two other gaps to keep in mind for #5645, since the handler is presented as owning "key-code mapping":

  • KEYCODE_MEDIA_PAUSE, KEYCODE_MEDIA_SKIP_FORWARD and KEYCODE_MEDIA_SKIP_BACKWARD (Media3SessionCallback.kt:210-245, including the PiP skip buttons) are not mapped here, so the callback must still intercept them before delegating.
  • The LogBuffer.i(..., "media button event: keyCode=...") breadcrumb present in both callbacks has no equivalent here; it's load-bearing for diagnosing headphone bug reports.

Worth adding a test that asserts the intended return value for ACTION_UP so the contract is pinned before the integration PR depends on it.

Fix this →

Comment on lines +137 to +159
@Test
fun `handle concurrent immediate single taps exactly once`() = runBlocking {
val handler = MediaEventQueue(scopeProvider = { this })
val immediateTapCount = AtomicInteger()
val eventCount = 8
val startBarrier = CyclicBarrier(eventCount)
val dispatcher = Executors.newFixedThreadPool(eventCount).asCoroutineDispatcher()

dispatcher.use {
List(eventCount) {
async(dispatcher) {
startBarrier.await()
handler.consumeEvent(MediaEvent.SingleTap) {
immediateTapCount.incrementAndGet()
}
}
}.awaitAll()
}

assertEquals(1, immediateTapCount.get())
}

@Test

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test — this is the one that actually exercises the mutex. Three suggestions:

  1. Assert the resolved event, not just the callback count. Right now the test would still pass if the queue lost every event. Eight taps should collapse to exactly one non-null result, and it should be TripleTap:

    val results = /* ... */.awaitAll()
    assertEquals(1, immediateTapCount.get())
    assertEquals(listOf(MediaEvent.TripleTap), results.filterNotNull())

    (TripleTap because counter reaches 8 and event() maps anything > 2 to TripleTap.)

  2. This is runBlocking, so the 600 ms window is real wall-clock time, unlike the sibling runTest cases. That's unavoidable for genuine multi-thread contention, but it makes the test the slowest in the class — worth a comment so a future reader doesn't "fix" it by switching to runTest and quietly removing the concurrency.

  3. CyclicBarrier(8) on a pool sized exactly 8 deadlocks if the pool ever loses a thread, and CyclicBarrier.await() blocks the carrier threads rather than suspending. Executors.newCachedThreadPool() (or a CountDownLatch + Dispatchers.IO.limitedParallelism) removes that coupling between the participant count and the pool size.

Comment on lines 73 to 88
@@ -60,6 +81,8 @@ internal class MediaEventQueue(

suspend fun await() = job.join()

fun cancel() = job.cancel()

fun incrementTaps() {
counter++
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: counter is a plain Int mutated from arbitrary threads, so its cross-thread visibility depends entirely on callers happening to hold stateMutex. That's true today, but it's an invariant enforced nowhere in this class — and it's the reason the final read has to be wrapped in stateMutex.withLock { newSingleTapJob.event() } at line 57 even though nothing there mutates shared state.

An AtomicInteger makes the thread-safety self-evident, lets event() be called without the lock, and removes one lock acquisition from the hot path:

Suggested change
private class SingleTapJob(
scope: CoroutineScope,
) {
private val counter = AtomicInteger(1)
private val job = scope.launch { delay(600) }
val isActive get() = job.isActive
suspend fun await() = job.join()
fun cancel() = job.cancel()
fun incrementTaps() {
counter.incrementAndGet()
}
fun event() = when (counter.get()) {
1 -> MediaEvent.SingleTap
2 -> MediaEvent.DoubleTap
else -> MediaEvent.TripleTap
}
}

(Requires import java.util.concurrent.atomic.AtomicInteger.) Alternatively, a // guarded by stateMutex comment on counter, singleTapJob and multiTapJob would document the invariant cheaply.

@wpmobilebot wpmobilebot modified the milestones: 8.19, 8.20 Aug 17, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 8.19 has now entered code-freeze, so the milestone of this PR has been updated to 8.20.

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

Labels

[Area] Playback Episode playback issue [Type] Bug Not functioning as intended.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants