ipn/ipnlocal: send initial NetMap to watchers subscribing to peer change deltas

updates tailscale/corp#40994

If a watcher doesn't also set NotifyInitialNetMap, they can end up receiving
deltas temporarily without any base netmap to which to apply them to.
This change ensures  that there is no requirement to set both flags.  There
is no scenario in which a caller doesn't need the base netmap in the first response.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Jonathan Nobels 2026-04-30 19:34:29 +00:00
parent cac94f51cc
commit 8730cce217
3 changed files with 70 additions and 3 deletions

View File

@ -90,7 +90,8 @@ const (
// NotifyPeerChanges, if set, causes netmap delta updates to be sent as [tailcfg.PeerChange] rather than a full NetMap.
// Full netmap responses from the control plane are still sent as a full NetMap. PeerChanges are only sent to sessions
// that have opted in to this mode.
// that have opted in to this mode. Setting this flag also implies NotifyInitialNetMap, as the first message must
// contain a full netmap to establish the baseline for future peer changes.
NotifyPeerChanges NotifyWatchOpt = 1 << 12
)

View File

@ -3296,7 +3296,7 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
b.mu.Lock()
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialDriveShares | ipn.NotifyInitialSuggestedExitNode | ipn.NotifyInitialClientVersion | ipn.NotifyPeerChanges
if mask&initialBits != 0 {
cn := b.currentNode()
ini = &ipn.Notify{Version: version.Long()}
@ -3310,7 +3310,10 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
if mask&ipn.NotifyInitialPrefs != 0 {
ini.Prefs = new(b.sanitizedPrefsLocked())
}
if mask&ipn.NotifyInitialNetMap != 0 {
if mask&(ipn.NotifyInitialNetMap|ipn.NotifyPeerChanges) != 0 {
// Include the initial NetMap if explicitly requested, or if the
// client subscribes to peer change deltas. Deltas are useless
// without a base netmap to apply them to.
ini.NetMap = cn.NetMap()
}
if mask&ipn.NotifyInitialDriveShares != 0 && b.DriveSharingEnabled() {

View File

@ -7642,6 +7642,69 @@ func unexpectedClientVersion(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool
return false
}
func TestWatchNotificationsPeerChangesImpliesInitialNetMap(t *testing.T) {
tests := []struct {
name string
mask ipn.NotifyWatchOpt
wantInitialNetMap bool
}{
{
name: "with_notify_peer_changes",
mask: ipn.NotifyPeerChanges,
wantInitialNetMap: true,
},
{
name: "with_notify_initial_state_only",
mask: ipn.NotifyInitialState,
wantInitialNetMap: false,
},
{
name: "with_no_flags",
mask: 0,
wantInitialNetMap: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lb := newTestLocalBackend(t)
testNetMap := &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
ID: 1,
Name: "self.tailscale.ts.net.",
}).View(),
}
lb.mu.Lock()
lb.setNetMapLocked(testNetMap)
lb.mu.Unlock()
nw := newNotificationWatcher(t, lb, ipnauth.Self)
if tt.wantInitialNetMap {
nw.watch(tt.mask, []wantedNotification{
{
name: "InitialNetMap",
cond: func(_ testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
return n.NetMap != nil
},
},
})
} else {
nw.watch(tt.mask, nil, func(t testing.TB, _ ipnauth.Actor, n *ipn.Notify) bool {
if n.NetMap != nil {
t.Errorf("unexpected NetMap in initial notification without NotifyInitialNetMap or NotifyPeerChanges")
return true
}
return false
})
}
nw.check()
})
}
}
func checkError(tb testing.TB, got, want error, fatal bool) {
tb.Helper()
f := tb.Errorf