agent: reconnect to the servers when the network changes

After the network changes (e.g. the IP address changed after internet loss) the
connections to the servers are dead, but the OS reports it only after TCP
retransmissions time out - ~15 minutes on Linux/Android while the agent keeps
writing to the dead socket. Until then message delivery, subscriptions and file
transfers keep failing on the same client.

setUserNetworkInfo on every online network event (the clients report one when
the network type changes and when it goes offline to online):

- closes the connected SMP and NTF clients and fails their pending requests, so
  that the workers that resume do not wait for the responses that will not
  arrive - the requests are failed only once the client's threads terminated,
  which the client now records, as its session var is also removed when a
  resubscription fails (each client type bounded by 1 second; the requests of a
  client that did not terminate in this time are left to time out as before);
- discards the errors of the clients that failed to connect, cached for
  persistErrorInterval, and removes a client that is still connecting, in the
  transaction that reads it, so that the resumed workers create a new client
  instead of waiting for a connection made via the previous network - that
  client is closed by newProtocolClient when it finds it is no longer in the map;
- closes the XFTP clients asynchronously, removing them from the map.

A client that is no longer the one in the map is rejected in the same
transaction that would publish it, so that the requests made on it do not wait
for the timeout, and it is closed in a forked thread, as closing an XFTP client
waits for its thread to terminate; it sets its session id while its var is the
one in the map and updates its service only if it was.

The clients are closed in a forked thread, so that the API does not block, and
the closes of an event that a newer one superseded are skipped. When the agent
was offline the reported network info is applied when the clients are closed,
so that the workers waiting for the network do not resume before then; when it
was online it is applied immediately.

As the clients are closed, the network change now results in DISCONNECT and
DOWN events followed by CONNECT and UP, as any other reconnection.

