From 9371b4ee2889fc4d605d89a299cec3cb6a925710 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 8 Apr 2026 12:26:44 +0000 Subject: [PATCH] mapper: fix empty Peers list not clearing client peer state When a FullUpdate produces zero visible peers (e.g., a restrictive policy isolates a node), the MapResponse has Peers: [] (empty non-nil). The Tailscale client only processes Peers as a full replacement when len(Peers) > 0 (controlclient/map.go:462), so an empty list is silently ignored and stale peers persist. This triggers when a FullUpdate() replaces a pending PolicyChange() in the batcher. The PolicyChange would have used computePeerDiff to send explicit PeersRemoved, but the FullUpdate goes through buildFromChange which sets Peers: [] that the client ignores. When a full update produces zero peers, compute the peer diff against lastSentPeers and add explicit PeersRemoved entries so the client correctly clears its stale peer state. --- hscontrol/mapper/batcher.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/hscontrol/mapper/batcher.go b/hscontrol/mapper/batcher.go index 8caa901d..6fbefdbe 100644 --- a/hscontrol/mapper/batcher.go +++ b/hscontrol/mapper/batcher.go @@ -128,6 +128,30 @@ func generateMapResponse(nc nodeConnection, mapper *mapper, r change.Change) (*t return nil, fmt.Errorf("generating map response for nodeID %d: %w", nodeID, err) } + // When a full update (SendAllPeers=true) produces zero visible peers + // (e.g., a restrictive policy isolates this node), the resulting + // MapResponse has Peers: []*tailcfg.Node{} (empty non-nil slice). + // + // The Tailscale client only treats Peers as a full authoritative + // replacement when len(Peers) > 0 (controlclient/map.go:462). + // An empty Peers slice is indistinguishable from a delta response, + // so the client silently preserves its existing peer state. + // + // This matters when a FullUpdate() replaces a pending PolicyChange() + // in the batcher (addToBatch short-circuits on HasFull). The + // PolicyChange would have computed PeersRemoved via computePeerDiff, + // but the FullUpdate path uses WithPeers which sets Peers: []. + // + // Fix: when a full update results in zero peers, compute the diff + // against lastSentPeers and add explicit PeersRemoved entries so + // the client correctly clears its stale peer state. + if mapResp != nil && r.SendAllPeers && len(mapResp.Peers) == 0 { + removedPeers := nc.computePeerDiff(nil) + if len(removedPeers) > 0 { + mapResp.PeersRemoved = removedPeers + } + } + return mapResp, nil }