diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index ec3a808e..18dd78d8 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -692,14 +692,16 @@ func electPrimaryRoutes( } } + // All-unhealthy fallback: preserve the previous primary only + // when it is still a candidate. Falling back to any candidate + // would point peers at a node the prober has already declared + // unreachable; leaving the prefix unmapped is honest until a + // probe cycle picks one that responds. if !found && len(candidates) >= 1 { if cur, ok := prev[prefix]; ok && slices.Contains(candidates, cur) { selected = cur - } else { - selected = candidates[0] + found = true } - - found = true } if found { diff --git a/hscontrol/state/primaries_property_test.go b/hscontrol/state/primaries_property_test.go index 2c583f76..a64664d1 100644 --- a/hscontrol/state/primaries_property_test.go +++ b/hscontrol/state/primaries_property_test.go @@ -97,18 +97,23 @@ func (m *primariesModel) updatePrimaries() { } } + // All-unhealthy fallback: preserve the previous primary if it + // is still a candidate, otherwise leave the prefix unmapped. + // electPrimaryRoutes was changed to drop the candidates[0] + // fallback so the Phase-5 (simultaneous dual-disconnect) + // regression cannot pick an already-unhealthy node as + // primary; the model has to track the same behaviour. if !found && len(nodes) >= 1 { if cur, ok := m.primary[p]; ok && slices.Contains(nodes, cur) { selected = cur - } else { - selected = nodes[0] + found = true } - - found = true } if found { m.primary[p] = selected + } else { + delete(m.primary, p) } } }