The retry loops are not restarted, so a worker already waiting out its retry
interval resumes only when it expires.
This commit is contained in:
Narasimha-sc
2026-10-05 20:57:41 +00:00
parent 656df39aa0
commit 8e3f9a47b9
5 changed files with 120 additions and 9 deletions
+30 -5
View File
@@ -266,7 +266,7 @@ import Simplex.RemoteControl.Invitation
import Simplex.RemoteControl.Types
import System.Mem.Weak (deRefWeak)
import UnliftIO.Async (mapConcurrently)
import UnliftIO.Concurrent (forkFinally, forkIO, killThread, mkWeakThreadId, threadDelay)
import UnliftIO.Concurrent (forkFinally, forkIO, forkIOWithUnmask, killThread, mkWeakThreadId, threadDelay)
import qualified UnliftIO.Exception as E
import UnliftIO.STM
@@ -713,14 +713,39 @@ setNetworkConfig c@AgentClient {useNetworkConfig, proxySessTs} cfg' = do
when changed $ liftIO $ reconnectAllServers c
setUserNetworkInfo :: AgentClient -> UserNetworkInfo -> IO ()
setUserNetworkInfo c@AgentClient {userNetworkInfo, userNetworkUpdated} ni = withAgentEnv' c $ do
setUserNetworkInfo c@AgentClient {userNetworkInfo, userNetworkUpdated, networkEventSeq} ni = withAgentEnv' c $ do
ts' <- liftIO getCurrentTime
i <- asks $ userOfflineDelay . config
-- if network offline event happens in less than `userOfflineDelay` after the previous event, it is ignored
atomically . whenM ((isOnline ni ||) <$> notRecentlyChanged ts' i) $ do
writeTVar userNetworkInfo ni
writeTVar userNetworkUpdated $ Just ts'
-- mask, so that the thread restoring the deferred online network info is always forked
liftIO $ E.mask_ $ do
event <- atomically $ do
update <- (isOnline ni ||) <$> notRecentlyChanged ts' i
if update
then do
writeTVar userNetworkUpdated $ Just ts'
-- when the agent was offline the reported info is applied when the clients are closed,
-- so that the workers waiting for the network do not resume before then
wasOnline <- isOnline <$> readTVar userNetworkInfo
writeTVar userNetworkInfo $ if wasOnline || not (isOnline ni) then ni else ni {online = False}
modifyTVar' networkEventSeq (+ 1)
Just <$> readTVar networkEventSeq
else pure Nothing
-- The client reports online network when it changes. The connections to the servers made via
-- the previous network are likely to be dead, but the OS may only report it after several minutes.
forM_ event $ \e -> when (isOnline ni) $ void $ forkIOWithUnmask $ \unmask ->
unmask (closeClients e) `E.finally` networkOnline e
where
-- skipped when a newer info superseded this one - it closes the clients itself, and the ones
-- that stay connected while the network is offline are closed by the next online info
closeClients e = do
whenCurrent $ closeNetworkClients c smpClients
whenCurrent $ closeNetworkClients c ntfClients
whenCurrent $ closeProtocolServerClients c xftpClients
where
whenCurrent = whenM (atomically $ isCurrent e)
networkOnline e = atomically $ whenM (isCurrent e) $ writeTVar userNetworkInfo ni
isCurrent e = (e ==) <$> readTVar networkEventSeq
notRecentlyChanged ts' i =
maybe True (\ts -> diffUTCTime ts' ts > i) <$> readTVar userNetworkUpdated
+49 -3
View File
@@ -21,6 +21,7 @@
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilyDependencies #-}
{-# LANGUAGE TypeOperators #-}
module Simplex.Messaging.Agent.Client
( AgentClient (..),
@@ -148,6 +149,7 @@ module Simplex.Messaging.Agent.Client
UserNetworkType (..),
getFastNetworkConfig,
waitForUserNetwork,
closeNetworkClients,
isNetworkOnline,
isOnline,
throwWhenInactive,
@@ -363,6 +365,8 @@ data AgentClient = AgentClient
presetServers :: [SMPServer],
userNetworkInfo :: TVar UserNetworkInfo,
userNetworkUpdated :: TVar (Maybe UTCTime),
-- incremented for each accepted network info, to identify the most recent one
networkEventSeq :: TVar Int,
subscrConns :: TVar (Set ConnId),
currentSubs :: TSessionSubs,
removedSubs :: TMap (UserId, SMPServer) (TMap SMP.RecipientId SMPClientError),
@@ -535,6 +539,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, entitlements, netCf
useNetworkConfig <- newTVarIO (slowNetworkConfig netCfg, netCfg)
userNetworkInfo <- newTVarIO $ UserNetworkInfo UNOther True
userNetworkUpdated <- newTVarIO Nothing
networkEventSeq <- newTVarIO 0
subscrConns <- newTVarIO S.empty
currentSubs <- SS.emptyIO
removedSubs <- TM.emptyIO
@@ -579,6 +584,7 @@ newAgentClient clientId InitialAgentServers {smp, ntf, xftp, entitlements, netCf
presetServers,
userNetworkInfo,
userNetworkUpdated,
networkEventSeq,
subscrConns,
currentSubs,
removedSubs,
@@ -767,8 +773,12 @@ smpConnectClient c@AgentClient {smpClients, msgQ, proxySessTs, presetDomains} nm
smp <- liftError (protocolClientError SMP $ B.unpack $ strEncode srv) $ do
ts <- readTVarIO proxySessTs
ExceptT $ getProtocolClient g nm tSess cfg' presetDomains (Just msgQ) ts $ smpClientDisconnected c tSess env v' prs
atomically $ SS.setSessionId tSess (sessionId $ thParams smp) $ currentSubs c
updateClientService service smp
-- the client removed from the map while it was connecting (see newProtocolClient) must not
-- replace the session or the service of the client that is current now
current <- atomically $ do
cur <- currentSessVar v' tSess smpClients
cur <$ when cur (SS.setSessionId tSess (sessionId $ thParams smp) $ currentSubs c)
when current $ updateClientService service smp
pure SMPConnectedClient {connectedClient = smp, proxiedRelays = prs}
updateClientService service smp = case (service, smpClientServiceId smp) of
(Just (_, serviceId_), Just serviceId) -> withStore' c $ \db -> do
@@ -954,8 +964,18 @@ newProtocolClient ::
newProtocolClient c tSess@(userId, srv, entityId_) clients connectClient v =
tryAllErrors (connectClient v) >>= \case
Right client -> do
-- the client removed from the map while it was connecting, e.g. when the network changed, must
-- not be used - the requests made on it would wait for the responses that will not arrive; it is
-- published and rejected in one transaction, so that the caller always receives the one in the map
let e = BROKER (B.unpack $ strEncode srv) (NETWORK NEFailedError)
current <- atomically $ do
cur <- currentSessVar v tSess clients
cur <$ putTMVar (sessionVar v) (if cur then Right client else Left (e, Nothing))
unless current $ do
-- closed in another thread, as closing an XFTP client waits for its thread to terminate
void $ liftIO $ forkIO $ closeProtocolServerClient (protocolClient client) `catchAll_` pure ()
throwE e
logInfo . decodeUtf8 $ "Agent connected to " <> showServer srv <> " (user " <> bshow userId <> maybe "" (" for entity " <>) entityId_ <> ")"
atomically $ putTMVar (sessionVar v) (Right client)
liftIO $ nonBlockingWriteTBQueue (subQ c) ("", "", AEvt SAENone $ hostEvent CONNECT client)
pure client
Left e -> do
@@ -1049,6 +1069,32 @@ reconnectServerClients :: ProtocolServerClient v err msg => AgentClient -> (Agen
reconnectServerClients c clientsSel =
readTVarIO (clientsSel c) >>= mapM_ (forkIO . closeClient_ c)
-- The pending requests of the clients that are closed fail before this function returns, so that
-- the workers that resume do not wait for the responses that will not arrive; the requests of
-- a client that did not terminate within the timeout are left to time out as before.
closeNetworkClients :: (ProtocolServerClient v err msg, ProtoClient msg ~ ProtocolClient v err msg) => AgentClient -> (AgentClient -> TMap (TransportSession msg) (ClientVar msg)) -> IO ()
closeNetworkClients c clientsSel = do
closed <- readTVarIO (clientsSel c) >>= fmap catMaybes . mapM closeNetworkClient . M.toList
void $ 1000000 `timeout` atomically (mapM_ (\pc -> whenM (clientConnected pc) retry) closed)
-- the requests are failed after the client threads terminate, so that the responses that arrive
-- before that are processed as usual rather than as the responses to the failed requests
forM_ closed $ \pc -> unlessM (atomically $ clientConnected pc) $ failPendingRequests pc
where
closeNetworkClient (tSess, v) =
atomically (tryReadTMVar (sessionVar v) >>= \r -> r <$ removeUnconnected r) >>= \case
Just (Right client) -> do
let pc = protocolClient client
Just pc <$ forkIO (closeProtocolServerClient pc `catchAll_` pure ())
_ -> pure Nothing
where
-- the client that failed to connect is removed so that the resumed workers do not get its cached
-- error, and the one that is connecting - so that they create a new client instead of waiting for
-- the connection made via the previous network; it is read and removed in one transaction, so that
-- it cannot publish itself in between - newProtocolClient then rejects and closes it
removeUnconnected = \case
Just (Right _) -> pure ()
_ -> removeSessVar v tSess (clientsSel c)
reconnectSMPServer :: AgentClient -> UserId -> SMPServer -> IO ()
reconnectSMPServer c userId srv = do
cs <- readTVarIO $ smpClients c
+10 -1
View File
@@ -35,6 +35,8 @@ module Simplex.Messaging.Client
ProxiedRelay (..),
getProtocolClient,
closeProtocolClient,
failPendingRequests,
clientConnected,
protocolClientServer,
protocolClientServer',
transportHost',
@@ -654,7 +656,7 @@ getProtocolClient g nm transportSession@(_, srv, _) cfg@ProtocolClientConfig {qS
writeTVar (connected c) True
putTMVar cVar $ Right c'
raceAny_ ([send c' th, process c', receive c' th] <> [monitor c' | smpPingInterval > 0])
`E.finally` disconnected c'
`E.finally` (atomically (writeTVar (connected c) False) >> disconnected c')
send :: Transport c => ProtocolClient v err msg -> THandle v c 'TClient -> IO ()
send ProtocolClient {client_ = PClient {sndQ}} h = forever $ atomically (readTBQueue sndQ) >>= sendPending
@@ -759,6 +761,13 @@ closeProtocolClient :: ProtocolClient v err msg -> IO ()
closeProtocolClient = mapM_ (deRefWeak >=> mapM_ killThread) . action
{-# INLINE closeProtocolClient #-}
clientConnected :: ProtocolClient v err msg -> STM Bool
clientConnected ProtocolClient {client_ = PClient {connected}} = readTVar connected
failPendingRequests :: ProtocolClient v err msg -> IO ()
failPendingRequests ProtocolClient {client_ = PClient {sentCommands}} =
readTVarIO sentCommands >>= mapM_ (\Request {pending, responseVar} -> atomically $ whenM (swapTVar pending False) $ void $ tryPutTMVar responseVar $ Left $ PCENetworkError NEFailedError)
-- | SMP client error type.
data ProtocolClientError err
= -- | Correctly parsed SMP server ERR response.
+5
View File
@@ -6,6 +6,7 @@ module Simplex.Messaging.Session
( SessionVar (..),
getSessVar,
removeSessVar,
currentSessVar,
withGetSessVar,
withGetSessVar',
tryReadSessVar,
@@ -44,6 +45,10 @@ removeSessVar v sessKey vs =
Just v' | sessionVarId v == sessionVarId v' -> TM.delete sessKey vs
_ -> pure ()
-- the client is current while its session var is the one in the map
currentSessVar :: Ord k => SessionVar a -> k -> TMap k (SessionVar a) -> STM Bool
currentSessVar v sessKey vs = maybe False ((sessionVarId v ==) . sessionVarId) <$> TM.lookup sessKey vs
-- | Get or create a session var and route to onNew (newly created) or onExisting. The new-var
-- branch is bracketed from the point of creation: if it is interrupted before filling the var
-- (e.g. an async exception during connect), the still-empty var is dropped from the map so the
+26
View File
@@ -91,6 +91,7 @@ import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestSte
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Env (..), InitialAgentServers (..), createAgentStore)
import Simplex.Messaging.Agent.Protocol hiding (CON, CONF, INFO, REQ, SENT)
import qualified Simplex.Messaging.Agent.Protocol as A
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), RetryInterval2 (..))
import Simplex.Messaging.Agent.Store (Connection' (..), SomeConn' (..), StoredRcvQueue (..))
import Simplex.Messaging.Agent.Store.AgentStore (deleteRatchetKeyHashesExpired, getConn, getRatchetX3dhKeys)
import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction)
@@ -606,6 +607,7 @@ functionalAPITests ps = do
describe "user network info" $ do
it "should wait for user network" testWaitForUserNetwork
it "should not reset online to offline if happens too quickly" testDoNotResetOnlineToOffline
it "should reconnect to servers when network changes" $ testNetworkChangeReconnect ps
it "should resume multiple threads" testResumeMultipleThreads
describe "SMP queue info" $ do
it "server should respond with queue and subscription information" $
@@ -4790,9 +4792,31 @@ testWaitForUserNetwork = withAgent 1 aCfg initAgentServers testDB $ \a -> do
(threadDelay 50000 >> setUserNetworkInfo a (UserNetworkInfo UNCellular True))
(networkDelay a 50000)
noNetworkDelay a
-- the agent is disposed, so that the workers the network events resumed do not use the database
-- of the test that runs next - they share the file
disposeAgentClient a
where
aCfg = agentCfg {userNetworkInterval = 100000, userOfflineDelay = 0}
testNetworkChangeReconnect :: HasCallStack => (ASrvTransport, AStoreType) -> IO ()
testNetworkChangeReconnect ps =
withAgentClientsCfg2 agentCfg {userOfflineDelay = 0} agentCfg $ \a b -> withSmpServer ps $ do
(aId, bId) <- runRight $ makeConnection a b
-- network type changed while online - connections to servers are re-established
setUserNetworkInfo a $ UserNetworkInfo UNCellular True
nGet a =##> \case ("", "", DOWN _ [c]) -> c == bId; _ -> False
nGet a =##> \case ("", "", UP _ [c]) -> c == bId; _ -> False
-- the same network type reported again (the client only reports it when the network changes) - reconnects
setUserNetworkInfo a $ UserNetworkInfo UNCellular True
nGet a =##> \case ("", "", DOWN _ [c]) -> c == bId; _ -> False
nGet a =##> \case ("", "", UP _ [c]) -> c == bId; _ -> False
setUserNetworkInfo a $ UserNetworkInfo UNNone False
noMessages a "no reconnection when offline"
setUserNetworkInfo a $ UserNetworkInfo UNWifi True
nGet a =##> \case ("", "", DOWN _ [c]) -> c == bId; _ -> False
nGet a =##> \case ("", "", UP _ [c]) -> c == bId; _ -> False
runRight_ $ exchangeGreetings a bId b aId
testDoNotResetOnlineToOffline :: IO ()
testDoNotResetOnlineToOffline = withAgent 1 aCfg initAgentServers testDB $ \a -> do
noNetworkDelay a
@@ -4811,6 +4835,7 @@ testDoNotResetOnlineToOffline = withAgent 1 aCfg initAgentServers testDB $ \a ->
setUserNetworkInfo a $ UserNetworkInfo UNWifi True
setUserNetworkInfo a $ UserNetworkInfo UNNone False -- ingnored
noNetworkDelay a
disposeAgentClient a
where
aCfg = agentCfg {userNetworkInterval = 100000, userOfflineDelay = 0.1}
@@ -4832,6 +4857,7 @@ testResumeMultipleThreads = withAgent 1 aCfg initAgentServers testDB $ \a -> do
let average = sum ts `div` fromIntegral (length ts)
average < 3000000 `shouldBe` True
maximum ts < 4000000 `shouldBe` True
disposeAgentClient a
where
aCfg = agentCfg {userOfflineDelay = 0}