From 8730cce2175ca0d107d86b32e33e570f26515a59 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Thu, 30 Apr 2026 19:34:29 +0000 Subject: [PATCH] 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 --- ipn/backend.go | 3 +- ipn/ipnlocal/local.go | 7 +++-- ipn/ipnlocal/local_test.go | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/ipn/backend.go b/ipn/backend.go index 51617e08e..32b38200a 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -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 ) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 7432e33a7..d6aee5da1 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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() { diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 70cbc8991..4409befbb 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -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