Compare commits

...

1 Commits

Author SHA1 Message Date
Zoltán Papp
b8f139c91f [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.
2026-06-16 19:26:33 +02:00

View File

@@ -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 {