/
/
# What does this implement/fix? Follow-up to #6051. That PR stopped a Sendspin bridge from putting a second cliairplay process on a receiver that was already serving a native session, but only for the deterministic case. The two paths that start a stream for a speaker still ran without any mutual exclusion, so a native start arriving while the bridge was releasing the displaced session (or connecting its own transport) published its stream inside that window. The bridge then connected a second process over it and overwrote the publication, leaving two sessions fighting over one receiver's RTSP channel and the native process orphaned with nothing able to stop it. The speaker goes silent, same symptom as #6051. A stop had the same problem from the other side: it drops the player's stream reference straight away but only kills the process in a background task, so a play arriving in between found nothing to displace and connected on top of a process that was still there. Everything that starts or releases a stream for a speaker now takes a dedicated per-player lock, so only one thing is ever pairing with (or letting go of) a receiver at a time. **Related issue (if applicable):** - follow-up to #6051 ## Changes - Added `AirPlayPlayer.stream_spawn_lock`, taken by `AirPlayStreamSession._start_client` and by the Sendspin bridge's `_start_protocol_from_chunk` - The bridge's background cleanup holds the same lock while it actually releases the speaker, so a start can no longer sneak in behind a stop - Only the teardown that owns a speaker's stream unpublishes it, holding that same lock, so no start can find the speaker free while a process is still on it - A member's ffmpeg is wired to its speaker under the same lock, so a displacement in between cannot leave an untracked ffmpeg behind - A start now waits out a stop already running on the speaker, instead of treating a stream that has begun stopping as already gone - Because a speaker's stream now outlives its teardown, the checks that used to read "still ours" as "still healthy" ask directly whether the stream still describes the device. Without this a routine teardown would warn that a perfectly fine speaker had stopped answering, and lose an announcement into a stream that was about to be killed - The lock is always acquired inside `AirPlayPlayer._lock`, never around it - Added tests for every way the paths can overlap, each asserting only one process is ever paired with the receiver ## Types of changes - [x] Bugfix (non-breaking change which fixes an issue) — `bugfix` - [ ] New feature (non-breaking change which adds functionality) — `new-feature` - [ ] Enhancement to an existing feature — `enhancement` - [ ] New music/player/metadata/plugin provider — `new-provider` - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change` - [ ] Refactor (no behaviour change) — `refactor` - [ ] Documentation only — `documentation` - [ ] Maintenance / chore — `maintenance` - [ ] CI / workflow change — `ci` - [ ] Dependencies bump — `dependencies` ## Checklist - [x] The code change is tested and works locally. - [x] `pre-commit run --all-files` passes. - [x] `pytest` passes, and tests have been added/updated under `tests/` where applicable. - [ ] For changes to shared models, the companion PR in `music-assistant/models` is linked. - [ ] For changes affecting the UI, the companion PR in `music-assistant/frontend` is linked. - [x] I have read and complied with the project's [AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md) for any AI-assisted contributions. - [ ] I have [raised a PR against the documentation repository](https://github.com/music-assistant/music-assistant.io/blob/main/CONTRIBUTING.md) targeting the main or beta branch as appropriate.