Sendspin dev server runbook
Sendspin dev server runbook
How to run a local Sendspin server that refuses the legacy dialect, so Phase 1 work is verified against a spec-compliant peer instead of Music Assistant’s compatibility shim.
Deliverable for audit item 0.2 (issue #190). See
docs/spec-compliance-audit-2026-08-13.md.
Why this exists
Music Assistant ships allow_legacy_clients=true, which maps to aiosendspin’s
allow_unencrypted / allow_noncompliant_clients. With that on, SendSpinDroid’s
current client/hello-first dialect is accepted and every Phase 1 failure is
invisible. MA documents the toggle as temporary.
This server sets both flags to False. Against it, the current app must fail to
connect - that failure is the baseline Phase 1 has to turn green.
One-time setup
Requires Python 3.12+.
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install "aiosendspin[server]==10.0.0"
10.0.0 is the first release that speaks the Sendspin 1.0.0-rc1 wire, and the
version Music Assistant pins. aiosendspin.noise.* is not a stability-guaranteed
API, so expect renames across major versions and keep the pin exact.
Running
python ci/conformance/dev_server.py --name "Sendspin Dev" --trust-all-unpaired
Expected startup output (every line is prefixed with
HH:MM:SS INFO dev_server: , elided here for width; on a first run a
“Generated a new server identity” line precedes it, and aiosendspin logs a
couple of its own lines):
========================================================================
Sendspin dev server 'Sendspin Dev' listening on 0.0.0.0:8927/sendspin
server_id: O67GqkUcDwDyoaIToq1vqI474fNR3sZp8CV1kul35W0
identity: .dev\sendspin\identity.key (do NOT delete - see runbook)
records: .dev\sendspin\pairing_store.json
allow_unencrypted=False allow_noncompliant_clients=False
auto-trusting unpaired clients on connect (--trust-all-unpaired)
========================================================================
The server_id must be identical on every restart. If it changes, the
identity file was recreated and every paired client is now broken (see below).
Console commands
The server reads commands on stdin while running:
| command | effect |
|---|---|
clients |
list connected clients with pairing and security state |
trust [id] |
make an unpaired (Sentinel-PSK) client playback-capable |
untrust [id] |
revoke that |
pair <token> |
pair using a SP:0... pairing token from the device (Phase 2) |
pair-dynamic [id] |
pair using the six-digit dynamic pairing code shown on the device |
unpair [id] |
drop the record and send server/unpair (exercises item 2.7) |
quit |
stop |
id defaults to the only connected client when omitted.
--trust-all-unpaired is not optional in practice
A Sentinel-keyed client completes the handshake but is not playback-capable
until the operator trusts it. In aiosendspin,
SendspinConnection._playback_capable requires
client_info.unpaired_access.enabled and _trusted_unpaired, and the latter comes
from pairing_store.trusted_unpaired(client_id).
Forget this and you get a clean handshake followed by a server/activate with an
empty active_roles - which reads exactly like a bug in the client’s
server/activate handling (item 1.6). It is the single easiest way to lose a day
on Phase 1. --trust-all-unpaired takes it off the table.
Dynamic pairing code procedure
This exercises the CPace-based dynamic pairing flow end to end: the tablet displays a six-digit code, the operator reads it and types it at this server’s console, and both sides derive a shared long-term PSK from a CPace exchange keyed by that code – never by transmitting the code itself.
- Start the server as above and connect the tablet to it (manual entry or discovery; see “Windows and WSL2” below if discovery does not find it).
- Confirm the tablet shows up: run
clientsat the console, or watch for theclient connected: ...log line. - On the server console, run:
pair-dynamic(or
pair-dynamic <client_id>if more than one client is connected). The console prints “initiating dynamic pairing; read the six digits off the device screen” and then blocks waiting for input – this isrun_dynamic_pairing_code_server’spairing_code_providercallback, which this script implements by reading a line from stdin. - On the tablet, choose dynamic pairing. It displays a six-digit code.
- Read that code and type it at the server console, then press Enter, at
the prompt:
Enter the pairing code shown on the device: - On success the console prints “pairing initiated” and the server logs the
client’s pairing state moving to paired. Confirm:
- The pairing record persists. Check
.dev/sendspin/pairing_store.json(or your--pairing-storepath) for a new entry after the exchange completes, and confirm it survives a server restart (quit, restart,clientsshould show the device aspaired=Trueon reconnect without repeating this procedure). - The re-handshake to the new long-term PSK succeeds. The dynamic pairing exchange itself runs over the old connection security; once it finishes, the server re-handshakes in band to the newly stored PSK without closing the WebSocket. Confirm the tablet’s connection stays healthy across that transition rather than dropping.
- The pairing record persists. Check
A wrong code makes the client send pair/abort with reason
pairing_code_mismatch and stop showing the code. The attempt is over (the
client runs a single round and does not send client/pair-retry); the
connection stays open, and pair-dynamic again starts a new attempt with a new
code.
Verifying the wire end to end
--play-test-audio SECONDS makes the server stream that many seconds of PCM to
the first client that is granted a player role. The samples are a frame
counter, so a receiver can prove it read every chunk at the right offset.
NoiseHandshakeCheck drives the app’s real handshake driver, wire codec,
builders, activation rules and binary parser against it:
python ci/conformance/dev_server.py --host 127.0.0.1 --port 18931 \
--trust-all-unpaired --no-console --debug --play-test-audio 5
cd android && ./gradlew :conformance-client:fatJar
java -cp conformance-client/build/libs/conformance-client-all.jar \
com.sendspindroid.conformance.NoiseHandshakeCheck \
ws://127.0.0.1:18931/sendspin --hold-seconds=16 --expect-audio
It passes only if audio arrived, every chunk was a whole number of PCM frames,
the chunk timestamps follow one another, and the frame counter never breaks.
The server log must contain no non-compliant client line.
--send-test-artwork makes the server send two album artwork images to the
first client with an artwork stream - one that fits a single part and one that
needs several - and then clear the channel. Add --expect-artwork to the
client: it reassembles them with the app’s ArtworkReceiver and passes only if
an image arrived in more than one part and the last one was cleared. Both sides
print each image’s SHA-256, which must match.
To exercise the in-band re-handshake as well, add
--pair-token-file <identity-file>.token to the server and
--expect-rehandshake to the client. The tool writes its pairing token to that
file; the server then starts a Pairing PSK pairing, which re-handshakes the
Sentinel-keyed connection to the pairing PSK. The tool checks that no hello was
repeated and that server/activate followed, then declines the pairing with
pair/abort.
--offer-seek SEEK_MAX_MS makes the server offer seek (up to that position)
and seek_relative to the first controller, and log a controller event: line
for every command it accepts. Add --seek to the client: once the controller
state offers both, it sends one of each, built by the app’s
MessageBuilder.buildCommand. The server log must then show a
ControllerSeekEvent and a ControllerSeekRelativeEvent.
Pairing without an operator
With --pair the tool runs the pairing instead of declining it, through the
app’s own PairingPskFlow and DynamicPairingCodeFlow. It keeps its records
in <identity-file>.records, and passes once the record is persisted, the
server has re-handshaken to the new long-term PSK and a server/activate has
followed. A second run with --expect-paired then passes only if the fresh
handshake matched that record.
ID=/tmp/noisecheck/client.key
CHECK="java -cp conformance-client/build/libs/conformance-client-all.jar \
com.sendspindroid.conformance.NoiseHandshakeCheck ws://127.0.0.1:18931/sendspin $ID"
# Pairing PSK: the server reads the token the tool writes.
python ci/conformance/dev_server.py --host 127.0.0.1 --port 18931 \
--trust-all-unpaired --no-console --debug --pair-token-file $ID.token
$CHECK --pair && $CHECK --expect-paired
# Dynamic pairing code: the server enters the code the tool writes to $ID.code.
# --pair-dynamic-wrong-codes 1 enters a wrong code first; the tool answers
# pair/abort pairing_code_mismatch, stays connected and pairs on the next
# attempt, which --expect-mismatches=1 requires.
python ci/conformance/dev_server.py --host 127.0.0.1 --port 18931 \
--trust-all-unpaired --no-console --debug \
--pair-dynamic-code-file $ID.code --pair-dynamic-wrong-codes 1
$CHECK --pair --expect-mismatches=1 && $CHECK --expect-paired
Use a fresh --state-dir and identity file per run. --pair does not drive
SendSpinProtocolHandler, which is Android-only: the pairing_index count,
the routing of messages to the selected flow and the attempt timer are covered
by PairingAttemptTest instead.
Verifying the target is configured correctly
Run the automated checks:
python ci/conformance/verify_dev_server.py
which asserts, on a throwaway state directory:
- the persistent identity is stable across restarts, and
server_idis 43 chars - an empty identity file is refused rather than silently replaced
- a corrupt identity file is refused rather than silently replaced
- a strict server closes a legacy
client/hello-first connection - a permissive server accepts that same connection
The last check is the control that gives the one before it meaning. Without it,
a server that was simply broken would also “reject” the legacy client and this
script would report success for the wrong reason. It uses dev_server’s own
build_server, so it exercises the shipped configuration rather than a copy.
Expected output (aiosendspin also logs an “Accepting unencrypted legacy connection (transition mode)” line during the control):
PASS identity is stable across restarts
PASS server_id is 43 base64url chars
server_id: Q14IWqmBv7Me1wJAXmOqW-Ge9BXKK_6g6fJZotcERwA
PASS refuses to mint over an EMPTY identity file
PASS refuses to mint over a CORRUPT identity file
PASS strict server REJECTS the legacy client/hello
PASS control: permissive server ACCEPTS the same legacy client/hello
ALL CHECKS PASSED
The server_id differs on every run: the script uses a fresh temporary state
directory, so it never reuses the one your dev server prints.
Then point the current app build (2.0.0-Beta14) at the running server. It must
discover the server and then fail to establish a session, with the server
logging the client/hello-first frame being rejected. That failure is the
expected pre-Phase-1 baseline.
Device acceptance checklist
Unit and instrumentation tests cover most of the dynamic-pairing-code implementation, but the items below only exist at the seam between the app, the OS, and a human, so they can only be verified by actually running this procedure against a real tablet and a real server. Work through this list during device acceptance and record the result of each:
- The four
activePairingMethodout-of-sequence guards inhandleServerPairInit/Auth/Confirm– confirm the client rejects or ignores a pairing message that arrives in the wrong order or for a method that is not the one currently active, rather than crashing or silently accepting it. runDynamicPairingActions’ fail-closed path when the handshake hash, store, or cipher suite is null – these are states that should be unreachable in a real run; confirm that if one is somehow hit, the client aborts the pairing rather than proceeding with missing material.- The
dynamicPairingFlowlazy-build versus reuse branch – pair once, then pair again (e.g. after a successfulverifyor a second device) and confirm the flow object is correctly rebuilt or reused rather than reusing stale state from the first attempt. - Every
DynamicPairingActionarm in the handler dispatch loop – walk through a full successful pairing and confirm each action the flow emits is actually handled (not just the happy-path subset exercised by unit tests with a fake transport). resetForRehandshake()clearing dynamic-flow fields mid-attempt – trigger a rehandshake (e.g. by forcing a reconnect) while a dynamic pairing attempt is in progress and confirm no stale field from the aborted attempt leaks into the next one.- The
COMMAND_ALLOW_PAIRINGcustom-command round trip andMainActivity.onAllowPairingClicked()– confirm the gesture-gated “Allow pairing” button actually reaches the service via the MediaSession custom command and unblocks the pending pairing attempt. - A wrong code producing
pair/abortwithpairing_code_mismatchand the UI recovering – see step 4 in the plan; confirm the app shows a clear failure state and lets the operator retry rather than getting stuck. - Five failed attempts escalating the sixth to the gesture gate, and a success de-escalating – confirm the attempt counter is per-pairing- session state that a subsequent success actually clears, not a counter that stays escalated forever once tripped.
- MediaSession IPC delivery, TalkBack announcing the code, and on-screen legibility across a room – confirm the six digits reach the UI promptly over the MediaSession IPC boundary, that TalkBack reads the code aloud usably, and that the digits are legible at a normal viewing distance (not just readable in a close-up screenshot).
Resetting state
| to reset | delete | consequence |
|---|---|---|
| all pairings | .dev/sendspin/pairing_store.json |
clients must re-pair; safe |
| the server’s identity | .dev/sendspin/identity.key |
destructive |
Deleting identity.key changes server_id. Every stored-pubkey pairing record on
every paired device then matches on psk_id but fails the server_id check, and
the spec’s failure handling for that is to close the WebSocket with no
application-level error message (connection.md#failure-handling). The symptom
is an unexplained disconnect loop with nothing useful in any log. The script
refuses to overwrite a corrupt identity file for this reason.
Windows and WSL2
Run the server on the Windows host, not inside WSL2. WSL2 sits behind a NAT, so neither mDNS advertising nor an inbound WebSocket from a phone reaches it.
If WSL2 is unavoidable:
netsh interface portproxy add v4tov4 listenport=8927 listenaddress=0.0.0.0 `
connectport=8927 connectaddress=<wsl-ip>
…and use the app’s Add Server Manually flow with an explicit host:8927,
because discovery will not work. Android’s NsdManager is also unreliable on some
OEM builds and on networks with client isolation, so keep manual entry in mind
regardless of WSL2.
Debugging the Noise prologue
The prologue is the concatenation of client/init and server/init’s exact
wire bytes - the spec requires hashing what was sent and received, not a
re-serialization. kotlinx.serialization will not round-trip byte-identically,
so a mismatch here is the most likely silent failure in item 1.2.
python ci/conformance/dev_server.py --debug
raises aiosendspin and this script to DEBUG. Be aware of what that does not
give you: aiosendspin 9.1.0 does not log the raw init bytes at any level. It
builds the prologue in aiosendspin/noise/driver.py as
client_init_text.encode() + server_init_text.encode() with no log statement.
To diff our concatenation against the server’s you have to capture the frames
yourself - a WebSocket proxy in front of the server, or a local one-line patch
adding a log call to that function. (An earlier version of this runbook claimed
--dump-wire surfaced these bytes. It did not; the flag has been renamed to
--debug to stop promising it.)
Relationship to the conformance harness
The harness (.github/workflows/conformance.yml) constructs its own server via
Sendspin/conformance’s aiosendspin_server.py adapter. Since 2026-09-04 that
server requires the Noise handshake (allow_unencrypted is off unless a
scenario asks for the legacy mode), derives its identity from the case’s ids on
every run, and approves every unpaired client that connects.
The adapter the harness launches (conformance-client’s Main.kt, through
ci/conformance/sendspindroid_client.py) therefore speaks the encrypted wire
only. It shares EncryptedSocket with NoiseHandshakeCheck: the app’s
handshake driver and wire codec, with the app’s activation rules, builders and
parsers on top. It connects unpaired on the Sentinel PSK with a fresh identity.
ci/conformance/register_sendspindroid.py copies the launcher into the harness
and appends the registry entry; it no longer patches the server adapter. The
workflow asserts on the three client-initiated scenarios:
| scenario | what the adapter does |
|---|---|
client-initiated-pcm |
hashes the PCM it received; the harness compares it with the source |
client-initiated-request-format-pcm |
starts on 24-bit PCM, then prefers 16-bit |
client-initiated-request-format-flac |
starts on PCM, then prefers FLAC |
The two renegotiation scenarios are named after stream/request-format, which
rc1 removed. The adapter asks the rc1 way, with format in the client/state
player object, and the harness only checks that a second stream/start carried
the requested format. Server-initiated scenarios stay declared unsupported: the
app only ever dials out.
To run it locally, from a directory holding clones of Sendspin/conformance,
Sendspin/aiosendspin and Sendspin/sendspin-cli (Python 3.12):
pip install -e conformance -e aiosendspin
python <repo>/ci/conformance/register_sendspindroid.py conformance
cd conformance
SENDSPINDROID_CLIENT_JAR=<repo>/android/conformance-client/build/libs/conformance-client-all.jar \
conformance run --from aiosendspin --to sendspindroid --results-dir results
The command exits non-zero because the server-initiated scenarios are reported
as failed; each case’s logs and summaries are under results/data/.