diff --git a/pkg/service/soundtouchweb/websocket.go b/pkg/service/soundtouchweb/websocket.go index 394b2440..b0ad54aa 100644 --- a/pkg/service/soundtouchweb/websocket.go +++ b/pkg/service/soundtouchweb/websocket.go @@ -541,6 +541,7 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) { activity := time.Now() + app.applyVolumeEvent(conn, &event.Volume) conn.MarkEventStreamActivity(activity) }) @@ -562,12 +563,14 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) { activity := time.Now() + app.applyPresetEvent(conn, &event.Presets) conn.MarkEventStreamActivity(activity) }) wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) { activity := time.Now() + app.applyBassEvent(conn, &event.Bass) conn.MarkEventStreamActivity(activity) }) @@ -582,16 +585,19 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device }) wsClient.OnTransportState(func(connected bool, generation uint64) { + // ObserveEventStreamTransport already derives status.IsConnected + // (via applyConnectivityLocked, alongside Connectivity/ + // HTTPReachable/WebSocketConnected) from this same call. Do NOT + // also route it through applyConnectionStateEvent/ + // ApplyFieldEvent(FieldConnectivity, ...): that unconditionally + // bumps FieldConnectivity's applied generation past whatever an + // in-flight HTTP poll already reserved, so a transient transport + // blip would silently discard a concurrently-completing, + // genuinely successful poll's IsConnected=true merge. if !conn.ObserveEventStreamTransport(generation, connected, time.Now()) { return } - // Drives the same FieldConnectivity fencing the old inline - // connect/disconnect sites used to update directly -- ordered by - // ObserveEventStreamTransport's own generation check above, so a - // reordered transport callback can no longer apply here either. - app.applyConnectionStateEvent(conn, connected) - if connected { if generation > 1 { log.Printf("WebSocket reconnected for device %s", sanitizeLog(deviceID)) diff --git a/pkg/service/soundtouchweb/webtypes/status_test.go b/pkg/service/soundtouchweb/webtypes/status_test.go index a3e7ad30..cb42009d 100644 --- a/pkg/service/soundtouchweb/webtypes/status_test.go +++ b/pkg/service/soundtouchweb/webtypes/status_test.go @@ -596,6 +596,32 @@ func TestUnrelatedFieldEventDoesNotInvalidateInFlightPoll(t *testing.T) { } } +// TestTransportStateObservationDoesNotFenceFieldConnectivityPoll guards +// against routing ObserveEventStreamTransport through +// ApplyFieldEvent(FieldConnectivity, ...): that would unconditionally bump +// FieldConnectivity's applied generation past whatever an in-flight HTTP +// poll already reserved, so a transient WebSocket transport blip could +// silently discard a concurrently-completing, genuinely successful poll's +// IsConnected merge. +func TestTransportStateObservationDoesNotFenceFieldConnectivityPoll(t *testing.T) { + conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"}) + connectivityPoll := conn.BeginFieldPoll(FieldConnectivity) + + if !conn.ObserveEventStreamTransport(1, false, time.Now()) { + t.Fatal("first transport observation should be accepted") + } + + if !conn.CompleteFieldPoll(FieldConnectivity, connectivityPoll, func(status *DeviceStatus) { + status.IsConnected = true + }) { + t.Fatal("a transport-state observation must not invalidate an in-flight FieldConnectivity poll") + } + + if !conn.Status().IsConnected { + t.Fatal("FieldConnectivity poll result was discarded by an unrelated transport-state observation") + } +} + func TestStatusSnapshotIsolation(t *testing.T) { // A snapshot returned by Status() must NOT change when a later // UpdateStatus replaces a pointer field. This proves the atomic