Compare commits

...

3 Commits

Author SHA1 Message Date
Zoltán Papp
4642233b83 [client] Make next-hop check injectable for network monitor tests
Move the next-hop comparison behind a NetworkMonitor field set by New(),
so tests can supply a stub instead of hitting the host's real default
route. Fixes the Event/MultiEvent tests hanging after the unchanged-route
check was added.
2026-06-23 14:05:12 +02:00
Zoltán Papp
65b3117603 [client] Treat missing default route per protocol in next-hop check
A failed GetNextHop lookup is now treated as an absent route (zero
Nexthop) and compared per protocol, instead of forcing a restart. In a
single-stack network the missing IPv6 default route no longer counts as
a change on every debounce, which previously defeated the unchanged-route
check.
2026-06-23 14:05:12 +02:00
Zoltán Papp
0be397431e [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.
2026-06-23 14:05:12 +02:00
2 changed files with 67 additions and 2 deletions

View File

@@ -28,11 +28,16 @@ type NetworkMonitor struct {
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.Mutex
// nexthopChanged reports whether the default next hop changed; overridable in tests.
nexthopChanged func(oldv4, oldv6 systemops.Nexthop) bool
}
// New creates a new network monitor.
func New() *NetworkMonitor {
return &NetworkMonitor{}
return &NetworkMonitor{
nexthopChanged: nexthopChanged,
}
}
// Listen begins monitoring network changes. When a change is detected, this function will return without error.
@@ -97,7 +102,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 nw.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 +144,26 @@ func (nw *NetworkMonitor) checkChanges(ctx context.Context, event chan struct{},
}
}
}
// nexthopChanged reports whether the current default next hop differs from the
// given baseline, per protocol. A failed lookup is treated as an absent route
// (zero Nexthop), so a protocol that had no default route to begin with (e.g.
// IPv6 in a single-stack network) does not by itself count as a change.
func nexthopChanged(oldv4, oldv6 systemops.Nexthop) bool {
newv4, _ := systemops.GetNextHop(netip.IPv4Unspecified())
newv6, _ := systemops.GetNextHop(netip.IPv6Unspecified())
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
}

View File

@@ -3,6 +3,8 @@ package networkmonitor
import (
"context"
"errors"
"net"
"net/netip"
"testing"
"time"
@@ -59,6 +61,7 @@ func TestNetworkMonitor_Event(t *testing.T) {
}
}
nw := New()
nw.nexthopChanged = func(_, _ systemops.Nexthop) bool { return true }
defer nw.Stop()
var resErr error
@@ -80,6 +83,7 @@ func TestNetworkMonitor_MultiEvent(t *testing.T) {
checkChangeFn = me.checkChange
nw := New()
nw.nexthopChanged = func(_, _ systemops.Nexthop) bool { return true }
defer nw.Stop()
done := make(chan struct{})
@@ -97,3 +101,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)
}
})
}
}