From a071b55f35a7d5eb6f83d0a9b3bb17600c5217f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 22 Jun 2026 23:53:06 +0200 Subject: [PATCH] [client] Skip engine restart when default route is unchanged After the network monitor's debounce window, re-check the default next hop before triggering a client restart. A flapping NIC that returns to the same default route no longer forces a restart, avoiding redundant sync stream reconnects and peer meta churn. --- client/internal/networkmonitor/monitor.go | 32 ++++++++++++++++++- .../internal/networkmonitor/monitor_test.go | 30 +++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/client/internal/networkmonitor/monitor.go b/client/internal/networkmonitor/monitor.go index 6d019258d..b80bf9b69 100644 --- a/client/internal/networkmonitor/monitor.go +++ b/client/internal/networkmonitor/monitor.go @@ -97,7 +97,12 @@ func (nw *NetworkMonitor) Listen(ctx context.Context) (err error) { case <-event: timer.Reset(debounceTime) case <-timer.C: - return nil + // A flapping NIC may return to the same default route after the + // debounce window; only restart if the next hop actually changed. + if nexthopChanged(nexthop4, nexthop6) { + return nil + } + log.Debug("Network monitor: default route unchanged after debounce, ignoring") case <-ctx.Done(): timer.Stop() return ctx.Err() @@ -134,3 +139,28 @@ func (nw *NetworkMonitor) checkChanges(ctx context.Context, event chan struct{}, } } } + +// nexthopChanged reports whether the current default next hop differs from the +// given baseline. A lookup error is treated as a change so we fail safe and +// restart rather than miss a real network change. +func nexthopChanged(oldv4, oldv6 systemops.Nexthop) bool { + newv4, errv4 := systemops.GetNextHop(netip.IPv4Unspecified()) + newv6, errv6 := systemops.GetNextHop(netip.IPv6Unspecified()) + if errv4 != nil || errv6 != nil { + return true + } + return !sameNexthop(oldv4, newv4) || !sameNexthop(oldv6, newv6) +} + +func sameNexthop(a, b systemops.Nexthop) bool { + if a.IP != b.IP { + return false + } + if (a.Intf == nil) != (b.Intf == nil) { + return false + } + if a.Intf != nil && a.Intf.Index != b.Intf.Index { + return false + } + return true +} diff --git a/client/internal/networkmonitor/monitor_test.go b/client/internal/networkmonitor/monitor_test.go index 164686689..a58013fd0 100644 --- a/client/internal/networkmonitor/monitor_test.go +++ b/client/internal/networkmonitor/monitor_test.go @@ -3,6 +3,8 @@ package networkmonitor import ( "context" "errors" + "net" + "net/netip" "testing" "time" @@ -97,3 +99,31 @@ func TestNetworkMonitor_MultiEvent(t *testing.T) { t.Errorf("unexpected duration: %v", time.Since(started)) } } + +func TestSameNexthop(t *testing.T) { + eth0 := &net.Interface{Index: 1, Name: "eth0"} + eth1 := &net.Interface{Index: 2, Name: "eth1"} + ip1 := netip.MustParseAddr("192.168.1.1") + ip2 := netip.MustParseAddr("192.168.2.1") + + tests := []struct { + name string + a, b systemops.Nexthop + want bool + }{ + {"identical", systemops.Nexthop{IP: ip1, Intf: eth0}, systemops.Nexthop{IP: ip1, Intf: eth0}, true}, + {"same index different pointer", systemops.Nexthop{IP: ip1, Intf: eth0}, systemops.Nexthop{IP: ip1, Intf: &net.Interface{Index: 1, Name: "eth0"}}, true}, + {"different ip", systemops.Nexthop{IP: ip1, Intf: eth0}, systemops.Nexthop{IP: ip2, Intf: eth0}, false}, + {"different interface", systemops.Nexthop{IP: ip1, Intf: eth0}, systemops.Nexthop{IP: ip1, Intf: eth1}, false}, + {"both nil interface", systemops.Nexthop{IP: ip1}, systemops.Nexthop{IP: ip1}, true}, + {"one nil interface", systemops.Nexthop{IP: ip1, Intf: eth0}, systemops.Nexthop{IP: ip1}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sameNexthop(tt.a, tt.b); got != tt.want { + t.Errorf("sameNexthop() = %v, want %v", got, tt.want) + } + }) + } +}