Files
-rpi-sendspin/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md

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/hello first; server responds server/hello
  • Mutual exclusion is per-client: a player must NOT both advertise _sendspin._tcp AND 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 — add ClientInfo, BrowseClients(), TXT parsing helper, parseClientEntry()
  • internal/discovery/mdns_test.go — add tests for TXT parsing and entry conversion
  • pkg/sendspin/server.go — add DiscoverClients config field, wire ClientDialer in Start() / Stop()
  • pkg/sendspin/server_test.go — add integration test with a fake mDNS advertiser
  • cmd/sendspin-server/main.go — add -discover-clients flag

Created:

  • pkg/sendspin/client_dialer.goClientDialer struct, goroutine loop, backoff, instance-name dedupe
  • pkg/sendspin/client_dialer_test.go — unit tests with injected dial func
  • internal/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 near ServerInfo at 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 ClientInfo and clientInfoFromEntry to mdns.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 clients channel and Clients() / 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.go with 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 clientDialer struct and run loop to client_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 into Start/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 DiscoverClients config 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 dialClient helper to client_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-clients flag

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.md if 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 ./... passes
  • make lint clean
  • make server builds
  • ./bin/sendspin-server -discover-clients starts and logs Browsing for clients advertising _sendspin._tcp
  • Integration test confirms a fake mDNS advertisement triggers a real WebSocket dial and handshake
  • handleConnection in pkg/sendspin/server.go is unmodified by this plan — grep the final diff and confirm