Let a player be driven from its own host - #9
Merged
chrisuthe merged 7 commits intoAug 11, 2026
Merged
Conversation
Roadmap item 7. 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: 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 on, and the same binary is now its own client: sendspin-cli status sendspin-cli pause sendspin-cli vol 40 sendspin-cli seek-rel -30000 The whole of controller@v1, one subcommand each. Three are easy to misread and are documented for what they do: vol is *group* volume (so status prints group and player volume as two named lines, not one ambiguous one), switch re-homes this client between groups rather than changing source, and seek-rel is bounded only by int32_t because the server does not bound it and our own track progress is an interpolated shadow. No thread and no command queue. ControlSocket::poll(now_ms) runs from the main loop beside mdns.poll(), which is forced rather than chosen: send_command() reaches ConnectionManager::current(), documented main-thread-only, and get_controller_state() returns a reference to a vector drain_events() move-assigns from inside client.loop(). A round trip costs up to one tick. Three failure modes stay distinct, with their own exit statuses, because they need three different actions: nothing listening (3), no server connection (4), and a command absent from supported_commands (5). The ordering is load-bearing -- on_controller_state_clear() empties supported_commands on a disconnect, so a gate that consulted it first would answer "pause is not supported" when the truth is that nothing is connected. The socket is 0600 at $XDG_RUNTIME_DIR/sendspin-cli-<port>.sock, with the mode set by bracketing bind() rather than a later chmod(), and a sibling <path>.lock held under flock() so a stale socket and a live one are different answers rather than a guess. There is deliberately no /tmp fallback: a world-writable directory would let any local user pause playback and switch this endpoint out of its group. An absent $XDG_RUNTIME_DIR warns once and the player carries on serving audio. One bug found on the way, silent and platform-specific: rebuilding argv without POSIX's argv[argc] == NULL sentinel breaks BSD getopt_long(), whose long-option path does optarg = nargv[optind++] unconditionally and then tests for NULL. So "--port" with no value read one past the end of the array and accepted whatever was there. It surfaced as a test that segfaulted in a different case each run. Also fixed, 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. 90 new tests (147 to 237), none of which binds a socket, plus five smoke checks for what needs two processes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three are the same shape: an invariant that held, but only because something two files away was keeping it. The subcommand client could be killed by SIGPIPE rather than reporting EPIPE. A daemon that goes away between our connect() and our write() -- because it was at its connection cap, or was shutting down -- makes that write raise it, and the default disposition is terminate, so the process died with signal 13 instead of printing the error it had ready. Ignored in the subcommand path only; that process does nothing else with a socket. control.h failed with a complaint about a missing member function under -DSENDSPIN_ENABLE_CONTROLLER=OFF, which is reachable because CMakeLists.txt sets the option without FORCE. The control channel *is* the controller role, so it now says that. And the client memcpy'd into sun_path on the strength of the parser having refused an over-long path. That is true, and it is an invariant held two files from the fixed-size array it protects, so the check is local now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three real defects and a set of comments describing rules the code does not implement. status said "stream: idle" during a stream whose format the device had refused. The flag was inferred from the configured format, which is set only on configure()'s success path -- so the one case where "audio is arriving here" matters most, because it is arriving and being discarded, reported the opposite. An operator diagnosing "nothing is coming out" was told nothing was being sent. PlayerListener now owns an explicit flag set above every guard in on_stream_start(), and streaming() && !stream_format() is the refused case rather than an inconsistency. StreamFormat moves to audio_sink.h, beside the configure() argument list it mirrors, so the audio adapter no longer depends on the control channel's header. The flock scheme was a second copy of daemon.cpp's, down to near-identical strings -- while README.md and ROADMAP.md both assert the two "already running" refusals are worded the same, with nothing keeping them that way. Extracted as daemon.h's lock_file(), which both callers use. And under -z that refusal was invisible. ControlSocket::open() runs after the fork, correctly, so its message landed in a log the shell had stopped watching -- and without -f, nowhere at all. -P is probed pre-fork for exactly this reason. probe_control_socket() mirrors it: the parent takes and drops the lock, the child acquires it for real. The parity README claimed with -P was true of the wording and false of the place. Also, smaller: a connection refused by the cap is answered in the protocol's own shape instead of hung up on, and logged at debug rather than warn so a connect() loop cannot flood an -f logfile; encode_control_reply() flattens newlines out of a reason, since the first newline is the format's only framing; the write path no longer prints strerror(errno) on a zero-length write, where errno is stale; --no-control alongside a subcommand no longer blames the wrong command line; and the smoke test's pre-existing checks pass --no-control so they stop leaving sockets in the developer's real $XDG_RUNTIME_DIR. Comment fixes, all describing behaviour rather than intent: the idle deadline runs from accept() and is not a silence timer; the poll loop collects survivors rather than compacting in place; smoke_test.sh's one-port-per-phase claim; two constants sharing one Doxygen block; and a credit to the wrong test. Recorded in ROADMAP item 7: the macOS default path is a real gap, since launchd sets no $XDG_RUNTIME_DIR, and confstr(_CS_DARWIN_USER_TEMP_DIR) would close it without weakening the argument against /tmp. Left as a decision rather than a drive-by. Item 12 now owes a ThreadSanitizer leg, which this item's whole safety argument leans on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
launchd sets no $XDG_RUNTIME_DIR -- not for a system service and not in an interactive shell -- so the default socket path never resolved on the one platform the PortAudio backend exists to serve, and every macOS user needed an explicit --control-socket. The first thing the feature said to them was advice to turn it off. So there is a second source: confstr(_CS_DARWIN_USER_TEMP_DIR), the per-user /var/folders directory launchd already provides. $XDG_RUNTIME_DIR still wins wherever it is set, on every platform. Deliberately not $TMPDIR, which usually names the same directory. confstr() reads nothing from the environment, so unlike $TMPDIR it cannot be pointed at a directory someone else can write -- which is exactly what stops this being the /tmp fallback the design refuses, rather than a softening of it. And it is verified rather than trusted: a directory, owned by the effective uid, with neither the group- nor the other-write bit. That check is load-bearing, not decorative -- macOS and the BSDs do not enforce socket-inode permissions on connect() at all, so there the directory is the only thing between another local account and this player's transport controls. There is still no third source, and still no /tmp. With neither available -- now the Linux systemd system-unit case -- it warns once and keeps serving audio, exactly as before. Nine new tests, six of them on the verification itself: /tmp is refused, a group- or world-writable directory is refused, a non-directory and a missing path are refused, one owned by someone else is refused, and whatever the platform hands back is asserted to pass the same check the code applies. The two tests that had asserted the old behaviour now read the platform off the function the parser uses rather than an #ifdef, so each platform is checked for the right outcome instead of one being skipped. The smoke test does the same, and on macOS now exercises the fallback end to end: socket bound 0600 under a directory this user owns, reachable by a subcommand given no path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things a reader would reasonably ask about the new check, both now answered where the code is rather than left to be re-derived. stat() rather than lstat() is deliberate and is the safe direction: a link into a world-writable directory is refused for the target's mode, and one into someone else's tree for the target's owner, while lstat() would instead refuse a legitimately symlinked $XDG_RUNTIME_DIR for being a link at all. Now covered both ways by a test. And there is a window between the check and the bind() that follows it. It is left open knowingly: closing it needs the socket created through a descriptor opened on the verified directory, which bind() has no interface for, and swapping the directory in between needs write access to its parent -- which on both sources is itself user-owned. An attacker with that does not need the race. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS fallback was right; three pieces of prose describing the old behaviour were not, and one diagnostic was computed and thrown away. --help still said the default was $XDG_RUNTIME_DIR and that without it there was none. That is the first place a macOS user looks, and it was wrong for them specifically. It now conditionalises the same way two other lines in print_usage() already do. control_socket_absent_reason()'s docstring contradicted its own body -- "an absent variable means no socket" beside an #ifdef added because it no longer does -- and the smoke test still justified itself with "$XDG_RUNTIME_DIR ... is deliberately the only thing" the path is built from. is_private_runtime_dir() produces exactly the actionable message -- which directory, and whether it was group-writable or pruned away -- and the caller dropped it, leaving the operator with "could not be used" and nowhere to go. It now travels out on a ControlRuntimeDir and into the absent reason, so a found-and-refused candidate is diagnosed as that rather than as an unset variable. That struct also carries a warning, which closes a gap the review raised: the one source actually used on Linux was never checked at all, so a unit with RuntimeDirectoryMode=0775 got a group-reachable control socket in silence. $XDG_RUNTIME_DIR is still honoured unverified -- it is the user's declaration -- but it is now checked, and a directory anyone else can write is used *and* said out loud. Only for a daemon: the subcommand merely connects, and repeating it per `status` would be noise about another process's decision. And two tests were passing without asserting anything. PlatformRuntimeDir returned early on Linux, so it reported a pass having checked nothing; it now asserts the answer is empty there, which locks in "no third source" against a future accidental fallback, and asserts it is present on macOS. The cli_test case derived its expectation from the function under test, so a confstr() that regressed to empty would have taken the other branch and still passed; the acceptance criterion is now asserted directly. 251 tests, up from 247. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "90 new tests (147 to 237)" figure was true when it was written and three rounds of review fixes ago; it is 104 and 251 now, and the list did not mention the directory check's rejection paths. Item 12's account of what the smoke test covers still described only the pre-item-7 checks. Both are claims about current coverage, which is the kind that quietly stops being true and is worth keeping honest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Roadmap item 7: a Unix control socket in the daemon, plus
sendspin-cli <subcommand>on the same binary, so a player can be driven from its own host and not only by a remote controller.What blocked it was a wrong comment
CMakeLists.txtpinnedSENDSPIN_ENABLE_CONTROLLER OFFbecause the role was "for driving other clients, which this daemon does not do". That is not what it is:controller@v1carries the transport verbs for the group this client is part of, and theplayerrole has none of them — only this endpoint's own volume, mute and static delay. So the role is on, its rationale is rewritten, and the same binary is now its own client:Every verb
controller@v1has, one subcommand each.repeatandshuffleare the two that are not pass-throughs — the mode is the command — soshuffle offsendsUNSHUFFLE. A test walks the table against the library's enum, so a protocol command with no subcommand fails the suite.Three things that are easy to misread, so they are documented for what they do
volis group volume. The server spreads it across the group and clamps per player, sostatusprintsgroup volumeandplayer volumeas two named lines. A squeezelite refugee expectsvol 50to move this box; it does not, and one ambiguousvolume:would hide that.switchis not a source selector. Per the spec's switch cycle it re-homes this client between groups. It sits next toplayand means something quite different.seek-relis bounded only byint32_t. The server does not bound it, and our own track progress is this client's interpolated shadow — bounding against that would refuse legitimate commands.No thread, and that is forced rather than chosen
send_command()reachesSendspinClient::send_text()andConnectionManager::current(), documented main-thread-only. Reading is no safer:get_controller_state()returns a reference to a vectordrain_events()move-assigns insideclient.loop(). Sopoll(now_ms)runs from the main loop besidemdns.poll(), carrying the same THREAD SAFETY notesrc/mdns.hdoes, and the daemon copies the state into a plain snapshot. A round trip costs up to one 10 ms tick; that is stated rather than fixed by shortening the tick.Three failure modes, three exit statuses
Because they need three different actions: nothing listening (3), the player up with no server connection (4), a command absent from
supported_commands(5). The ordering is load-bearing —on_controller_state_clear()emptiessupported_commandson a disconnect, so a gate that consulted it first answers "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.Where the socket goes
0600, at$XDG_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(), which would leave a window where any local account can connect.Where
$XDG_RUNTIME_DIRis unset — which on macOS is always, since launchd sets no such variable — it falls back toconfstr(_CS_DARWIN_USER_TEMP_DIR), the per-user directory launchd already provides. Deliberately not$TMPDIR, which usually names the same place:confstr()reads nothing from the environment, so unlike$TMPDIRit cannot be pointed at a directory someone else can write. Verified before use, not trusted. There is no/tmpfallback on any platform — a world-writable directory would let any local account pause playback andswitchthis endpoint out of its group. With neither source available (a systemd system unit) it warns once and keeps serving audio.A sibling
<path>.lockunderflock()makes "stale" and "in use" different answers rather than a guess, through the samelock_file()helper-Puses — which is what makes the two "already running" refusals identically worded rather than coincidentally so. Probed pre-fork as well, so-zrefuses a duplicate at the terminal and not only in a log the shell has stopped watching.The parser
argv[1]is 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. Every argument is validated at parse time, sovol 500fails at the terminal like a bad--buffer-msrather than out on the wire.One real bug found on the way, silent and platform-specific: rebuilding argv without POSIX's
argv[argc] == NULLsentinel breaks BSDgetopt_long(), whose long-option path doesoptarg = nargv[optind++]unconditionally and then tests for NULL.--portwith no value read one past the array and accepted whatever was in memory. It surfaced as a test that segfaulted in a different case on each run.Tests
104 new tests (147 → 251), none of which binds a socket: the argv split, every subcommand's parse and protocol mapping, a wire round-trip, the refusal predicate against hand-built snapshots (including the empty-
supported_commandsdisconnected case), thestatusformatter, the reply status line, line framing, and every rejection path of the directory check.scripts/smoke_test.shgains ten checks for what needs two processes.Green on:
ctestin the default and-DSENDSPIN_CLI_WITH_MDNS=OFFconfigurations, clean under-DSENDSPIN_CLI_WERROR=ON, shellcheck clean, and the whole smoke path under-fsanitize=threadwith 0 reports.What this does not claim
Nothing here has been driven against a real Sendspin server, so these are reasoned from the library's source and covered by unit tests rather than observed: a command actually reaching a server and moving playback, the
supported_commandsrefusal against a real published set,seekagainst a realseek_max_ms, and everystatusfield only a connected server fills in.docs/ROADMAP.mditem 7 says so.Two unrelated fixes, both prerequisites
check_default_mdns_bootchose its expected outcome from whether the host had an mDNS daemon, ignoring whether the build had mDNS — so the smoke test died partway through against a-DSENDSPIN_CLI_WITH_MDNS=OFFbuild, before reaching any control check. AndScopedEnvwas lifted out oflast_server_test.cppintotests/scoped_env.hrather than copied.