1373 lines
37 KiB
Markdown
1373 lines
37 KiB
Markdown
# 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.go` — `ClientDialer` 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`**
|
|
|
|
```go
|
|
// 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`**
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`):
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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`):
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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:
|
|
|
|
```go
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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`:
|
|
|
|
```go
|
|
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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
// 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`:
|
|
|
|
```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`:
|
|
|
|
```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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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
|