Files
Tobias GesellchenandClaude Opus 4.7 d74bb9b5ca feat(service): add passive peer-reachability probe handler
RunPeerReachabilityProbe is the post-migration replacement for the
active swUpdateUrl round-trip: register the device IP with the
in-process observer, nudge :8090/swUpdateCheck, and wait for any
inbound from that IP. No device-state mutation. Any inbound counts
as proof — on a migrated speaker, DNS interception routes the
daemon's outbounds through this service regardless of which URL it
resolved internally, so reachability reduces to "did the device
dial us at all."

PeerHit and the abstract observer interface live in setup alongside
the probe logic; handlers.peerObserver implements the interface and
the existing observer files now import from setup.

Route: POST /setup/peer-probe/{deviceId}. Timeout: 30s, surfaced as
result.ElapsedMs so the budget can be tuned from real data. The
pre-flight orchestrator gains the branch in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:31:24 +02:00

73 lines
2.1 KiB
Go

package handlers
import (
"sync"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// peerObserver is the rendezvous between the passive reachability probe
// (which registers interest in a device IP and waits for any inbound)
// and the chi middleware (which signals on every request whose source
// IP matches a registration).
//
// Unlike probeRegistry, which keys on a unique per-probe token, this
// observer keys on the device's IP — the probe doesn't mutate device
// state, so there's no token to thread through the request path. Any
// inbound from the IP counts as proof of reachability.
//
// PeerHit and the abstract handle interface live in the setup package
// alongside the probe logic; this type implements that interface.
type peerObserver struct {
mu sync.Mutex
pending map[string]chan setup.PeerHit
}
func newPeerObserver() *peerObserver {
return &peerObserver{pending: make(map[string]chan setup.PeerHit)}
}
// Register creates a one-shot buffered channel keyed by IP. The buffer
// of 1 lets the middleware deliver the first hit and silently drop
// subsequent hits during the wait window without blocking. Caller is
// responsible for pairing every Register with Forget.
func (o *peerObserver) Register(ip string) <-chan setup.PeerHit {
o.mu.Lock()
defer o.mu.Unlock()
ch := make(chan setup.PeerHit, 1)
o.pending[ip] = ch
return ch
}
// Signal delivers a hit to the channel for ip, non-blocking. Returns
// true when a matching registration existed AND the hit was delivered
// (i.e. the channel had buffer space — first hit during the window).
// Subsequent hits during the same window return false without blocking.
func (o *peerObserver) Signal(ip string, hit setup.PeerHit) bool {
o.mu.Lock()
defer o.mu.Unlock()
ch, ok := o.pending[ip]
if !ok {
return false
}
select {
case ch <- hit:
return true
default:
return false
}
}
// Forget removes the entry. Safe to call regardless of whether a hit
// landed — does not affect already-returned channels.
func (o *peerObserver) Forget(ip string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.pending, ip)
}