fix(soundtouch-web): keep device WebSocket alive across disconnects

ConnectDeviceWebSocket was a one-shot: connect, wait for disconnect,
log, return. Once the device-side WebSocket died (idle timeout, blip,
speaker reboot), the goroutine ended and conn.WebSocket stayed
pointing at the (now-dead) client — which made the duplicate-spawn
guard `if device.WebSocket == nil` at the five callsites in
handler.go correctly skip spawning, but with nothing else trying to
reconnect, the speaker's status flow froze for the rest of the
process's lifetime. The browser kept receiving status_update
messages on the 5 s ticker (HandleWebSocket), but every payload
carried the same stale data the service last knew.

Symptom: load the page, NowPlaying shows fresh state; some minutes
later, the speaker switches presets or tracks but NowPlaying never
updates — even though playback itself works because those are
one-shot HTTP calls that don't depend on the WebSocket.

Fix: wrap the connect-and-wait in a for-loop with exponential
backoff (1 s → 30 s cap, reset on every successful connect). The
goroutine now lives for the device entry's lifetime; conn.WebSocket
is updated on each successful reconnect and never cleared, so the
existing guards keep working without spawning duplicate loops.

Pre-existing main bug — preserved by the relocation, surfaced when
testing the rebased branch. Fix is contained to the one function;
behaviour is byte-identical for the happy path (one connect, no
disconnect ever).

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-18 22:34:26 +02:00
co-authored by Claude Opus 4.7
parent 9d2ebb2edd
commit 75118d9a92
+80 -45
View File
@@ -129,67 +129,102 @@ func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
}
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
// and keeps it alive: on disconnect or connect failure, it reconnects
// with exponential backoff (1 s → 30 s cap, reset after each successful
// connect). The goroutine runs for the lifetime of the device entry,
// so status flows from the speaker keep streaming through transient
// network blips, speaker reboots, and idle timeouts.
//
// conn.WebSocket is only updated on a successful (re)connect, never
// cleared, so the duplicate-spawn guards at the callsites (which check
// `if device.WebSocket == nil`) stay correct — once this goroutine is
// running for a device, no second one is needed.
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
// Skip WebSocket connection if client is not available (e.g., in tests)
if conn.Client == nil {
return
}
wsClient := conn.Client.NewWebSocketClient(nil)
const (
initialBackoff = 1 * time.Second
maxBackoff = 30 * time.Second
)
// Setup event handlers. Each handler funnels its change through
// UpdateStatus so concurrent events and the periodic poller
// (UpdateDeviceStatus) cannot lose each other's writes.
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.NowPlaying = &event.NowPlaying
s.LastActivity = time.Now()
backoff := initialBackoff
for {
wsClient := conn.Client.NewWebSocketClient(nil)
// Setup event handlers. Each handler funnels its change through
// UpdateStatus so concurrent events and the periodic poller
// (UpdateDeviceStatus) cannot lose each other's writes.
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.NowPlaying = &event.NowPlaying
s.LastActivity = time.Now()
})
})
})
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.Volume = &event.Volume
s.LastActivity = time.Now()
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.Volume = &event.Volume
s.LastActivity = time.Now()
})
})
})
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = event.ConnectionState.IsConnected()
s.LastActivity = time.Now()
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = event.ConnectionState.IsConnected()
s.LastActivity = time.Now()
})
})
})
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.Presets = &event.Presets
s.LastActivity = time.Now()
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.Presets = &event.Presets
s.LastActivity = time.Now()
})
})
})
// Connect WebSocket
if err := wsClient.Connect(); err != nil {
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
return
if err := wsClient.Connect(); err != nil {
log.Printf("Failed to connect WebSocket for device %s: %v (retrying in %s)", deviceID, err, backoff)
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
conn.WebSocket = wsClient
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = true
})
log.Printf("WebSocket connected for device %s", deviceID)
// Reset backoff after a successful connect so the next failure
// starts at the lowest cadence again.
backoff = initialBackoff
// Block until the device-side WebSocket disconnects.
wsClient.Wait()
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = false
})
log.Printf("WebSocket disconnected for device %s — reconnecting in %s", deviceID, backoff)
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
conn.WebSocket = wsClient
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = true
})
log.Printf("WebSocket connected for device %s", deviceID)
// Wait for disconnection
wsClient.Wait()
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
s.IsConnected = false
})
log.Printf("WebSocket disconnected for device %s", deviceID)
}
// UpdateDeviceStatus fetches current status from the device.