Skip to content

Let a player be driven from its own host - #9

Merged
chrisuthe merged 7 commits into
mainfrom
chrisuthe/task/add-a-local-control-socket-and-sendspin-cli
Aug 11, 2026
Merged

chrisuthe merged 7 commits into
mainfrom
chrisuthe/task/add-a-local-control-socket-and-sendspin-cli

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

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.txt pinned SENDSPIN_ENABLE_CONTROLLER OFF because the role was "for driving other clients, which this daemon does not do". That is not what it is: 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, its rationale is rewritten, and the same binary is now its own client:

$ sendspin-cli status
name: living-room
server: Music Assistant (connected)
state: playing
stream: receiving
track: Nils Frahm - Says
position: 2:05 / 9:03
group volume: 55
player volume: 80
output: default (48000 Hz / 2 ch / 16-bit)

$ sendspin-cli pause
$ sendspin-cli vol 40
$ sendspin-cli seek-rel -30000

Every verb controller@v1 has, one subcommand each. repeat and shuffle are the two that are not pass-throughs — the mode is the command — so shuffle off sends UNSHUFFLE. 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

  • vol is group volume. The server spreads it across the group and clamps per player, so status prints group volume and player volume as two named lines. A squeezelite refugee expects vol 50 to move this box; it does not, and one ambiguous volume: would hide that.
  • switch is not a source selector. Per the spec's switch cycle it re-homes this client between groups. It sits next to play and means something quite different.
  • seek-rel is bounded only by int32_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() reaches SendspinClient::send_text() and ConnectionManager::current(), documented main-thread-only. Reading is no safer: get_controller_state() returns a reference to a vector drain_events() move-assigns inside client.loop(). So poll(now_ms) runs from the main loop beside mdns.poll(), carrying the same THREAD SAFETY note src/mdns.h does, 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-bearingon_controller_state_clear() empties supported_commands on 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 bracketing bind() with a umask() rather than a later chmod(), which would leave a window where any local account can connect.

Where $XDG_RUNTIME_DIR is unset — which on macOS is always, since launchd sets no such variable — it falls back to confstr(_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 $TMPDIR it cannot be pointed at a directory someone else can write. Verified before use, not trusted. There is no /tmp fallback on any platform — a world-writable directory would let any local account pause playback and switch this endpoint out of its group. With neither source available (a systemd system unit) it warns once and keeps serving audio.

A sibling <path>.lock under flock() makes "stale" and "in use" different answers rather than a guess, through the same lock_file() helper -P uses — which is what makes the two "already running" refusals identically worded rather than coincidentally so. Probed pre-fork as well, so -z refuses a duplicate at the terminal and not only in a log the shell has stopped watching.

The parser

argv[1] is split off before getopt_long(). Not by reading getopt's leftovers: glibc permutes a positional argument out of the way and the BSDs stop at it, and seek-rel -5000 is indistinguishable from a flag cluster to getopt regardless. Every argument is validated at parse time, so vol 500 fails at the terminal like a bad --buffer-ms rather than out on the wire.

One real 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. --port with 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_commands disconnected case), the status formatter, the reply status line, line framing, and every rejection path of the directory check. scripts/smoke_test.sh gains ten checks for what needs two processes.

Green on: ctest in the default and -DSENDSPIN_CLI_WITH_MDNS=OFF configurations, clean under -DSENDSPIN_CLI_WERROR=ON, shellcheck clean, and the whole smoke path under -fsanitize=thread with 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_commands refusal against a real published set, seek against a real seek_max_ms, and every status field only a connected server fills in. docs/ROADMAP.md item 7 says so.

Two unrelated fixes, both prerequisites

check_default_mdns_boot chose 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=OFF build, before reaching any control check. And ScopedEnv was lifted out of last_server_test.cpp into tests/scoped_env.h rather than copied.

chrisuthe and others added 7 commits August 10, 2026 23:23
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>
@chrisuthe
chrisuthe marked this pull request as ready for review August 11, 2026 13:53
@chrisuthe
chrisuthe merged commit 6d58bca into main Aug 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant