diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index 1fa7db3d8..0f3e4c021 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -986,7 +986,7 @@ func (s *NodeStore) PrimaryRouteFor(prefix netip.Prefix) (types.NodeID, bool) { } // PrimaryRoutesForNode returns the prefixes for which id is the current -// primary advertiser. +// primary advertiser, sorted so an unchanged node renders identically. func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix { snap := s.data.Load() if !snap.isPrimaryRoute[id] { @@ -1001,6 +1001,8 @@ func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix { } } + slices.SortFunc(out, netip.Prefix.Compare) + return out } diff --git a/hscontrol/state/node_store_test.go b/hscontrol/state/node_store_test.go index 8d07eaf03..8c8a27501 100644 --- a/hscontrol/state/node_store_test.go +++ b/hscontrol/state/node_store_test.go @@ -1496,6 +1496,28 @@ func TestHealthOnlyWriteReusesPeerMap(t *testing.T) { peersCalls.Load()) } +// TestPrimaryRoutesForNodeSorted checks a router's primaries come back in +// a stable order: they render into its self node, which the mapper resends +// whenever it differs from what the node holds (issue #3502). +func TestPrimaryRoutesForNodeSorted(t *testing.T) { + node := createTestNode(1, 1, "user1", "router") + + prefixes := make([]netip.Prefix, 0, 8) + for i := range 8 { + prefixes = append(prefixes, netip.PrefixFrom(netip.AddrFrom4([4]byte{10, byte(i), 0, 0}), 16)) + } + + node.Hostinfo = &tailcfg.Hostinfo{Hostname: "router", RoutableIPs: prefixes} + node.ApprovedRoutes = prefixes + node.IsOnline = new(true) + + store := NewNodeStore(types.Nodes{&node}, allowAllPeersFunc, TestBatchSize, TestBatchTimeout) + + for range 20 { + require.Equal(t, prefixes, store.PrimaryRoutesForNode(1)) + } +} + func BenchmarkSnapshotPayloadDense(b *testing.B) { const nodeCount = 500