diff --git a/pkg/service/soundtouchweb/handler_stereopair.go b/pkg/service/soundtouchweb/handler_stereopair.go index 4db82b51..912788c4 100644 --- a/pkg/service/soundtouchweb/handler_stereopair.go +++ b/pkg/service/soundtouchweb/handler_stereopair.go @@ -266,9 +266,26 @@ func (app *WebApp) completeStereoPairMutation( operationErr error, ) { app.applyStereoPairProjection(result) + baselines := app.stereoPairGroupBaselines(result) app.awaitPriorGlobalWebSocketWrites() app.writeStereoPairResult(w, info, result, operationErr) - app.refreshStereoPairMembersAsync(result) + app.refreshStereoPairMembersAsync(result, baselines) +} + +// stereoPairGroupBaselines captures each member's just-applied group +// generation immediately after applyStereoPairProjection, so the async +// follow-up refresh can tell "nothing changed since our projection landed" +// apart from "a fresher event or poll already superseded it." +func (app *WebApp) stereoPairGroupBaselines(result stereopair.Result) map[string]uint64 { + baselines := make(map[string]uint64, len(result.Members)) + + for i := range result.Members { + if conn, ok := app.deviceByStereoPairIPAddress(result.Members[i].IPAddress); ok && conn != nil { + baselines[result.Members[i].IPAddress] = conn.GroupGeneration() + } + } + + return baselines } // applyStereoPairProjection publishes the coordinator's final fresh group @@ -297,7 +314,7 @@ func (app *WebApp) applyStereoPairProjection(result stereopair.Result) { } } -func (app *WebApp) refreshStereoPairMembersAsync(result stereopair.Result) { +func (app *WebApp) refreshStereoPairMembersAsync(result stereopair.Result, baselines map[string]uint64) { go func() { defer func() { if recovered := recover(); recovered != nil { @@ -305,14 +322,16 @@ func (app *WebApp) refreshStereoPairMembersAsync(result stereopair.Result) { } }() - app.refreshStereoPairMembers(result) + app.refreshStereoPairMembers(result, baselines) }() } -func (app *WebApp) refreshStereoPairMembers(result stereopair.Result) { +func (app *WebApp) refreshStereoPairMembers(result stereopair.Result, baselines map[string]uint64) { for i := range result.Members { - if conn, ok := app.deviceByStereoPairIPAddress(result.Members[i].IPAddress); ok && conn != nil { - app.UpdateDeviceStatus(result.Members[i].IPAddress, conn) + ipAddress := result.Members[i].IPAddress + + if conn, ok := app.deviceByStereoPairIPAddress(ipAddress); ok && conn != nil { + app.refreshDeviceStatusAfterStereoPairMutation(ipAddress, conn, baselines[ipAddress]) } } diff --git a/pkg/service/soundtouchweb/websocket.go b/pkg/service/soundtouchweb/websocket.go index b80507a5..ed227239 100644 --- a/pkg/service/soundtouchweb/websocket.go +++ b/pkg/service/soundtouchweb/websocket.go @@ -631,7 +631,22 @@ func sleepOrDone(conn *webtypes.DeviceConnection, d time.Duration) bool { // can supersede only that field. A slow-but-successful fetch for one field // is never discarded merely because a DIFFERENT field's event or poll // completion happened to land first. -func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) { +func (app *WebApp) UpdateDeviceStatus(deviceID string, conn *webtypes.DeviceConnection) { + app.updateDeviceStatus(deviceID, conn, nil) +} + +// refreshDeviceStatusAfterStereoPairMutation behaves like UpdateDeviceStatus, +// but only applies its /getGroup read if the device's applied group +// generation is still exactly groupBaseline -- i.e. nothing (no push event, +// no other poll) has changed group state since the caller captured that +// baseline immediately after applying its own lifecycle projection. This +// stops a slow, now-stale follow-up read from clobbering a fresher result +// that already landed while it was in flight. +func (app *WebApp) refreshDeviceStatusAfterStereoPairMutation(deviceID string, conn *webtypes.DeviceConnection, groupBaseline uint64) { + app.updateDeviceStatus(deviceID, conn, &groupBaseline) +} + +func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection, groupBaseline *uint64) { // Skip status update if client is not available (e.g., in tests) if conn.Client == nil { return @@ -650,7 +665,7 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) stereoCapable := stereoPairCapable(conn.DeviceInfo) var groupGeneration uint64 - if stereoCapable { + if stereoCapable && groupBaseline == nil { groupGeneration = conn.BeginGroupRefresh() } @@ -735,7 +750,11 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) }) if stereoCapable && groupErr == nil { - conn.ApplyPolledGroup(groupGeneration, group) + if groupBaseline != nil { + conn.ApplyPolledGroupIfBaseline(*groupBaseline, group) + } else { + conn.ApplyPolledGroup(groupGeneration, group) + } } } diff --git a/pkg/service/soundtouchweb/webtypes/types.go b/pkg/service/soundtouchweb/webtypes/types.go index 1647f601..cef8a0be 100644 --- a/pkg/service/soundtouchweb/webtypes/types.go +++ b/pkg/service/soundtouchweb/webtypes/types.go @@ -262,6 +262,39 @@ func (c *DeviceConnection) ApplyPolledGroup(generation uint64, group *models.Gro return c.replaceGroup(normalizeGroup(group), time.Time{}) } +// GroupGeneration reports the generation of the most recently applied group +// result (poll or event). Callers use this to snapshot a baseline before +// starting an async /getGroup read they only want to apply if nothing else +// has changed group state in the meantime. +func (c *DeviceConnection) GroupGeneration() uint64 { + c.groupMu.Lock() + defer c.groupMu.Unlock() + + return c.groupAppliedGeneration +} + +// ApplyPolledGroupIfBaseline stores a /getGroup result only if the applied +// group generation is still exactly baseline, i.e. no poll or event has +// applied since the caller captured that baseline via GroupGeneration. Unlike +// ApplyPolledGroup, a caller here never minted its own generation up front, +// so it cannot rely on "strictly newer" to detect a stale read -- an +// unconditionally-incrementing generation would always look newer than a +// baseline captured earlier, even when the read itself raced a fresher +// event or poll to completion first. +func (c *DeviceConnection) ApplyPolledGroupIfBaseline(baseline uint64, group *models.Group) bool { + c.groupMu.Lock() + defer c.groupMu.Unlock() + + if c.groupAppliedGeneration != baseline { + return false + } + + c.groupGeneration++ + c.groupAppliedGeneration = c.groupGeneration + + return c.replaceGroup(normalizeGroup(group), time.Time{}) +} + // ApplyGroupEvent stores the newest groupUpdated event and invalidates all // in-flight /getGroup requests, including ones that have not started yet. // Empty teardown events clear the current claim.