Skip to content

Bump sendspin-cpp to v0.9.5 (Sendspin 1.0.0-rc1) with persisted player identity - #222

Merged
bbangert merged 6 commits into
mainfrom
chore/sendspin-cpp-bump-b95cf85
Oct 21, 2026
Merged

bbangert merged 6 commits into
mainfrom
chore/sendspin-cpp-bump-b95cf85

Conversation

@bbangert

@bbangert bbangert commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Pin

Old 9331ace6428979982c934384702d289958ca125e (v0.8.0)
New b95cf854a784ed308b1f8fe9697f414b02768ddd (v0.9.5)

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.
  • Follow-ups, deliberately out of scope:

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. No priv/ binaries or build output are included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HjxHZcnPSdkBqnmQUAbJk2

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Failed keypair writes still allow startup with an identity that cannot survive respawn.

1 open issue
What changed in this PR

Updates sendspin-cpp to v0.9.5 for encrypted Sendspin connections, with persistent per-output identity replacing configured client IDs.

Changes:

  • Adds file-backed identity storage and zero-configuration admission.
  • Reports derived identities through mDNS and the audio UI.
  • Updates build settings, documentation, and identity contract tests.
File Description
test/​universal_proxy/​audio/​player_test.exs Tests state-directory naming and advertised identities.
test/​universal_proxy_web/​live/​audio_live_test.exs Tests displayed identity.
test/​support/​fake_sendspin_player.py Mirrors the updated binary contract.
test/​sendspin_player_contract_test.exs Tests identity persistence and permissions.
lib/​universal_proxy/​audio/​store.ex Documents the retained storage token.
lib/​universal_proxy/​audio/​server.ex Caches reported identity.
lib/​universal_proxy/​audio/​player.ex Passes state directories and advertises identity.
lib/​universal_proxy_web/​live/​audio_live.ex Displays reported identity.
c_src/​sendspin_player/​src/​main.cpp Integrates persistence and updated library APIs.
c_src/​sendspin_player/​src/​file_persistence.h Defines the persistence provider.
c_src/​sendspin_player/​src/​file_persistence.cpp Implements file-backed blob storage.
c_src/​sendspin_player/​README.md Documents identity, admission, and CLI changes.
c_src/​sendspin_player/​patches/​0003-listener-bound-hook.patch Refreshes the listener hook patch.
c_src/​sendspin_player/​CMakeLists.txt Updates the pin, features, and build sources.

Review effort: Balanced

Comment thread c_src/sendspin_player/src/main.cpp
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Identity-read failures can overwrite existing keys, and hotplug event ordering can leave the displayed identity unset.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Prevent overwriting unreadable existing identities

c_src/​sendspin_player/​src/​file_persistence.cpp:115

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.

Medium severity 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.

1 resolved since last review

Review effort: Balanced

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity 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.

Review effort: Balanced

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The protocol and synchronization upgrade still needs rebuilt-firmware validation of playback, multi-room sync, and restart identity on rpi3 hardware.

No open issues 🎉

Review effort: Balanced

@bbangert
bbangert merged commit 6acebe5 into main Oct 21, 2026
8 checks passed
@bbangert
bbangert deleted the chore/sendspin-cpp-bump-b95cf85 branch October 21, 2026 14:51
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.

3 participants