Tester les battements WebSocket et isoler les reconnexions iOS
La page de discussion a déjà retrouvé sa connexion, mais un callback de fermeture tardif la fait repasser à l’état « Connexion en cours ». Ce type de problème ne vient généralement pas d’un nombre insuffisant de tentatives de reconnexion : c’est une ancienne connexion qui peut encore modifier la nouvelle session. Lors de la validation d’un client WebSocket iOS sur un Mac dans le cloud GNUMac, mieux vaut d’abord séparer les échéances de battement et l’identité des connexions du code réseau, puis tester le transport réel. Les cas limites deviennent ainsi plus faciles à reproduire qu’en coupant sans cesse le réseau.
Définir l’identité des connexions et les règles temporelles
À chaque création d’une tâche de transport, attribuez-lui un numéro de génération croissant. Les callbacks d’ouverture, de réception d’un Pong, d’échec de réception et de fermeture doivent transmettre le numéro capturé à la création, et non lire le « numéro courant » au moment de leur exécution. Sinon, un ancien callback risque d’être pris pour un événement de la nouvelle connexion.
Utilisez une horloge monotone, pas la date système. En production, vous pouvez calculer le temps écoulé avec ContinuousClock ; dans les tests, passez directement un nombre entier de millisecondes. La stratégie retenue ici est explicite : attendre un intervalle après l’ouverture avant d’envoyer un Ping, ne pas envoyer de deuxième Ping tant qu’un Pong est attendu, et fermer la connexion si aucune réponse n’a été reçue à l’échéance.
Annuler une ancienne tâche ne constitue pas une barrière d’isolation. Les callbacks déjà en file d’attente peuvent encore s’exécuter : le destinataire des événements doit vérifier le numéro de connexion et la phase courante.
Ce modèle ne traite que les battements et l’identité d’une seule connexion. Il exclut la temporisation des reconnexions, le rejeu des messages et les accusés de réception applicatifs, pour ne pas réunir plusieurs protocoles dans un ensemble difficile à valider.
Écrire une machine à états indépendante du réseau
Enregistrez le contenu suivant dans heartbeat.swift. tick renvoie l’action à exécuter ; l’adaptateur externe se charge d’envoyer réellement le Ping ou d’annuler la tâche de transport. Les intervalles courts de cet exemple servent uniquement aux tests et ne constituent pas une recommandation pour la production.
struct Heartbeat {
enum Phase {
case idle, connecting, open
}
enum Action: Equatable {
case ping(Int)
case close(Int)
}
private(set) var generation = 0
private(set) var phase = Phase.idle
private var nextPing = 0
private var deadline: Int?
let interval: Int
let timeout: Int
mutating func connect() -> Int {
generation += 1
phase = .connecting
deadline = nil
return generation
}
mutating func opened(_ id: Int, now: Int) {
guard id == generation, phase == .connecting else {
return
}
phase = .open
nextPing = now + interval
}
mutating func pong(_ id: Int, now: Int) {
guard id == generation, phase == .open,
let limit = deadline, now < limit else {
return
}
deadline = nil
nextPing = now + interval
}
mutating func tick(now: Int) -> Action? {
guard phase == .open else { return nil }
if let limit = deadline {
guard now >= limit else { return nil }
phase = .idle
deadline = nil
return .close(generation)
}
guard now >= nextPing else { return nil }
deadline = now + timeout
return .ping(generation)
}
}
Par convention, un Pong reçu exactement à l’échéance est lui aussi considéré comme tardif. Ainsi, à un même instant, le résultat du test d’expiration reste identique, que le minuteur ou la réponse soit traité en premier. L’action de fermeture n’est toutefois déclenchée qu’au prochain appel à tick : l’ordonnanceur doit donc continuer à lancer ces vérifications.
Couvrir les cas limites de reconnexion avec des instants explicites
Ajoutez les assertions suivantes, puis exécutez xcrun swift heartbeat.swift dans le mode par défaut, sans optimisation. L’échec d’une assertion fait terminer le processus avec un code de sortie non nul ; si elles réussissent toutes, rien ne s’affiche. N’utilisez pas de paramètres de compilation qui désactivent ces vérifications.
var h = Heartbeat(interval: 100, timeout: 1000)
let first = h.connect()
h.opened(first, now: 0)
precondition(h.tick(now: 99) == nil)
precondition(h.tick(now: 100) == .ping(first))
precondition(h.tick(now: 1099) == nil)
h.pong(first, now: 1100)
precondition(h.tick(now: 1100) == .close(first))
let second = h.connect()
h.opened(first, now: 1200)
precondition(h.phase == .connecting)
h.opened(second, now: 2000)
precondition(h.tick(now: 2100) == .ping(second))
h.pong(first, now: 2150)
precondition(h.tick(now: 3100) == .close(second))
let third = h.connect()
h.opened(third, now: 4000)
precondition(h.tick(now: 4100) == .ping(third))
h.pong(third, now: 4200)
precondition(h.tick(now: 4299) == nil)
precondition(h.tick(now: 4300) == .ping(third))
La deuxième séquence fait délibérément arriver le Pong de l’ancienne connexion pendant que la nouvelle attend une réponse. Sans vérification de génération, ce Pong prolongerait à tort la durée de vie de la nouvelle connexion. Ajoutez aussi des cas d’ouverture répétée, de réception d’un Pong à l’état inactif et de vérifications répétées après expiration, afin de confirmer que chaque défaillance ne produit qu’une seule action de fermeture.
Préserver la sérialisation lors de l’intégration au transport
Dans un client réel, un actor peut détenir la machine à états. Le minuteur, les callbacks de connexion et les demandes d’annulation doivent tous passer par le même domaine d’isolation ; plusieurs files ne doivent jamais modifier directement l’état. Le numéro porté par chaque action doit lui aussi être vérifié : si une nouvelle connexion a été créée avant l’exécution d’une action, une ancienne action de fermeture ne doit pas annuler la tâche courante.
Avec URLSessionWebSocketTask.sendPing, transmettez un événement Pong au modèle uniquement si le callback de fin ne signale aucune erreur. En cas d’erreur, passez par un traitement de défaillance qui vérifie la génération. La réception d’un message applicatif n’équivaut pas à un Pong et ne doit pas, par défaut, remplacer le battement au niveau du transport.
Pour respecter l’hypothèse du modèle selon laquelle un seul Ping attend une réponse, l’adaptateur doit garantir que chaque Ping ne produit qu’un seul événement de fin. Si vous utilisez à la place une couche d’encapsulation susceptible d’appeler plusieurs fois son callback, ajoutez un identifiant de requête Ping. La génération permet d’isoler les connexions entre elles, mais pas de distinguer plusieurs battements sur une même connexion.
Terminer par la validation des connexions réelles
Une fois les assertions hors ligne validées, vérifiez le comportement du protocole avec un serveur de test contrôlable. Consignez au minimum quatre résultats : réponse normale, réponse retardée, fermeture directe et échec d’établissement de la connexion. Vérifiez aussi que la boucle de réception continue à lire les messages suivants. Les journaux doivent se limiter à la génération, au type d’événement, au temps monotone et aux changements d’état, sans enregistrer de jetons ni de contenu applicatif.
Lorsque l’application passe en arrière-plan, rien ne garantit que les callbacks du minuteur continuent à s’exécuter. Ce modèle de battement n’est donc pas un mécanisme de maintien en activité en arrière-plan. Au retour au premier plan, réévaluez d’abord l’état de la connexion, puis décidez de la réutiliser ou d’en créer une nouvelle. Le simulateur permet de vérifier le routage des événements, mais les tests sur appareil réel doivent aussi couvrir le retour au premier plan et les changements de réseau.
La validation ne consiste pas à constater que « la connexion finit par s’établir ». Elle doit démontrer que les anciens événements ne peuvent pas modifier une nouvelle session, que la limite d’expiration est sans ambiguïté et que chaque fermeture a une cause identifiable. Intégrez ces assertions au pipeline : les futurs ajustements de l’intervalle de battement ne vous obligeront plus à attendre manuellement qu’une condition de concurrence se manifeste.
Questions fréquentes
Faut-il couper le réseau pour tester les battements ?
Non pour les tests unitaires : injectez un temps monotone et vérifiez les instants juste avant et à l’échéance. Réservez les échanges Ping/Pong réels et les changements de réseau aux tests d’intégration.
Pourquoi vérifier la génération après avoir annulé la connexion ?
L’annulation ne retire pas les callbacks déjà en file. Incrémentez la génération à chaque connexion et vérifiez à la fois cet identifiant et l’état courant avant toute modification.
Configurez un Mac mini dédié pour votre workflow
Choisissez le modèle, la région et la durée selon votre projet. Vérifiez la mémoire, le stockage et la connectivité avant de commander. La disponibilité réelle est indiquée en temps réel dans la console.