From 1401348a7af495f52e4b135325e3995db5e35203 Mon Sep 17 00:00:00 2001 From: guko Date: Sat, 5 Sep 2026 20:27:38 +0800 Subject: [PATCH] Supervise the local snapclient so a native exit self-heals The native client can exit on its own and nothing noticed: start() returned normally, the launching coroutine completed, and snapclientProcess stayed non-null so even startLocalSnapclient refused to act. On the rig that left a broadcaster silent with a healthy server and no UI trace. The supervisor keys on the JOB, not the process. stopLocalSnapclient stops the client with destroyForcibly(), which from inside start() looks exactly like a crash, so a supervisor keyed on "the process is gone" would restart it the instant AudioFocusController paused it and play over whatever app just took the speaker. Every intentional stop cancels the coroutine before destroying the process, so isActive is already false when start() returns. Rig-verified both directions: SIGKILL to libsnapclient.so is back in ~3 s; a focus loss to Spotify leaves it stopped for as long as Spotify holds the speaker, then it returns ~6 s after Spotify pauses. The state mirror moves 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. --- .../quantumcast/player/PlaybackService.kt | 79 +++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt index d066f29..bde0a07 100755 --- a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt +++ b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt @@ -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" @@ -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)") @@ -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, @@ -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 @@ -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) } }