Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,12 @@ class PlaybackService : Service() {
private const val NOTIFICATION_ID = 1
private const val TAG = "QCPlaybackService"

/** Backoff bounds for restarting a local snapclient that exited on its own. Starts short
* so a one-off native fault is invisible, and caps so a genuinely broken device (missing
* server, unroutable host) retries forever without spinning. */
private const val SNAPCLIENT_RETRY_MIN_MS = 1_000L
private const val SNAPCLIENT_RETRY_MAX_MS = 30_000L

/** Calibration logs go under the LIBRARY's tag, not this service's, so one
* `logcat -s SyncCalibrator` shows a run and its diagnostics together. */
private const val TAG_CAL = "SyncCalibrator"
Expand Down Expand Up @@ -1166,14 +1172,12 @@ class PlaybackService : Service() {
snapcastStreamArtUrl = "",
)
}
scope.launch { sc.connectionState.collect { s -> _state.update { it.copy(snapclientState = s) } } }
snapclientJob = scope.launch {
sc.start(
snapserverAddress = host,
snapserverPort = port,
audioChannel = _state.value.snapclientChannel,
)
}
snapclientJob = superviseSnapclient(
sc,
host,
port,
channel = _state.value.snapclientChannel,
)
startSnapcastControl(host, httpPort)
audioFocus.request()
Log.d(TAG, "Snapclient → $host:$port (control :$httpPort)")
Expand Down Expand Up @@ -1977,7 +1981,7 @@ class PlaybackService : Service() {
_state.update { it.copy(broadcastHttpPort = ports.httpPort, isBroadcasting = true) }
val sc = SnapclientProcess(this).also { snapclientProcess = it }
snapserverJob = scope.launch { snapserver.start() }
snapclientJob = scope.launch { sc.start(snapserverAddress, ports.streamPort) }
snapclientJob = superviseSnapclient(sc, snapserverAddress, ports.streamPort)

snapcontrolPlugin = SnapcontrolPlugin(
state = snapNowPlaying,
Expand Down Expand Up @@ -2260,6 +2264,12 @@ class PlaybackService : Service() {
}

// Restart after focus regain, with the same target/channel the session used.
//
// The early return cannot collide with a supervisor that is mid-backoff after an unexpected
// exit, even though that supervisor leaves [snapclientProcess] set while it sleeps. This has
// exactly one caller, the focus controller's onResume, which only fires once a loss has run
// onPause -> [stopLocalSnapclient]; that cancels the supervisor and nulls the field first. So
// whenever this runs there is no supervisor left to race.
private fun startLocalSnapclient() {
if (snapclientProcess != null) return
val st = _state.value
Expand All @@ -2271,11 +2281,52 @@ class PlaybackService : Service() {
"localhost" to sp.ports.streamPort
}
val sc = SnapclientProcess(this).also { snapclientProcess = it }
if (st.broadcastMode == BroadcastMode.SNAPCLIENT) {
scope.launch { sc.connectionState.collect { s -> _state.update { it.copy(snapclientState = s) } } }
}
snapclientJob = scope.launch {
sc.start(host, port, audioChannel = st.snapclientChannel)
snapclientJob = superviseSnapclient(
sc,
host,
port,
channel = st.snapclientChannel,
)
}

/**
* Launch the local snapclient and keep it running until somebody actually asks it to stop.
*
* The native client can exit on its own, and nothing used to notice: [SnapclientProcess.start]
* returned normally, the launching coroutine completed, and [snapclientProcess] stayed non-null
* so even [startLocalSnapclient] refused to act. On the rig (2026-09-05) that left a
* broadcaster silent for 1h50m with a healthy server, a healthy app and no UI trace.
*
* THE TRAP THIS AVOIDS. [stopLocalSnapclient] stops the client with `destroyForcibly()`, which
* from inside `start()` looks exactly like a crash. A supervisor keyed on "the process is gone"
* would therefore restart the client the instant [AudioFocusController] paused it and play over
* whatever app just took the speaker, precisely the behaviour that controller exists to
* prevent. So the signal is the JOB, not the process: every intentional stop cancels this
* coroutine BEFORE destroying the process, so `isActive` is already false when `start()`
* returns and the loop ends instead of restarting.
*
* The state mirror lives inside the job so it dies with it. In snapclient mode it used to be
* launched separately on every focus regain, one live collector per regain; the broadcaster
* had none at all, so it could not show its own client's health.
*/
private fun superviseSnapclient(
sc: SnapclientProcess,
host: String,
port: Int,
channel: String? = null,
): Job = scope.launch {
launch { sc.connectionState.collect { s -> _state.update { it.copy(snapclientState = s) } } }
var backoffMs = SNAPCLIENT_RETRY_MIN_MS
while (isActive) {
if (channel != null) {
sc.start(host, port, audioChannel = channel)
} else {
sc.start(host, port)
}
if (!isActive) break
Log.w(TAG, "local snapclient exited on its own - restarting in ${backoffMs}ms")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(SNAPCLIENT_RETRY_MAX_MS)
}
}

Expand Down
Loading