Skip to content

Supervise the local snapclient so a native exit self-heals - #8

Open
guerman5 wants to merge 1 commit into
mainfrom
focus-recovery-broadcaster
Open

guerman5 wants to merge 1 commit into
mainfrom
focus-recovery-broadcaster

Conversation

@guerman5

@guerman5 guerman5 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

App-side half of capullo-tech/capullo-audio#7. That PR fixes why a focus loss never recovered; this one fixes the case where the native client simply dies.

The bug

libsnapclient.so can exit on its own and nothing noticed. SnapclientProcess.start() returned normally, the launching coroutine completed, and snapclientProcess stayed non-null so even startLocalSnapclient() refused to act on its if (snapclientProcess != null) return guard. The result on the rig was a broadcaster with a healthy server, a healthy app, a stream still reaching web listeners, and no local audio and no UI trace. Recovery meant a manual --es dbg snaptest or a broadcast restart, which is why this has read as a rig quirk rather than a bug.

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 restart the client the instant AudioFocusController paused it, and QuantumCast would play over whatever app just took the speaker, which is precisely the behaviour that controller exists to prevent.

So the signal is the job, not the process. Every intentional stop cancels the coroutine before destroying the process, so isActive is already false when start() returns and the loop ends instead of restarting.

One invariant worth stating, now recorded in a comment: the supervisor leaves snapclientProcess set while it sleeps in backoff, but that cannot collide with startLocalSnapclient()'s early return. That method has exactly one caller, the focus controller's onResume, which only fires after a loss has run onPause -> stopLocalSnapclient(), and that cancels the supervisor and nulls the field first.

Verification

Rig-verified on an OPPO PFFM10 (Android 15) broadcasting with a local snapclient, both directions:

trigger expected result
kill -9 on libsnapclient.so restart back in ~3 s, exited on its own (code=137) -> restarting in 1000ms
Spotify takes focus no restart stopped as asked (code=0), stays stopped for as long as Spotify holds the speaker
Spotify pauses recover back in ~6 s via the library's fixed quiet-watcher

spotlessCheck and :app:assembleDebug green.

Pin

No pin bump in this branch. The supervisor only calls sc.start() and sc.connectionState, both of which already exist in the currently pinned library, so CI should be green as-is and this can be reviewed independently.

Merging this alone gives only half the fix: the client would self-heal after a native exit, but a focus loss to another app would still leave it stopped until the app is foregrounded. The full behaviour needs capullo-audio#7 merged, jitpack built, and a follow-up pin bump.

Also here

The connectionState mirror moves inside the supervisor 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. Nothing in the UI reads snapclientState yet, so this is plumbing rather than a visible change.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant