diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 27a2a722d..ef695dc46 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -14,19 +14,51 @@ import ( ) type Notifier struct { + mu sync.Mutex currentPrefixes []string + listener listener.NetworkChangeListener - listener listener.NetworkChangeListener - listenerMux sync.Mutex + // updates carries route snapshots to the single delivery goroutine. A + // dedicated worker (rather than a fresh goroutine per notify) guarantees + // the listener observes updates in the exact order they were produced. + // + // Without this ordering guarantee a stale snapshot can be delivered last + // and clobber the correct one. On exit-node disable the ::/0 removal + // arrives as a separate prefix update right after the 0.0.0.0/0 removal; + // with a goroutine-per-notify the two could be reordered, leaving the + // synthesized ::/0 default route installed on the tunnel and black-holing + // all IPv6 traffic while IPv4 worked. + updates chan string } func NewNotifier() *Notifier { - return &Notifier{} + n := &Notifier{ + // Buffered so producers (route updates run under the route manager + // lock) don't block on the listener callback. A small buffer absorbs + // the bursts seen during exit-node toggles. + updates: make(chan string, 16), + } + go n.deliverLoop() + return n +} + +// deliverLoop is the single consumer of n.updates. Serializing delivery here +// is what preserves ordering: snapshots reach the listener one at a time, in +// production order. +func (n *Notifier) deliverLoop() { + for routes := range n.updates { + n.mu.Lock() + l := n.listener + n.mu.Unlock() + if l != nil { + l.OnNetworkChanged(routes) + } + } } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } @@ -43,30 +75,25 @@ func (n *Notifier) OnNewRoutes(route.HAMap) { } func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { - newNets := make([]string, 0) + newNets := make([]string, 0, len(prefixes)) for _, prefix := range prefixes { newNets = append(newNets, prefix.String()) } sort.Strings(newNets) + n.mu.Lock() if slices.Equal(n.currentPrefixes, newNets) { + n.mu.Unlock() return } - n.currentPrefixes = newNets - n.notify() -} -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return - } + // Snapshot the delivered string under the lock so it is consistent and + // can't race with the next update mutating currentPrefixes. + routes := strings.Join(n.currentPrefixes, ",") + n.mu.Unlock() - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) - }(n.listener) + n.updates <- routes } func (n *Notifier) GetInitialRouteRanges() []string {