fix(service): stop async stereo-pair refresh from clobbering a fresher projection

completeStereoPairMutation's refreshStereoPairMembersAsync ran after
applyStereoPairProjection, and its UpdateDeviceStatus call always
minted a strictly newer group generation via BeginGroupRefresh -- so
ApplyPolledGroup's staleness guard could never reject it, even if its
/getGroup read raced a slower path and was stale relative to the
mutation that had already completed. The refresh now snapshots each
member's post-projection generation and only applies its own read via
ApplyPolledGroupIfBaseline, which requires nothing else (no other
event or poll) to have changed group state in the meantime. Other
UpdateDeviceStatus callers keep their existing always-newer semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-09-05 11:08:04 +02:00
co-authored by Claude Sonnet 5
parent aa49d904c4
commit dbedef614b
3 changed files with 80 additions and 9 deletions
@@ -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])
}
}
+22 -3
View File
@@ -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)
}
}
}
@@ -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.