ipn/ipnlocal,net/netmon: make frequent darkwake more efficient

Investigating battery costs on a busy tailnet I noticed a large number
of nodes regularly reconnecting to control and DERP. In one case I was
able to analyze closely `pmset` reported the every-minute wake-ups being
triggered by bluetooth. The node was by side effect reconnecting to
control constantly, and this was at times visible to peers as well.

Three changes here improve the situation:
- Short time jumps (less than 10 minutes) no longer produce "major
  network change" events, and so do not trigger full rebind/reconnect.
- Many "incidental" fields on interfaces are ignored, like MTU, flags
  and so on - if the route is still good, the rest should be manageable.
- Additional log output will provide more detail about the cause of
  major network change events.

Updates #3363

Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
James Tucker
2026-04-02 17:02:03 -07:00
committed by James Tucker
parent d0cd0906d5
commit 21695cdbf8
5 changed files with 410 additions and 35 deletions
+223 -1
View File
@@ -473,6 +473,98 @@ func TestRebindRequired(t *testing.T) {
},
want: false,
},
{
name: "interface-flags-changed-no-ip-change",
s1: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
},
s2: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast, // FlagRunning removed
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
},
want: false,
},
{
name: "interface-mtu-changed-no-ip-change",
s1: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
MTU: 1500,
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
},
s2: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
MTU: 9000,
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
},
want: false,
},
{
name: "interface-went-down",
s1: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
},
s2: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagBroadcast | net.FlagMulticast, // FlagUp removed
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
},
want: true,
},
}
withIsInterestingInterface(t, func(ni Interface, pfxs []netip.Prefix) bool {
@@ -498,7 +590,7 @@ func TestRebindRequired(t *testing.T) {
}
SetTailscaleInterfaceProps(tt.tsIfName, 1)
cd, err := NewChangeDelta(tt.s1, tt.s2, false, true)
cd, err := NewChangeDelta(tt.s1, tt.s2, 0, true)
if err != nil {
t.Fatalf("NewChangeDelta error: %v", err)
}
@@ -510,6 +602,71 @@ func TestRebindRequired(t *testing.T) {
}
}
func TestTimeJumpedDoesNotTriggerRebind(t *testing.T) {
s := &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.12/24")},
},
HaveV4: true,
}
// A short time jump (e.g., macOS DarkWake maintenance cycle ~55s)
// with unchanged network state should NOT trigger rebind.
cd, err := NewChangeDelta(s, s, 55*time.Second, true)
if err != nil {
t.Fatalf("NewChangeDelta error: %v", err)
}
if cd.RebindLikelyRequired {
t.Error("RebindLikelyRequired = true for short time jump with unchanged state; want false")
}
if !cd.TimeJumped() {
t.Error("TimeJumped = false; want true")
}
// A major time jump (>10m) with unchanged state SHOULD trigger rebind,
// because NAT mappings are likely stale.
cd2, err := NewChangeDelta(s, s, 2*time.Hour, true)
if err != nil {
t.Fatalf("NewChangeDelta error: %v", err)
}
if !cd2.RebindLikelyRequired {
t.Error("RebindLikelyRequired = false for major time jump (2h); want true")
}
// A short time jump with changed state SHOULD trigger rebind.
s2 := &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast | net.FlagRunning,
}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("10.0.0.99/24")}, // IP changed
},
HaveV4: true,
}
saveAndRestoreTailscaleIfaceProps(t)
SetTailscaleInterfaceProps("", 0)
cd3, err := NewChangeDelta(s, s2, 55*time.Second, true)
if err != nil {
t.Fatalf("NewChangeDelta error: %v", err)
}
if !cd3.RebindLikelyRequired {
t.Error("RebindLikelyRequired = false for time jump with changed IP; want true")
}
}
func saveAndRestoreTailscaleIfaceProps(t *testing.T) {
t.Helper()
index, _ := TailscaleInterfaceIndex()
@@ -612,6 +769,71 @@ func TestPrefixesEqual(t *testing.T) {
}
}
func TestInterfaceDiff(t *testing.T) {
tests := []struct {
name string
s1, s2 *State
wantDiff string // substring expected in diff output; "" means no diff
}{
{
name: "equal",
s1: &State{HaveV4: true, DefaultRouteInterface: "en0"},
s2: &State{HaveV4: true, DefaultRouteInterface: "en0"},
wantDiff: "",
},
{
name: "flags-changed",
s1: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp | net.FlagRunning,
}},
},
},
s2: &State{
DefaultRouteInterface: "en0",
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{
Name: "en0",
Flags: net.FlagUp,
}},
},
},
wantDiff: "flags",
},
{
name: "mtu-changed",
s1: &State{
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{Name: "en0", MTU: 1500}},
},
},
s2: &State{
Interface: map[string]Interface{
"en0": {Interface: &net.Interface{Name: "en0", MTU: 9000}},
},
},
wantDiff: "MTU",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.s1.InterfaceDiff(tt.s2)
if tt.wantDiff == "" {
if got != "" {
t.Errorf("InterfaceDiff = %q; want empty", got)
}
} else {
if !strings.Contains(got, tt.wantDiff) {
t.Errorf("InterfaceDiff = %q; want substring %q", got, tt.wantDiff)
}
}
})
}
}
func TestForeachInterface(t *testing.T) {
tests := []struct {
name string