From 8ae2cd0a08af0a5311cc3d3d52656448141afbb3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 17 Jun 2026 18:29:33 +0200 Subject: [PATCH] [client] Fix ios route notify ordering (#6454) * [client] fix iOS route-update reordering that black-holed IPv6 on exit-node disable On iOS the route notifier delivered each prefix update from its own fire-and-forget goroutine (notify -> `go func`), so Go provided no ordering guarantee between consecutive updates. It also read currentPrefixes inside that goroutine without holding the lock, racing the next OnNewPrefixes write. On exit-node disable the core removes the default routes as two separate prefix updates (0.0.0.0/0, then the synthesized ::/0). When the two goroutines were reordered, the stale snapshot still containing ::/0 was delivered last and clobbered the correct default-free one. iOS then kept the ::/0 default route on the tunnel with no exit node to carry it, black-holing all IPv6 traffic while IPv4 recovered correctly. Fix: deliver updates through a single worker goroutine fed by a buffered channel, preserving production order, and snapshot the joined prefix string under the mutex so it can't race a concurrent update. Buffered so producers (which run under the route manager lock) don't block on the listener callback. * [client] close iOS notifier delivery goroutine on Stop, unbounded queue The delivery goroutine was never stopped, leaking on every engine restart. Add Notifier.Close, called from the route manager Stop after routing cleanup. Replace the buffered update channel with a cond-driven linked-list queue so route-update producers (running under the route manager lock) never block when the listener callback is slow. --- client/internal/routemanager/manager.go | 2 + .../routemanager/notifier/notifier_android.go | 6 +- .../routemanager/notifier/notifier_ios.go | 64 +++++++++++++------ .../routemanager/notifier/notifier_other.go | 4 ++ 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 0edf4607f..22458d575 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) { } } + m.notifier.Close() + m.mux.Lock() defer m.mux.Unlock() m.clientRoutes = nil diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 140a583f7..49300dbb2 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -16,7 +16,7 @@ import ( type Notifier struct { initialRoutes []*route.Route currentRoutes []*route.Route - fakeIPRoutes []*route.Route + fakeIPRoutes []*route.Route listener listener.NetworkChangeListener listenerMux sync.Mutex @@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string { sort.Strings(initialStrings) return initialStrings } + +func (n *Notifier) Close() { + // unused +} diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 27a2a722d..d0888f3a1 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,6 +3,7 @@ package notifier import ( + "container/list" "net/netip" "slices" "sort" @@ -14,19 +15,26 @@ import ( ) type Notifier struct { + mu sync.Mutex + cond *sync.Cond currentPrefixes []string - - listener listener.NetworkChangeListener - listenerMux sync.Mutex + listener listener.NetworkChangeListener + queue *list.List + closed bool } func NewNotifier() *Notifier { - return &Notifier{} + n := &Notifier{ + queue: list.New(), + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n } 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,32 +51,52 @@ 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() + routes := strings.Join(n.currentPrefixes, ",") + n.queue.PushBack(routes) + n.cond.Signal() + n.mu.Unlock() } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return - } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) - }(n.listener) +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } + +func (n *Notifier) deliverLoop() { + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + routes := n.queue.Remove(n.queue.Front()).(string) + l := n.listener + n.mu.Unlock() + + if l != nil { + l.OnNetworkChanged(routes) + } + } +} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index f57cadb0b..71b1096c2 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { func (n *Notifier) GetInitialRouteRanges() []string { return []string{} } + +func (n *Notifier) Close() { + // unused +}