You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Bump sendspin-cpp to v0.9.5 (Sendspin 1.0.0-rc1) with persisted player identity - #222
Supersedes the draft #216 (v0.9.3), which stalled on the client_id question this PR resolves.
Why
Upstream implemented the Sendspin 1.0.0-rc1 protocol (#140): connections are now Noise-encrypted, and the receive path and connection lifecycle were reworked around a library-owned protocol task. v0.9.4 adds the player sync and decode fixes#216 had to leave out (#193, #194, #195, #197, #198). v0.9.5 adds #203.
It's also time-bound. Our v0.8.0 player only connects to Music Assistant as a legacy unencrypted client, through MA's hidden allow_legacy_clients toggle. MA's UI text says that toggle "is temporary and will be removed in a future release". It's still there in MA 2.10.7 and on dev, but without this bump the outputs stop connecting when it goes.
What changed
Identity. Upstream removed SendspinClientConfig::client_id. It's now base64url(X25519 public key), from a keypair the library generates on first start and stores through a SendspinPersistenceProvider. Without a provider, every respawn would get a new identity.
src/file_persistence.{h,cpp}: a small file-backed provider. Each library blob is one file under --state-dir. The directory is 0700 and the files 0600, since one holds the private key. Writes go through temp file + fsync + rename + dir fsync. Keys are validated before they become paths.
Fails closed on storage problems. A state dir that can't be created makes the process exit before the client starts. The library only logs a failed save of a freshly generated keypair (full or read-only /data) and starts anyway, so the binary checks for that after start() and exits with a state_dir error event before listening.
The library's output_delay blob is deliberately not stored: Elixir already owns that value (static_delay event → --initial-static-delay-ms), and a stored blob would take priority over the CLI flag.
--client-id is replaced by a required --state-dir. Audio.Player names each output's directory after the Store's existing per-output client_id, so no DETS migration is needed. That field is now only a storage token, documented in Audio.Store. A value that isn't a safe file name is hashed so it can't escape the base dir.
The binary reports the derived id on listening ({"event":"listening","port":…,"client_id":…}).
Audio.Player advertises that id in the mDNS TXT record, and leaves the entry out if the event has none. aiosendspin's discovery only reads path from TXT, so this is informational only.
Audio.Server caches the id, and the Audio page's "Client ID" disclosure shows it instead of the Store token.
Admission.set_unpaired_access_enabled(true) is called before start(). MA 2.10.7's _auto_trust_guest_access approves a player-only client that offers unpaired access with no setup step, which keeps outputs zero-config. With it off (the library default), MA would wait for a pairing the player has no UI for yet.
Build.
Upstream's new source (capture) role defaults ON, so it's forced OFF to keep the build player-only.
SENDSPIN_ENABLE_OPUS is pinned ON, because we advertise Opus and upstream made the decoder optional.
The static_delay → output_delay library API renames are applied. Our own CLI flag and the static_delay event name are unchanged, since they're the contract with the Elixir side.
⚠️ Upgrade impact (for release notes)
Each output gets a new Sendspin identity once. That can't be avoided: the old ids were 32-hex strings, which never matched the 43-char key-derived form the spec requires. In MA 2.10.7, the old player stays as an unavailable entry with its settings, and the new id appears as a fresh player with no settings or group membership. MA's built-in carry-over (_take_over_mac_player) only applies when the old id was the device's MAC, so it doesn't apply to us. Users will need to delete the unavailable entries and redo groups and settings once.
Patch 0003
Regenerated. Hunk 1 no longer applied because upstream reshuffled the include block. Hunk 2 (the hook after server_->start()) applied at an offset. patch -p1 --dry-run exits 0 against v0.9.5, both asserted markers are kept verbatim, and upstream hasn't absorbed the hook.
Verification
CI: all checks green, including the Elixir suite, Dialyzer and mix format.
Host build: a from-scratch configure and build pass on the merged tree (cmake 3.28, g++ 13.3). Configure passing also confirms assert_patch_markers. No warnings from our code under -Wall -Wextra.
Persistence, against the real binary:
client_id is 43 chars and decodes to 32 bytes. It's stable across restarts on the same dir and different across dirs.
The dir is 0700, keypair is 0600 and 32 bytes, and no output_delay file is written.
A missing or unusable --state-dir exits 1 with a clear error.
A failed keypair write after a successful dir init: reproduced on the earlier build, which emitted listening with an identity it never stored. It now emits a state_dir error and exits 1 before listening.
Interop with aiosendspin 10.2.0 (the version MA 2.10.7 pins), with a harness that follows MA's provider flow: the handshake completes encrypted on the Sentinel PSK, the server sees the same client_id as listening, unpaired_access.enabled is in the hello, and player@v1 is negotiated. No role is active before approval, and player@v1 is active after trust_unpaired. Audio streaming wasn't exercised.
Tests added or updated:
contract test (real binary): client_id shape, state-dir permissions, identity stable across restart, exit without --state-dir, exit on a failed keypair write
player_test: TXT uses the reported id, TXT entry left out when there's none, the retry path keeps the id, --state-dir naming and hashing
audio_live_test: the disclosure shows the reported id and never the Store token
Notes
The first commit's message (beff863) says the bump "does not compile yet". That was true when it was written and is fixed by the next commit, so a squash merge gives a cleaner history.
Rebuild firmware (mise run firmware -- <target>) and validate on rpi3 hardware against a real MA 2.10.7: discovery, auto-approval, playback, multi-room sync, and respawn keeping the same player. The protocol change and the sync rework are what host builds don't cover. No priv/ binaries or build output are included.
Upstream's 1.0.0-rc1 protocol implementation and the connection/receive
rework that followed it land a wire-format change on an Experimental
protocol, alongside player-visible sync and Opus decode fixes that tagged
in v0.9.4 and v0.9.5. Patch 0003 is regenerated against the drifted
include block in ws_server.cpp, and the new upstream source role is
forced off to keep the build player-only.
Does not compile yet: upstream removed SendspinClientConfig::client_id in
favour of an identity derived from a persisted X25519 keypair, which needs
a persistence provider here and a matching change to how Elixir advertises
the id over mDNS. That decision is tracked on PR #216 and is the only
remaining compile error.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxHZcnPSdkBqnmQUAbJk2
sendspin-cpp v0.9 derives client_id from an X25519 keypair it stores
through a persistence provider, so the player now gets a per-output
--state-dir backed by a small file provider (0700 dir, 0600 files,
atomic writes) and reports the derived id on `listening`, which
Audio.Player advertises and the Audio page shows instead of the Store's
token. Unpaired access is turned on so Music Assistant approves each
output without a pairing step, matching today's zero-setup behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxHZcnPSdkBqnmQUAbJk2
sendspin-cpp only logs a failed save of a freshly generated keypair and
starts anyway, so on a full or read-only /data the player would advertise
a client_id the next launch replaces. The provider now records that
failure and the binary exits with a state_dir error before `listening`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxHZcnPSdkBqnmQUAbJk2
An existing keypair can be unreadable while its directory remains writable, such as a mode-000 file on a non-root host. Returning nullopt makes the library generate a new identity, and the temp-file rename can overwrite the original key successfully. The new save-failure check therefore does not catch this identity loss. Treat non-ENOENT identity-load errors, including read() failures below, as fatal: prevent subsequent keypair writes and report a startup error before listening. Add a regression test that also verifies the original key remains unchanged.
Republish player identity after hotplug output notification
lib/universal_proxy/audio/server.ex:468
On hotplug, refresh_outputs/1 starts players before broadcasting output_added. A Player can publish its one-shot listening event first; LiveView drops it because the card does not exist yet. The later add contains no derived identity, and this handler only updates the server cache, so the open page can show no Client ID until reload. Republish the identity from Audio.Server after caching it: this occurs after its output_added broadcast and gives both notifications the same sender. Exclude the server itself from that broadcast to avoid feedback. Add a regression test for listening-first delivery.
Refuse to overwrite a keypair file that exists but cannot be read, so the
player exits instead of minting a new identity. Re-publish the cached
Sendspin client_id from Audio.Server so a card added after `listening`
still shows it, and correct the README on the always-offered pairing_psk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
The protocol upgrade still needs real-device playback and synchronization validation, and a hotplug identity race remains unresolved.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Late listening event repopulates removed output cache
lib/universal_proxy/audio/server.ex:478
A player can broadcast listening while Audio.Server is processing hotplug removal. When the queued event is handled after removal, this unconditional update recreates the removed output's cached ID. On replug, that same persisted ID suppresses republication; if the new player's listening arrives before output_added, LiveView drops it and the new card shows no Client ID until reload. Only update the cache when the output still exists, just as for broadcasting. Add a regression test for removal → late listening → re-add.
A `listening` still queued when its output was hot-unplugged re-created the
cache entry the removal dropped; on replug the same id then looked unchanged,
so it was not re-published and a card added after the player's own event
showed no Client ID. The clause now only matches known outputs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxHZcnPSdkBqnmQUAbJk2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pin
9331ace6428979982c934384702d289958ca125e(v0.8.0)b95cf854a784ed308b1f8fe9697f414b02768ddd(v0.9.5)Supersedes the draft #216 (v0.9.3), which stalled on the
client_idquestion this PR resolves.Why
Upstream implemented the Sendspin 1.0.0-rc1 protocol (#140): connections are now Noise-encrypted, and the receive path and connection lifecycle were reworked around a library-owned protocol task. v0.9.4 adds the player sync and decode fixes #216 had to leave out (#193, #194, #195, #197, #198). v0.9.5 adds #203.
It's also time-bound. Our v0.8.0 player only connects to Music Assistant as a legacy unencrypted client, through MA's hidden
allow_legacy_clientstoggle. MA's UI text says that toggle "is temporary and will be removed in a future release". It's still there in MA 2.10.7 and ondev, but without this bump the outputs stop connecting when it goes.What changed
Identity. Upstream removed
SendspinClientConfig::client_id. It's nowbase64url(X25519 public key), from a keypair the library generates on first start and stores through aSendspinPersistenceProvider. Without a provider, every respawn would get a new identity.src/file_persistence.{h,cpp}: a small file-backed provider. Each library blob is one file under--state-dir. The directory is0700and the files0600, since one holds the private key. Writes go through temp file + fsync + rename + dir fsync. Keys are validated before they become paths./data) and starts anyway, so the binary checks for that afterstart()and exits with astate_direrror event beforelistening.output_delayblob is deliberately not stored: Elixir already owns that value (static_delayevent →--initial-static-delay-ms), and a stored blob would take priority over the CLI flag.--client-idis replaced by a required--state-dir.Audio.Playernames each output's directory after the Store's existing per-outputclient_id, so no DETS migration is needed. That field is now only a storage token, documented inAudio.Store. A value that isn't a safe file name is hashed so it can't escape the base dir.listening({"event":"listening","port":…,"client_id":…}).Audio.Playeradvertises that id in the mDNS TXT record, and leaves the entry out if the event has none. aiosendspin's discovery only readspathfrom TXT, so this is informational only.Admission.
set_unpaired_access_enabled(true)is called beforestart(). MA 2.10.7's_auto_trust_guest_accessapproves a player-only client that offers unpaired access with no setup step, which keeps outputs zero-config. With it off (the library default), MA would wait for a pairing the player has no UI for yet.Build.
ON, so it's forcedOFFto keep the build player-only.SENDSPIN_ENABLE_OPUSis pinnedON, because we advertise Opus and upstream made the decoder optional.static_delay→output_delaylibrary API renames are applied. Our own CLI flag and thestatic_delayevent name are unchanged, since they're the contract with the Elixir side.Each output gets a new Sendspin identity once. That can't be avoided: the old ids were 32-hex strings, which never matched the 43-char key-derived form the spec requires. In MA 2.10.7, the old player stays as an unavailable entry with its settings, and the new id appears as a fresh player with no settings or group membership. MA's built-in carry-over (
_take_over_mac_player) only applies when the old id was the device's MAC, so it doesn't apply to us. Users will need to delete the unavailable entries and redo groups and settings once.Patch 0003
Regenerated. Hunk 1 no longer applied because upstream reshuffled the include block. Hunk 2 (the hook after
server_->start()) applied at an offset.patch -p1 --dry-runexits 0 against v0.9.5, both asserted markers are kept verbatim, and upstream hasn't absorbed the hook.Verification
mix format.assert_patch_markers. No warnings from our code under-Wall -Wextra.client_idis 43 chars and decodes to 32 bytes. It's stable across restarts on the same dir and different across dirs.0700,keypairis0600and 32 bytes, and nooutput_delayfile is written.--state-direxits 1 with a clear error.listeningwith an identity it never stored. It now emits astate_direrror and exits 1 beforelistening.client_idaslistening,unpaired_access.enabledis in the hello, andplayer@v1is negotiated. No role is active before approval, andplayer@v1is active aftertrust_unpaired. Audio streaming wasn't exercised.client_idshape, state-dir permissions, identity stable across restart, exit without--state-dir, exit on a failed keypair writeplayer_test: TXT uses the reported id, TXT entry left out when there's none, the retry path keeps the id,--state-dirnaming and hashingaudio_live_test: the disclosure shows the reported id and never the Store tokenNotes
beff863) says the bump "does not compile yet". That was true when it was written and is fixed by the next commit, so a squash merge gives a cleaner history.on_display_pairing_code,confirm_pairing_window)./data.mainhas CI: run Argus on ubuntu-24.04 instead of the deprecated 22.04 image #208 (CLOCK_MONOTONIC_RAWhost clock) past v0.9.5, untagged.Before release
Rebuild firmware (
mise run firmware -- <target>) and validate on rpi3 hardware against a real MA 2.10.7: discovery, auto-approval, playback, multi-room sync, and respawn keeping the same player. The protocol change and the sync rework are what host builds don't cover. Nopriv/binaries or build output are included.🤖 Generated with Claude Code
https://claude.ai/code/session_01HjxHZcnPSdkBqnmQUAbJk2