diff --git a/client/ios/NetBirdSDK/connection_listener.go b/client/ios/NetBirdSDK/connection_listener.go index 26c3849c7..d792537ba 100644 --- a/client/ios/NetBirdSDK/connection_listener.go +++ b/client/ios/NetBirdSDK/connection_listener.go @@ -6,9 +6,9 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" ) -// Client state values delivered via ConnectionListener.OnStateChanged, -// re-exported as basic constants so gomobile emits them into the generated -// bindings. They mirror peer.ClientState*: append-only, never reorder. +// Client state values, re-exported as basic constants so gomobile emits them +// into the generated bindings. They mirror peer.ClientState*: append-only, +// never reorder. const ( ClientStateDisconnected = int(peer.ClientStateDisconnected) ClientStateConnected = int(peer.ClientStateConnected) @@ -17,11 +17,13 @@ const ( ClientStateNoNetwork = int(peer.ClientStateNoNetwork) ) -// ConnectionListener export internal Listener for mobile. It mirrors -// peer.Listener with OnStateChanged taking a plain int (one of the -// ClientState* constants), because gomobile cannot bind named types. +// ConnectionListener export internal Listener for mobile. +// +// It intentionally lacks OnStateChanged for now: adding a method to a gomobile +// interface breaks every Swift implementation, so the iOS app keeps building +// against the legacy per-state callbacks. A follow-up will extend it together +// with the app. type ConnectionListener interface { - OnStateChanged(state int) OnConnected() OnDisconnected() OnConnecting() @@ -31,11 +33,11 @@ type ConnectionListener interface { } // connectionListenerAdapter adapts the gomobile-facing ConnectionListener to -// peer.Listener, converting the typed state to the int the binding carries. +// peer.Listener. type connectionListenerAdapter struct { ConnectionListener } -func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) { - a.ConnectionListener.OnStateChanged(int(state)) -} +// OnStateChanged is dropped on iOS until the app adopts the state callback; +// the legacy per-state callbacks continue to fire. +func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {} diff --git a/client/netsweep/netsweep.go b/client/netsweep/netsweep.go new file mode 100644 index 000000000..23a7820e5 --- /dev/null +++ b/client/netsweep/netsweep.go @@ -0,0 +1,119 @@ +// Package netsweep cuts network-bound activity when the OS switches networks: +// a sweep closes the registered connections and aborts the in-flight dials, so +// their owners redial immediately instead of waiting for the old sockets to +// time out. +// +// A nil *Sweeper disables everything: all methods are nil-safe no-ops. +package netsweep + +import ( + "context" + "net" + "sync" + + log "github.com/sirupsen/logrus" +) + +// sweptConn deregisters itself from the sweeper when closed. +type sweptConn struct { + net.Conn + sweeper *Sweeper + id uint64 +} + +func (c *sweptConn) Close() error { + c.sweeper.deregister(c.id) + return c.Conn.Close() +} + +// Sweeper registers live connections and in-flight dials so Sweep can cut +// everything that started before the network changed. +type Sweeper struct { + mu sync.Mutex + conns map[uint64]net.Conn + dials map[uint64]context.CancelFunc + nextID uint64 +} + +// New creates an empty sweeper. +func New() *Sweeper { + return &Sweeper{ + conns: make(map[uint64]net.Conn), + dials: make(map[uint64]context.CancelFunc), + } +} + +// WrapConn registers conn and returns a wrapper that deregisters it on Close. +func (s *Sweeper) WrapConn(conn net.Conn) net.Conn { + if s == nil { + return conn + } + + s.mu.Lock() + id := s.nextID + s.nextID++ + s.conns[id] = conn + s.mu.Unlock() + + return &sweptConn{Conn: conn, sweeper: s, id: id} +} + +// WrapDialContext derives a context that Sweep cancels. The returned release +// must be called when the dial finishes, typically deferred. +func (s *Sweeper) WrapDialContext(ctx context.Context) (context.Context, context.CancelFunc) { + if s == nil { + return ctx, func() {} + } + + ctx, cancel := context.WithCancel(ctx) + + s.mu.Lock() + id := s.nextID + s.nextID++ + s.dials[id] = cancel + s.mu.Unlock() + + release := func() { + s.mu.Lock() + delete(s.dials, id) + s.mu.Unlock() + cancel() + } + return ctx, release +} + +// Sweep closes every registered connection, aborts every in-flight dial, and +// returns how many connections it closed. +func (s *Sweeper) Sweep() int { + if s == nil { + return 0 + } + + s.mu.Lock() + conns := s.conns + dials := s.dials + s.conns = make(map[uint64]net.Conn) + s.dials = make(map[uint64]context.CancelFunc) + s.mu.Unlock() + + if len(dials) > 0 { + log.Debugf("aborting %d in-flight dials", len(dials)) + for _, cancel := range dials { + cancel() + } + } + + for _, conn := range conns { + log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr()) + if err := conn.Close(); err != nil { + log.Debugf("swept connection close error: %v", err) + } + } + return len(conns) +} + +func (s *Sweeper) deregister(id uint64) { + s.mu.Lock() + delete(s.conns, id) + s.mu.Unlock() +} diff --git a/client/netsweep/netsweep_test.go b/client/netsweep/netsweep_test.go new file mode 100644 index 000000000..5c631d15b --- /dev/null +++ b/client/netsweep/netsweep_test.go @@ -0,0 +1,119 @@ +package netsweep + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSweepClosesRegisteredConns(t *testing.T) { + sweeper := New() + + c1 := sweeper.WrapConn(connPair(t)) + c2 := sweeper.WrapConn(connPair(t)) + + assert.Equal(t, 2, sweeper.Sweep(), "both live connections should be closed") + + // The wrappers must report closed now. + buf := make([]byte, 1) + _, err := c1.Read(buf) + assert.Error(t, err, "first connection should be unusable after the sweep") + _, err = c2.Read(buf) + assert.Error(t, err, "second connection should be unusable after the sweep") + + assert.Equal(t, 0, sweeper.Sweep(), "second sweep should find nothing") +} + +func TestCloseDeregisters(t *testing.T) { + sweeper := New() + + conn := sweeper.WrapConn(connPair(t)) + require.NoError(t, conn.Close()) + + assert.Equal(t, 0, sweeper.Sweep(), "closed connection must leave the registry") +} + +func TestCloseIsIdempotent(t *testing.T) { + sweeper := New() + + conn := sweeper.WrapConn(connPair(t)) + require.NoError(t, conn.Close()) + assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic") +} + +func TestSweepOnlyAffectsOlderConns(t *testing.T) { + sweeper := New() + + _ = sweeper.WrapConn(connPair(t)) + assert.Equal(t, 1, sweeper.Sweep()) + + // A connection dialed after the sweep must survive until the next one. + _ = sweeper.WrapConn(connPair(t)) + assert.Equal(t, 1, sweeper.Sweep(), "post-sweep connection belongs to the next sweep") +} + +func TestSweepAbortsInFlightDials(t *testing.T) { + sweeper := New() + + dialCtx, release := sweeper.WrapDialContext(context.Background()) + defer release() + + sweeper.Sweep() + + assert.ErrorIs(t, dialCtx.Err(), context.Canceled, "sweep must cancel the in-flight dial context") +} + +func TestReleasedDialIsNotAborted(t *testing.T) { + sweeper := New() + + // Simulate a dial that finished before the sweep. + _, release := sweeper.WrapDialContext(context.Background()) + release() + + // A dial still in flight during the sweep. + pendingCtx, pendingRelease := sweeper.WrapDialContext(context.Background()) + defer pendingRelease() + + sweeper.Sweep() + assert.ErrorIs(t, pendingCtx.Err(), context.Canceled, "pending dial must be aborted") +} + +func TestNilSweeperIsNoop(t *testing.T) { + var sweeper *Sweeper + + conn := connPair(t) + assert.Equal(t, conn, sweeper.WrapConn(conn), "nil sweeper must return the conn unchanged") + assert.Equal(t, 0, sweeper.Sweep(), "nil sweeper closes nothing") + + ctx, release := sweeper.WrapDialContext(context.Background()) + release() + assert.NoError(t, ctx.Err(), "nil sweeper must not cancel the dial context") +} + +// connPair dials a loopback TCP connection against a throwaway listener. +func connPair(t *testing.T) net.Conn { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Logf("listener close error: %v", err) + } + }) + + go func() { + conn, err := l.Accept() + if err != nil { + return + } + _ = conn.Close() + }() + + conn, err := net.Dial("tcp", l.Addr().String()) + require.NoError(t, err) + return conn +}