sendspin-cpp-cli is being built as an epic: this scaffold brings up the build and
boots a real sendspin-cpp client, and each
item below is a follow-up task that fills in one part of the player.
The goal is a headless endpoint that advertises itself, is discovered, and is driven remotely, with a small flag set for identity, output, discovery, logging, and daemonization.
- Builds
sendspin-cli, pullingsendspin-cppvia CMakeFetchContent(pinned tag). - Boots a
SendspinClientwith theplayer,metadataandcontrollerroles, starts its WebSocket server, pumpsclient.loop(), and shuts down cleanly onSIGINT/SIGTERM. - Speaks both of the protocol's connection modes, and keeps them exclusive as the spec
requires: it advertises
_sendspin._tcpover mDNS by default, and any-sinstead makes it dial out — to an address, or to a server discovered on_sendspin-server._tcp— retrying with a backoff until it answers (item 5). - Defines the
AudioSinkseam (src/audio_sink.h) and plays real audio through it: auto-detected ALSA (item 2), PortAudio (item 3), PulseAudio (item 18) and PipeWire (item 19) backends, with the device-less null/stdout sink as the fallback, so the binary still runs where there is no sound card. - Parses the command-line flag surface:
-o -l -n -s -z -P -d -f --port --buffer-ms --no-mdns --mdns-name --help --version, validating every value at parse time and refusing to start on a bad one (item 1). - Runs as a real daemon:
-zforks and detaches,-Pholds a locked pidfile that refuses a second instance and needs no stale cleanup, and every log line carries a level letter and a subsystem tag — timestamped andSIGHUP-reopenable under-f(item 6). - Advertises the formats the selected output device will actually take, derived by probing it and crossed with what each codec can carry -- in priority order, so a server picking automatically lands on FLAC 2 ch 16-bit @ 48 kHz (item 4).
- Can be driven from its own host: a
0600Unix control socket in a user-private directory, polled from the main loop, andsendspin-cli <subcommand>on the same binary covering the whole ofcontroller@v1—status,play/pause/stop/next/prev,vol,mute,seek,seek-rel,repeat,shuffle,switch(item 7). - Runs stream hooks:
--hook-start/--hook-stopshell commands on stream start and stop, with the event's facts in the sameSENDSPIN_*environment variables the Python CLI exports, spawned and reaped without ever blocking the audio path (item 22). - Reads a config file whose keys are the long flag names, layered under the command line, and
remembers its own state across restarts — the last server, the static delay a server set, and
its volume and mute — in a separate file it writes atomically at
0600(item 8). - Installs:
cmake --install --component sendspin-clilays down the binary, an annotated example config, and on Linux a systemd system unit that pairsRuntimeDirectory=andStateDirectory=with the flags they exist for — and CI publishes that same payload, staged withDESTDIR, instead of a hand-rolled tar (item 10).
The flag surface had the flags but not the depth behind them: nothing was validated at
parse time, the parser could not be called twice in one process, and -l named devices
without saying what they would play.
Shipped in src/cli.{h,cpp}, src/audio_sink.{h,cpp}, src/alsa_sink.cpp and
tests/:
- Parse-time validation that hard-fails. Every value is checked before the daemon
starts, and a bad one exits 1 with a single
error:line naming it.-sis the reason: it used to warn about a malformed port and dial 8927 anyway, and a player quietly talking to the wrong endpoint is harder to diagnose than one that refuses to start.parse_server_url()rejects an empty host, a non-numeric or out-of-range port, an unbracketed IPv6 literal, an unterminated[, and any scheme other thanws:///wss://;-o,-n,-Pand-freject empty values rather than silently defaulting. - A resolved server URL on
Options, so nothing downstream re-parses a value that has already been accepted. -o <backend>:<device>specs, split on the first colon because ALSA device names carry their own —alsa:hw:2,0is the alsa backend playinghw:2,0. Bare PCM names (hw:2,0,default) keep working unprefixed, andnull/stdout/-stay reserved. Backend names this build lacks are reserved on purpose, so-o portaudio:2says which backends exist instead of being handed to ALSA as a PCM name.- Per-card capability detail in
-l: onesnd_pcm_open()per PCM, then in-memorysnd_pcm_hw_params_test_*()probes for rates, channel counts, and the four formats the sink can emit (S8,S16_LE,S24_3LE,S32_LE). A busy or unopenable device gets a note and enumeration continues. Measured at ~40 ms for 15 PCMs on a PipeWire host, so the detail is always on. - A testable parser: getopt's process-global scan state is reset on entry, and every
diagnostic goes to a caller-supplied
std::FILE*instead of barestderr. - The test harness itself — GoogleTest via
FetchContent(pinned tag), wired to CTest, withsrc/split into asendspin-cli-corelibrary the tests and the binary both link. 35 tests covering the flag surface, the-smatrix,-ospec resolution, and a parse-twice-in-one-process case. - A config-file precedence hook:
Options::was_given()distinguishes "the user typed this" from "this is the default".
Not in this slice. Each one belongs to an item that already owns that area:
- The config file itself — format, path search, reading → item 8. This task built only the hook a precedence layer needs, deliberately stopping short of choosing a format.
- Per-category log levels —
-d slimproto=infostill parses the category, ignores it, and says so → item 6, which owns logging, and which closed the question rather than answering it: the library has no log sink hook, so per-line tags plusgrepare the filtering that works. - A CI workflow → item 12. This task leaves a harness for it to call; wiring it into a build matrix is that item's job.
The default Linux and Docker backend. In a container this needs only
--device /dev/snd — no sound-server socket to forward.
Shipped in src/alsa_sink.{h,cpp}, built when find_package(ALSA) succeeds
(-DSENDSPIN_CLI_WITH_ALSA=OFF forces it out):
snd_pcmplayback overSND_PCM_ACCESS_RW_INTERLEAVED, format negotiated from the stream's bit depth (S16_LE/S24_3LE/S32_LE, plusS8) at an exact rate, with a close/reopen on a mid-stream format change.snd_pcm_delay()-based playout timing fed back throughon_frames_played, as the microsecond timestamp at which the frames just written finish playing.- Underrun (
-EPIPE) recovery viasnd_pcm_prepare(), and suspend (-ESTRPIPE) viasnd_pcm_resume()with aprepare()fallback. Anything either of those cannot clear is device loss, handled throughSinkRecovery— see item 14. - Device enumeration for
-lthroughsnd_device_name_hint(), and-o <any PCM name>, withnull/stdout/-still reserved.-odefaults todefaultwherever the backend is compiled in. - Software volume: Q32 fixed-point sample scaling. The taper started as upstream's quadratic
one and is now the spec's
(volume/100)^1.5(item 7). Item 3 moved it tosrc/pcm_volume.{h,cpp}so the PortAudio backend shares one copy rather than carrying a second. - libasound's own stderr diagnostics routed through the CLI logger at
debug.
Not in this slice. Three things were deliberately left out; each is now tracked on the item that owns it, rather than as a loose follow-up here:
- The ALSA hardware mixer (
snd_mixer_*) → item 15. Software scaling was chosen first because the usualdefaultdevice here is PipeWire's ALSA plugin, where a hardware mixer element either does not exist or moves something other than this stream. A hardware path is worth having forhw:output, where it is the real volume control. - Buffer/period tuning → item 4, where it shipped as
--buffer-ms. The ring was a fixed 100 ms with 20 ms periods; it is now that by default and settable from one flag. - Richer per-card enumeration → item 1, where it shipped.
-lnow prints each PCM's rates, formats, and channel counts alongside its hint.
The cross-platform backend (macOS and Linux), and the only way this player makes noise on
macOS, where there is no ALSA at all. Upstream's examples/common/portaudio_sink.cpp was
the reference, but it is not an AudioSink, has no device selection, and logs with bare
fprintf(stderr) — so it was ported into src/ rather than compiled out of the
FetchContent tree, whose examples/ path is not a stable interface.
Shipped in src/portaudio_sink.{h,cpp}, built when pkg_check_modules(portaudio-2.0)
succeeds (-DSENDSPIN_CLI_WITH_PORTAUDIO=OFF forces it out):
- A lock-free SPSC ring buffer bridging
write()'s push model to PortAudio's pull callback, lifted from upstream so both implementations buffer alike — but sized by time (100 ms, matching ALSA's ring) rather than upstream's fixed 16 KB, which is 85 ms at 48 kHz/16-bit and only 10 ms at 192 kHz/32-bit. Floored at 3x theoutputLatencyPortAudio reports for the open stream — the callback asks for a whole device buffer at a time, so a smaller ring would starve on every wakeup — and at 1024 frames for a device that reports no latency at all. - Callback DAC-time sync feedback through
on_frames_played:outputBufferDacTime's distance fromcurrentTimeis an offset into the future, so it carries into our clock unchanged, plus the buffer's own duration for when the last frame leaves the DAC. -o portaudio[:<device>], the first backend whose device is optional: bare means this host's default output,portaudio:2an index as-lprints it, andportaudio:<name>a full case-insensitive name match. A name matching more than one device is refused naming the candidates, since two host APIs can offer the same card under one name. The device is re-resolved at every stream, so a bare-o portaudiofollows the host's default as the user changes it.-lgrows a PortAudio section: index, name, host API, output channels, default rate, with the system default marked and input-only devices left out.-odefaults toportaudiowherever PortAudio is the only backend, so a bare run plays on macOS.- 8/16/24/32-bit (
paInt8/paInt16/paInt24/paInt32). 8-bit is deliberately beyond upstream's reference, which refuses it:AlsaAudioSinktakesS8, and a stream one backend plays and the other does not would be a difference with no cause. Item 4 made these reachable: the advertised list is derived from the device rather than pinned to 2 ch / 16-bit, so a server can now ask for the other depths. - Software volume shared with the ALSA backend, extracted to
src/pcm_volume.{h,cpp}rather than copied — ~50 lines of Q32 fixed-point including 24-bit sign extension, now with a no-device test suite of its own.
Four divergences from upstream worth knowing, the first three forced by the AudioSink
contract:
configure()does not callstop(). Upstream's does, then clears its own abort latch. Ours cannot:stop()is contractually "released, during shutdown" and its latch must never be cleared, or a shutdown racing a finalon_stream_start()gets un-latched. Split into privateopen_stream_()/close_stream_()with the publicstop()on top, asalsa_sink.cppdoes, so a mid-stream format change cannot poison writes.configure()refuses oncestop()has latched. The latch is still write-once and nothing clears it;configure()only reads it. Without that, a stream start racing shutdown reopens a devicewrite()can never feed and the destructor then has to tear down.- Everything the audio callback reads is mutated only while the callback provably cannot
run — before
Pa_StartStream(), or afterPa_AbortStream()/Pa_CloseStream()has returned. That is what makes the unsynchronised reads ofbytes_per_frame_,bits_, the stream's real rate and the ring's own storage legal; upstream writes those fromconfigure()while its callback may be running, which is a data race. The invariant is written down in the header because it is invisible from the code. write()carries a stream generation. It blocks on a condition variable rather than holding the mutex throughout, which is howstop()stays prompt — but waiting releases the mutex, so aconfigure()orclear()can complete while a write is parked. Without a generation check the loop would resume and feed the rest of that buffer to the new stream, using the old frame size, and report it as consumed — which is exactly what the sync task builds its playtime estimate from.AlsaAudioSinkneeds no equivalent: it holds its mutex acrosssnd_pcm_wait(), so the window does not exist there.
Not in this slice:
- Buffer and latency tuning → item 4, where the ring became
--buffer-ms. PortAudio'ssuggestedLatencystays pinned to the device'sdefaultHighOutputLatency. - Hardware volume → item 15. Both backends scale in software today.
- A CI matrix and a sink contract suite → item 12. This task added parser-level tests
for the new
-oforms and apcm_volumesuite, neither of which opens a device; a suite that exercisesAudioSinkimplementations themselves is still that item's job.
What has and has not been exercised. The sink was driven directly on macOS — sine tone
through Core Audio at 48 kHz/16-bit, 44.1 kHz/32-bit and 44.1 kHz/24-bit, with exact
on_frames_played accounting, a plausible DAC offset against the reported device latency,
volume and mute, the same-format restart, a mid-stream format change, recovery from a refused
format, and the shutdown latch. It has not yet been driven by a real Sendspin server, so
the sync loop converging over a live stream, controller volume, and pause/resume/next-track
are unproven in the field. Item 12's matrix now builds the Linux dual-backend configuration
on every push and asserts from the configure output that it really is null, stdout, alsa, portaudio — but building it is all that proves. Neither -l section nor a note out of
AlsaAudioSink has been exercised on Linux: this was written and heard on macOS.
One known rough edge, and only on Linux: AlsaAudioSink routes libasound's own stderr
diagnostics through the CLI logger, but it installs that handler from its own probe()
and list_devices(). So -o portaudio:... on a dual-backend host lets PortAudio's ALSA
host API spray raw ALSA lib pcm.c:... lines onto stderr during device enumeration.
Deliberately not fixed here: coupling the two sinks together just to suppress a diagnostic
is a poor trade, and Linux hosts should be using -o ALSA directly anyway. (-l is
already clean on such a host, since the ALSA section runs — and installs the handler —
before the PortAudio one.)
Both backends sized their buffer by a fixed constant, and supported_formats() advertised
2 ch / 16-bit whatever the device could do — so the 8/24/32-bit paths items 2 and 3 built
were unreachable by construction.
Shipped in src/cli.{h,cpp}, src/audio_sink.{h,cpp}, both sinks,
src/supported_formats.{h,cpp} (new), src/player_listener.{h,cpp} and src/main.cpp:
--buffer-ms <ms>, 10–2000, one figure for both backends. ALSA divides it into five periods (so the default is the 100 ms ring / 20 ms period it has always been); PortAudio makes it the ring, where the 3×outputLatencyand 1024-frame floors still win and say atdebugwhich of the two did. Validated at parse time and hard-failing, per item 1. Deliberately not using an ALSA-specific<b>:<p>:<f>:<m>buffer grammar: it is ALSA-only, and two of its four subfields are already fixed here — the format is negotiated from the stream and the access mode is pinned to interleaved. One flag with two grammars per backend is the failuresrc/audio_sink.halready refuses for-o, so-ais left unclaimed and the flag is long-only, as--portis.- A lower ALSA start threshold: one period rather than a full ring.
snd_pcm_delay()only counts frames queued ahead of a running stream, so starting on a full ring meant the first ring's worth of timestamps described a stream that had not begun — the "known rough edge" item 2 recorded. It is a timing-correctness fix rather than a knob, and it rides here because it shares thesw_paramspath with the buffer work. AudioSink::capabilities(), aSinkCapabilitiesof rates, depths and channel counts with a permissive default — everything this player can emit — soNullAudioSinkneeds no override.AlsaAudioSinkanswers it from the same prober that backs-l's per-PCM detail, split out ofprint_device_capabilities()so the two cannot drift.PortAudioSinkanswers viaPa_IsFormatSupported()and grows the same three lines under each device in-l. A busy, absent or unopenable device yields the permissive set, never an empty one — an empty advertisement leaves the player unable to play at all.- A derived advertisement, crossed per codec rather than as one cross product:
OPUS only at 48 kHz / 16-bit (the decoder writes
int16_t), FLAC and PCM at every depth and rate the device takes. The crossing is a pure function insrc/supported_formats.cppwith its own no-device test suite, and the result is logged at startup — a digest atinfo, every entry atdebug. - In priority order, not probe order. The protocol has
supported_formatsfirst-preferred and servers read it that way -- Music Assistant'saiosendspintakes the first entry it can encode and never looks further. Emitting the ladders in the ascending order they are probed in therefore nominated the worst format the device would take: FLAC at 22050 Hz on ALSAdefault, whose plugin accepts the whole ladder.supported_formats()ranks instead, on three named ladders -- codecFLAC, OPUS, PCM; rate48000, 44100, 96000, 88200, 192000, 176400, 32000, 22050; depth16, 24, 32, 8-- so a permissive device leads with FLAC 2 ch 16-bit @ 48 kHz, and one that takes neither 48 nor 44.1 kHz falls to its best remaining rate rather than its lowest. A permutation and never a filter: the same list is the menu a user picks a format from by hand, so ranking it must not take entries away. - Loud refusals, which is why the capability work and this ship together: widening the
advertisement multiplies the routes into a player that looks healthy and plays nothing.
A refused
configure()now names the format and the device and says the audio is being discarded, and a stream that passes entirely discarded says so again at its end rather than leaving one line and silence.
Two staleness limits the derived list carries, both commented at the point of probing rather than only here:
- PortAudio re-resolves its device at every stream, so a bare
-o portaudiofollows the host's default output as the user changes it — while the advertisement describes whichever device was default at startup. The loud-refusal path above is what makes a later mismatch diagnosable. - ALSA's
defaultis usually PipeWire's plugin, so the probe reports what the plugin accepts, not what the card does. That is still the honest answer to "what can I push through this-ovalue", which is the question an advertisement answers — but it is not a description of the hardware.
Not in this slice. Each was split out of the original item 4 into an item of its own, carrying the constraints found while scoping this one:
PlayerRoleConfigwiring → item 13.- PortAudio in-place device recovery → item 14.
- The ALSA hardware mixer (
-V) → item 15. - Mid-stream format changes, which the original item 4 still asked for, were already
done:
AlsaAudioSink::configure()reuses the device on a same-format restart and closes/reopens otherwise, andPortAudioSinkdoes the same behind its stream-generation guard. They shipped in items 2 and 3.
The player only listened, and said so: src/main.cpp logged "This build does not
advertise over mDNS yet", and -s fired exactly one connect_to() that was never
retried. Both halves of the protocol's connection model land here.
Shipped in src/mdns.h, src/mdns_common.cpp, src/mdns_dnssd.cpp,
src/mdns_null.cpp, src/outbound.{h,cpp}, src/last_server.{h,cpp},
src/cli.{h,cpp}, src/main.cpp and tests/:
_sendspin._tcpadvertisement on the--portport, TXTpath=/sendspinand TXTname, viadns_sd.h— Bonjour on macOS (built in),libavahi-compat-libdnssdon Linux. Optional and auto-detected exactly like the audio backends, reported in its own configure line, withsrc/mdns_null.cppkeeping a build without it working. The register callback is kept rather than upstream'snullptr, so the name logged is the name that actually registered — the daemon renames on a collision. The record is withdrawn explicitly during shutdown, before the client disconnects.- The two modes, made exclusive, because the spec says so: "Do not advertise
_sendspin._tcpif the client plans to initiate the connection." Any-ssuppresses the advertisement and logs which flag did it;--no-mdnscovers the no--scase;--mdns-namealongside-swarns it is unused rather than failing. Nothing can turn both on together. -s mdns:[<name>], discovery on the existing flag via a reserved prefix split on the first colon — the idiom-oalready uses for<backend>:<device>. Every existing-sform is untouched,hifi:8927is still a host and a port, and a bare-s mdnsis still a host. A separate--discoverwas the alternative; the prefix keeps-sas the one place a server is named. Discovery is not a bare-sbecause the optstring iss:, so a bare one would swallow the next word. On a build without dns_sd it is refused at parse time, as-o portaudioalready is without PortAudio.- Discovery of
_sendspin-server._tcp: browse, resolve, then A/AAAA queries, with the URL built from the server's own TXTpath. An instance whosepathis missing or does not start with/is skipped and said so atdebug, matching the reference server. A non-link-local IPv4 wins over IPv6, and an IPv6-only server yields a bracketed URL. The browse stays open for the daemon's lifetime, and an instance that goes away leaves the candidate set rather than staying in it as a dial target. - Server selection, which the spec leaves implementation-defined: the
-s mdns:<name>filter is a hard constraint, and among what survives it the last server whose handshake completed wins, else the first to resolve. That preference is decidable before dialling because the_sendspin-server._tcpinstance label is the protocolserver_id— the reference server registersf"{id}.{service_type}"and sends the sameidinserver/hello, which was confirmed against a live server. - Outbound retry, since the library deliberately does none:
ConnectionManager::connect_to()callsset_auto_reconnect(false)andSendspinClientListenerhas no connect/disconnect callback. 1 s doubling to a 30 s ceiling, reset on a completed handshake, restarted from the floor on a drop, and no dialling at all while connected — including when the connection came in. - A no-background-threads model. Every
DNSServiceRefispoll()ed with a zero timeout from the main loop andDNSServiceProcessResult()is called only on a descriptor already shown readable, so every callback — and therefore everyconnect_to()— lands on the thread both are documented to require. Upstream'sexamples/tui_clientinstead runs a browse thread plus a detached thread per resolve. - 28 new tests across
tests/discovery_test.cpp,tests/last_server_test.cppandtests/cli_test.cpp, none of which opens a socket or the mDNS daemon: the browse result is a plain struct andRetryPaceris handed its clock.
Three correctness points worth writing down, each one a thing that fails silently if got wrong:
- Redials are paced from the last
connect_to(), not from observing "not connected".is_connected()only goes true on a completed handshake, so it reads false for the whole of an in-flight attempt — andconnect_to()releases any previous outbound nursery entry withANOTHER_SERVERbefore pushing the new one. Redialling per tick would therefore cancel the attempt in progress every time. The 30 s ceiling matches the library's ownNURSERY_ESTABLISH_TIMEOUT_S, in its privatesrc/connection_manager.h— not included from there, so aSENDSPIN_GIT_TAGbump should re-check it. DNSServiceGetAddrInfois not usable. It is a Bonjour extension thatlibavahi-compat-libdnssddoes not implement at all — not even as a stub — so the obvious resolve chain would take the Linux build with it.DNSServiceQueryRecordis in the compat layer and is asynchronous, so A/AAAA lookup stays on the one thread; upstream's example falls back to a threaded POSIXgetaddrinfo()instead. Addresses are resolved rather than putting the.local.target in the URL, because that only resolves throughgetaddrinfowhere nss-mdns is installed.- Resolves are issued on
kDNSServiceInterfaceIndexAny, not on the browse reply's interface. A multi-homed host gets one browse reply per interface, and a resolve pinned to an interface the instance is not really on simply never calls back — a silent stall, since there is no negative reply. Only one candidate is kept per instance, so the daemon is left to answer from wherever it can.
Not in this slice:
- An Avahi-native backend → item 16, split out with what was found here.
- Activities-based inbound admission → item 17, likewise.
- A configurable state-file path, and a real
SendspinPersistenceProvider→ item 8, which already owns persistence. This task added only the one small file its own tie-break needs, deliberately not via that provider: it stores an FNV1 hash computed byConnectionManager::fnv1_hash(), which lives in the library'ssrc/and is not installed, so a browsed candidate could not be matched against it without reimplementing a private hash and staying bit-compatible across tag bumps. -zdaemonization, which an advertising player wants → item 6, where it shipped.
What has and has not been exercised. Driven against two real
aiosendspin/Music Assistant servers on macOS, both modes: the announce mode was
discovered and dialled by a server with no configuration at all (handshake complete,
connection_reason=discovery), and -s mdns: browsed, resolved both servers to IPv4,
dialled, completed a handshake and received metadata. The remembered-server preference,
the -s mdns:<name> filter (matching and non-matching), --no-mdns, the 1/2/4/8 s
retry ramp against a closed port, prompt SIGTERM during a backoff, and the parse-time
refusal on a -DSENDSPIN_CLI_WITH_MDNS=OFF build were each exercised directly. Clean
under -fsanitize=thread in the announce mode, in discovery (browse, resolve, address
queries, dial and retry against a locally registered _sendspin-server._tcp record), when
dialling a real server by address, and while a discovered server disappears mid-run.
Two things the suite does not prove, and does not claim to. src/mdns_dnssd.cpp has no
unit coverage at all — the poll/retire/doom lifecycle, the settle window, the interface
refcount and the restart backoff are the riskiest code here and are exercised only by hand,
on macOS. (Impl is factored so a fake dns_sd could drive it; that is item 12's job.) And
the property that a per-tick redial would cancel the attempt in flight is reasoned from
ConnectionManager::connect_to()'s source, not exercised — what the RetryPacer suite
proves is the schedule. The Linux path is no longer only read: item 12's matrix advertises,
browses and resolves through a real avahi-daemon on every push, which is what confirms the
Avahi compat findings above — a ws:// URL cannot be built without an address, so it cannot
be logged unless DNSServiceQueryRecord answered. A mid-session server disappearance and the
reconnect-after-drop path were covered by unit tests rather than in the field.
-z was parsed and then refused: src/main.cpp printed "not implemented yet" and exited
-Pwas a plainfopen/write/remove with no lock, no stale handling and no "already running" refusal — and it opened with"w", so a second instance truncated the running one's pid on its way to discovering it had lost. And no log line said which part of the binary produced it.
Shipped in src/daemon.{h,cpp}, src/log.{h,cpp}, src/cli.{h,cpp}, src/main.cpp,
the five files that log, and tests/daemon_test.cpp:
-z: onefork(),setsid(),chdir("/"),umask(0022), fd 0 and fd 1 to/dev/null. A second fork is deliberately absent:setsid()already leaves the child a session leader with no controlling terminal, and the classic second fork only stops a session leader re-acquiring one by opening a tty, which this daemon never does.daemon(3)is not used — deprecated on macOS, so it cannot compile clean under this repo's-Wall -Wextra -Wpedantic, and it gives no way to keep fd 2 on an already-opened logfile.- No readiness handshake; the failure boundary moved instead. Everything cheap,
deterministic and resource-free was hoisted above the fork, so it still fails at the
terminal: the parse, opening
-f, and a probe of the-Plock. Everything after the fork reports into the log, andREADME.mdsays which is which rather than leaving an exit 0 to be discovered. A self-pipe handshake was the alternative and buys nothing the hoist does not. - The fork-ordering rule is written at the fork site as an invariant — fork before the
process acquires any resource — rather than as a list of three current call sites. All
three are live:
make_audio_sink()already probes the device (src/audio_sink.cpp:258,267) andPortAudioSinkthen holds aPortAudioGuardthat brings up the CoreAudio HAL's mach ports and helper threads,start()starts the sync task'sstd::thread, and aDNSServiceRefis a per-process connection tomDNSResponder/avahi-daemon. Only the forking thread survives a fork, so an item 7 control socket or an item 8 config file cannot quietly land above the line without reading why it must not. Item 7's socket did land below it, for exactly that reason. - Two conflicts
-zintroduces, both closed before the fork.-zwith-o stdoutor-o -is refused at parse time: a daemon's stdout is/dev/null, so the PCM sink would silently become a second discard sink.-zwithout-fis not refused — a supervisor that captures nothing is a legitimate way to run it — but warns, because otherwise the silence reads exactly like a crash. - A relative
-Por-funder-zis resolved against the invoking directory at parse time. Without that,chdir("/")splits one flag value into two files: the parent's probe creates the one the operator typed, and the child writes/<name>— which for a non-root user fails after the terminal has already been told 0. -Pis an exclusiveflock()held for the process lifetime, in the one order that works:openwithoutO_TRUNC→flock(LOCK_EX|LOCK_NB)→ftruncate→write. The lock is what makes stale detection free — a crashed process has its descriptor closed by the kernel, so a leftover file simply has no lock and is reused, with no pid parsed and nokill(pid, 0)and therefore no pid-reuse race.EWOULDBLOCK/EAGAINis reported as "already running", every other errno as itself. NoFILE*anywhere in the path, sincefclose()would release the lock. The parent probes and the child acquires, which sidesteps flock-across-fork semantics that Linux and the BSDs document differently. The lock is taken abovemake_audio_sink(), so two instances racing collide on the pidfile rather than on the sound card.- Every log line carries a level letter and a tag:
<L> <tag>: <message>, withcli,audio,mdns,discovery,outbound,playerandmetadata. The tail is byte-identical to what the library'sSS_LOG*macros emit, sogrep 'I mdns:'andgrep 'I sendspin.ws_server:'work on the same file. The 96 existingcli_logcall sites were not touched: each file declares onestatic constexpr const char* LOG_TAG— mirroring the library's own per-fileTAGidiom — that a macro picks up, and the handful of sites speaking for a second subsystem calllog_line()with the tag explicitly.__attribute__((format(printf)))survives onlog_line(). - A UTC timestamp under
-fonly. That is the case where nothing else records when; a foreground run under systemd or Docker is already stamped by journald or the container runtime. SIGHUPreopens the-fpath, handing rotation tologrotateandnewsyslog. The handler sets avolatile sig_atomic_tand the main loop does the work, which flushes the old stream and then logs the result -- neither of those being async-signal-safe is what keeps it off the handler. The handler is installed only with-f:SIGHUP's default disposition is terminate, which is what a foreground run whose terminal has closed should keep doing.PidFileand the stream redirection leftmain.cppforsrc/daemon.{h,cpp}andsrc/log.{h,cpp}insidesendspin-cli-core, which is what makes the riskiest code in this item testable at all — the entry point is not linked by the test binary.main.cppnow holds only the call order.- 17 new tests — 10 in
tests/daemon_test.cppand 7 intests/cli_test.cpp, taking the suite from 130 to 147 — none of which forks, opens a device or a socket, or touches the mDNS daemon.
Three asks removed from this item rather than deferred, all for one reason worth
writing down: sendspin-cpp v0.8.0 logs through src/platform/logging.h's bare
fprintf(stderr, "E %s: " fmt "\n", tag, ...) macros, gated on a single global
int ss_host_log_level, with no callback or sink hook. Library lines cannot be
reformatted, redirected or filtered per tag by any API call this layer can make.
- syslog /
os_logwould carry our half of the log and leave the library's half on stderr, splitting one narrative across two destinations — worse than the single file-falready gives. - Per-category
-dlevels have no coherent answer for library lines: raising the global level to serve-d mdns=debugfloods the log with unrelated library debug, and not raising it shows nothing from the library.-dkeeps its warn-and-ignore, and the warning now names the tags and points atgrep, which is per-category filtering after the fact and works on the library's lines too. - The workaround was considered and rejected: a pipe over fd 2 plus a reader thread
reassembling and re-emitting lines does work, and costs a thread, a pipe, partial-line
buffering and a shutdown-ordering problem in order to prepend twenty bytes — while
breaking interactive foreground use. The correct owner of a log sink is sendspin-cpp's
SS_LOG*macros. PerAI_POLICY.mdno upstream issue was opened here.
In-process rotation is declined on different grounds: it duplicates logrotate and
newsyslog wholesale, and SIGHUP-reopen hands them the job in about ten lines.
What has and has not been exercised. -z is not in the suite — forking inside a gtest
process leaves two runners reporting results — so it was driven by hand on macOS, and
these were each checked directly: the shell returning immediately; ps showing the daemon
reparented to pid 1 with no controlling terminal; lsof showing cwd /, fd 0 and 1 on
/dev/null and fd 2 on the logfile; an unopenable -f and a -P under a missing
directory both failing at the terminal with exit 1; a second instance refused at the
terminal while the first's pidfile stayed byte-identical; SIGKILL followed by a clean
restart with no cleanup; the pidfile removed on SIGTERM from both a foreground and a
detached run; a relative -P landing where it was typed rather than under /; a post-fork
device failure appearing in the log with exit 0 at the terminal; an mv plus SIGHUP
producing a fresh file at the original path with nothing more written to the old
descriptor; and SIGHUP to a foreground run with no -f still terminating it (128+1).
The pidfile lock, stale reuse, the ftruncate case, the probe, and the reopen are covered
by tests/daemon_test.cpp — affordable without forking because an flock lock belongs to
the open file description, so two open() calls conflict inside one process exactly as
two instances would (fcntl record locks would not, and that is why they were not used).
log_fatal(), for the errors that stop the player coming up. Tagged and stamped like every other line, so under-fthe one line explaining why a daemon never started is the onegrep 'E 'finds — but past the level gate, because-d nonemeans "do not narrate", not "exit 1 without saying why". Emitted under the tag of the subsystem that failed rather than of the startup phase, so a device that will not open joins theaudiothread. Two kinds of diagnostic stay plainerror:lines on their own stream -- the flag parser's and the pre-fork pidfile probe's -- because both answer a command line rather than recording a run, and both are printed before there is a log to write them to.open()+dup2()rather thanfreopen()for both the-fopen and the SIGHUP reopen.freopen()closes the stream even when it fails, which had cost three workarounds: an injected diagnostics stream because the complaint could not go to the stderr that had just died, that complaint landing on stdout where a2>capture misses it and where-o stdoutputs PCM, and a/dev/nullrescue so later log lines were not written through a deadFILE*. All three are gone: a failed open leaves stderr untouched, and a failed reopen keeps the log on the descriptor it already had — so it can report itself, into the filelogrotatehas just moved.
This branch was verified on Linux only against a patched tree, and that is worth being
precise about. The code in this item builds warning-free and passes 148/149 on Debian
bookworm against real libasound, portaudio and libavahi-compat-libdnssd; the one
failure is LastServer.AnUnwritableDirectoryFails, which fails only because the container
runs as root and root ignores directory permissions — it passes as a normal user in the same
container. The src/mdns_dnssd.cpp break that made main itself uncompilable on Linux, and
that had to be patched out in a throwaway copy to get this far, is fixed under item 12 — the
whole tree now builds and tests on Linux in CI.
Two things this item does not claim. The library's own lines are not timestamped
under -f and cannot be, for the same missing-sink-hook reason its category cannot be
filtered — so a -f file has stamped lines from us and unstamped lines from the library.
And "the WebSocket port is already taken" turns out not to be a post-fork failure at
all: two instances on one --port both report listening, which is pre-existing
sendspin-cpp/IXWebSocket behaviour and is not addressed here.
Userinfo in a -s URL is masked wherever this player names a server. A -s value may
carry userinfo — ws://user:token@host:8927/sendspin — and parse_server_url() passes a
full URL through verbatim, so the whole of it reached Connecting to %s at info on every
dial, repeating under the retry pacer's backoff. redact_url_userinfo() in
src/cli.{h,cpp} is what names a server now: it masks the secret half of the userinfo and
keeps the rest, so the line still says which endpoint was dialled —
ws://user:***@host:8927/sendspin, or ***@host where a single userinfo field could be a
bearer token and there is nothing to tell it from one. A fixed *** rather than one * per
character, a secret's length being worth nothing to a reader. The dial itself is handed the
real URL, and nothing credential-bearing reaches disk: last-server holds the server id,
not a URL. Every rejection parse_server_url() writes goes through the same helper, a URL
that does not parse being the one most likely to have been mistyped around a password. The
helper is unit-tested on its own; that the dial sink actually calls it is a
scripts/smoke_test.sh check, src/main.cpp not being linked by the test binary.
Userinfo is the boundary, and it is the boundary because of where the spec puts
authentication. The spec authenticates in the handshake — pairing and a PSK — so a credential
in a URL is never something this protocol asked for; it is something in front of the server
asking, a reverse proxy wanting HTTP Basic being the case this came from. And it does not reach
that proxy either: the 6-argument UrlParser::parse() IXWebSocket's transport calls copies out
scheme, host, path, query and port and discards the user and password it just parsed
(IXUrlParser.cpp), and nothing in either library builds an Authorization header. Confirmed
against a listening socket rather than by reading: the upgrade request carries Host,
Upgrade, Connection, Sec-WebSocket-* and Origin, and no credential in any form. So
userinfo here is tolerated and dropped, and the reason to mask it is that an operator will
still type it, not that it does anything.
Two limits on the masking, both of them real. A credential written into a query string is
not masked: ?token= is not a shape this player can tell from any other query, and guessing at
parameter names would mask what is not secret while missing what is. And the mask reads the URL
the way a URL parser does — the authority ends at the first /, ? or # — so a userinfo
field containing one of those unencoded ends the authority early, leaves no @ inside it, and
comes back unmasked. A raw base64 secret is the case that bites: / is in the alphabet, so a
32-character token carries one about two times in five. RFC 3986 requires those percent-encoded
in userinfo and IXWebSocket's own parser says the same, and such a URL does not resolve to the
endpoint the operator meant in the first place — the host reads as everything up to that / —
so this is a malformed value being printed faithfully rather than a parse to be repaired by
guesswork. README.md and the wiki both say to percent-encode.
What is owed, and it is the honest answer to all of the above: stop accepting a credential
this player cannot send. Refusing a -s value that carries userinfo closes every leak by
construction — a URL that is refused reaches neither Connecting to %s nor connect_to(), so
it never reaches the sendspin.* lines either — and it closes the unencoded-delimiter shape
with it, since that shape cannot reach the endpoint it appears to name in any case. It also
stops the worse failure this item only documents: an operator running a player they believe is
authenticated. It is a breaking change for anyone passing such a URL today, which is why it is
owed here rather than done here. Masking is what this item ships; refusing is the fix.
A third thing this item does not claim: the library's own dial lines. sendspin-cpp v0.8.0
logs the URL it is dialling at info from ConnectionManager::connect_to() and again at
error from SendspinClientConnection, through the same sink-less SS_LOG* macros that put
timestamps and per-tag filtering out of reach above — so those lines still carry whatever the
URL carries, at the default level, and no call this layer can make will change it.
connect_to() takes a URL and nothing else, so the credentials cannot be moved off it either.
A pipe over fd 2 would catch them and is rejected here for the reason it is rejected above.
This one is sendspin-cpp's to fix, in the same place a log sink hook belongs; per
AI_POLICY.md no upstream issue was opened here. Until it is, the boundary is exactly the tag
prefix: every line tagged sendspin.* is the library's, and every line tagged otherwise is
this player's and is held to the paragraph above.
The player could only be driven by a remote controller, and CMakeLists.txt pinned
SENDSPIN_ENABLE_CONTROLLER OFF with a comment saying the role was "for driving other
clients, which this daemon does not do". That was wrong, and it was the thing blocking this
item: controller@v1 carries the transport verbs for the group this client is part of, and
the player role has none of them — only this endpoint's own volume, mute and static delay.
So the role is now on, and local control lands here.
Shipped in src/control.h, src/control_common.cpp, src/control_socket.cpp,
src/control_client.cpp, src/cli.{h,cpp}, src/main.cpp, src/player_listener.{h,cpp},
src/audio_sink.h, src/daemon.{h,cpp}, src/log.h, CMakeLists.txt,
tests/control_test.cpp, tests/scoped_env.h and scripts/smoke_test.sh:
- The whole of
controller@v1, one subcommand each:status,play,pause,stop,next,prev,vol,mute,seek,seek-rel,repeat,shuffle,switch.repeatandshuffleare the two that are not pass-throughs — the mode is the command (REPEAT_OFF/ONE/ALL,SHUFFLE/UNSHUFFLE), soshuffle offis its own command rather than a false-valued parameter. A test walks the table against the library's enum, so a protocol command with no subcommand behind it fails the suite. volis group volume, andstatusprintsgroup volumeandplayer volumeas two named lines rather than onevolume:. The server spreads a group volume across the group and clamps per player, sovol 50does not move only this box. One ambiguous line would leave that unclear.switchis documented for what the spec's switch cycle does — re-home this client between groups — not as the "switch playback source" its library comment suggests.- No thread and no command queue.
ControlSocket::poll(now_ms)runs from the main loop besidemdns.poll(), carrying the same THREAD SAFETY notesrc/mdns.hcarries. That is forced rather than chosen:send_command()reachesSendspinClient::send_text()andConnectionManager::current(), which is documented main-thread-only (current_shared()exists for off-thread callers), and reading is no safer —get_controller_state()returns a reference to a vectordrain_events()move-assigns from insideclient.loop(), so the daemon copies it into a plainControllerSnapshot. A round trip is therefore bounded byLOOP_INTERVAL_MS; that is stated rather than fixed by shortening the tick. - Three failure modes kept distinct, with three exit statuses, because they need three
different actions: nothing listening (3), the player up but with no server connection (4),
and a command absent from the server's
supported_commands(5). The ordering is the load-bearing part —on_controller_state_clear()emptiessupported_commandson a disconnect, so a gate that consulted it first would answer "pause is not supported" when the truth is that nothing is connected, sending the operator to read their server's capabilities instead of its connection.send_text()also no-ops silently with no connection, so nothing may report success for a command that never left the process. - A
0600socket at<runtime dir>/sendspin-cli-<port>.sock, the port in the leaf so two players on one host each get their own. The mode is set by bracketingbind()with aumask()rather than a laterchmod():bind()applies the umask,daemonize()setsumask(0022), and a post-bind()chmod()leaves a window where any local account can connect. - Two sources for the runtime directory, and no
/tmpamong them.$XDG_RUNTIME_DIRwherever it is set, on every platform, trusted because it is the user's own declaration — and checked, so a group-writable one is used but warned about. Then the platform's own equivalent, which on macOS isconfstr(_CS_DARWIN_USER_TEMP_DIR), the per-user/var/foldersdirectory launchd already provides. That second source exists because launchd sets no$XDG_RUNTIME_DIRat all, not even in an interactive shell, so without it the default path never resolved on the one platform the PortAudio backend exists to serve. Deliberately not$TMPDIR, which usually names the same directory:confstr()reads nothing from the environment, so unlike$TMPDIRit cannot be pointed at a directory someone else can write — which is precisely what stops it being the/tmpfallback this design refuses. Verified rather than trusted byis_private_runtime_dir(): a directory, owned by the effective uid, with neither the group- nor the other-write bit. That check is load-bearing rather than decorative, since macOS and the BSDs do not enforce socket-inode permissions onconnect()at all — there the directory is the only thing between another local account and this player's transport controls. Both sources are impermanent by design (macOS prunes/var/folders, Linux clears$XDG_RUNTIME_DIRat logout), and--control-socketis the answer where that matters. - With neither source available it is still non-fatal. One
warnnaming the reason and--control-socket, and the player carries on serving audio — the shape a failed mDNS advertisement already has. In practice that is the Linux systemd system unit case, which wantsRuntimeDirectory=paired with--control-socket. There is deliberately no third source: a world-writable directory would let any local account pause playback andswitchthis endpoint out of its group. - A sibling
<path>.lockunderflock(), through the same helper-Puses. The open/flock/classify sequence and its two messages live indaemon.h'slock_file(), because there are now two callers and bothREADME.mdand this file assert that the two "already running" refusals are worded alike — sharing the code is what makes that true rather than a coincidence two files apart. Held for the process's life, withunlink()+bind()underneath it, which is what makes "stale" and "in use" different answers rather than a guess:unlink()-then-bind()alone races a live daemon's socket away, and connect-to-probe is a TOCTOU. Its own lock rather than a second use of-P, so--control-socketdoes not acquire a dependency on-P. A lock already held is the one control-socket failure that is fatal; everything else warns and carries on. Taken abovemake_audio_sink(), for the reason the pidfile is: two instances racing should collide on a lock, not on the sound card. And probed before the fork, mirroringprobe_pidfile(), so a duplicate under-zis refused at the terminal rather than in a log the shell has stopped watching — the socket itself must be bound afterdaemonize(), being one of the resources that invariant exists for. argv[1]split off beforegetopt_long(). Not by reading getopt's leftovers: glibc permutes a positional argument out of the way and the BSDs stop at it, andseek-rel -5000is indistinguishable from a flag cluster to getopt regardless — so the subcommand and its arguments leave argv by count, from the table's own arity, before the scan. A subcommand after the flags is told to move rather than called junk. Every argument is validated at parse time, sovol 500fails at the terminal like a bad--buffer-msrather than on the wire.- A one-command-per-connection wire format: a line in,
okorerror <kind>: <reason>and any payload out, then the daemon closes. The kind is one machine token so the client can map a refusal onto an exit status without parsing the reason, and the line still reads as English tosocat. Nothing to version and nothing parsed twice. The kernel'slisten()backlog is tied toMAX_CONTROL_CONNECTIONSrather than chosen independently, because whichever is smaller is the real limit and only our own cap can explain itself in the log; a connection refused by the cap is answered in the protocol's own shape rather than hung up on, andECONNREFUSEDandENOENTare reported differently — a socket with nothing behind it is a different problem from no socket at all. statusreports what is true locally, and marks what is not. Four decisions, each one a line that used to say something it had not earned:- The player's volume is the gain the sink is applying, tracked in
PlayerListener— the only caller ofset_volume(), so the only thing that knows what the sink was told — and marked(default; no server has set it)until a server sends one.PlayerRole::get_volume()is the wrong source: it stores 0 until a server speaks, while every sink starts atDEFAULT_SINK_VOLUME, so an untouched player plays at full while the role reads zero. streamand the output format are two separate facts. A stream whose format the device refused has no format and is still a stream, and that is when knowing audio is arriving matters most, since it is arriving and being discarded. SoPlayerListenerowns an explicit flag set above every guard inon_stream_start(), andstreaming() && !stream_format()is the refused case rather than an inconsistency. It is also the only thing that tellspausefromstopin the output: both leavestate: paused, butpausekeepsstream: receivingwherestopdrops it toidle.repeatandshuffleare reported at all, beside the group volume they arrive with, because this CLI can change them and their effect would otherwise be invisible.positionsays(estimated)while playing, and onenote:line namesstate,position,repeatandshuffleas the server's word. See the staleness note below.
- The player's volume is the gain the sink is applying, tracked in
- The software volume taper is the spec's,
amplitude = (volume / 100)^1.5, replacing the^2inherited from upstream'sPortAudioSink::update_volume_multiplier_(). Not a taste call: the spec defines a volume as perceived loudness — "volume 50 should be perceived as half as loud as volume 100" — and^1.5is the mapping that makes the number mean that. This is audible for every existing user, and only in one direction: every volume below 100 gets louder, by about 3 dB at 50, 6 dB at 25 and 10 dB at 10. Computed in floating point because it runs once per volume change, not per sample;apply_volume()stays integer Q32. Pinned on the two volumes where the curve is exact —(1/4)^1.5is1/8,(1/25)^1.5is1/125— plus a test asserting the perceptual property directly, and one guarding the divergence from upstream so it cannot be tidied back. StreamFormatlives insrc/audio_sink.h, next to theconfigure()argument list it mirrors, so the audio adapter does not depend on the control channel's header.- 110 new tests (147 to 257), none of which binds a socket: the argv split, every
subcommand's argument parse and its protocol mapping, a request round-trip through the wire
form, the refusal predicate against hand-built snapshots (including the empty
supported_commandsdisconnected case), thestatusformatter, the reply status line, line framing — partial reads, no trailing newline, CRLF, an empty line, an over-long line, an embedded NUL, bytes after the first newline — and every rejection path of the directory check, including both symlink directions and/tmp. They touch the filesystem, which this suite's boundary allows: what it forbids is opening a device, a socket or the mDNS daemon.tests/scoped_env.hwas lifted out oflast_server_test.cpprather than copied so both suites share one$XDGhelper.
Three things worth writing down, each one silent if got wrong:
- Rebuilding argv without POSIX's
argv[argc] == NULLsentinel breaks BSDgetopt_long(). Its long-option path doesoptarg = nargv[optind++]unconditionally and then testsoptarg == NULL, so the sentinel is the only thing that tells it a required value is missing. Without it,--portwith no value read one past the end of the array and accepted whatever was in memory. It surfaced as a test that segfaulted in a different case on each run. - A player must report the volume it is actually applying, which is why
main.cppcallsplayer.update_volume(DEFAULT_SINK_VOLUME)before anything can connect. Four spec rules make it necessary rather than tidy:client/state's playervolumeMUST be included when thevolumecommand is advertised; "group volume is the average of the volumes of players in the group that support thevolumecommand", so group volume is derived from us and a misreport corrupts the group reading for every controller; setting group volume works off "delta = requested_volume - current_group_volume", so that misreport then mis-applies every later group volume change by exactly the error — a player claiming 0 while playing at full hears a request for 30 as a cut from full rather than a rise from silence; and "a server MUST NOT assume these values are unchanged after a reconnect".update_volume()rather than reaching for the sink, because it does not invokeon_volume_changed()— that fires only for server-initiated changes — sostatusstill tells a default apart from a volume a server chose. - Four
statusfields are the server's word and can lag what is true, which is what thenote:line exists to say.positionis interpolated forward from the last progress the server sent whenever the group is playing, so a server that does not resend progress after a seek leaves the anchor stale and the figure drifts by however far the seek moved; it is marked(estimated)while playing and only while playing, since paused it is the server's own snapshot.statecomes from the same progress object and shares that staleness.repeatandshuffleare reported when the server sends them and default tooffwhen it does not, becauseServerStateControllerObjectholds them as a plain enum and a plain bool and the parser assigns them only when the field is present —seek_max_msin the same object is anoptionaland does not have the problem. The spec does not oblige a server to republish state after acting, so an unchanged figure is not evidence a command did nothing; onenote:line rather than a qualifier per field, so the block stays scannable and every line stayskey: value.
Also fixed here, because it stood between the smoke test and these checks:
check_default_mdns_boot chose its expected outcome from whether the host had an mDNS
daemon, ignoring whether the build had mDNS at all — so the whole script failed partway
through against a -DSENDSPIN_CLI_WITH_MDNS=OFF build on a host with a daemon, which is a
configuration item 12 really produces. It now reads the build's own ready log too.
Not in this slice:
- The socket path in a config file → item 8, which owns configuration.
--control-socketis a flag, andOptions::was_given(Opt::ControlSocket)is the hook a precedence layer needs. Note the ordering constraint: the path's length is validated againstsockaddr_un::sun_pathat parse time, so whatever supplies it has to be resolved before that check. - Two remaining spec deviations in the volume path → item 13, which owns advertised state
matching reality, and where both shipped. Persisting
volumeandmutedacross restarts is the spec's RECOMMENDED and needed item 8's store. And the spec says volume changes SHOULD be ramped to avoid clicks; nothing in this slice ramped, which item 13 closed with the shared ramp insrc/pcm_volume.{h,cpp}. The third — the taper — is fixed here, see below. - Hardware volume → item 15.
volis a group command and does not touch the sink. - A
player-scoped volume subcommand. Theplayerrole's own volume and mute are reachable in the library and have no subcommand here, deliberately: every verb this item ships moves the group, which is what a controller does. This is not item 15, which is about driving a hardware mixer rather than about which scope a command addresses — so a local "set just this box's level" verb would be new scope on this item rather than something tracked elsewhere. - An interactive TUI over this socket → item 11. The wire format is deliberately one command per connection, so a long-lived subscribing client is a new shape rather than an extension.
--no-controlis kept but is not load-bearing. Unlike--no-mdns, which the spec requires of a dialling client, nothing forces it now that the socket is0600inside a user-private directory. It stays because it silences the missing-runtime-directory warning for a systemd system unit that is only ever driven by its server.
What has and has not been exercised. ctest and scripts/smoke_test.sh both pass on macOS
in the default and -DSENDSPIN_CLI_WITH_MDNS=OFF configurations, and the whole smoke path is
clean under -fsanitize=thread. The smoke test covers what needs two processes: the socket at
its default path as 0600, a status round trip, --no-control, stale-socket takeover after a
SIGKILL, removal on SIGTERM, a second instance refused both in the foreground and — through
the pre-fork probe — at the terminal under -z, and the missing-runtime-directory branch on
whichever side of it the platform falls. By hand on macOS: the 0600 mode read off the inode,
the connection cap, the 5 s idle deadline, and through a raw socket an empty line, an unknown
command, an out-of-range argument, an embedded NUL, an over-long line, two lines in one write,
CRLF, extra whitespace and a request with no trailing newline.
Driven against a live aiosendspin/Music Assistant server on the LAN, dialling out with
-s, which is what moved the server-dependent paths from reasoned to observed. All thirteen
subcommands act. play, pause and stop move real playback, and stop is distinguishable
from pause only by the stream line. next and prev walk a real queue in both directions.
seek moves the position, and seek past the server's published seek_max_ms (231000, exactly
the track duration) is refused locally with its own exit status. seek-rel, repeat one and
shuffle were confirmed behaviourally rather than from reported state, since this server
acts on all three without republishing any of them: a relative jump to a track's opening was
audibly indistinguishable from the absolute seek used as a control; shuffle off walked an album
in order where shuffle on produced tracks from four different albums; and repeat one held a
track across its end instead of advancing. vol and mute were confirmed by ear, alternating
12/50 with a mute between. switch re-establishes the stream, visible as Stream ended followed
by Stream started and a fresh codec header. The status block fills in from real metadata, and
reads unknown in the window before the first arrives. Reporting the sink's real gain makes the
server report group volume: 100 for a single player at 100 where it reported 0 before, and a
following vol 30 lands at a real 30. The three failure statuses were exercised against real
daemons rather than only unit-tested: all twelve transport commands return 4 against a daemon
that is up and has never reached a server, each naming the connection rather than the command,
while status on that same daemon returns 0 and prints what it knows locally; and 3
against a socket with no daemon behind it.
Two limits worth knowing before reading a status. The position is only meaningful behind a
sink that paces itself: under -o null it saturates at the track duration within seconds, because
the null sink consumes instantly and leaves on_frames_played unset, so nothing paces the stream
and the server sends the whole track as fast as it can — status then faithfully reports a server
that believes the track has finished. Behind -o portaudio it advances at 1x. And switch was
only ever exercised with a single group available, where the cycle returns to where it began; the
multi-group path is unproven.
A daemon needed its whole flag line every time, and it forgot everything when it stopped.
Item 5's src/last_server.{h,cpp} held one server id in a file of its own, and the library's
SendspinPersistenceProvider was never implemented — so set_static_delay_adjustable(true)
advertised an adjustable delay with nothing behind it, leaving a spec requirement unmet:
"Clients must persist static_delay_ms locally across reboots and server reconnections."
Shipped in src/key_value_file.{h,cpp}, src/state_store.{h,cpp},
src/config_file.{h,cpp}, src/cli.{h,cpp}, src/main.cpp, src/player_listener.{h,cpp},
src/audio_sink.h, src/control.h, src/control_common.cpp, CMakeLists.txt,
tests/state_store_test.cpp, tests/config_file_test.cpp, tests/parse_harness.h and
scripts/smoke_test.sh:
- Two files, and the split is the point. The config file is the operator's and is only ever
read; the state file is the daemon's and is only ever written by it. A daemon that rewrote its
own config would destroy the comments and ordering someone put there, and a config the daemon
could not write would have nowhere to record a volume. One flat
key = valuereader (src/key_value_file.h) serves both, so the two formats cannot drift; what differs is only that the config refuses a line it cannot read and the state store skips it. Nothing but this daemon writes the latter, so a bad line there means corruption rather than a typo. - Keys are the long flag names minus the dashes, which is why
-o -n -s -P -f -dgained--output --name --server --pidfile --logfile --log-level. One vocabulary, so--helpis the config reference rather than a second document to keep in step. - Search order
--config→$XDG_CONFIG_HOME→$HOME/.config→/etc/sendspin-cli.conf, first found used whole. No merging across layers, and no$XDG_CONFIG_DIRStraversal — that is exactly where this item's scope would have inflated. There is deliberately no--no-config: a named file that cannot be read is fatal while a missing layer is silent, so--config /dev/nullalready says it. A file that exists and does not parse stops the run rather than falling through, because an operator's config that is quietly skipped is the failure mode this surface exists to avoid. - The merge sits inside
parse_options(), after the getopt loop and above every resolution. That position is load-bearing rather than tidy. Above it,was_given()still means what the command line said, so precedence is one test per option. Below it, the-sURL parse, the-z-with--o stdoutcontradiction,--no-controlagainst--control-socket, and the socket path's absolutization andsun_pathlength check all run over merged options without knowing a file was involved — which satisfies item 7's "resolved before the length check" constraint for free. Two alternatives were rejected: seedingOptionsbefore getopt destroyswas_given(), the entire hook item 1 built, and a separateload_config()the caller composes puts merge order inmain.cppon every subcommand path and has to re-run the resolutionparse_options()owns. - The merge marks options as supplied, not merely set.
Options::advertises()is!no_mdns && !was_given(Opt::Server), so a configuredserverleft unmarked would have this player dial and advertise_sendspin._tcp— which the spec forbids — whileserver_urlwas never filled, leaving the value inert as well as non-compliant. So the bit now means "this was supplied" rather than "the user typed this", and there is no second bitmask: nothing downstream needs to tell the two apart. The one exception is diagnostics — a small origin map lets the two refusals that happen after the merge still name the file and line. - One validator per option. Every settable option goes through a single
apply_option(), which both the getopt switch and the merge call, sobuffer-ms = 5is refused with exactly the message--buffer-ms 5gets, prefixed with the file and line. An unknown key or a malformed line is fatal the same way.--help,--versionand-lshort-circuit above the config entirely: a broken config must not stop--helpexplaining how to fix it. - Config-settable is the
Optenum minus run shape —-l,-z,--config,--help,--versionand any subcommand are out, and a config naming one is refused as an unknown key. Excluding is reversible; debugging adaemonizethat came out of a file under systemd is not. - A subcommand reads the config too, or
sendspin-cli statuswould look for a player on the default port rather than the configured one. It applies onlyportandcontrol-socket— the two it actually reads — while still validating the rest, so the "a subcommand reads only --port and --control-socket" warning stays a report of what was typed instead of firing at every operator who has a config file at all. src/last_server.{h,cpp}folded away intoStateStore, which now holdslast-server,last-server-hash,static-delay-ms,volumeandmuted. Writes go through a temporary, anfsyncand arenameat mode0600: a player whose usual way of stopping is losing power must not leave a half-file a later run has to reason about.--state-diroverrides the XDG search outright, because a systemd system unit has neither variable and gets/var/lib/sendspin-clifromStateDirectory=— which is the pairing item 10's unit ships.last-serverandlast-server-hashstay two independent keys with nothing reconciling them, and the constraint item 5 found is why. The provider's key is an FNV1 hash computed byConnectionManager::fnv1_hash(), which lives in the library's uninstalledsrc/— so we store the number we are handed and hand it back, and never compute it. Discovery's own tie-break needs the raw id, which the hash cannot be turned back into.CliPersistenceProvideris installed beforeadd_player()andstart(), and neither is negotiable: the pointer is copied intoPlayerRoleat construction, andstart()is what loads the remembered hash. Installed after either, it is a provider the library never asks.- Volume and mute are the CLI's own half, since the provider has no hook for either.
PlayerListenerwrites through on every server change, and startup seeds the sink, the listener's applied pair and the role together.statusgrew a third case for it: a restored volume is neither the sink default nor something this connection's server chose, soplayer_volume_from_serverbecameVolumeSource.
Deliberately not here, and each one names its owner rather than being left implied:
- Observing and locally setting the static delay — item 13's, and the half this item does not
touch. It remembers and reports the figure honestly; reading it back out in
statusand setting it withsendspin-cli delayshipped there. (This item originally recorded the remaining half as "applying it to the audio path". That was a misreading: the library's sync task had been subtracting the delay all along — see item 13, which corrects it.) - Ramping volume changes — item 13's too, where it shipped.
- Debouncing the state write. Every distinct volume a server sends costs one whole-file
rewrite plus an
fsync, on the main loop. Two things blunt it: the library's command slot is latest-wins within a drain, andset_all()short-circuits a value that has not changed — so a slider drag costs at most one write per main-loop tick, not one per message, and audio is untouched either way since it runs on the sync task's own thread. What it can delay is the control socket and the mDNS poll beside it, on a slow enough card. Left as-is because writing on change is what makes the file true at any instant a player might lose power, which is this item's whole point; if the cost shows up in practice, a dirty flag flushed on a timer and at shutdown is the fix. Still owed: item 13 shipped its volume work without needing it. - Two players sharing one
$XDG_STATE_HOMEstill share one state file and overwrite each other's keys.--state-diris the answer; deriving the filename from--portwas left out rather than guessed at.
Image and compose file. ALSA with /dev/snd passed through for real output, and the
null sink for device-less containers and CI.
10. Packaging — shipped (install rules, the unit, the CI payload, the tagged release, the macOS .pkg, the unit's own user and hardening; signing still owed)
Nothing in this tree could be installed. cmake --build left the binary where it built it, and
.github/workflows/ci.yml hand-rolled a tarball around it with three cp paths inside a
workflow step — no install() rule anywhere for those paths to agree with. Item 6 had shipped
everything a unit file needs and then stopped at documenting it, so README.md told an
operator to choose between Type=simple and Type=forking and write the unit themselves.
Shipped in CMakeLists.txt, packaging/sendspin-cli.service.in,
packaging/sendspin-cli.sysusers.conf, packaging/sendspin-cli.conf.example,
scripts/build_macos_pkg.sh,
.github/workflows/{build,ci,release}.yml and README.md:
- A staged
cmake --installplus an explicittar, and no CPack. The per-leg archive name is not what decides it —CPACK_PACKAGE_FILE_NAMEhandles that. It is the payload'sBUILD-INFO.txt, whose content comes from the CI environment: the commit, the runner's OS and architecture, and the leg's list of runtime packages. Generating that under CPack meansCPACK_PRE_BUILD_SCRIPTSor install rules reading environment variables, which is the point at which the tool is being worked around rather than used. One mechanism, not two. - Every install rule names one component, and the payload is asked for by name:
cmake --install build --component sendspin-cli. That is load-bearing rather than tidy. The dependencies fetched at configure time declare install rules of their own, and a barecmake --installinto an empty prefix stages 149 files on Linux where the component stages six: the other 143 are every ArduinoJson header and its three CMake export files.IXWEBSOCKET_INSTALLis nowOFFbeside the existingINSTALL_GTEST OFF, since otherwise IXWebSocket adds its archive, its headers, a.pcand aninstall(EXPORT)as well; but ArduinoJson declares its rules with no option at all, and patching a fetched subproject to get a clean tarball is not a trade worth making. Opus declares unguarded rules of its own and stays out only because micro-opus compiles its sources rather than adding that project — a fact a payload should not have to rest on. Naming a component is CMake's own answer to "which of these rules are mine", and unlike a list of upstream options it stays true across aSENDSPIN_GIT_TAGbump that adds a dependency. - The install set is deliberately six files:
bin/sendspin-cli,lib/systemd/system/sendspin-cli.serviceandlib/sysusers.d/sendspin-cli.confon Linux only, andshare/doc/sendspin-cli/holdingREADME.md,LICENSEandsendspin-cli.conf.example. The count is a claim that every member is argued for rather than a cap — the sysusers fragment is the unit'sUser=made installable, and belongs to the hardening slice below — and CI's payload diff is what stops a seventh arriving unnoticed.sendspin-cli-coreis absent on purpose — it exists so the tests can link the parser without a process entry point, and nothing outside this build wants a static archive of it. The doc directory is named after the binary rather than taken fromCMAKE_INSTALL_DOCDIR, which is built from the project name: everything anyone types issendspin-cli, andshare/doc/sendspin-cpp-cliwould have been the single place the other spelling surfaced. Nothing is written to/etc: the daemon reads/etc/sendspin-cli.conf, so installing one would overwrite an operator's file on the next upgrade, and the annotated example goes beside the README instead. - The whole install section is top-level-only, on the same test the test suite already used —
hoisted into one
SENDSPIN_CLI_IS_TOP_LEVELrather than written twice. A parent that vendors this project throughFetchContenthas no use for our binary, our README, or a unit whoseExecStartnames their prefix; leaving the rules unconditional would have been the exact complaint this item makes of ArduinoJson, made of somebody else. - The unit is generated rather than copied, because
ExecStartis an absolute path:configure_file()fills inCMAKE_INSTALL_FULL_BINDIR, so a unit installed from one prefix cannot start a binary from another. The consequence is worth stating rather than discovering — the prefix is fixed at configure time, socmake --install --prefixrelocates the files around a unit that still names the old path. That is why the payload is staged withDESTDIRbelow, and whyREADME.mdsays to reconfigure rather than redirect. lib/systemd/system, not${CMAKE_INSTALL_LIBDIR}/systemd/system, with the reason written at theinstall()call so it survives a tidy-up: a unit file is architecture-independent and systemd reads<prefix>/lib/systemd/system, never Debian'slib/aarch64-linux-gnu/…or Fedora'slib64/…./usr/local/lib/systemd/systemis on that search path alongside/usr/lib/systemd/system, which is what lets the default prefix work with nothing copied by hand and nopkg-config --variable=systemdsystemunitdir.Type=simple, which is the shapeREADME.mdalready documented as primary. journald captures stderr, so-zbuys nothing and-fwould put the log somewherelogrotatehas to be told about — and item 6's timestamps are-f-only for exactly this reason.Type=notifyis not available:sd_notifyis not wired up, and this item deliberately did not wire it.Type=forkingwithPIDFile=pointing at-Pstays documented as the alternative for a supervisor with no journal, rather than shipped as a second unit.RuntimeDirectory=andStateDirectory=paired with--control-socketand--state-dir, written as%t/sendspin-cli/control.sockand%S/sendspin-cliso each flag names the directory the unit itself creates. Both flags exist for this case and items 7 and 8 said so: unpaired, the socket is skipped with one warning because a system unit has no$XDG_RUNTIME_DIR, and the volume, mute and static delay are forgotten on every restart because it has no$XDG_STATE_HOME. Item 7's private-directory check does not apply here — it guards the derived default path, and an explicit--control-socketgoes tobind()— so systemd's own0755runtime directory is not warned about, and the0600socket inode inside it is what Linux enforces onconnect().After=twice,Wants=never.network.targetrather thannetwork-online.target, because the player retries its advertisement and an-sdial on a backoff, so it comes up fine ahead of the network whilenetwork-online.targetwould delay every boot to buy nothing.avahi-daemon.servicefor ordering only: a failed advertisement warns and retries rather than being fatal, so a host whose operator turned Avahi off should stay that way instead of having this unit pull it back in.Restart=on-failurewithRestartSec=5, and it never gives up — checked, not assumed. A clean exit here means SIGTERM, which issystemctl stopor a shutdown, and neither wants the player back; a device that is not ready yet exits 1, which is what the delay covers. A config file that does not parse also exits 1, and one restart every 5 s never trips systemd's default start limit of five starts in ten seconds, so the unit retries indefinitely with the parse error naming its file and line in the journal each time. That is the wanted end of it: an operator who fixes the file gets a player back without also having toreset-faileda unit that had given up.- It runs as
sendspin-cliwith a hardening block, and neither shipped without the other. The account is declared inlib/sysusers.d/sendspin-cli.confbeside the unit, carrying two lines that are owed together — the user, and its membership ofaudio— because a uid with noaudiois a player that starts and cannot open/dev/snd(root:audiomode0660). That is also what rules outDynamicUser=, which hands the player a uid in no supplementary group at all. A tarball still has nopostinst, sosystemd-sysusersis a commandREADME.mdandBUILD-INFO.txtboth tell an operator to run once; skipping it is not a quiet degradation but217/USERinsystemctl status. Nothing has to be done to an existing/var/lib/sendspin-clileft root-owned by the version before this one:StateDirectory=chowns a directory it finds as well as one it creates, recursively, documented since systemd 235 and checked against 255 — the state a root-run player wrote is read back by the new account. The whole of it, the hardening block included, is exercised on every Linux CI leg rather than reasoned about: the unit starts as the account, the control socket answersstatus, a planted root-owned state file survives the ownership change,delaylands 0600, it comes back fromSIGKILL, andsystemctl stopleavesResult=success. - The hardening block is what was tried, not what a list suggests.
ProtectSystem=strict,ProtectHome=,PrivateTmp=,NoNewPrivileges=, an emptyCapabilityBoundingSet=,RestrictSUIDSGID=, theProtect*=kernel family,ProtectProc=invisible,RestrictNamespaces=,LockPersonality=,MemoryDenyWriteExecute=,RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK,SystemCallArchitectures=nativeandSystemCallFilter=@system-service. Two of those needed more than "it booted".AF_NETLINKis in the list because glibc'sgetifaddrs()has no way to read the interface list without it, and that list is where the library finds the MAC it derives the default client id from: without the family a player with noidsays hello with an emptyclient_id, which Music Assistant refuses withNo key provided. Booting, browse, resolve and a dial all succeed either way, so CI asserts the hello itself — a connection to the hardened unit has to be greeted with a non-emptyclient_id. And@system-servicecovers every syscalllibasoundimports,ioctl,mmap,mlockand the SysV IPC callsdmixuses included, read off the shipped library's own import table rather than assumed, which is what keeps the audio path from being the thing that directive is gambling on. One existing configuration stops working, and it is the whole of what an upgrade breaks:ProtectSystem=strictrefuses alogfileorpidfilein/etc/sendspin-cli.confthat points outside the unit's own two directories. It fails the way this project prefers — the error names the path and the reason on every retry, rather than a player logging into nowhere — andReadWritePaths=in a drop-in is the documented way back. Neither key was ever the shape for this unit,Type=simplehaving handed stderr to journald already. The block also moves the unit's effective floor, which is worth stating rather than discovering:ProtectProc=is from 247 and four more land between 242 and 245, against the 236 the unit needs to start at all. Below 247 systemd logsUnknown key name … ignoringand runs it anyway — checked on 245 — so the degradation is one directive and a warning rather than a unit that will not load, and 247 is Debian 11.systemd-analyze securityscores the result 1.8 against the root unit's 9.6 on systemd 255; CI prints it rather than asserting it, since a number is evidence and not a target, and the one remaining✗isUMask=, which buys nothing here — the player creates its socket and its state file0600itself. - Four directives are deliberately absent, and that is the item's own rule applied to
itself.
PrivateDevices=,DeviceAllow=,ProcSubset=pidandRestrictRealtime=each gate what the ALSA backend reaches —/dev/snd,/proc/asound,SCHED_FIFO— and every one of them passes the checks above under-o null, which is exactly why passing proves nothing about them. An untested hardening directive is worse than none, so they wait for a run against a real card; see the open work below. - CI stages the payload with
DESTDIR, not--prefix, so every path under the archive'susr/is the path the file installs to and the whole thing goes in withsudo tar -xzf … --strip-components=1 -C / <name>/usr. The member is named rather than omitted, or--strip-componentswould depositBUILD-INFO.txtat the filesystem root. Two other things fall out ofDESTDIR: the unit's absoluteExecStartnames a path the archive actually contains, which--prefixwould have left dangling, and the.pkgtask inherits the payload rootpkgbuild --rootwants.BUILD-INFO.txtsits at the archive root and tells an operator how to install it, how to run it in place, and what the unit does. - Assertions rather than claims, in the shape
ci.ymlalready uses for the configure output. Every publishing leg diffs the staged payload against the file list it is supposed to be, then diffs the tarball's own listing against the same list and checks the staged binary is executable — the archive being onetaraway from the directory and the thing anybody actually downloads. That catches a dependency which starts declaring install rules on a tag bump and a file quietly dropped from our own. The Linux legs then install at the real prefix and runsystemd-analyze verifyon the installed unit, which resolvesExecStartand so needs the binary really there — exercising the install rules at the prefix the unit names, which the staged payload does not. - That verify step runs last, and the ordering is load-bearing. A component install writes
install_manifest_sendspin-cli.txtinto the build directory, so asudoinstall before the staging one leaves a root-owned manifest and the unprivilegedDESTDIRinstall afterwards fails onEACCES— a red leg, out of a file nobody thinks about. Observed under Ubuntu 24.04's own cmake 3.28.3, which rewrites the manifest unconditionally; cmake 4.4.1 skips a write whose content has not changed and gets away with it, so the break is version-dependent and putting the root install last is what stops it being anybody's problem. Unprivileged first, root over the top;README.mdsays the same to anyone doing both in one build tree, and.gitignorenow coversinstall_manifest*.txtrather than only the un-suffixed name.
The release workflow, the second slice, in .github/workflows/release.yml and the
build.yml it shares with CI:
- The matrix moved into
build.yml, a reusable workflow both paths call. A composite action was the other option and is the wrong one here specifically: what must not be duplicated is the matrix, not the steps. A composite action cannot ownstrategy.matrix, sorelease.ymlwould have had to restate the per-legpublishandavahikeys that the comment above them warns a new leg owes both of — the exact block whose duplication is dangerous. It takes no inputs, deliberately: a release is gated on precisely what a push is gated on, the non-publishing no-mDNS leg included, since a break insrc/mdns_null.cppis a real break and an input is the seam along which the two would start to differ. - A called workflow inherits none of the caller's
defaults,envorpermissions, sobuild.ymlrestates all three. The one that would have bitten isshell: bash: left behind inci.yml, the configure step runs under the defaultbash -ewith no pipefail, its| teereturns 0 on a failed configure, and the assertion after it greps a truncated log — both callers green on a build that never configured. A fail-open gate is worse than no gate, which is the argument the shellcheck job at the top ofci.ymlalready makes. ci.ymlgained a ref filter on itspush, and that is the whole of its behavioural change — the rest of its diff is the matrix moving out tobuild.yml. Itspushwas unfiltered on purpose, and av*push would otherwise have run the matrix twice over and reported two statuses for one tag — theconcurrencygroup is keyed by workflow name, so nothing collapses the pair. The filter isbranches: ['**'], which is what excludes tags: apushfiltered on one kind of ref does not fire for the other kind at all. The inverse spelling,tags-ignore: ['**'], reads like the same sentence but is that rule the other way around, and stops branch pushes firing at all.- Fail closed, in two places, because
needs:only covers one of them.needs: buildcatches a leg that went red. What it does not catch is a leg that went green while publishing nothing —Uploadisif: matrix.publish, andif-no-files-found: errorcatches an empty upload rather than an absent one, whiledownload-artifactmatching nothing is an empty directory rather than an error. So the release job names the assets it expects and diffs the set, in the shape the payload assertions already use — four tarballs and, since the third slice below, the macOS.pkg. Only the set: each archive's own file list was diffed twice inside the build, and re-opening them here would be the duplication this split exists to avoid. - Created as a draft, published only once the API says every asset arrived.
gh release createdoes draft-upload-publish internally already, but it publishes on "no upload returned an error" rather than on "every asset is present", and that is an implementation detail ofghrather than a promise. Splitting it makes the check ours: the asset names are read back withgh release view --json assetsand diffed beforegh release edit --draft=false. A partial upload therefore leaves an invisible draft, never a published half-release. A draft left by a failed run makes the next attempt fail rather than overwrite —--clobberon a step that may be facing an already-published release is the opposite of the point. --verify-tag, which is what makes "the workflow never creates tags" a gate rather than a claim: without itgh release createcreates a missing tag from the default branch. The human tags; this publishes what that tag builds.- The version is checked, not derived.
project(VERSION)stays a hand-edited line bumped in a PR before the tag is pushed. Deriving it fromgit describewould tie the version to the presence of a.gitdirectory — breaking a build from a source tarball — and put the tag intoCMakeLists.txt's hash, which is the deps cache key. A preflight job instead refuses a tag that is notvMAJOR.MINOR.PATCH(thev*trigger also matchesv0.2.0-rc1, which would becomelatestwith nobody having decided that) and refuses a tag that disagrees withCMakeLists.txt, counting the matchedVERSIONlines rather than taking the first: no match yields an empty string that would fail only by luck, two would silently pick one. It gates the matrix, so a mistyped tag costs seconds rather than three runners for half an hour. It duplicates part of the archive-name check that follows, and earns it the same way the tarball listing does: the later check is authoritative because it reads names the binary produced, and this one is an early mirror of the likeliest human error. - No third-party action, and no checkout, in the job that can write to the repository.
permissions: contents: writeis scoped to the release job alone with the workflow default left atread. Actions in this repo are pinned to a SHA because a tag can be repointed and they run with write access to the workspace; this job runs with write access to the repository, so it is the worst place in the tree to add one.ghships on the runner,GH_REPOreplaces the checkout since nothing here reads the tree, and the only pinned action left isdownload-artifact. "Pin exact tool versions" cannot honestly be done for a runner-providedgh; what is pinned is the image label —ubuntu-24.04, neverubuntu-latest— andgh --versionis printed into the log so a future break is diagnosable, in the same print-then-assert habit as the rest of the matrix. SHA256SUMSis generated from inside the directory so its entries are bare filenames:sha256sum -cresolves paths relative to the working directory, and a file namingdist/…is one a downloader cannot use without knowing that. The notes give the macOS spelling (shasum -a 256 -c) beside the Linux one, two of the five assets being macOS-only, and say out loud that the checksums do not cover theSource codearchives GitHub attaches on its own. Both are given with--ignore-missing, which is not a detail:SHA256SUMSlists all five and a reader has almost certainly taken one, so the bare form reports the rest as failures and exits non-zero on a file that is perfectly good. Supported by GNU coreutils and by macOS's Perlshasumalike, both checked rather than assumed.- The notes are written by hand, not
--generate-notes. On a first tag that emits every merged PR since the initial commit as a flat list, there is no.github/release.ymltaxonomy to shape it, and the narrative of what shipped already exists here and is better. They state plainly that the macOS binary is ad-hoc signed and what Gatekeeper will do about it — the third rendering of that paragraph afterREADME.mdandBUILD-INFO.txt, kept short with the README linked for the full account, because a reader deciding whether to download is a third audience and the alternative is presenting an unsigned binary as something else. Links are pinned to the tag rather than relative: a relative link in a release body resolves against the default branch, where the text it points at is free to move after the release is cut. - No
concurrencyblock, whereci.ymlhas one.cancel-in-progress: trueis free to cancel a run midway through uploading assets, which is the exact partial state the draft gate above exists to prevent. A tag is pushed once; there is no superseded run to collapse.
The macOS installer .pkg, the third slice, in scripts/build_macos_pkg.sh, the
build.yml both callers share, and release.yml:
- The macOS
.pkgwraps that payload rather than restating it, inscripts/build_macos_pkg.sh. The stagedDESTDIRtree is the input, so the installer ships exactly the files theinstall()rules do and cannot drift from the tarball published beside it; nothing is compiled, fetched or installed, and given a staged tree it runs offline in a second. A script and not lines inbuild.ymlfor two reasons: an installer has to be buildable and installable by a developer with no runner, andscripts/*.shis already gated byci.yml'sshellcheckjob, so the file arrives linted with no workflow change. - It lives in
build.yml, the shared build, not inci.yml. That is the whole of the re-homing this slice owes the second one, and it falls out of what the reusable workflow is for:build.ymltakes no inputs precisely so both callers get the same legs, and an installer that only a push produced would be the seam that rationale warns about. One definition therefore builds it, installs it and asserts it on both paths, andrelease.ymlattaches what that build hands it. pkgbuild --root <payload>/usr/local --install-location /usr/local, not a root one level up paired with/usr. The staged tree carriesBUILD-INFO.txtbesideusr/for the tarball's readers, and the wider root would install that file at/— excluded structurally, by where the root points, rather than by a filter a later tidy-up can drop. The tighter pair also declares the one directory this package writes, instead of nominally claiming a SIP-protected/usr.--ownership recommendedis pkgbuild's default and is named anyway, because it is what makes the installed filesroot:wheel: the staged payload is owned by whoever rancmake --install, andpreservewould file a build user's uid into the receipt. The destination is/usr/localand not/optbecause it is not a free choice — the prefix is baked in at configure time, and the install rules above already chose it.productbuildover the component package, for two things a component package cannot do. It has no architecture gate, so an arm64-only installer reports success on an Intel Mac and leaves the user a binary that answersBad CPU type in executable;<options hostArchitectures>is the declarative gate, and the value is read off the binary withlipo -archsrather than passed in — so a universal build widens the installer by itself and a per-arch one stays refused where it could only fail. And distribution panes are a product-archive feature, so a component package has nowhere to say the installer is unsigned at the moment somebody is deciding whether to run it. Two independent reasons is what earns the extra layer. The distribution XML is generated rather than checked intopackaging/, because three of its fields — version, the component's filename, the architectures — are computed by the script, and a template would be a second place to look for values it already holds.<product>carries that same identifier and version rather than a second one nobody types, which is what makes Installer refuse to put this over an install that is already newer. No<license>pane:LICENSEships inside the payload either way, so the only difference would be a click-through Agree gate the tarball does not have.- The version is an argument, and an empty one is refused. It is read off the binary once,
in
build.yml'sPackagestep, and carried to the installer step through$GITHUB_OUTPUTrather than derived a second time — the same "one mechanism, not two" the CPack decision above turns on.build_macos_pkg.shrejects an empty version rather than lettingpkgbuildfile a receipt with nothing in it, because the assertion downstream compares the receipt against the value that was passed: without the guard, a dropped output would leave both sides equal and empty and the check would pass on an installer that had lost its version. - The receipt is
io.github.chrisuthe.sendspin-cli. A namespace the author holds, rather than anio.sendspin.*or Open Home Foundation one that was not this project's to take when the identifier was minted — the same manners the component argument above makes of ArduinoJson.sendspin-cliand notsendspin-cpp-clifor the reason the doc directory is named that way: everything anyone types is the binary's name, and this string is typed, by an operator runningpkgutil --forget. The repository has since moved under the Sendspin org, which makesio.sendspin.*available to it for the first time, and the identifier deliberately does not follow: changing it orphans the receipt of every install before the change rather than upgrading it. - The
.pkgis shipped honestly, and the installer says so itself. It is unsigned and unnotarized;spctl -a -t installrejects it exactly asspctl -a -t execrejects the ad-hoc-signed binary inside. The welcome pane carries that, what gets installed, and how to undo it.README.mdseparates the four ways a reader can come by the file, because they genuinely differ: taken from a release page the.pkgis the download, so it is quarantined and Gatekeeper refuses it outright; taken from the Actions tab it arrives inside a zip, so the flag lands on the zip and the.pkginherits it only from Finder's Archive Utility and not fromunzip— the same distinction the tarball paragraph above it already draws; a locally built one is never quarantined at all; andinstaller -pkgmakes no Gatekeeper assessment whatever the file is flagged with, which is what lets CI install its own artifact. The release notes say the same in short, which is where a reader deciding whether to download actually is. What the.pkgdoes fix is narrow and is named as such:installerdoes not quarantine what it writes, so the installed binary needs noxattr -dwhere a Finder-unpacked tarball's does. That is convenience, not identity. - The build installs the
.pkgat/and checks what came out, on a push and on a tag alike, because item 10's first slice named this the untested half of the payload and an installer nobody installed is not shipped. The receipt's own file list is diffed against the four paths it should hold, itsversion:andlocation:are asserted so the version threaded through rather than defaulted, and then the installed binary answers--versionand runs the whole ofscripts/smoke_test.sh— the same suite the build tree's binary runs, pointed at/usr/local/bin/sendspin-cliinstead. The two runs are sequential rather than parallel: the suite binds fixed ports 39281-39288.pkgutil --filesprints paths relative to the receipt's install location, so that expected list isbin/sendspin-cliand friends rather than a copy of the tarball's absolute one.._-prefixed siblings are filtered even though that listing turns out not to need it, and the asymmetry is worth recording: macOS stamps every payload file with acom.apple.provenanceextended attribute that cannot be removed —xattr -cis undone before the nextstat— andpkgbuildcarries extended attributes as AppleDouble members, so the.pkg's own BOM does list them, aslsbomover the expanded archive shows. The receipt's does not, becauseinstallerfolds those members back into attributes rather than writing files. Filtering rather than naming them is what keeps the assertion right in both directions. - The installer is a second artifact, not a second path on the tarball's upload.
if-no-files-found: erroris wanted on both, and a.pkgpath on the existing step would fail every Linux leg;upload-artifactalso refuses two artifacts under one name, so the macOS leg publishes…-macos-arm64and…-macos-arm64-installer. release.ymlnames it in both asset sets, which is the third thing a new build output owes. The matrix comment inbuild.ymlsays so already: the release job diffs whatdownload-artifacthanded it against a set spelled out by hand, and diffs the draft's assets read back from the API against the same set before publishing. An output nobody added there fails a release with a diff rather than a reason, and this one is not per-leg — three tarballs and one.pkg— so the sets are no longer a loop over the legs.SHA256SUMScovers it too: a release asset a downloader cannot verify is worse than one that is not there, and the notes'--ignore-missingadvice was already the reason a reader can check just the one file they took.
Not in this slice, each with its owner named rather than implied:
- Developer ID signing and notarization → a task of their own, gated on an enrolment that
has not happened, and none of the analysis has changed — only the artifact it applies to now
exists. Everything shipped is ad-hoc signed:
codesignreportsadhoc, linker-signed, the minimum an arm64 Mach-O needs to execute at all and no identity, sospctl -a -t execrejects the binary andspctl -a -t installrejects the.pkgaround it. Clearing that needs a Developer ID Application certificate and a Developer ID Installer certificate — a paid enrolment — and notarization;README.mdstill answers withxattr -d com.apple.quarantineand the Privacy & Security override, which are workarounds rather than fixes. The part that decided the.pkgbelongs here rather than there, and is now discharged:xcrun staplertakes.app,.dmgand.pkgand refuses a bare Mach-O, so notarizing the loose binary alone would still leave Gatekeeper asking Apple on first run — no good for an offline Pi or Mac. The.pkgthis slice produces is the artifact that ticket will staple to. Nothing was stubbed for it:scripts/build_macos_pkg.shgrows a--signon its two calls, or aproductsignover the finished archive, and no placeholder branch waits in the tree for it. A public repo also gets no secrets on a pull request from a fork, so that step has to be conditional rather than assumed — which is why it is not written until there is an identity to write it against. - A minimum-macOS gate on the
.pkg→ owed with the signing task, no task open for it yet. The architectures are derived from the binary and declared, and the deployment target is the asymmetry: nothing setsCMAKE_OSX_DEPLOYMENT_TARGET, so the binary'sminosis whatever the runner's SDK defaulted to, and<options>carries no OS floor to match thehostArchitecturesone. Same class of failure as the arch gate — an install that reports success and then cannot run — and the same declarative remedy, but it wants the deployment target pinned first, which is a build decision rather than a packaging one. - The four
/dev/snd-gating directives, with no task open for them yet:PrivateDevices=,DeviceAllow=,ProcSubset=pidandRestrictRealtime=, all named in the unit where a reader meets them. What is missing is not analysis but a machine — every one passes CI under-o nulland would keep passing while deafening a real card, so the check that settles them is a player on hardware with a device configured, listened to. Worth doing as one sitting: the four share a fixture, andDeviceAllow=char-alsa rwwithDevicePolicy=closedis what the first three are really reaching for. - A drift guard on the version strings in
README.md, with no task open for it yet, and worth more now than when it was first named. Several places spell an artifact name out in full —sendspin-cli-0.1.0-linux-x86_64, its macOS twin, and the.pkgandbuild_macos_pkg.shlines the third slice below added — and nothing checks any of them againstproject(VERSION). They are correct for the first release and rot at 0.2.0. The release notes avoid the same trap by interpolating the version they were built from, and the same fix would suit here: generate the examples, or assert them. Named rather than left to be noticed, since the version is now a thing a tag agrees with. - A drift guard on
packaging/sendspin-cli.conf.example. Item 8's "one vocabulary" claim means every key in that file is a long flag name, and nothing enforces it — a renamed flag would leave a shipped example that stops a player with an unknown-key error. It was checked by hand this time (see below). The cheap fix is a test that uncomments the file and feeds it throughconfig_file, and it needs the source path handing to the suite. .deb, Homebrew and AUR are not planned at all. The sanctioned set stays the tarball payload, the unit, item 9's Docker image and this item's macOS.pkg.- Docker is item 9's, and is unaffected: it builds the binary rather than consuming a payload.
What has and has not been exercised. On macOS, against this branch's own build: configure
and build clean under -DSENDSPIN_CLI_WERROR=ON, 346 of 347 tests passing — the exception is
ConfigMerge.AConfiguredControlSocketIsAbsolutizedUnderDaemonize, which fails only because this
worktree's path is long enough that the absolutized socket path exceeds sockaddr_un's 104 bytes,
and it passes from a shorter directory — scripts/smoke_test.sh green, and the payload staged three
ways and read back: --component sendspin-cli giving four files with no unit (correct: a systemd
unit on a Mac is a file that can never run), DESTDIR giving the same four under usr/local/,
and a bare cmake --install giving 147 — the 143 extra files being the number the component
exists for. CI's own packaging step and its payload assertion were run against that build too,
including with a stray file planted to check the assertion fails rather than merely reports.
The unit itself was proven on Linux, in a container, rather than reasoned about. On
ubuntu:24.04 (aarch64, systemd 255) the tree configures and builds -Werror-clean with the
tests off — CI is what builds and runs those there —
cmake --install --component sendspin-cli puts the unit at
/usr/local/lib/systemd/system/sendspin-cli.service, and systemd-analyze verify accepts it
with nothing to say — including with no avahi-daemon.service on the host, which is what the
After= line names. Under a real systemd as PID 1, with the DESTDIR payload untarred into
/ exactly as BUILD-INFO.txt says: systemctl enable --now starts it, the journal carries the
startup lines with no -f and no timestamps of ours, RuntimeDirectory= yields
/run/sendspin-cli/control.sock as a srw------- socket beside its .lock, StateDirectory=
yields /var/lib/sendspin-cli, sendspin-cli delay 250 round-trips over that socket and lands
in a 0600 state file, a systemctl restart comes back reporting the remembered 250 ms, and
systemctl stop leaves Result=success, ExecMainStatus=0 and NRestarts=0 — with systemd
removing the whole runtime directory, so the unit never meets the stale-socket case at all. A
deliberately broken /etc/sendspin-cli.conf was left in place for a minute and reached
NRestarts=18, still activating, which is where the never-gives-up note above comes from. The
shipped example config was checked the same way, every commented line uncommented: all eight keys
and values are accepted, the run gets as far as the sound card the container does not have.
The hardening slice was proven the same way, one directive at a time. On the same
ubuntu:24.04 (aarch64, systemd 255) with avahi-daemon running, every candidate was applied on
its own and put through the whole round — unit active, control socket answering status,
WebSocket port accepting a connection, the _sendspin._tcp advertisement reaching Avahi, delay
landing in a 0600 state file, that value surviving a restart, a SIGKILL recovered from, and
systemctl stop leaving Result=success — then the shipped block was put through it whole, and
then the real installed unit with no drop-in at all. systemd-sysusers creating the account from
/usr/local/lib/sysusers.d was run from a host that had no such user, 217/USER was seen before
it and the account with its audio membership after, and a root-owned /var/lib/sendspin-cli
holding a 175 ms delay was planted and read back through the new uid. PrivateDevices= and
ProcSubset=pid were run too, and passed — which is the evidence for excluding them rather than
against it.
One thing that was not exercised and is worth knowing: no audio came out of a unit. The
container has no /dev/snd, so Type=simple under systemd is proven to start, log, serve its
socket and stop cleanly, but the ALSA path under it is inference from a foreground run — which is
also why the four device-gating directives above are named rather than shipped.
A third, for the release slice: release.yml has never run. Everything asserted about it
above is reasoning plus a local dry run, not a release. What was exercised, on macOS against
this branch: actionlint over all three workflows with shellcheck present, so the run:
blocks are linted; the preflight's tag and version checks driven through their accepting and
every rejecting case; the completeness assertion driven through a skipped leg, an empty
download, a version mismatch and an unexpected extra file; the publish gate driven through a
complete asset set and two incomplete ones against a stubbed gh; and the whole version chain
end to end — CMakeLists.txt 0.1.0, --version 0.1.0, and a real cmake --install payload
tarred as sendspin-cli-0.1.0-macos-arm64.tar.gz, which is the name the release job expects.
What that leaves unproven is everything only GitHub can answer: that gh release view finds a
draft by tag name (the REST releases/tags/{tag} endpoint does not return drafts, so this
rests on gh's list-and-match fallback), that download-artifact's merge-multiple lays the
files out flat as read here, and that the release job's narrowed permissions still let it
reach the artifact service. A throwaway tag on a fork is what would settle all three, and is
worth doing before v0.1.0 rather than after.
The .pkg was closed out the same way, and it is no longer the untested half of the payload.
Locally on macOS 26.6 (arm64), against this branch's own build: the payload staged with DESTDIR,
scripts/build_macos_pkg.sh writing an installer whose BOM holds exactly the four expected files
— com.apple.provenance proving unstrippable on the way, which is where the ._ filter and its
comment come from — whose Distribution carries hostArchitectures="arm64" read off the binary,
and whose welcome pane reads back as written, its install list read out of the payload rather
than kept by hand. shellcheck and actionlint clean. The guard
paths were tried rather than assumed: a wrong argument count, an empty version and a payload root
with no binary each fail with the message that names the fix. The universal case was tried rather
than reasoned about too — a real fat binary (clang -arch arm64 -arch x86_64) staged as the
payload widens the declaration to hostArchitectures="x86_64,arm64" by itself, which is the
claim the lipo line is there to make. spctl -a -t install was run rather than predicted, and
rejects the archive with no usable signature, as codesign -dv reports adhoc, linker-signed
on the binary inside — the two sentences the honesty paragraphs rest on. The smoke suite passes
against a staged-payload binary outside the build tree, which is the shape the installed run
takes. What a developer's Mac cannot prove without sudo is the install itself, and that is
precisely what the shared build now does on every push and every tag —
installer -pkg at /, the receipt's file list, version and location asserted, then --version
and the full smoke suite against /usr/local/bin/sendspin-cli. The one thing an arm64 runner
cannot demonstrate is the refusal on an Intel Mac, so the architecture declaration is what
is asserted instead. That pass has happened, against these step bodies before the move that gave
them their present home: installer reports success, the receipt reads location: usr/local with
no leading slash and the version it was given, pkgutil --files lists the four paths relative to
that location, and the full smoke suite passes against /usr/local/bin/sendspin-cli rather than a
build-tree binary. It is also where the ._ comment above comes from — the receipt lists no
AppleDouble members even though the .pkg's own BOM does, which lsbom over an expanded archive
confirms locally.
The release.yml half was driven the way the release slice drove its own, against the real .pkg
and a stubbed gh: the completeness assertion passes on the four-file set and fails on a macOS
leg that published only its tarball and on an unexpected extra file; the checksum step writes four
bare names and, with no .pkg staged, fails on the literal glob rather than quietly checksumming
three; shasum -a 256 --ignore-missing -c verifies a directory holding only the two macOS assets,
which is the case the notes tell a reader to expect; and the publish gate publishes on the full
asset set while refusing a draft whose .pkg is absent or whose name arrived sanitized. What stays
unproven is what the paragraph above already says only a real tag can answer.
Optional, later. Upstream's examples/tui_client shows the shape.
.github/workflows/ci.yml builds and tests every branch push and pull request on
ubuntu-24.04, ubuntu-24.04-arm and macos-14, a fourth cross-compiled for ARMv7 on
ubuntu-24.04, and a fifth configured -DSENDSPIN_CLI_WITH_MDNS=OFF — which compiles
src/mdns_null.cpp instead of src/mdns_dnssd.cpp, so that translation unit is built rather
than assumed. Every leg configures -DSENDSPIN_CLI_WERROR=ON and runs the CTest suite, the
no-mDNS leg included:
discovery_test.cpp links whichever MdnsService went in, and that configuration has no
other coverage. Each leg also asserts against its own configure output that it found the
backends it expects — a missing -dev package does not fail a configure, since every
backend is optional and auto-detected, so without that assertion the matrix would go green
on a null-sink-only, mDNS-less binary. The four platform legs additionally run the smoke
test and upload the binary they built, kept 14 days.
The matrix itself lives in .github/workflows/build.yml, called by ci.yml and by item 10's
release.yml alike, so one definition answers for both paths; ci.yml filters its push to
branches: ['**'], which excludes tags, so a tag push builds once rather than twice. See item
10 for why that split is a reusable workflow rather than a composite action.
The sixth archive is not in that matrix, and its absence is deliberate. linux-armv6 builds
natively inside an emulated Raspbian container, which pays for every compile in the FetchContent
tree under qemu-user: 23 minutes measured, against the two or three every leg above takes. As a
matrix leg it ran whenever the matrix ran — every branch push, and again on every pull request —
so every push waited half an hour on the one leg that almost never had anything new to say.
It is now .github/workflows/build-armv6.yml, one job carrying its own triggers: workflow_call,
so release.yml calls it beside build.yml; push: branches: [main], so a merge is covered
within half an hour of a merge; a pull_request filtered to that workflow,
scripts/build_armv6_container.sh and CMakeLists.txt; and workflow_dispatch. Deliberately no
unfiltered branch push — that is the cost being removed. The alternative considered and rejected
was leaving the leg in the matrix behind continue-on-error, which would still hold a runner for
25 minutes twice per pull request and would turn a red leg into one that gates nothing.
What that buys is a seam, and it is worth naming: a tag now builds one thing a push does not.
The pull_request path filter is honest about its own reach — it catches infrastructure changes,
not the ARMv6-only source breaks this project has actually met, which are the gcc 12 -Wrestrict
false positive and the -latomic class described below. Those are caught after the merge instead.
Two things bound the cost. release.yml needs both build jobs, so a red ARMv6 build fails the
release rather than publishing an incomplete one, and the release's expected asset set still names
sendspin-cli-<version>-linux-armv6.tar.gz — so an archive nobody built is a diff rather than a
quietly smaller release. And push: branches: [main] means a break can delay a tag but has at most
one merge in which to go unnoticed. Raising the ARMv6 leg's coverage back towards per-push without
paying for it per push — a nightly, or a merge-queue check — is a separate piece of work and is not
owed by anything today.
The unit harness is item 1's and unchanged: GoogleTest via FetchContent pinned to a tag,
wired to CTest with gtest_discover_tests(), defaulting ON only when this is the top-level
project. Nothing in tests/ opens an audio device, a socket, or the mDNS daemon, which is
what keeps the suite runnable on a bare CI runner — and is why the smoke test is
scripts/smoke_test.sh rather than another suite. It covers what a gtest process cannot:
--version/--help, a foreground run reaching its ready log and exiting 0 on SIGTERM,
-z forking with -P writing a live pidfile and refusing a second instance holding the
same lock, a default mDNS-on run surviving a daemon it cannot reach, and — item 7's, since a
listening socket and a connect() to it are two processes by definition — the control socket
appearing at its default path as 0600, a status round trip, --no-control, stale-socket
takeover after a SIGKILL, removal on SIGTERM, and a second instance refused both in the
foreground and, through the pre-fork probe, at the terminal under -z. It is runnable by hand
against any build, which is what item 6's hand-driven -z checks became.
Both breaks this entry used to predict are fixed. src/mdns_dnssd.cpp no longer names
kDNSServiceErr_ServiceNotRunning and kDNSServiceErr_Timeout unconditionally.
#ifdef guards — what this entry previously recommended — would not have worked: both
are enumerators of an anonymous enum rather than macros, so the test is false on every
platform, and it would have dropped the two cases on macOS as well as Linux. CMake compiles
a use of each against the header the build will really use and defines
SENDSPIN_CLI_HAVE_ERR_* only where it is genuinely there. The dangling
this->name().c_str() at src/portaudio_sink.cpp:495 is gone as well, which is what lets
-Werror hold at all.
Both runtime claims this owed item 5 are now made rather than read. The linux-x86_64 leg
starts a real avahi-daemon, browses our own _sendspin._tcp advertisement back to a
resolved address, then publishes a _sendspin-server._tcp instance and has the player
discover it. That second half is the one that matters: a ws:// URL cannot be built without
an address, so it cannot succeed unless libavahi-compat-libdnssd really does implement
DNSServiceQueryRecord for A and AAAA — the call it provides in place of the
DNSServiceGetAddrInfo it lacks entirely. The Linux legs' configure assertion is what pins
down the other half, that find_path/find_library pick the compat library up.
Still owed. The sink contract remains untested: a NullAudioSink/AlsaAudioSink/
PortAudioSink suite is what would cover it, and is the largest gap left in tests/.
And a ThreadSanitizer leg, which item 7 made more than a nicety. Every no-background-thread
argument in this repo is a threading argument — item 5's poll()-from-the-main-loop discovery
and item 7's control socket both rest on library calls documented as main-loop-only — and the
only verification either has on record is a developer running -fsanitize=thread by hand, on
macOS. A leg that configures -DCMAKE_CXX_FLAGS=-fsanitize=thread and runs
scripts/smoke_test.sh under it would turn the largest correctness claim in the tree from
reasoning into a check. It is cheap: the smoke test already drives the whole boot, socket and
shutdown path, and produced zero reports when run that way by hand.
The 32-bit Pi leg is there. linux-armv7 cross-compiles on ubuntu-24.04 against the
armhf multiarch tree — scripts/build_arm32.sh owns the configure — and runs the whole unit
suite and scripts/smoke_test.sh under qemu-user rather than skipping them, so it holds the
same rule every other leg does. Nothing builds it natively: GitHub has no armv7 runner, and its
arm64 runners are Neoverse N1 with no AArch32 at EL0, so no runs-on value reaches the target
at all — and a cross build is what buys back everything the ARMv6 build below has to emulate.
What keeps the leg's name honest is a readelf of the linked binary: the linker merges build
attributes across every object in the link and reports the highest, so one read covers the
FetchContent tree as well as our own sources, and a dependency compiled for the wrong
architecture fails the leg instead of shipping.
The ARMv6 archive is there as well, and it is not the same job. A Pi Zero, a Pi Zero W and an
original Pi are ARM1176 cores, and Debian and Ubuntu armhf are an ARMv7-A port — which is where
a cross toolchain's own crt1.o, crtbegin.o and libgcc.a come from. Compiling our sources
-march=armv6 therefore does not produce an ARMv6 archive: the startup and helper objects
linked in beside them are ARMv7, and the merged Tag_CPU_arch says so. What an ARMv6 build
needs is an ARMv6 libgcc and ARMv6 startup objects, which is a distribution rather than a
flag — and Raspbian is one. Its gcc is configured --with-arch=armv6 --with-float=hard, and
every one of libgcc.a's members reads Tag_CPU_arch: v6, so the build passes no -march at
all.
So linux-armv6 is not a cross build at all: it is an ordinary native build run inside a
digest-pinned Raspbian bookworm container under qemu-user, which is why it is the one archive
scripts/build_arm32.sh has nothing to do with. That script still refuses armv6, and its
reasoning is untouched — it cross-compiles against the host distribution's armhf tree, which
is still ARMv7. scripts/get_started_linux.sh now maps an armv6l host onto the new archive
instead of refusing it; ARMv5 and a bare arm are still refused, the ARMv6 archive being the
oldest one built.
Three things about that build are load-bearing and none of them are about ARMv6 the instruction
set. Everything it runs in the container runs under QEMU_CPU=arm1176, because qemu-arm
defaults to a Cortex-A15-class core and would happily execute the ARMv7 instructions the
hardware cannot — an emulator more permissive than the target proves nothing. That covers the
configure as well as the suite, a CMake try_run probe asking what the CPU can do being
answerable for the wrong CPU otherwise. The container runs --init and builds as a non-root
user, because without a reaping PID 1 the smoke test reads an exited daemon's zombie as a player
that outlived SIGTERM, and because three StateStore cases assert that an unwritable
directory is refused, which root is refused nothing by. And the link needs -latomic: ARMv6 has
no LDREXD, so the 64-bit atomics in src/pulse_sink.cpp, src/player_listener.cpp and
sendspin-cpp's own connection_manager.cpp become libatomic calls. It goes in
CMAKE_CXX_STANDARD_LIBRARIES rather than CMAKE_EXE_LINKER_FLAGS, which places it ahead of
the objects that need it and leaves the linker to discard it.
That leg holds the warning line like the rest, with one diagnostic exempted: Raspbian's gcc
12.2.0 has a -Wrestrict false positive on line += " " + std::to_string(...), at
src/control_common.cpp:391 and :401 and at tests/cli_test.cpp:1133. It passes
-Wno-error=restrict, which is not interchangeable with -Wno-restrict: CMAKE_CXX_FLAGS
lands before the -Wall target_compile_options adds, and a later -Wall turns a disabled
warning back on — where a later blanket -Werror does not re-promote a diagnostic an earlier
-Wno-error= has already exempted. So an ARMv6-only warning nobody has met yet still fails the
leg.
One thing improves for free. The published archives need GLIBC_2.38, which Raspberry Pi OS
bookworm's 2.36 refuses at load; the ARMv6 binary references nothing newer than bookworm's own
2.36, so it loads on bookworm and trixie alike. That is asserted off the finished binary rather
than assumed, and its BUILD-INFO.txt names the version it actually came out at. The bookworm caveat the
ARMv7 archive's BUILD-INFO.txt carries therefore does not apply to this one, and it does not
say it. Raising the floor for linux-x86_64, linux-arm64 and linux-armv7 is a separate
piece of work and still owed.
There is no macOS x86_64 leg. Artifacts are per-commit workflow artifacts only; the tagged
release that does not expire is item 10's, and shipped. The hand-rolled tar this entry used to
describe is gone: item 10 shipped the install() rules, and the archive is now a
DESTDIR-staged cmake --install payload whose file list this workflow asserts — plus, on the
legs that run on the architecture they built for, a systemd-analyze verify of the unit it
installs.
Every PlayerRoleConfig field is now set deliberately, the static delay is observable and
locally settable, and volume changes are ramped. Split out of item 4.
This item's opening premise was wrong, and correcting it is part of what shipped. It claimed
set_static_delay_adjustable(true) was advertised with no on_static_delay_changed() override,
so "a controller can offer the user a static delay this player then applies to nothing". Against
sendspin-cpp v0.8.0 that is not true, and it never was: SyncTask::decode_chunk() subtracts
get_effective_static_delay_ms() from every chunk's client timestamp
(_deps/sendspin-src/src/sync_task.cpp:597), and that value becomes decoded_timestamp, which
is what raw_error is measured against — the drift correction itself. get_effective_static_delay_ms()
returns the stored delay precisely because adjustability is on. So the delay was already being
obeyed; the override was never the thing standing between the value and the audio path. A future
reader tempted to look for a playout shift in PlayerListener should stop here: there is none,
and there should not be.
What was genuinely missing was that nothing in this repo observed or set it. That is what landed:
PlayerListener::on_static_delay_changed()logs a server-set delay at INFO, and its doc comment says outright that both applying and persisting it are already the library's — so the override is observability and nothing else.statusreports it, asstatic delay: <n> ms, read fromPlayerRole::get_static_delay_ms()rather than from a listener-held shadow. That is not a style choice:update_static_delay()does not invoke the listener (only a server'sset_static_delaydoes,_deps/sendspin-src/src/player_role.cpp:396-400), so a shadow would be stale the moment the local knob below was used.sendspin-cli delay <0-5000>sets it locally. The first mutating request answered without a server —statuswas previously the only locally answered one at all — which the spec explicitly provides for: the delay compensates for hardware past the audio port, the client is required to persist it, and "clients may updatestatic_delay_ms... when audio output changes". It is spec-clean becausePlayerRole::update_static_delay()callspublish_state()itself, so the server still learns the value without a controller command carrying it.--static-delay <ms>/static-delay =seedsinitial_static_delay_ms. A first-run default only, and documented as one everywhere it appears:load_static_delay()prefers a persisted value and reads the config field only when there is none (player_role.cpp:565-590), exactly as a restored volume beatsDEFAULT_SINK_VOLUME.- The bound is this repo's own,
MAX_STATIC_DELAY_MSinsrc/control.h, and it tracksSendspin/specroles/player/v1.mdrather than the library — the same relationshipq32_gain_for()'s^1.5has to upstream's^2. The library's own limit is a file-local constant andupdate_static_delay()silently clamps to it, sodelay 5001would have been reported as a success that applied 5000. It is refused at parse time instead.
The volume ramp shipped, closing the last of the two deviations item 7 left here. (The other,
volume/muted persistence, was item 8's; the ^2 taper was fixed in item 7 itself.) The
arithmetic is in src/pcm_volume.{h,cpp} — volume_ramp_step(), ramped_gain() and
apply_volume_ramp() — so it is shared by both scaling backends and testable with no device, the
same reason apply_volume() lives there. Four decisions in it are load-bearing:
- A fixed slew rate, not a fixed duration.
VOLUME_RAMP_MS(20 ms) is what a full-scale change takes, and a smaller change is proportionally quicker. A constant-duration ramp would have to divide by the distance still to travel, which means reading the current gain from the main loop — and only the sinks' own audio paths may advance the current gain. The step is derived from the stream's rate alone, so each sink's setter writes only its atomic target and takes no lock. (ALSA's snap points do write the current gain from the main loop, under thedevice_mutex_that already serialises everything it touches; PortAudio has no such lock and so snaps only where its callback provably cannot run.) - Per frame, not per sample, or the ramp would scale a frame's channels by different gains: an inter-channel amplitude skew, which is worse than the click it removes.
- The advance is committed differently in each sink, and both are right.
AlsaAudioSink::write()commits by the frames it wrote, because its loop can break out early and the sync task re-presents the unconsumed tail — advancing by the frames scaled would leave a gain step across that seam.PortAudioSink::pa_callback()advances by every frame it scales, because it consumes every frame it scales, including the zero-filled tail of a short ring read: that silence is played, so it spends ramp time as legitimately as audio. Both sites carry the comment, so neither gets "fixed" to match the other. - A stream starts at its gain rather than fading into it. ALSA snaps in
configure()andclear(), both underdevice_mutex_. PortAudio snaps inopen_stream_()andrestart_stream_()— beforePa_StartStream(), which is the only place its ordering invariant allows the main loop to touch a field the callback reads — and deliberately not inclear(), where the callback keeps running and where a mid-stream flush is followed by more audio through the same stream, so a ramp in progress should carry on rather than jump. The audible result is the same either way: snapped before the next stream's first sample.
NullAudioSink is excluded deliberately. It records volume without applying it
(src/null_sink.cpp), so there is no sample scaling to ramp; its mute-to-silence path for the
stdout sink stays a jump. Not an oversight.
The DEFAULT_SINK_VOLUME mismatch was already closed, so it was re-documented rather than
fixed. Item 8 made startup call player.update_volume()/update_muted() unconditionally, with
the volume falling back to DEFAULT_SINK_VOLUME, before start() — so the role and the
sink agree from before the first client/state, and the disagreement four comments still
described was no longer observable. Those four (src/audio_sink.h, src/player_listener.h,
src/control.h, src/main.cpp's status()) now describe the current arrangement, keeping the
reasons those members exist: PlayerListener is still the only thing that knows what the sink was
told, and VolumeSource still has no equivalent in the role.
The two remaining PlayerRoleConfig fields, and why they are where they are:
fixed_delay_usis now set explicitly to 0, and must stay there. Both sinks already report future finish timestamps that include their own buffering —snd_pcm_delay()on ALSA,outputBufferDacTimeon PortAudio — so folding device latency in here would count it twice. The value matches the library's default; writing it out puts the constraint beside the field instead of only in this document.extra_startup_silence_msstays at the library's 50 ms default. Item 4's ALSA start-threshold fix has shipped, so the original blocker is gone — but choosing a different figure needs underflow measurements on real hardware across both backends, which is not something to guess at. A comment at the config site says so.
One gap found while scoping this belongs upstream, not here. required_lead_time_ms and
min_buffer_ms are REQUIRED in client/state per roles/player/v1.md — the server uses them
to decide how far ahead to send audio, scheduling the first chunk at least
min_buffer_ms + static_delay_ms out — and sendspin-cpp v0.8.0 implements neither anywhere. A
grep of the whole library, headers and sources, matches neither name. Nothing in this repo can
supply them: ClientPlayerStateObject, which PlayerRole::Impl::build_state_fields() fills, has
no field for either. It needs a library change, not a change here.
PortAudioSink no longer latches into discarding for the rest of a track when its device goes
away: it gets the device back mid-stream, or exhausts a bounded set of attempts trying. ALSA
already recovered -EPIPE/-ESTRPIPE inside its own write(); this is the same idea for the
other backend. Split out of item 4.
The gap was narrower and deeper than the item claimed. Narrower, because configure()
already re-resolves the device at every stream, so only the mid-stream case was broken.
Deeper, because reopening in place is not enough on its own: PortAudio enumerates devices at
Pa_Initialize() and never revisits that list, and resolve_pa_device() walks that cached
list. A DAC pulled out and pushed back in usually comes back as a device the list has never
seen, with the old index left dead — so no amount of reopening reaches it, and a
Pa_Terminate()/Pa_Initialize() cycle is the only thing that can. That single fact is what
made this two tiers across two threads rather than one function.
Tier 1 — one in-place reopen, inline in write(), on the sync task's thread. Where write()
finds that a stream it had has stopped being driven, reopen_in_place_() re-resolves the device
and reopens at the remembered format before the discard path is reached; on success the same call
carries on filling the ring, so playback resumes without waiting for a track boundary. This is all
a host default-output switch needs. It is safe from that thread for a reason now written into the
header, because the class previously read as though only the main loop ever opened a stream:
- The ordering invariant is about when, not which thread.
Pa_AbortStream()/Pa_CloseStream()do not return while the callback is running, andopen_stream_()writes every callback-read field beforePa_StartStream()lets it run again. Neither depends on who is calling. mutex_already serialises it.write(),configure(),clear()andstop()all hold it, so no twoPa_OpenStream()/Pa_CloseStream()calls can overlap; the only otherPa_*callers —probe(),list_devices(),capabilities()— run on the main loop before the server starts.- A parked
write()was already handled.close_stream_()bumpsstream_generation_and notifiesspace_available_, which is the existing mechanism for exactly this.
"A stream it had" is stream_ != nullptr, and that test is load-bearing rather than a
formality. PortAudio does not null the handle when a device goes away, but open_stream_() does
when it fails — so the handle is exactly what separates this stream died from we never got
one. Without it, a device that merely refused a format would be chased: configure()'s failed
open leaves a remembered format behind, the very next write() would repeat the identical failing
open on the sync thread, and its failure would then escalate to a full device-list rescan for a
stream no rescan can help — the device is present and simply will not take that format. That is a
blocking open plus a main-loop stall plus duplicate errors, per stream, bought for nothing.
PlayerListener keeps forwarding audio after a refusal by design, so this is the ordinary path
for an unsupported format rather than a corner.
Two smaller things fell out of it. stopping_ is re-checked after a successful open, because
stop() deliberately latches before it takes the mutex and so can arrive while Pa_OpenStream()
is running — leaving a live stream for the destructor is the hazard configure() already refuses
to create. And the discard return stays frame-aligned after a failed reopen has zeroed
bytes_per_frame_, by falling back to the remembered format.
The reopen holds mutex_ across Pa_OpenStream(), so a configure(), clear() or stop() on
the main loop can block behind it for a few hundred milliseconds. Bounded by the same budget as
everything else here, and against the one such open configure() already performs per stream.
Tier 2 — the device-list rescan, on the main loop. AudioSink gains
virtual void poll(int64_t now_ms) {}, a no-op by default, called from main.cpp's loop beside
mdns.poll() and control_socket.poll(). PortAudioSink::poll() closes the stream, cycles
PortAudioGuard::reinitialize(), re-resolves against the fresh list and reopens. It cannot be
inlined into write(): it invalidates every PaDeviceIndex in the process, and neither
Pa_Initialize() nor Pa_Terminate() is thread-safe.
The costs are real and are documented at the call sites rather than only here. Re-enumerating
every host API blocks the main loop — and so SendspinClient::loop() beside it — for hundreds of
milliseconds to seconds, depending on the host; the figure is not pinned down, which is why the
hardware pass below is asked to measure it. That is affordable because it happens at most once
per stream and only on a stream that has already died (nothing but reopen_in_place_() can ask
for a rescan, and it only runs on a dead stream). The transport itself runs on its own thread, so
what is delayed is the dispatch of messages already received; the tightest deadline on that path
is the time-sync burst response, which the library gives ten seconds. It also only really
terminates because the sink's guard is the sole live one at runtime, PortAudio's init pair being
reference-counted;
anything that later reached probe() from the running loop would turn the rescan into a silent
no-op, which is now noted on reinitialize().
Both attempts are spent per configured stream, not per outage — and that bound is the design,
not a detail. A successful reopen does not refill the budget. Without that, a half-present
device (a dock mid-handshake, a hub browning out) that opens and dies again would have write()
calling Pa_OpenStream() on the sync task's thread fifty times a second, logging an INFO line
each time, forever — the failure mode is worse than the silence being fixed. So a stream that
dies a second time goes straight to the rescan, and once both are gone the sink discards until
the next configure(), which re-resolves anyway and now does so against a list the rescan has
already refreshed.
The two-second gap before the rescan does two jobs, and the second is easy to miss. The obvious
one: the rescan is the last attempt there is, and a replugged DAC takes the host a moment to
enumerate, so spending it the instant the reopen failed would usually spend it before the device
is back. The load-bearing one: it is the gap, not the budget, that bounds the rescan across
streams. reset() refills the budget at every stream and how often streams start is the
server's choice — so the budget alone would permit one teardown per stream, however fast they
came. Because each cycle needs a fresh escalation and each escalation must then wait out the gap,
cycles stay floored that far apart whatever the server does.
The decision lives in src/sink_recovery.{h,cpp} and is unit-tested there, for the reason
mdns_common.cpp and pcm_volume.cpp exist: src/portaudio_sink.cpp is compiled only under
SENDSPIN_CLI_PORTAUDIO_ENABLED where sendspin-cli-tests is built unconditionally, so a policy
living in the sink would go untested precisely on the hosts that have no PortAudio. SinkRecovery
takes now_ms and never reads a clock, holds no PortAudio type and logs nothing, which is what
lets its tests be a plain table. The Pa_* calls themselves stay untested; the hardware pass
below is what covers them.
last_format_ is set from configure()'s arguments, not from a stream that opened. It has to
outlive close_stream_(), which zeroes rate_/channels_/bits_ — but taking it from a
successful open would be wrong in a way that matters: a configure() whose open failed is still
the format the player is about to send audio in, and recovering to the previous one would play
that audio at the wrong frame size. stop() zeroes it, which is what makes a write after shutdown
attempt nothing.
Deliberately left out. No timer thread, and — as this item shipped — no retry loop or backoff curve either: the whole shape was two attempts and a stop. Item 20 later gave the second attempt a backoff and a bounded retry, for the backends whose version of it is a server reconnect rather than a device-list rebuild; PortAudio's remains the one attempt described here.
No re-advertising of formats mid-session: capabilities() is answered once
before start(), so a rescan does not change what the server was told, and the refusal
path that already names the device and the format it would not take stays the mitigation, as the
comment on capabilities() has said since item 3. NullAudioSink is untouched, having no
device to lose.
What remains after this. A device unplugged and replugged between tracks is still not
found, and that is the price of the stream_ != nullptr gate above. The next configure()
resolves against the stale cached list, gets the dead index, fails to open, and ends with no
stream — which from the sink's side is indistinguishable from the device refusing the format, the
case the gate exists to stop chasing. Telling them apart means reading why Pa_OpenStream()
failed and deciding which PaErrors mean "gone" on each host API, which is not worth guessing at
without hardware to check it against. The mid-stream case, which is what this item was for, does
reach the rescan.
Once both attempts are spent, recovery waits for the next
configure() — which is a second disappearance if the first reopen failed, and a third only in
the luckier case where it worked. A rescan renumbers PortAudio's device list, so a numeric
-o portaudio:2 re-resolved after one may name a different card than it did before; the recovery
log line names the device it actually landed on rather than only the spec, but the spec's meaning
genuinely moved, and any later configure() inherits the same shift. capabilities() still
describes whichever device was default when the player started. And the rescan's main-loop stall
is a real, if bounded, pause in protocol handling.
And a correction to what this item left out. AlsaAudioSink was excluded above on the
reading that ALSA recovers inside its own write() and so has nothing to do with a main-loop
tick. That reading was wrong, and issue #45 is what it cost. recover_() handled the three
transients in place and treated everything else — -ENODEV among them — as a failed write:
the handle stayed open on hardware that was gone, and nothing before the next stream retired
it. A device pulled out mid-track therefore moved no audio at all — write() broke out of its
loop and returned 0, the sync task re-presented the same buffer against the dead handle, and the
unthrottled ERROR repeated once per retry until the process restarted. Discarding is what the
sink does now; what it did then was spin. (The next configure() did retire the handle, by
failing its prepare() and reopening — which is why a fresh stream recovered by accident, and
why the outage lasted exactly as long as the track did.)
It now closes the device where the loss is found and escalates to poll() like the rest, with
one difference from the sound-server sinks: the inline attempt is spent rather than made.
snd_pcm_open() cannot be bounded the way PULSE_RECOVERY_TIMEOUT_MS bounds a Pulse reconnect,
and on a plugin PCM — default on a PipeWire host, or the alsa:pulse route — it parses config
and waits on a daemon socket with no timeout at all. Making that call on the sync task's thread,
under device_mutex_, would break the rule the sink's threading model rests on: that write()
never blocks unboundedly while holding it. SND_PCM_NONBLOCK is not the way out either — it
changes the opened stream's semantics, which write()'s snd_pcm_wait() model depends on, and
it bounds neither the config parse nor a plugin's connect. So poll() pays the open instead,
which is also the attempt a replug actually wants: SINK_RESCAN_DELAY_MS is roughly how long a
USB device takes to come back, and an immediate retry would be made before it had.
What that costs, said out loud. poll() holds device_mutex_ across that open, so the stall
is the same real pause in protocol handling PortAudio's rescan is, and a concurrent write()
blocks on the mutex rather than honouring its timeout_ms. Neither is new: configure() already
makes an identical unbounded snd_pcm_open() on the main loop under the same mutex, on every
stream rather than only on a lost device. What bounds it here is the budget — at most
SINK_RESCAN_ATTEMPTS opens per configured stream, spaced by a doubling delay — and the case
that actually stalls is narrow: an absent hw: PCM fails fast, so paying real time needs a
plugin PCM waiting on a daemon socket, or an exclusive device another process now holds. Moving
the open off the loop would mean a recovery thread and a handle published under the lock, which
is a background thread this sink is deliberately built without. The measurement below is what
would justify revisiting that.
Reasoned, not measured. Unlike item 20, this was derived from the code and from the reporter's logs; nobody has yet pulled a DAC out of a running player and watched it come back. See the hardware pass below.
Hardware verification is still owed and is what the shipped qualifier above refers to. The
five cases to run, and to record here once run: a USB DAC unplugged and left out mid-track (logs
once, does not spin or wedge); the same DAC replugged mid-track (playback resumes without a track
boundary); the host default output switched mid-track under a bare -o portaudio; normal
playback, track changes and stop() unaffected when nothing goes wrong; and a measurement of how
long the rescan really stalls the loop, so the estimate above can be replaced with a figure.
The ALSA correction owes its own pass, and it is a differently shaped one — -o hw:CARD=<name>
rather than -o portaudio, and a snd_pcm_open() on the main loop rather than a device-list
rebuild. Four cases: a USB DAC powered off mid-track (two ERRORs, one naming the device and one
saying it is discarding, and then quiet — not one line per buffer, which is the symptom #45 was
reported for); the same DAC powered back on inside the
budget (playback resumes with no track boundary and no restart); one powered back on after the
budget is spent, which should recover on the next configure() and not before; and a measurement
of how long snd_pcm_open() really stalls the loop on an absent hw: device, which is the one
figure the "cannot be bounded" argument above leaves as an estimate.
snd_mixer_* where the device has a real control to drive — chiefly hw: output straight
to a card, where a hardware mixer is the actual volume rather than a scaling of the samples.
Split out of item 4; items 2 and 3 had both deferred it here.
Keep src/pcm_volume.{h,cpp} for plugin devices: the usual default PCM is PipeWire's ALSA
plugin, where a mixer element is either absent or moves something other than this stream.
One path or the other per device, chosen at open time — never both stacked, which would
square the taper.
Linux only, and a second backend rather than a replacement — which is why item 5 shipped
the dns_sd.h one first and left this its own item. The MdnsService seam
(src/mdns.h, one implementation per build chosen by CMake) is what makes it a drop-in.
What it buys, all of it specific to libavahi-compat-libdnssd:
- Daemon-restart recovery that is told to us. The compat shim reports
kDNSServiceErr_ServiceNotRunningon every ref onceavahi-daemonrestarts; item 5 handles that by tearing down and re-registering on a backoff, which works but is a poll. Avahi's own client has a state callback that says it directly. - Collision signalling. With
flags = 0the daemon renames on a conflict and the register callback reports the new name — good enough, but Avahi names the collision. - No compat shim, and so no
*** WARNING *** The program 'sendspin-cli' uses the Apache Portable Runtime...-style stderr banner the shim prints on some distributions, outside the CLI logger entirely. DNSServiceGetAddrInfo's absence stops mattering. Item 5 works around it withDNSServiceQueryRecord; Avahi has a first-class address resolver.
Real spec drift, found while scoping item 5, and inbound-only — so it did not block that
item, but it should not be lost. The spec has moved inbound arbitration to an
activities ranking: management > playback > pairing > empty, provisional until the
first server/activate, with "higher or equal is accepted, lower is rejected", plus a
persisted last-playback server.
Pinned sendspin-cpp v0.8.0 has no activities and no server/activate at all, and
still implements the older connection_reason DISCOVERY/PLAYBACK handoff. So this item is
gated on a library that speaks the newer shape, and is likely to arrive with a
SENDSPIN_GIT_TAG bump rather than on its own.
Item 5's src/last_server.{h,cpp} is the nearest thing that exists today and is
deliberately named for what it observes — the last server whose handshake completed, not
its last playback server, which v0.8.0 gives no way to know.
-o pulse[:<sink>] on libpulse. Not required to reach a PulseAudio server — -o pulse fell through resolve_device_spec()'s rule 3 to ALSA's plugin PCM and played
long before this existed, and still does as -o alsa:pulse. This item is a
deliberate step past that minimum, and what it buys is on the far side of the socket:
- Sink enumeration in
-l, so-ocan name one. The plugin exposes a single ALSA hint, so through it a sink is chosen withPULSE_SINKor~/.asoundrc. - Honest sync feedback.
pa_stream_get_latency()is the server's answer for when queued audio plays out. Through the plugin the same question reachessnd_pcm_delay(), which reports the plugin's own buffering — and this is a synchronised multi-room player, so that is not cosmetic. - A named stream, so the host's mixer shows
sendspin-cliand can route it per-application rather than showing another "ALSA plug-in".
Shipped in src/pulse_sink.{h,cpp}, built when pkg_check_modules(libpulse)
succeeds (-DSENDSPIN_CLI_WITH_PULSE=OFF forces it out):
-
No ring buffer, deliberately — the one structural difference from item 3. libpulse lets any thread write to a stream under the mainloop lock, so the server's own queue is the ring, sized by
--buffer-msthroughpa_buffer_attr::tlengthand withPA_STREAM_ADJUST_LATENCYsizing the rest around it. A ring here would be latencypa_stream_get_latency()could not see, which is the very thing the backend exists to report honestly. -
Threading follows
AlsaAudioSinkrather thanPortAudioSink: one mutex serialises everything, and volume is scaled on the way in with the ramp committed by the frames really written. Lock order is the sink's mutex then the mainloop lock, and no libpulse callback takes the sink's mutex. -
Every wait has a deadline.
pa_threaded_mainloop_wait()has none — it returns when a callback signals it and never otherwise — so a socket that accepts and then says nothing would hang the player at startup. Every wait is a condition variable of our own instead, and a query that times out ispa_operation_cancel()ed so no late callback can write through a dead pointer. -
8/16/24/32-bit. 8-bit is the awkward one: the player emits signed 8-bit and PulseAudio has no signed 8-bit format at all, so it goes out as
PA_SAMPLE_U8with the sign bit flipped in the same pass that scales the volume. Refusing it would leave a stream the other backends play and this one does not. -
Recovery reuses
SinkRecoveryrather than a policy of its own, with its two attempts mapped onto what a sound server actually needs: the inline one reopens the stream when the context is still up, and the delayed one on the main loop reconnects the context — which is what a restarted daemon needs, and whySINK_RESCAN_DELAY_MSis the right gap rather than an arbitrary one.Its one poor fit — found by killing
pulseaudiomid-stream rather than by reasoning — was that the second attempt was retired as it was handed out, so a daemon slower thanSINK_RESCAN_DELAY_MSto come back was never asked again. Item 20 fixed that in the same change, and is where the whole of it is written down.
The sharp edge, accepted knowingly. -o pulse used to mean the ALSA plugin PCM
and now means this backend: a live command line changing what it does on upgrade.
The null precedent is not the justification — null is device-less, so shadowing
it cost nothing, where pulse is a real device on exactly these hosts. What pays for
it instead: -o alsa:pulse is the documented way back and is named in the README, in
-l, and in the message a build without the backend gives; and -l's shadow filter
is derived from the backend table through alsa_pcm_is_reachable() rather than from
a second hardcoded name list, so the PCM list cannot drift into naming a device -o
can no longer reach.
Volume stays on src/pcm_volume.{h,cpp} and is not pushed to the server as a
sink-input volume — the same one-path-or-the-other call item 15 makes for ALSA's
hardware mixer, for three reasons that agree: the spec's (volume/100)^1.5 is not
PulseAudio's cubic taper, stacking the two would square it, and a gain pavucontrol
can move behind our back makes the guarantee in src/audio_sink.h — never report a
volume the speaker is not at — unenforceable, with group volume derived from what
players report.
-o pipewire[:<node>] on libpipewire. This item does not clear the bar item 3
set, and says so rather than leaning on it: item 3's justification was that
PortAudio is the only way this player makes noise on macOS, and libpipewire reaches
no host libpulse cannot — pipewire-pulse is on every PipeWire desktop, and item 18
therefore already plays there. It is here for what is on the far side of the socket:
- Node selection.
pipewire-pulsepresents sinks, which is a compatibility view of the graph rather than the graph. Only a native client can name a node, and only a native client has no compatibility layer in the audio path at all. - The same enumeration and mixer-visibility arguments item 18 makes, one layer further in.
Shipped in src/pipewire_sink.{h,cpp}, built when
pkg_check_modules(libpipewire-0.3 >= 0.3.64) succeeds
(-DSENDSPIN_CLI_WITH_PIPEWIRE=OFF forces it out). The version floor is real:
PW_KEY_TARGET_OBJECT, which is how -o pipewire:<node> names a node, arrived in
0.3.64 and an older libpipewire would build and then quietly ignore the node.
- A ring buffer, unlike item 18 — and the difference is not a preference. PipeWire
pulls: the graph runs
process()on its own realtime data thread and wants a buffer filled there and then, with no way to block. That is PortAudio's shape, so it buffers PortAudio's way, through the same SPSC ring — which moved tosrc/pcm_ring.{h,cpp}rather than being copied, since a second copy of a lock-free ring is the kind of duplication that drifts silently. It is device-free and clock-free there, so it compiles and can be tested on a host with no audio backend, the splitsrc/sink_recovery.{h,cpp}andsrc/pcm_volume.{h,cpp}already make. - Sync feedback from
pw_stream_get_time_n():pw_time::delayis when the next sample this stream hands over is presented, plus whateverqueuedandbufferedsay is already ahead of it, plus the buffer's own duration for its last frame.pw_buffer::sizeis set in frames, because that is whatpw_time::queuedsums. - Volume is scaled on the way into the ring rather than in
process(), so no gain arithmetic runs on the realtime thread at all — the opposite of item 3's choice, and for the reason item 2 makes it: the realtime thread should carry as little as possible. Not that it carries nothing:process()still queriespw_time, notifies the producer and callson_frames_played. That is the same pragmatic tradesrc/pcm_ring.hnames, and the one item 3 already made. - Node enumeration for
-landprobe()is a registry walk with its own thread loop, context and core, ended by apw_core_sync()round trip so one pass is complete rather than merely likely, and bounded bypw_thread_loop_timed_wait(). OnlyAudio/Sinknodes are listed, since-ocannot play through the others. - No node is marked as the default in
-l, deliberately: where a playback stream lands with no target named is the graph's own routing decision, taken per stream and changeable while one is running, so marking one would be a claim-lcannot make.
Item 18's note on shadowing, on volume, and on SinkRecovery applies here unchanged —
-o pipewire was an ALSA plugin PCM too, and -o alsa:pipewire is the way back.
Cross-repo consequence, flagged rather than fixed here. Both backends are
auto-detected and default ON, and
local-audio-addon's
Dockerfile asserts the configure summary line exactly
(grep -qE '^-- sendspin-cli audio backends: null, stdout, alsa$'). If that build
image ever gains libpulse-dev or libpipewire-0.3-dev, its own assertion fails. It
needs -DSENDSPIN_CLI_WITH_PULSE=OFF -DSENDSPIN_CLI_WITH_PIPEWIRE=OFF alongside the
-DSENDSPIN_CLI_WITH_PORTAUDIO=OFF it already passes, or a widened grep — in that
repo, not this one.
Found while building items 18 and 19, by killing pulseaudio mid-stream rather than by
reasoning about it: the sink went silent for the rest of the track. Not a defect in
either sound-server backend — they reuse SinkRecovery exactly as items 3 and 14 wrote
it — but a place where that policy had been shaped by the one backend that then existed.
rescan_due() used to mark the second attempt spent as it handed it out, and
src/sink_recovery.h said why: a rescan that failed and one that worked leave nothing
further to try either way. True of a PortAudio device-list rebuild, which is what it was
written for. Not true of a reconnect: a daemon still down two seconds after the outage
may well be up two seconds later, and nothing has been learned that makes asking again
pointless.
Shipped in src/sink_recovery.{h,cpp}, its callers (src/portaudio_sink.cpp,
src/pulse_sink.cpp, src/pipewire_sink.cpp, and src/alsa_sink.cpp since item 14's
correction) and tests/sink_recovery_test.cpp:
rescan_done(bool), symmetric withreopen_done(bool). A failure re-arms the attempt; a success retires it. Which of the two an attempt is worth is now the backend's to say rather than a property baked into the helper.- PortAudio's behaviour is bit-identical.
PortAudioSink::poll()reportsrescan_done(true)whatever the outcome — itsPa_Terminate()/Pa_Initialize()cycle really is one-shot — and reports it before the work, so none of that function's early returns can drop it. A caller that reports nothing at all still gets exactly one attempt, so the change could not silently turn some future backend into a retry loop;ACallerThatReportsNothingGetsExactlyOneRescancovers that arm on its own. - Bounded twice over, in the code rather than in a promise.
SINK_RESCAN_ATTEMPTS(5) caps the count, and the delay doubles fromSINK_RESCAN_DELAY_MSto a ceiling ofSINK_RESCAN_MAX_DELAY_MS— 2 + 4 + 8 + 16 + 30 seconds, a minute of cover. The doubling is the half that matters to the main loop: a reconnect that waits on an unresponsive socket costs the same every time, so the only way to stop paying it at a fixed duty cycle is to ask less often. - One loud line at the end, and quiet ones before it. Each failed attempt is a
normal step of a restart and logs at
debug; the last one, where the sink has really given up until the next stream, logs atwarn.pending()is what tells the two apart, so the sinks need no counter of their own.
Measured, not reasoned: killing the sound server mid-stream and restarting it a few seconds later now gets audio back without waiting for the next track.
Item 14's hardware pass is still the place to exercise the PortAudio half — a USB DAC
replug and a systemctl --user restart pipewire are the two ways to produce one of
these on purpose, and only the second has been run.
Found in review of items 18 and 19, not by playing anything: RING_QUANTUM_MULTIPLE
was documented as a floor on the ring and never actually applied to one. The ring was
sized from --buffer-ms alone, the quantum is only knowable once the graph has run a
cycle, and the constant was reached for exactly once — to decide the wording of a
debug line. A floor that only ever describes is not a floor.
That matters because process() asks for a whole quantum. A ring that cannot hold one
short-reads and zero-fills the remainder on every cycle, for the life of the stream,
however promptly write() refills it. It is not a starvation that recovers.
Half of it is fixed in src/pipewire_sink.{h,cpp}, because half of it is cheap:
- The stream now asks.
PW_KEY_NODE_LATENCYis set to a third of the ring at the stream's rate, so the graph is told what this client can absorb. Previously it stated no latency at all and took whatever the graph was running. - The arithmetic moved out and grew tests.
pipewire_ring_frames()andpipewire_quantum_fit()are pure and live beside the sink rather than inside it, covered bytests/pipewire_sink_test.cpp— including the case that cannot be produced on demand from a daemon, a quantum larger than the whole ring. The same split, for the same reason, assrc/pcm_ring.{h,cpp}andsrc/sink_recovery.{h,cpp}. - A ring that cannot hold a quantum is now a
warn, not adebug, and it names the--buffer-msthat would clear the floor — a figure rounded up, so passing it back in actually satisfies the check it was printed for.
What is left, and why it is left. PW_KEY_NODE_LATENCY is a request. A graph with
default.clock.force-quantum set overrides it, and then the warning is all there is:
the player names the fault and keeps zero-filling. Fixing that properly means observing
the quantum and resizing the ring to suit — which cannot be done in place, because the
realtime data thread is reading that ring with no way to be told to wait. It means
tearing the stream down and reopening it at a new size, on the first process() of a
stream that has just started, which is a reconnect in the middle of the audible path and
wants the same care item 20's recovery got. Worth doing deliberately rather than as a
review fix.
Flagged rather than fixed here. The new pipewire-minimum CI job — Debian 12, and
so libpipewire 0.3.65, the oldest release the README claims — is the only build in
.github/workflows/build.yml that does not pass -DSENDSPIN_CLI_WERROR=ON. gcc 12 has
a -Wrestrict false positive on line += " " + std::to_string(...) in
src/control_common.cpp, and it is the only thing standing between that leg and the
warning line every other leg holds. One site, one rewrite; it belongs to whoever next
touches the control protocol rather than to a PipeWire change.
The Python sendspin-cli grew hooks because headless installs asked for them: the box a
player runs on usually has an amplifier relay, a light, or a curl-able switch next to
it, and "a stream started" is the moment to flip it. The C++ CLI had no equivalent, and
no workaround either — the control socket answers questions, it does not announce events.
Shipped in src/hooks.{h,cpp}, src/cli.{h,cpp}, src/player_listener.{h,cpp},
src/main.cpp and tests/hooks_test.cpp:
- Two long-only flags, and their config keys.
--hook-start/--hook-stoprun a shell command (/bin/sh -c, so pipes and&&are one hook) on stream start and stop.hook-start/hook-stopin the config file, through the sameapply_option()door as every other key. - The Python CLI's vocabulary, verbatim.
SENDSPIN_EVENT(start|stop) always;SENDSPIN_SERVER_ID,SENDSPIN_SERVER_NAME,SENDSPIN_SERVER_URL(the URL this run dialled — outbound only, as in Python) andSENDSPIN_CLIENT_NAMEwhere known. A hook script written against one player runs unchanged against the other.SENDSPIN_CLIENT_IDcarries the--idvalue item 23 chooses: the library's MAC-derived default is not exposed, so without the flag it stays unset.SENDSPIN_SERVER_URLis what this run dialled, which is not the same claim as which server answered:-sleaves the inbound listener up, and the library reports that a connection is up without saying where it came from — no connect callback, and nothing exposing a connection's URL or direction — so one that dialled in while an outbound attempt was outstanding or had failed carries the attempt's URL. Closing that needs sendspin-cpp to name the live connection's origin; until it does, the docs point a hook that must be certain atSENDSPIN_SERVER_ID, which is read from the connection the stream arrived on. An unknown is left unset rather than exported empty, and inheritedSENDSPIN_*variables are cleared so a wrapper script's stale export cannot describe some other run. Both events of a stream are told what was gathered when it started: by the time one ends its connection is usually already gone, and asking again would hand the stop hook a server it can no longer name. - Nothing waits on a hook. The spawn is a
fork()/execve()with the environment built before the fork — this process has the library's background threads, so the child may only touch async-signal-safe calls — and the reap is aWNOHANGwaitpid()polled from the main loop, next to mDNS and the control socket. A non-zero exit or a signal death is oneW hook:line; a hook still running at shutdown is left to finish, because an amplifier half-switched is worse than an orphan. - The stream's end is waited for on the way out.
client.stop()goodbyes every peer and deliverson_stream_end()before it returns, so the hook is queued by the time control comes back and thehooks.flush()below runs it. Without that wait a player killed mid-stream would reachreturn 0with its stop hook unrun and the amplifier still on. The stop hook it spawns is by definition the orphan case above: nothing waits on it. - Fired on the stream lifecycle, not on the format being accepted, through a
PlayerListener::on_stream_eventseam shaped likeAudioSink::on_frames_played. A stream the device refused is audio arriving and being discarded — the case where the amplifier being on matters most — so the hook window is exactlystatus'sstream: receiving. - The child's stdout goes to stderr, because with
-o stdoutthe parent's stdout is carrying PCM and a hook'sechowould land in the middle of the audio. Under-fboth end up in the logfile, which is where a hook's output belongs anyway. - And nothing else of the player's crosses into it. Every descriptor above stderr is
closed in the child, because an exec carries them all: a hook that kept the audio port's
listening socket holds it for as long as it runs, and a restart in that window fails to
bind while still logging that it is listening. Closed there rather than opened
close-on-exec at each site, since the library's and IXWebSocket's sockets are not ours to
reach into. SIGPIPE goes back to
SIG_DFLfor the same reason: the player ignores it, and an ignored disposition survivesexecve()where a caught one does not, so… | head -1inside a hook would otherwise report a failed write rather than ending.
The client id was the library's MAC-derived default with no way to choose one, and the
client/hello device info was hardcoded. The id is the half that bites: it is the
stable identity a server files volume, group membership and pairing under — -n is
only what it displays — and two players on one host derive the same MAC, so each
server-side setting lands on whichever connected last. The dual-mono
two-daemons-one-host pattern needs a flag; the Python CLI has all three.
Shipped in src/cli.{h,cpp}, src/main.cpp, tests/cli_test.cpp and
tests/config_file_test.cpp:
--id <id>setsSendspinClientConfig::client_id, and its config key isid. The default stays empty on purpose — the library derives the MAC-based id only when nothing is set, and that remains the right identity for one fixed endpoint per host. The flag also feeds item 22'sSENDSPIN_CLIENT_ID, which until now was reserved but never set: the derived id is not exposed by the library, so the flag's value is the first honest one a hook can be handed.--manufacturer/--product-nameoverride the hello device info, for the integrator whose product embeds this player and should be listed as itself. Defaults staysendspin-cpp-cli/sendspin-cli, declaring what this really is.- All three are refused empty, warned about on a subcommand run like every other
daemon-only flag, and settable from the config file through the same
apply_option()door.
Verified against a real aiosendspin server: the connected client reports the --id
value as its client_id, and the start hook exports it as SENDSPIN_CLIENT_ID.
Item 4 made the advertisement device-derived and ranked, and that is the right default —
but it decides for the operator. The case it cannot cover is an operator who knows
which formats they would rather receive: a DAC that sounds best at one shape, or a
network where FLAC should win over PCM. What they need is to put those formats at the
front of the advertisement without taking anything else away, since the server picks the
first entry it can encode and still needs somewhere to fall back. The Python CLI's
--audio-format does that for one format, validated against the device at startup with
a hard exit on failure. It does not make the player use only that format.
Shipped in src/supported_formats.{h,cpp}, src/cli.{h,cpp}, src/main.cpp,
tests/supported_formats_test.cpp, tests/cli_test.cpp and tests/config_file_test.cpp:
--audio-format <codec:rate:depth:channels>[,...](config keyaudio-format), e.g.flac:48000:24:2orflac:48000:24:2,pcm:48000:24:2. One or more specs, comma-separated in priority order. Each spec's grammar is the Python CLI's, extended withopusbecause this player decodes it. The value is one string like every other option, so a repeated flag or key replaces the list.- A reorder, not a narrowing.
pin_preferred_formats()moves the listed entries to the front of the derived advertisement in the order given. The protocol hassupported_formatsin priority order, so the front is the whole of what "preferred" means on the wire. Everything else the player advertises is still offered behind them, in its ranked order, so a server that cannot encode a listed format can still choose a later entry. Preferred, not exclusive. - A listed format the advertisement does not carry refuses to start, naming every
missing format, the device, and
-las the way to see what the device itself reports. That is not the same set as what is advertised, which carries a single channel count. Checked against the derived list, or the permissive fallback when the device reported nothing, since that is what actually goes out, so the refusal describes the real advertisement. On success the pinned formats are logged in order. - Parse-time shape validation, startup-time advertisement validation. The grammar, the
codec names, the four emittable bit depths and the one shape Opus is ever advertised in
are settled in
parse_format_spec(), and the list's empty and duplicate entries inparse_format_list(), when the flag is read. That way a config file is validated without opening a device, and whether the advertisement carries each format is answered where the sink is real.
Verified against a real aiosendspin server: with pcm:44100:16:2 pinned on a device
whose ranked head is FLAC 48 kHz, the server encoded and streamed PCM at 44100 Hz.
Bind the inbound listener — and scope the advertisement — to one interface, instead of taking every address the host holds. Nothing here is buildable against the pinned library, so this item exists to settle the design and the evidence rather than to leave them to be re-derived.
The gap, in the real code. The listener is constructed with a literal at
sendspin-cpp's src/host/ws_server.cpp:53 — ix::WebSocketServer(server_port_, "0.0.0.0", ...) — and its SendspinClientConfig (include/sendspin/config.h) carries
server_port and server_max_connections and nothing else about the socket. This repo's
advertisement registers on kDNSServiceInterfaceIndexAny at src/mdns_dnssd.cpp:200.
Those two are consistent today: both unrestricted. The flaw is not a mismatch between
them — it is that neither can be narrowed, and only the listener would matter if it could.
The one knob that looks like it and is not. SendspinClientConfig does carry a
mac_address override, documented upstream as "recommended on multi-homed hosts where
detection may pick the wrong interface" — the same scenario, so it is worth saying that it
is not the same lever. That value goes into client/hello's device_info: it is identity,
and it moves no socket.
Why this is more than "it listens broadly". Pinned v0.8.0 has no inbound
authentication of any kind: no PSK, no pairing gate on the inbound path, and — per item
17 — no activities / server/activate either, since it still runs the older connection_reason handoff.
server_max_connections is the only inbound limit, and it counts sockets rather than
judging them. Reachability is therefore authorization: whatever reaches the listen port
completes the handshake and drives the player, and a bind address would be the only such
control the player itself offers.
That is a statement about the pinned library, not about Sendspin. The spec authenticates in the handshake — pairing and a PSK, as item 6 records — and v0.8.0 does not implement that half yet. This item's premise expires when it does.
In proportion. Most installs sit behind NAT, where no interface holds a routable address and the exposure is the LAN the player is meant to serve anyway. The real cases are multi-homed hosts, VPSes, tunnel interfaces and untrusted VLANs. A host firewall is the working mitigation today, and it is the honest answer for an operator who needs one now.
Why the mDNS half is not shippable on its own. An advertisement scoped to one
interface while the socket still listens on 0.0.0.0 hides the player without protecting
it: anything that already knows the address and port still connects. It does buy something
real — an unscoped register publishes address records on every interface, and a server can
take one that is unreachable from where it sits — but that is a discovery-correctness
fix, not access control. Shipping it under this flag's name would sell it as the latter.
Both halves land together, or the mDNS half lands under its own name.
The parity record, corrected. The Python CLI is the usual reference for a flag like
this, and two things commonly assumed about it are wrong — the flag checked in
sendspin-python-cli, the behaviour in aiosendspin 6.0.1, the version that CLI pins:
-
Its
--interfacetakes an IP address, not an interface name.sendspin/cli.py:475documents it as "IP address of the network interface to bind to", andsendspin/daemon/daemon.py:212passes it through unexamined ashost=self._args.interface if ... else "0.0.0.0". There is noif_nametoindex()anywhere in it. -
It does not scope the advertisement to that address. The bind is honoured —
ClientListenerdoesweb.TCPSite(self._runner, self._host, self._port)— but_start_mdns()builds a bareAsyncZeroconf()and takes the address it publishes fromget_local_ip()inaiosendspin/util.py, a UDP route lookup to8.8.8.8.self._hostnever reaches it. Unchanged at9.1.0.What Python does scope locally is the browse:
ServiceDiscovery(interfaces=[...])hands the value toAsyncZeroconf(interfaces=...)insendspin/discovery.py:150. That is the outbound discovery path, not the inbound listener.
The flag, settled. --interface <name-or-address> (config key interface), long-only
like the other long-only flags. One value has to yield both halves — the bind
needs an address, DNSServiceRegister needs an index — and a name is what an operator
on a Pi actually knows, while the address is the thing a DHCP lease changes underneath
them. That makes this a deliberate superset of Python's address-only flag rather than a
divergence from it.
Both directions have to be resolved, and neither is a lookup. A name yields an index
from if_nametoindex() but must go through getifaddrs() for an address — and an
interface holds several: a v4, a link-local v6, often a global v6, sometimes aliases.
ix::WebSocketServer takes one host string, so which one is a decision to settle when
the flag is built, not a detail: prefer the first global v4, fall back to a global v6,
and refuse a link-local v6 rather than guess at a %scope suffix the transport may not
accept. In reverse, a literal address is what the bind wants directly but yields an index
only by scanning getifaddrs() for the interface holding it — which can match none, and
with aliases can match more than one. Both misses are refusals, not fallbacks.
Validated in two stages, the split item 24 established. The shape is checked when the
flag is read — a name within IFNAMSIZ, or an address inet_pton() accepts — so a config
file is validated without touching the host, and a typo still hard-fails at parse time with
the single error: line of item 1. Resolution happens at startup, where the interfaces
are real, because it depends on live host state: a Pi booting with interface = eth0 in
its config before DHCP has brought the link up must fail where a device failure is reported,
not from inside the parser. That is the same seam item 24 drew between parse_format_spec()
and the advertisement check, for the same reason.
Scope when it lands. The inbound listener, which is the whole point, and the
advertisement's interface index. The outbound -s dial stays out: it is kernel-routed,
as it is in Python. Scoping the browse at src/mdns_dnssd.cpp:217 is the one thing Python
does locally and can come along as an extension — but it is discovery convenience, not
security, and is not what the flag exists for.
The upstream gate. A bind address on SendspinClientConfig, threaded through
ConnectionManager into SendspinWsServer to replace the literal. Host-only and small,
but SendspinWsServer exposes only connection callbacks — there is no transport seam the
CLI can reach around it — so the flag cannot be expressed by any other route. Gated on a
SENDSPIN_GIT_TAG bump the same way item 17 is, and likely to arrive with one rather than
on its own. Not expected to move soon.
-s used to take a typed-in address — <host>[:<port>] or a ws:// URL — beside its
mdns: discovery form. The spec has exactly two ways to connect: the server discovers the
client through its _sendspin._tcp advertisement, or the client discovers the server on
_sendspin-server._tcp and connects "using the advertised address and path". Dialling a
hand-entered address is neither, so it went.
Shipped in src/cli.{h,cpp}, src/main.cpp, src/outbound.{h,cpp},
tests/cli_test.cpp, tests/config_file_test.cpp, tests/discovery_test.cpp and
scripts/smoke_test.sh:
-s mdns:[<name>]is the only form, typed or asserver =in a config file, and it behaves as it did: browse, pick with the last-server tie-break, dial on the retry pacer's backoff, advertisement suppressed.- Anything else is a hard error at parse time, not a warning, so an install still
configured with an address fails loudly rather than quietly changing how it connects. From a
config file the error names the file and line, and a bare
mdnsis asked whether it meantmdns:. - The error does not quote the value. An address was where credentials got typed, and
redact_url_userinfo()went withparse_server_url(): a discovered URL is built from a resolved address, a port and a TXTpath, and has no userinfo to mask. The smoke test's credential-redaction check went with them. SENDSPIN_SERVER_URLis answered only for the server_id the dial chose.LastDialhas no "literal URL, taken at its word" case left; a dial with no id answers nothing.