37 KiB
Server-Initiated Client Discovery Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a Sendspin server browse _sendspin._tcp.local. mDNS advertisements, dial out to each discovered player via WebSocket, and funnel the resulting connections into the existing handleConnection code path with zero changes below the transport layer.
Architecture: A new ClientDialer component owned by pkg/sendspin.Server subscribes to a new discovery.Manager.BrowseClients() stream, dedupes by mDNS instance name, dials each advertised ws://host:port{path} endpoint with exponential backoff, and hands the upgraded *websocket.Conn to s.handleConnection — the same entry point inbound connections already use. Dedupe-by-ClientID at the protocol layer (server.go:394-ish) provides a second safety net for races against inbound connections.
Tech Stack: Go 1.24, github.com/hashicorp/mdns v1.0.6 (browsing + TXT parsing), github.com/gorilla/websocket v1.5.3 (dialing), standard library testing.
Spec Reference
From https://www.sendspin-audio.com/spec/ (server-initiated discovery mode):
- Service type:
_sendspin._tcp.local.(note: client-advertised, not server-advertised) - Recommended port:
8928 - TXT records:
path=<ws endpoint>(typically/sendspin),name=<friendly name>(optional) - After dial: player still sends
client/hellofirst; server respondsserver/hello - Mutual exclusion is per-client: a player must NOT both advertise
_sendspin._tcpAND manually connect to a server
The server side we're building can freely run both modes in parallel — dedupe handles collisions.
File Structure
Modified:
internal/discovery/mdns.go— addClientInfo,BrowseClients(), TXT parsing helper,parseClientEntry()internal/discovery/mdns_test.go— add tests for TXT parsing and entry conversionpkg/sendspin/server.go— addDiscoverClientsconfig field, wireClientDialerinStart()/Stop()pkg/sendspin/server_test.go— add integration test with a fake mDNS advertisercmd/sendspin-server/main.go— add-discover-clientsflag
Created:
pkg/sendspin/client_dialer.go—ClientDialerstruct, goroutine loop, backoff, instance-name dedupepkg/sendspin/client_dialer_test.go— unit tests with injected dial funcinternal/discovery/txt.go— pure TXT parsing helper (no mDNS dependency, easier to test)internal/discovery/txt_test.go— table-driven tests for TXT parsing
Each file has one responsibility: discovery emits ClientInfo, the dialer turns ClientInfo into *websocket.Conn, and handleConnection remains untouched.
Task 1: TXT Record Parsing Helper
Files:
- Create:
internal/discovery/txt.go - Create:
internal/discovery/txt_test.go
Why a standalone file: hashicorp/mdns stores TXT records as []string of raw key=value entries in ServiceEntry.InfoFields. Parsing is a pure function with no mDNS types, so we isolate and test it in isolation before touching the browser loop.
- Step 1: Write failing tests for
parseTXT
// internal/discovery/txt_test.go
// ABOUTME: Tests for TXT record parsing
// ABOUTME: Pure parser, no mDNS dependency
package discovery
import (
"reflect"
"testing"
)
func TestParseTXT(t *testing.T) {
tests := []struct {
name string
fields []string
want map[string]string
}{
{
name: "empty",
fields: nil,
want: map[string]string{},
},
{
name: "single key",
fields: []string{"path=/sendspin"},
want: map[string]string{"path": "/sendspin"},
},
{
name: "multiple keys",
fields: []string{"path=/sendspin", "name=Living Room"},
want: map[string]string{"path": "/sendspin", "name": "Living Room"},
},
{
name: "key without value",
fields: []string{"flag"},
want: map[string]string{"flag": ""},
},
{
name: "value contains equals",
fields: []string{"token=abc=def=ghi"},
want: map[string]string{"token": "abc=def=ghi"},
},
{
name: "empty string ignored",
fields: []string{"", "path=/sendspin"},
want: map[string]string{"path": "/sendspin"},
},
{
name: "duplicate key: last wins",
fields: []string{"path=/old", "path=/new"},
want: map[string]string{"path": "/new"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTXT(tt.fields)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseTXT(%v) = %v, want %v", tt.fields, got, tt.want)
}
})
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/discovery/ -run TestParseTXT -v
Expected: FAIL with undefined: parseTXT.
- Step 3: Implement
parseTXT
// internal/discovery/txt.go
// ABOUTME: Pure TXT record parsing for mDNS service entries
// ABOUTME: Converts []string of "key=value" entries to map[string]string
package discovery
import "strings"
// parseTXT converts an mDNS TXT record slice (each element "key=value")
// into a map. Empty strings are ignored. Keys without '=' are stored
// with an empty value. When a key appears multiple times, the last
// occurrence wins.
func parseTXT(fields []string) map[string]string {
out := make(map[string]string, len(fields))
for _, f := range fields {
if f == "" {
continue
}
if idx := strings.Index(f, "="); idx >= 0 {
out[f[:idx]] = f[idx+1:]
} else {
out[f] = ""
}
}
return out
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/discovery/ -run TestParseTXT -v
Expected: PASS for all 7 subtests.
- Step 5: Commit
git add internal/discovery/txt.go internal/discovery/txt_test.go
git commit -m "feat(discovery): add TXT record parsing helper"
Task 2: ClientInfo Type and Entry Conversion
Files:
- Modify:
internal/discovery/mdns.go(add types + function nearServerInfoat lines 33-38) - Modify:
internal/discovery/mdns_test.go(add tests)
Why separate from Task 3: converting a *mdns.ServiceEntry into a ClientInfo is pure logic that we can test without spinning up an mDNS loop. Isolating it keeps the browse goroutine in Task 3 trivial.
- Step 1: Write failing test for
clientInfoFromEntry
Add to internal/discovery/mdns_test.go:
import (
"net"
"testing"
"github.com/hashicorp/mdns"
)
func TestClientInfoFromEntry(t *testing.T) {
tests := []struct {
name string
entry *mdns.ServiceEntry
want *ClientInfo
}{
{
name: "full entry with path and name",
entry: &mdns.ServiceEntry{
Name: "living-room._sendspin._tcp.local.",
Host: "living-room.local.",
AddrV4: net.ParseIP("192.168.1.42"),
Port: 8928,
InfoFields: []string{"path=/sendspin", "name=Living Room"},
},
want: &ClientInfo{
Instance: "living-room._sendspin._tcp.local.",
Name: "Living Room",
Host: "192.168.1.42",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "missing path defaults to /sendspin",
entry: &mdns.ServiceEntry{
Name: "kitchen._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.43"),
Port: 8928,
InfoFields: []string{"name=Kitchen"},
},
want: &ClientInfo{
Instance: "kitchen._sendspin._tcp.local.",
Name: "Kitchen",
Host: "192.168.1.43",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "missing name falls back to entry.Name",
entry: &mdns.ServiceEntry{
Name: "bedroom._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.44"),
Port: 8928,
InfoFields: []string{"path=/sendspin"},
},
want: &ClientInfo{
Instance: "bedroom._sendspin._tcp.local.",
Name: "bedroom._sendspin._tcp.local.",
Host: "192.168.1.44",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "no IPv4 address returns nil",
entry: &mdns.ServiceEntry{
Name: "ipv6-only._sendspin._tcp.local.",
Port: 8928,
InfoFields: []string{"path=/sendspin"},
},
want: nil,
},
{
name: "zero port returns nil",
entry: &mdns.ServiceEntry{
Name: "bad._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.45"),
Port: 0,
InfoFields: []string{"path=/sendspin"},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clientInfoFromEntry(tt.entry)
if (got == nil) != (tt.want == nil) {
t.Fatalf("clientInfoFromEntry nil-ness mismatch: got %+v, want %+v", got, tt.want)
}
if got == nil {
return
}
if *got != *tt.want {
t.Errorf("clientInfoFromEntry = %+v, want %+v", got, tt.want)
}
})
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/discovery/ -run TestClientInfoFromEntry -v
Expected: FAIL with undefined: ClientInfo and undefined: clientInfoFromEntry.
- Step 3: Add
ClientInfoandclientInfoFromEntrytomdns.go
Insert after the ServerInfo struct (currently internal/discovery/mdns.go:33-38):
// ClientInfo describes a discovered client (player) advertised via
// _sendspin._tcp.local.
type ClientInfo struct {
Instance string // fully-qualified mDNS instance name (stable dedupe key)
Name string // friendly name from TXT "name=" or falls back to Instance
Host string // IPv4 address as a string
Port int
Path string // WebSocket path from TXT "path=" (default "/sendspin")
}
// clientInfoFromEntry converts an mdns.ServiceEntry into a ClientInfo.
// Returns nil when the entry lacks a usable IPv4 address or port.
func clientInfoFromEntry(entry *mdns.ServiceEntry) *ClientInfo {
if entry == nil || entry.AddrV4 == nil || entry.Port == 0 {
return nil
}
txt := parseTXT(entry.InfoFields)
path := txt["path"]
if path == "" {
path = "/sendspin"
}
name := txt["name"]
if name == "" {
name = entry.Name
}
return &ClientInfo{
Instance: entry.Name,
Name: name,
Host: entry.AddrV4.String(),
Port: entry.Port,
Path: path,
}
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/discovery/ -run TestClientInfoFromEntry -v
Expected: PASS for all 5 subtests.
- Step 5: Commit
git add internal/discovery/mdns.go internal/discovery/mdns_test.go
git commit -m "feat(discovery): add ClientInfo type and entry conversion"
Task 3: BrowseClients Method
Files:
-
Modify:
internal/discovery/mdns.go(add channel field, method, loop) -
Modify:
internal/discovery/mdns_test.go(add sanity test that the channel is exposed) -
Step 1: Write failing test that
Clients()returns a readable channel
Add to internal/discovery/mdns_test.go:
func TestManagerExposesClientsChannel(t *testing.T) {
mgr := NewManager(Config{ServiceName: "Test", Port: 8928})
ch := mgr.Clients()
if ch == nil {
t.Fatal("Clients() returned nil channel")
}
// channel must be receive-only or bidirectional — just confirm we can select on it
select {
case <-ch:
t.Fatal("unexpected value on empty clients channel")
default:
}
}
- Step 2: Run test to verify it fails
Run: go test ./internal/discovery/ -run TestManagerExposesClientsChannel -v
Expected: FAIL with mgr.Clients undefined.
- Step 3: Add
clientschannel andClients()/BrowseClients()methods
In internal/discovery/mdns.go, modify the Manager struct (currently lines 26-31) to add a clients channel:
type Manager struct {
config Config
ctx context.Context
cancel context.CancelFunc
servers chan *ServerInfo
clients chan *ClientInfo
}
Update NewManager (currently lines 41-50) to initialize the new channel:
func NewManager(config Config) *Manager {
ctx, cancel := context.WithCancel(context.Background())
return &Manager{
config: config,
ctx: ctx,
cancel: cancel,
servers: make(chan *ServerInfo, 10),
clients: make(chan *ClientInfo, 10),
}
}
Add these methods at the bottom of mdns.go (before getLocalIPs):
// BrowseClients searches for Sendspin clients advertising _sendspin._tcp.
func (m *Manager) BrowseClients() error {
go m.browseClientsLoop()
return nil
}
// Clients returns the channel of discovered clients.
func (m *Manager) Clients() <-chan *ClientInfo {
return m.clients
}
// browseClientsLoop continuously browses for clients.
func (m *Manager) browseClientsLoop() {
for {
select {
case <-m.ctx.Done():
return
default:
}
entries := make(chan *mdns.ServiceEntry, 10)
go func() {
for entry := range entries {
info := clientInfoFromEntry(entry)
if info == nil {
continue
}
log.Printf("Discovered client: %s at %s:%d%s",
info.Name, info.Host, info.Port, info.Path)
select {
case m.clients <- info:
case <-m.ctx.Done():
return
}
}
}()
params := &mdns.QueryParam{
Service: "_sendspin._tcp",
Domain: "local",
Timeout: 3,
Entries: entries,
Logger: silentLogger,
}
if err := mdns.Query(params); err != nil {
log.Printf("mdns query error: %v", err)
}
close(entries)
}
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/discovery/ -v
Expected: PASS for all discovery tests including the new TestManagerExposesClientsChannel.
- Step 5: Commit
git add internal/discovery/mdns.go internal/discovery/mdns_test.go
git commit -m "feat(discovery): browse for clients advertising _sendspin._tcp"
Task 4: WebSocket Dial Helper
Files:
- Create:
pkg/sendspin/client_dialer.go(just the helper function for now) - Create:
pkg/sendspin/client_dialer_test.go
Why a thin helper before the full dialer: we want one place that turns a discovery.ClientInfo into a dialed URL string, so the tests for URL construction don't need a real network or an mDNS stack.
- Step 1: Write failing test for
clientInfoURL
// pkg/sendspin/client_dialer_test.go
// ABOUTME: Tests for outbound client discovery and dialing
// ABOUTME: Uses injected dial functions — no real network I/O
package sendspin
import (
"testing"
"github.com/Sendspin/sendspin-go/internal/discovery"
)
func TestClientInfoURL(t *testing.T) {
tests := []struct {
name string
info *discovery.ClientInfo
want string
}{
{
name: "ipv4 host",
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "/sendspin"},
want: "ws://192.168.1.42:8928/sendspin",
},
{
name: "path missing leading slash is normalized",
info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "sendspin"},
want: "ws://192.168.1.42:8928/sendspin",
},
{
name: "custom path preserved",
info: &discovery.ClientInfo{Host: "10.0.0.5", Port: 9000, Path: "/custom/endpoint"},
want: "ws://10.0.0.5:9000/custom/endpoint",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clientInfoURL(tt.info)
if got != tt.want {
t.Errorf("clientInfoURL = %q, want %q", got, tt.want)
}
})
}
}
- Step 2: Run test to verify it fails
Run: go test ./pkg/sendspin/ -run TestClientInfoURL -v
Expected: FAIL with undefined: clientInfoURL.
- Step 3: Create
client_dialer.gowith the helper
// pkg/sendspin/client_dialer.go
// ABOUTME: Outbound client discovery dialer for server-initiated mode
// ABOUTME: Converts discovery.ClientInfo into dialed WebSocket connections
package sendspin
import (
"fmt"
"strings"
"github.com/Sendspin/sendspin-go/internal/discovery"
)
// clientInfoURL builds a ws:// URL from a discovered ClientInfo.
// Normalizes Path to ensure a single leading slash.
func clientInfoURL(info *discovery.ClientInfo) string {
path := info.Path
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return fmt.Sprintf("ws://%s:%d%s", info.Host, info.Port, path)
}
- Step 4: Run test to verify it passes
Run: go test ./pkg/sendspin/ -run TestClientInfoURL -v
Expected: PASS for all 3 subtests.
- Step 5: Commit
git add pkg/sendspin/client_dialer.go pkg/sendspin/client_dialer_test.go
git commit -m "feat(sendspin): add URL builder for discovered client dialing"
Task 5: ClientDialer Struct with Instance Dedupe
Files:
- Modify:
pkg/sendspin/client_dialer.go - Modify:
pkg/sendspin/client_dialer_test.go
The dialer owns a goroutine that reads *discovery.ClientInfo from an input channel and calls a dialFunc for each new instance. The dedupe map is keyed by Instance (the mDNS fully-qualified name — stable across browse cycles). We inject the dial function so tests never touch the network.
- Step 1: Write failing test for dedupe behavior
Add to pkg/sendspin/client_dialer_test.go:
import (
"context"
"sync"
"sync/atomic"
"time"
)
func TestClientDialerDedupesByInstance(t *testing.T) {
in := make(chan *discovery.ClientInfo, 10)
var dialCalls int32
var mu sync.Mutex
var seen []string
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
mu.Lock()
seen = append(seen, info.Instance)
mu.Unlock()
return nil // success — slot stays occupied
}
d := newClientDialer(in, dial)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
d.run(ctx)
close(done)
}()
// Emit the same instance 3 times and a different instance once
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"}
in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
// Wait for the dialer to drain
deadline := time.After(500 * time.Millisecond)
for {
if atomic.LoadInt32(&dialCalls) >= 2 {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for dial calls, got %d", atomic.LoadInt32(&dialCalls))
case <-time.After(10 * time.Millisecond):
}
}
// Give any extra (incorrect) calls a chance to fire
time.Sleep(50 * time.Millisecond)
cancel()
<-done
if got := atomic.LoadInt32(&dialCalls); got != 2 {
t.Errorf("dialCalls = %d, want 2 (one per unique instance)", got)
}
mu.Lock()
defer mu.Unlock()
if len(seen) != 2 {
t.Fatalf("seen = %v, want 2 entries", seen)
}
}
- Step 2: Run test to verify it fails
Run: go test ./pkg/sendspin/ -run TestClientDialerDedupesByInstance -v
Expected: FAIL with undefined: newClientDialer.
- Step 3: Add
clientDialerstruct andrunloop toclient_dialer.go
Append to pkg/sendspin/client_dialer.go:
import (
"context"
"log"
"sync"
)
// dialFunc dials a discovered client and returns when the resulting
// connection has been fully handled (e.g. handleConnection returned).
// Returning nil means the connection completed normally; returning an
// error means the dial or handshake failed and the slot should be
// released for retry.
type dialFunc func(ctx context.Context, info *discovery.ClientInfo) error
// clientDialer consumes discovery events and dispatches one goroutine
// per unique mDNS instance, deduping so we never open two sockets to
// the same advertisement concurrently.
type clientDialer struct {
in <-chan *discovery.ClientInfo
dial dialFunc
mu sync.Mutex
active map[string]bool // instance name -> currently dialing/connected
}
func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer {
return &clientDialer{
in: in,
dial: dial,
active: make(map[string]bool),
}
}
// run pumps discovery events until ctx is cancelled.
func (d *clientDialer) run(ctx context.Context) {
var wg sync.WaitGroup
defer wg.Wait()
for {
select {
case <-ctx.Done():
return
case info, ok := <-d.in:
if !ok {
return
}
if info == nil {
continue
}
if !d.claim(info.Instance) {
continue
}
wg.Add(1)
go func(info *discovery.ClientInfo) {
defer wg.Done()
defer d.release(info.Instance)
if err := d.dial(ctx, info); err != nil {
log.Printf("dial client %s: %v", info.Instance, err)
}
}(info)
}
}
}
// claim returns true if the instance slot was free and is now owned by
// the caller. Returns false if another goroutine already owns it.
func (d *clientDialer) claim(instance string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.active[instance] {
return false
}
d.active[instance] = true
return true
}
func (d *clientDialer) release(instance string) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.active, instance)
}
Note: the existing top of the file will now need its imports consolidated. After editing, the import block should read:
import (
"context"
"fmt"
"log"
"strings"
"sync"
"github.com/Sendspin/sendspin-go/internal/discovery"
)
- Step 4: Run tests to verify they pass
Run: go test ./pkg/sendspin/ -run TestClientDialer -v
Expected: PASS.
- Step 5: Commit
git add pkg/sendspin/client_dialer.go pkg/sendspin/client_dialer_test.go
git commit -m "feat(sendspin): add clientDialer with instance-name dedupe"
Task 6: Exponential Backoff on Dial Failures
Files:
- Modify:
pkg/sendspin/client_dialer.go - Modify:
pkg/sendspin/client_dialer_test.go
When a dial fails, we want to back off before accepting another discovery event for the same instance. The backoff is tracked per-instance and resets after a successful dial.
- Step 1: Write failing test for backoff
Add to client_dialer_test.go:
import "errors"
func TestClientDialerBackoffOnFailure(t *testing.T) {
in := make(chan *discovery.ClientInfo, 10)
var dialCalls int32
dial := func(ctx context.Context, info *discovery.ClientInfo) error {
atomic.AddInt32(&dialCalls, 1)
return errors.New("boom")
}
d := newClientDialer(in, dial)
// Override backoff clock to keep the test fast
d.baseBackoff = 20 * time.Millisecond
d.maxBackoff = 80 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
d.run(ctx)
close(done)
}()
info := &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"}
// First event — should dial immediately
in <- info
// Wait for first dial
waitForCalls(t, &dialCalls, 1, 200*time.Millisecond)
// Immediately re-emit — should be rejected by backoff
in <- info
time.Sleep(5 * time.Millisecond)
if got := atomic.LoadInt32(&dialCalls); got != 1 {
t.Errorf("dialCalls after immediate retry = %d, want 1 (blocked by backoff)", got)
}
// Wait past base backoff, re-emit — should dial again
time.Sleep(30 * time.Millisecond)
in <- info
waitForCalls(t, &dialCalls, 2, 200*time.Millisecond)
cancel()
<-done
}
func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if atomic.LoadInt32(counter) >= want {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d calls, got %d", want, atomic.LoadInt32(counter))
}
- Step 2: Run test to verify it fails
Run: go test ./pkg/sendspin/ -run TestClientDialerBackoff -v
Expected: FAIL with d.baseBackoff undefined (or the test blows past its assertion because backoff isn't implemented).
- Step 3: Add backoff state and logic
Modify the clientDialer struct in pkg/sendspin/client_dialer.go:
type clientDialer struct {
in <-chan *discovery.ClientInfo
dial dialFunc
baseBackoff time.Duration
maxBackoff time.Duration
mu sync.Mutex
active map[string]bool // currently dialing/connected
cooldown map[string]time.Time // instance -> earliest retry time
failures map[string]int // consecutive failure count per instance
}
Update the constructor:
func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer {
return &clientDialer{
in: in,
dial: dial,
baseBackoff: 1 * time.Second,
maxBackoff: 30 * time.Second,
active: make(map[string]bool),
cooldown: make(map[string]time.Time),
failures: make(map[string]int),
}
}
Add "time" to the imports.
Replace claim with a version that checks cooldown:
func (d *clientDialer) claim(instance string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.active[instance] {
return false
}
if until, ok := d.cooldown[instance]; ok && time.Now().Before(until) {
return false
}
d.active[instance] = true
return true
}
Replace release with a version that records failures and sets cooldown:
func (d *clientDialer) release(instance string, dialErr error) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.active, instance)
if dialErr == nil {
delete(d.failures, instance)
delete(d.cooldown, instance)
return
}
d.failures[instance]++
backoff := d.baseBackoff * time.Duration(1<<(d.failures[instance]-1))
if backoff > d.maxBackoff {
backoff = d.maxBackoff
}
d.cooldown[instance] = time.Now().Add(backoff)
}
Update the run goroutine to pass the error into release:
wg.Add(1)
go func(info *discovery.ClientInfo) {
defer wg.Done()
err := d.dial(ctx, info)
d.release(info.Instance, err)
if err != nil {
log.Printf("dial client %s: %v", info.Instance, err)
}
}(info)
Important: the Task 5 test TestClientDialerDedupesByInstance calls d.release(instance) implicitly via the run loop. That test used a dial that returns nil, so with the new signature it still works — just update the goroutine call site once.
- Step 4: Run all dialer tests to verify they pass
Run: go test ./pkg/sendspin/ -run TestClientDialer -v
Expected: PASS for TestClientDialerDedupesByInstance, TestClientDialerBackoffOnFailure, TestClientInfoURL.
- Step 5: Commit
git add pkg/sendspin/client_dialer.go pkg/sendspin/client_dialer_test.go
git commit -m "feat(sendspin): add exponential backoff on dial failures"
Task 7: Real Dial Function + Integration with handleConnection
Files:
- Modify:
pkg/sendspin/client_dialer.go(add real websocket dial) - Modify:
pkg/sendspin/server.go(wire the dialer intoStart/Stop)
The real dial function opens a WebSocket, then calls s.handleConnection(conn) synchronously. When handleConnection returns, the dial function returns nil (handled normally). Dial failures return an error so backoff kicks in.
- Step 1: Add
DiscoverClientsconfig field
Modify pkg/sendspin/server.go ServerConfig (currently lines 43-58):
type ServerConfig struct {
Port int
Name string
Source AudioSource
EnableMDNS bool
Debug bool
// DiscoverClients enables server-initiated discovery: browse for
// clients advertising _sendspin._tcp and dial out to them.
// See https://www.sendspin-audio.com/spec/ — "server-initiated" mode.
DiscoverClients bool
}
Also add a field to the Server struct (currently lines 61-92) to hold the dialer's cancel:
// client dialer (server-initiated discovery)
dialerCancel context.CancelFunc
- Step 2: Add the real dial function and
dialClienthelper toclient_dialer.go
Append to pkg/sendspin/client_dialer.go:
import "github.com/gorilla/websocket"
// dialAndHandle opens a WebSocket to a discovered client and hands the
// connection to the provided handler. The handler is expected to block
// until the connection is fully drained (e.g. handleConnection).
func dialAndHandle(ctx context.Context, info *discovery.ClientInfo, handle func(*websocket.Conn)) error {
url := clientInfoURL(info)
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.DialContext(ctx, url, nil)
if err != nil {
return fmt.Errorf("dial %s: %w", url, err)
}
log.Printf("Dialed discovered client %s at %s", info.Name, url)
handle(conn)
return nil
}
Consolidated import block for client_dialer.go:
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
"github.com/gorilla/websocket"
)
- Step 3: Wire the dialer into
Server.Start()
In pkg/sendspin/server.go, inside Start(), directly after the existing mDNS advertisement block (currently server.go:172-185), add:
if s.config.DiscoverClients {
if s.mdnsManager == nil {
// If mDNS isn't running for advertising, start a manager just for browsing.
s.mdnsManager = discovery.NewManager(discovery.Config{
ServiceName: s.config.Name,
Port: s.config.Port,
ServerMode: true,
})
}
if err := s.mdnsManager.BrowseClients(); err != nil {
log.Printf("Failed to start client discovery: %v", err)
} else {
log.Printf("Browsing for clients advertising _sendspin._tcp")
dialCtx, cancel := context.WithCancel(context.Background())
s.dialerCancel = cancel
dialer := newClientDialer(s.mdnsManager.Clients(), func(ctx context.Context, info *discovery.ClientInfo) error {
return dialAndHandle(ctx, info, s.handleConnection)
})
s.wg.Add(1)
go func() {
defer s.wg.Done()
dialer.run(dialCtx)
}()
}
}
Add "context" to the pkg/sendspin/server.go imports if it isn't already present.
- Step 4: Wire the dialer shutdown into
Server.Stop()/ shutdown path
Find the shutdown sequence in pkg/sendspin/server.go (look for s.mdnsManager.Stop()). Directly before the existing if s.mdnsManager != nil { s.mdnsManager.Stop() } call, add:
if s.dialerCancel != nil {
s.dialerCancel()
}
This guarantees in-flight dials see context cancellation before the mDNS manager stops emitting.
- Step 5: Run full test suite to verify nothing broke
Run: go test ./...
Expected: PASS. No new tests in this task — the new wiring is exercised by the integration test in Task 9.
- Step 6: Commit
git add pkg/sendspin/client_dialer.go pkg/sendspin/server.go
git commit -m "feat(sendspin): wire ClientDialer into server start/stop"
Task 8: CLI Flag
Files:
-
Modify:
cmd/sendspin-server/main.go -
Step 1: Add
-discover-clientsflag
In cmd/sendspin-server/main.go, add alongside the existing flag declarations (currently lines 20-26):
discoverClients = flag.Bool("discover-clients", false,
"Enable server-initiated discovery: browse _sendspin._tcp and dial out to clients")
- Step 2: Pass the flag into
ServerConfig
Modify the sendspin.NewServer(sendspin.ServerConfig{...}) call (currently lines 73-79) to include the new field:
srv, err := sendspin.NewServer(sendspin.ServerConfig{
Port: *port,
Name: serverName,
Source: source,
EnableMDNS: !*noMDNS,
Debug: *debug,
DiscoverClients: *discoverClients,
})
- Step 3: Build to verify the CLI compiles
Run: make server
Expected: build succeeds, bin/sendspin-server (or equivalent) produced.
- Step 4: Smoke test the flag is wired
Run: ./bin/sendspin-server -h 2>&1 | grep discover-clients
Expected: output shows -discover-clients flag line.
- Step 5: Commit
git add cmd/sendspin-server/main.go
git commit -m "feat(cli): add -discover-clients flag to sendspin-server"
Task 9: End-to-End Integration Test
Files:
- Create:
pkg/sendspin/client_discovery_integration_test.go
This test spins up a fake mDNS advertiser for _sendspin._tcp pointing at a httptest.Server that upgrades to WebSocket and sends a client/hello. With DiscoverClients: true, the server should dial out, complete the handshake, and register the player in s.clients.
- Step 1: Write failing end-to-end test
// pkg/sendspin/client_discovery_integration_test.go
// ABOUTME: Integration test for server-initiated client discovery
// ABOUTME: Fakes an mDNS advertisement and verifies the server dials + handshakes
package sendspin
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/gorilla/websocket"
"github.com/hashicorp/mdns"
)
func TestServerDiscoversAndDialsClient(t *testing.T) {
// 1. Stand up a fake "player" HTTP server that upgrades to WS and
// sends client/hello.
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
playerReady := make(chan string, 1) // receives serverID via server/hello
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade: %v", err)
return
}
defer conn.Close()
hello := protocol.Message{
Type: "client/hello",
Payload: protocol.ClientHello{
ClientID: "test-player-1",
Name: "Test Player",
SupportedRoles: []string{"player@v1"},
},
}
data, _ := json.Marshal(hello)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
t.Errorf("write hello: %v", err)
return
}
_, resp, err := conn.ReadMessage()
if err != nil {
return
}
var msg protocol.Message
if err := json.Unmarshal(resp, &msg); err != nil {
return
}
if msg.Type == "server/hello" {
playerReady <- "ok"
}
// Block until the server closes us
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}))
defer ts.Close()
_, portStr, err := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://"))
if err != nil {
t.Fatalf("split host: %v", err)
}
port, _ := strconv.Atoi(portStr)
// 2. Advertise an mDNS _sendspin._tcp record pointing at httptest.
ips := []net.IP{net.ParseIP("127.0.0.1")}
svc, err := mdns.NewMDNSService(
"test-player-instance",
"_sendspin._tcp",
"",
"",
port,
ips,
[]string{"path=/", "name=Test Player"},
)
if err != nil {
t.Fatalf("mdns service: %v", err)
}
mdnsServer, err := mdns.NewServer(&mdns.Config{Zone: svc})
if err != nil {
t.Fatalf("mdns server: %v", err)
}
defer mdnsServer.Shutdown()
// 3. Start a Sendspin server with DiscoverClients=true.
source := NewTestTone(48000, 2)
srv, err := NewServer(ServerConfig{
Port: findFreePort(t),
Name: "Test Server",
Source: source,
EnableMDNS: false,
DiscoverClients: true,
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
serverDone := make(chan error, 1)
go func() { serverDone <- srv.Start() }()
defer srv.Stop()
// 4. Wait for the player to confirm it received server/hello.
select {
case <-playerReady:
// success
case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for dialed handshake")
}
}
// findFreePort returns a TCP port that is free at call time.
func findFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
Note: NewTestTone is the existing public test fixture used throughout pkg/sendspin/server_test.go (see e.g. line 16). It implements AudioSource with a sine test tone and is the same helper the other integration tests use.
- Step 2: Run the test to verify it fails if wiring is broken
Run: go test ./pkg/sendspin/ -run TestServerDiscoversAndDialsClient -v -timeout 30s
Expected (if previous tasks are implemented correctly): PASS. If it fails, the failure points at real wiring bugs — fix them in the appropriate earlier task and re-run.
If mDNS doesn't work reliably in the CI sandbox (some environments block multicast), mark the test with t.Skip("requires multicast") behind an env var gate like if os.Getenv("SENDSPIN_MULTICAST_TESTS") == "" { t.Skip(...) }. Leave the gate off by default so local developers running go test ./... aren't blocked by environment issues.
- Step 3: Run the full suite
Run: go test ./... -timeout 60s
Expected: PASS.
- Step 4: Run lint
Run: make lint
Expected: no new warnings.
- Step 5: Commit
git add pkg/sendspin/client_discovery_integration_test.go
git commit -m "test(sendspin): end-to-end test for server-initiated discovery"
Task 10: Documentation Update
Files:
-
Modify:
README.md(if it mentions server modes) -
Modify:
pkg/sendspin/doc.go(package doc) -
Step 1: Add a short section to
pkg/sendspin/doc.go
Locate the existing package doc and append a paragraph:
// Server-Initiated Discovery
//
// When ServerConfig.DiscoverClients is true, the server browses mDNS
// for clients advertising _sendspin._tcp.local. and dials each one
// using the advertised path (TXT "path=", default "/sendspin"). The
// dialed connection is handed to the same handleConnection funnel
// inbound clients use, so the protocol handshake and audio streaming
// paths are unchanged. Per the Sendspin spec, a player must either
// advertise _sendspin._tcp OR connect directly — never both.
- Step 2: Update
README.mdif it lists server flags
Check: grep -n "no-mdns\|EnableMDNS" README.md
If matches found, add a line mentioning -discover-clients in the same section.
If no matches, skip.
- Step 3: Commit
git add pkg/sendspin/doc.go README.md
git commit -m "docs: document server-initiated client discovery"
Done Criteria
go test ./...passesmake lintcleanmake serverbuilds./bin/sendspin-server -discover-clientsstarts and logsBrowsing for clients advertising _sendspin._tcp- Integration test confirms a fake mDNS advertisement triggers a real WebSocket dial and handshake
handleConnectioninpkg/sendspin/server.gois unmodified by this plan — grep the final diff and confirm