mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
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:
co-authored by
Claude Opus 4.7
parent
9d2ebb2edd
commit
75118d9a92
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user