Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 962b4dbc8c | |||
| aa4089118e | |||
| dabca3ad24 |
+90
-22
@@ -12,12 +12,15 @@ import androidx.media3.common.MediaItem
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
import java.io.IOException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -39,9 +42,15 @@ import timber.log.Timber
|
|||||||
* wrapped ExoPlayer stays paused at the position it had when the
|
* wrapped ExoPlayer stays paused at the position it had when the
|
||||||
* route was selected.
|
* route was selected.
|
||||||
*
|
*
|
||||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||||
* factory wraps that callback into a SharedFlow consumed by the
|
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||||
* NowPlaying surface as a snackbar.
|
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
||||||
|
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
||||||
|
* WiFi power-save can stall a single command's socket I/O for a second or
|
||||||
|
* two while the renderer is perfectly reachable, so commands retry on
|
||||||
|
* transient IO failure and otherwise defer to the poll loop. The factory
|
||||||
|
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
||||||
|
* surface as a snackbar.
|
||||||
*
|
*
|
||||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
@@ -174,12 +183,12 @@ class MinstrelForwardingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
remoteState.setPlayIntent(true)
|
remoteState.setPlayIntent(true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.play() }
|
runCatching { retryTransport { active.avTransport.play() } }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPlaying()
|
remoteState.applyTransportPlaying()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,12 +205,12 @@ class MinstrelForwardingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
remoteState.setPlayIntent(false)
|
remoteState.setPlayIntent(false)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.pause() }
|
runCatching { retryTransport { active.avTransport.pause() } }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPaused()
|
remoteState.applyTransportPaused()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,8 +233,8 @@ class MinstrelForwardingPlayer(
|
|||||||
trackNumber = remoteState.trackNumber,
|
trackNumber = remoteState.trackNumber,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.seek(positionMs) }
|
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,11 +264,13 @@ class MinstrelForwardingPlayer(
|
|||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
retryTransport {
|
||||||
if (positionMs > 0L) {
|
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||||
active.avTransport.seek(positionMs)
|
if (positionMs > 0L) {
|
||||||
|
active.avTransport.seek(positionMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { handleSoapFailure(active, it) }
|
}.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +293,8 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.next() }
|
runCatching { retryTransport { active.avTransport.next() } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +316,8 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.previous() }
|
runCatching { retryTransport { active.avTransport.previous() } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +398,34 @@ class MinstrelForwardingPlayer(
|
|||||||
super.release()
|
super.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
/**
|
||||||
pollJob?.cancel()
|
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||||
remoteState.applyError(t)
|
* first socket I/O for a second or two; a retry lets the command land once
|
||||||
handler.post { onDrop(active.routeName) }
|
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
||||||
|
* renderer answered and rejected the action) is NOT retried -- the device
|
||||||
|
* is alive and retrying won't change its verdict.
|
||||||
|
*/
|
||||||
|
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
||||||
|
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transport SOAP command failed even after retries. Deliberately does
|
||||||
|
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
||||||
|
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
||||||
|
* WiFi power-save can fail one command while the renderer is perfectly
|
||||||
|
* reachable; dropping on a single command falsely kicked playback back to
|
||||||
|
* the phone. We log, leave the poll loop running, and nudge an immediate
|
||||||
|
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
||||||
|
* truly gone, the poll loop trips the drop on its own.
|
||||||
|
*/
|
||||||
|
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
||||||
|
if (t is SoapFaultException) {
|
||||||
|
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
||||||
|
} else {
|
||||||
|
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
||||||
|
}
|
||||||
|
pollTrigger.trySend(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||||
@@ -515,5 +549,39 @@ class MinstrelForwardingPlayer(
|
|||||||
// when Sonos's reported Track matches the wrapped player; this only
|
// when Sonos's reported Track matches the wrapped player; this only
|
||||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||||
|
// Transport commands retry transient IO failures so a single WiFi
|
||||||
|
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||||
|
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||||
|
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||||
|
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||||
|
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||||
|
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||||
|
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||||
|
* answered and rejected the action -- propagates immediately: the device is
|
||||||
|
* alive, so retrying won't change its verdict. Extracted from
|
||||||
|
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||||
|
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||||
|
* playback to local audio).
|
||||||
|
*/
|
||||||
|
internal suspend fun <T> retryTransientIo(
|
||||||
|
attempts: Int,
|
||||||
|
backoffMs: Long,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
var attempt = 0
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (io: IOException) {
|
||||||
|
attempt += 1
|
||||||
|
if (attempt >= attempts) throw io
|
||||||
|
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||||
|
delay(backoffMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
|||||||
* Updates flow in from:
|
* Updates flow in from:
|
||||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||||
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
* - Poll failures -> recordPollFailure()
|
||||||
*
|
*
|
||||||
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
||||||
* consecutive poll failures = remote considered dropped (returns true
|
* consecutive poll failures = remote considered dropped (returns true
|
||||||
* from recordPollFailure for the caller to surface). Success resets it.
|
* from recordPollFailure for the caller to surface). Any success resets it.
|
||||||
|
* Failed transport commands no longer drop the route -- they retry and
|
||||||
|
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class RemotePlayerState @Inject constructor() {
|
class RemotePlayerState @Inject constructor() {
|
||||||
|
|||||||
+5
-4
@@ -162,7 +162,8 @@ class OutputPickerController @Inject constructor(
|
|||||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||||
mediaRouter.addCallback(selector, callback)
|
mediaRouter.addCallback(selector, callback)
|
||||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||||
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
// dropped (the poll loop's consecutive-failure threshold tripping --
|
||||||
|
// the sole drop arbiter; a failed transport command no longer drops),
|
||||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||||
// path but skips the Stop SOAP since the device is already unreachable.
|
// path but skips the Stop SOAP since the device is already unreachable.
|
||||||
@@ -420,9 +421,9 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||||
* threshold or SOAP exception). Captures the last remote position +
|
* consecutive-failure threshold tripping). Captures the last remote
|
||||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||||
* The snackbar is handled independently by the NowPlaying surface
|
* The snackbar is handled independently by the NowPlaying surface
|
||||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks in the transport-command retry semantics behind the locked-phone
|
||||||
|
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
|
||||||
|
* SOAP fault from a responding renderer must NOT be retried, and an
|
||||||
|
* exhausted retry must rethrow rather than loop forever. Without this, a
|
||||||
|
* single failed command falsely reverted Sonos playback to the phone.
|
||||||
|
*/
|
||||||
|
class RetryTransientIoTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns immediately on first success`() = runTest {
|
||||||
|
var calls = 0
|
||||||
|
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||||
|
calls += 1
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
assertEquals("ok", result)
|
||||||
|
assertEquals(1, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retries transient IO failure then succeeds`() = runTest {
|
||||||
|
var calls = 0
|
||||||
|
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||||
|
calls += 1
|
||||||
|
if (calls < 2) throw IOException("connect timeout")
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
assertEquals("ok", result)
|
||||||
|
assertEquals(2, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rethrows IO failure after exhausting attempts`() = runTest {
|
||||||
|
var calls = 0
|
||||||
|
assertFailsWith<IOException> {
|
||||||
|
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||||
|
calls += 1
|
||||||
|
throw IOException("still asleep")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(3, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
|
||||||
|
var calls = 0
|
||||||
|
assertFailsWith<SoapFaultException> {
|
||||||
|
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||||
|
calls += 1
|
||||||
|
throw SoapFaultException("718", "Invalid InstanceID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(1, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":semanticCommits"
|
||||||
|
],
|
||||||
|
"baseBranches": ["dev"],
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"schedule": ["every weekend"],
|
||||||
|
"prHourlyLimit": 2,
|
||||||
|
"prConcurrentLimit": 8,
|
||||||
|
"ignorePaths": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/vendor/**",
|
||||||
|
"flutter_client/**"
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 5am on the first day of the month"]
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
||||||
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"automerge": false,
|
||||||
|
"dependencyDashboardApproval": true,
|
||||||
|
"addLabels": ["deps", "deps:major"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Go module updates into one PR",
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"groupName": "go modules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group CI workflow action bumps",
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"groupName": "ci actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
||||||
|
"matchManagers": ["dockerfile", "docker-compose"],
|
||||||
|
"groupName": "docker images"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group the Android Gradle/Kotlin toolchain",
|
||||||
|
"matchManagers": ["gradle", "gradle-wrapper"],
|
||||||
|
"groupName": "android gradle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group web npm non-major bumps",
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "web npm (non-major)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user