FLAC encoder now tracks frame numbers and reuses per-channel sample buffers to reduce allocations. Client dialer always frees the active slot on release so mDNS re-emissions can reconnect cleanly. Server send buffer uses drop-oldest instead of hard failure, with lateness tracking. Added engine stats logging (chunks/sec, kbps) and writer diagnostics. UI simplified to a single play/pause toggle that auto-starts playback on preset selection. Added --preset CLI flag for headless startup. Removed stale systemd service files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
4.5 KiB
Go
155 lines
4.5 KiB
Go
// ABOUTME: Outbound client discovery dialer for server-initiated mode
|
|
// ABOUTME: Converts discovery.ClientInfo into dialed WebSocket connections
|
|
|
|
package sendspin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Sendspin/sendspin-go/internal/discovery"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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). The
|
|
// handler's return value is propagated so the dialer can distinguish
|
|
// "session ran to completion — latch the instance" from "rejected by
|
|
// filter — back off and retry."
|
|
func dialAndHandle(ctx context.Context, info *discovery.ClientInfo, handle func(*websocket.Conn) error) 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)
|
|
return handle(conn)
|
|
}
|
|
|
|
// dialFunc dials a discovered client and returns when the resulting
|
|
// connection has been fully handled. 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
|
|
|
|
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
|
|
}
|
|
|
|
// newClientDialer constructs a clientDialer that reads discovery events
|
|
// from in and dispatches them through dial.
|
|
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),
|
|
}
|
|
}
|
|
|
|
// run pumps discovery events until ctx is cancelled. It returns after
|
|
// all in-flight dial goroutines have completed.
|
|
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()
|
|
err := d.dial(ctx, info)
|
|
d.release(info.Instance, err)
|
|
if err != nil {
|
|
log.Printf("dial client %s: %v", info.Instance, err)
|
|
}
|
|
}(info)
|
|
}
|
|
}
|
|
}
|
|
|
|
// claim returns true if the instance slot was free, not in cooldown,
|
|
// and is now owned by the caller. Returns false otherwise.
|
|
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
|
|
}
|
|
|
|
// release updates the instance slot after a dial attempt completes.
|
|
// The slot is always freed — dial() only returns after handleConnection
|
|
// has returned, so the connection is already dead by the time we get
|
|
// here, and a future mDNS re-emission should be free to redial. On
|
|
// error it also schedules an exponentially-backed-off cooldown before
|
|
// the next retry.
|
|
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 <= 0 {
|
|
backoff = d.maxBackoff
|
|
}
|
|
d.cooldown[instance] = time.Now().Add(backoff)
|
|
}
|