tstest/integration/testcontrol: fix serveMap read-modify-write race

serveMap cloned s.nodes[nk], mutated the clone outside the mutex,
then wrote it back via updateNodeLocked. A concurrent UpdateNode,
SetNodeCapMap, or other writer landing between the clone and the
writeback would be silently clobbered. Mutate the live node under
the mutex instead.

Surfaces in tsnet's TestListenService as a flaky ErrUntaggedServiceHost
panic: the test calls control.UpdateNode to attach a tag, a concurrent
updateRoutine map request from the host races, and the host's next
netmap arrives with Tags=[].

Updates #19822

Change-Id: I6c5ebd5e5bf79a40316f53f627157230773cb469
Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
James Tucker
2026-05-20 18:29:58 -07:00
committed by James Tucker
parent 61277e3ad4
commit 36c52ef383
+21 -9
View File
@@ -1215,19 +1215,31 @@ func (s *Server) serveMap(w http.ResponseWriter, r *http.Request, mkey key.Machi
var peersToUpdate []tailcfg.NodeID
if !req.ReadOnly && !streamingNonUpdate {
endpoints := filterInvalidIPv6Endpoints(req.Endpoints)
node.Endpoints = endpoints
node.DiscoKey = req.DiscoKey
node.Cap = req.Version
var hi tailcfg.HostinfoView
var newDERP int
if req.Hostinfo != nil {
node.Hostinfo = req.Hostinfo.View()
if ni := node.Hostinfo.NetInfo(); ni.Valid() {
if ni.PreferredDERP() != 0 {
node.HomeDERP = ni.PreferredDERP()
}
hi = req.Hostinfo.View()
if ni := hi.NetInfo(); ni.Valid() {
newDERP = ni.PreferredDERP()
}
}
// Mutate the live node under the mutex; writing back the clone
// obtained above would clobber any concurrent writer's changes
// to other fields (e.g. UpdateNode, SetNodeCapMap).
s.mu.Lock()
peersToUpdate = s.updateNodeLocked(node)
live := s.nodes[req.NodeKey]
if live != nil {
live.Endpoints = endpoints
live.DiscoKey = req.DiscoKey
live.Cap = req.Version
if hi.Valid() {
live.Hostinfo = hi
if newDERP != 0 {
live.HomeDERP = newDERP
}
}
peersToUpdate = s.nodeIDsLocked(live.ID)
}
s.mu.Unlock()
}