iOS-WebSocket: Heartbeats testen und Verbindungsgenerationen trennen
Die Chatansicht ist bereits wieder online, doch ein verspäteter Schließen-Callback versetzt sie erneut in den Zustand „Verbindung wird hergestellt“. Bei solchen Fehlern mangelt es meist nicht an Wiederverbindungsversuchen. Vielmehr kann die alte Verbindung noch immer den Zustand der neuen Sitzung verändern. Wenn Sie einen iOS-WebSocket-Client auf einem Cloud-Mac von GNUMac abnehmen, trennen Sie zunächst Heartbeat-Fristen und Verbindungsidentität vom Netzwerkcode. Testen Sie erst danach den tatsächlichen Transport. So lassen sich Grenzfälle leichter reproduzieren als durch wiederholtes Trennen der Netzwerkverbindung.
Verbindungsidentität und Zeitbasis zuerst festlegen
Jeder neu angelegte Transport-Task erhält eine aufsteigende Generationsnummer. Verbindungserfolg, Pong, Empfangsfehler und Schließen-Callbacks müssen die bei der Erstellung erfasste Nummer mitführen, statt bei ihrer Ausführung die „aktuelle Nummer“ auszulesen. Andernfalls kann ein alter Callback fälschlich als Ereignis der neuen Verbindung gelten.
Als Zeitbasis dient eine monotone Uhr, nicht das Systemdatum. Im Produktivbetrieb lässt sich die verstrichene Zeit mit ContinuousClock berechnen; Tests übergeben stattdessen direkt ganzzahlige Millisekundenwerte. Für diesen Artikel gilt eine eindeutige Regel: Nach dem Öffnen der Verbindung verstreicht zunächst ein Intervall, bevor ein Ping gesendet wird. Solange die Antwort aussteht, wird kein zweiter Ping gesendet. Ist bis zum Ablauf der Frist kein Pong eingegangen, wird die Verbindung geschlossen.
Das Abbrechen eines alten Tasks ist keine Isolationsbarriere. Bereits eingereihte Callbacks können weiterhin ausgeführt werden. Die empfangende Seite muss daher Verbindungsnummer und aktuelle Phase prüfen.
Das Modell behandelt ausschließlich Heartbeat und Identität einer einzelnen Verbindung. Backoff bei Wiederverbindungen, erneutes Senden von Nachrichten und Bestätigungen auf Anwendungsebene bleiben außen vor. So werden unterschiedliche Protokolle nicht zu einem schwer prüfbaren Gesamtobjekt vermischt.
Eine netzwerkunabhängige Zustandsmaschine schreiben
Speichern Sie den folgenden Inhalt als heartbeat.swift. tick liefert die auszuführende Aktion zurück; der umgebende Adapter übernimmt das tatsächliche Senden von Pings beziehungsweise das Abbrechen des Transport-Tasks. Die kurzen Intervalle im Beispiel dienen nur dem Test und sind keine Empfehlung für den Produktivbetrieb.
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)
}
}
Ein Pong, das genau zum Fristende eintrifft, gilt hier ebenfalls als verspätet. Damit bleibt die Timeout-Entscheidung gleich, unabhängig davon, ob zum selben Zeitpunkt zuerst der Timer oder die Antwort verarbeitet wird. Die eigentliche Schließen-Aktion wird allerdings erst beim nächsten tick ausgelöst. Der Scheduler muss die Prüfung deshalb fortlaufend anstoßen.
Grenzfälle bei Wiederverbindungen mit expliziten Zeitpunkten testen
Fügen Sie anschließend die folgenden Zusicherungen hinzu und führen Sie xcrun swift heartbeat.swift im standardmäßigen, nicht optimierten Modus aus. Schlägt eine Zusicherung fehl, endet der Prozess mit einem Exitcode ungleich null. Sind alle Prüfungen erfolgreich, erfolgt keine Ausgabe. Verwenden Sie keine Build-Einstellungen, die diese Prüfungen deaktivieren.
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))
Im zweiten Testabschnitt trifft das Pong der alten Verbindung absichtlich während der Wartezeit auf eine Antwort der neuen Verbindung ein. Ohne Generationsprüfung würde es die Lebensdauer der neuen Verbindung fälschlich verlängern. Ergänzen Sie außerdem Fälle für mehrfach gemeldetes Öffnen, ein Pong im Ruhezustand und wiederholte Prüfungen nach einem Timeout. Damit stellen Sie sicher, dass jeder Fehler nur eine einzige Schließen-Aktion erzeugt.
Bei der Transportanbindung die serielle Verarbeitung sicherstellen
Im echten Client kann ein einzelner Actor die Zustandsmaschine verwalten. Timer, Verbindungs-Callbacks und Abbruchanforderungen müssen in denselben Isolationsbereich gelangen. Mehrere Queues dürfen den Zustand nicht direkt verändern. Auch die Nummer in einer Aktion muss geprüft werden: Wurde vor ihrer Ausführung bereits eine neue Verbindung angelegt, darf eine alte Schließen-Aktion den aktuellen Task nicht abbrechen.
Bei URLSessionWebSocketTask.sendPing darf dem Modell nur dann ein Pong-Ereignis übergeben werden, wenn der Completion-Callback keinen Fehler meldet. Bei einem Fehler ist stattdessen die Fehlerbehandlung mit Generationsprüfung aufzurufen. Eine eingehende Anwendungsnachricht ist kein Pong und darf nicht ohne Weiteres als Ersatz für den Transport-Heartbeat gelten.
Damit die Modellannahme „höchstens ein Ping wartet auf Bestätigung“ erhalten bleibt, muss der Adapter für jeden Ping genau ein Abschlussereignis übergeben. Wird stattdessen ein Wrapper verwendet, dessen Callbacks mehrfach aufgerufen werden können, ist zusätzlich eine Ping-Anfragenummer erforderlich. Die Verbindungsgeneration trennt verschiedene Verbindungen voneinander, unterscheidet aber nicht zwischen mehreren Heartbeats innerhalb derselben Verbindung.
Abschließend die echte Verbindung abnehmen
Nach bestandenen Offline-Prüfungen folgt die Überprüfung des Protokollverhaltens mit einer steuerbaren Testgegenstelle. Erfassen Sie mindestens die vier Ergebnisse normale Antwort, verzögerte Antwort, direktes Schließen und fehlgeschlagener Verbindungsaufbau. Prüfen Sie außerdem, ob die Empfangsschleife nachfolgende Nachrichten weiterhin lesen kann. Protokollieren Sie ausschließlich Generation, Ereignistyp, monotone Zeit und Zustandsänderungen, keine Tokens oder Inhalte von Anwendungsnachrichten.
Sobald die App in den Hintergrund wechselt, ist die fortlaufende Ausführung von Timer-Callbacks nicht garantiert. Das Heartbeat-Modell darf daher nicht mit einer Funktion zum Aufrechterhalten der Verbindung im Hintergrund verwechselt werden. Bewerten Sie beim Wechsel zurück in den Vordergrund zunächst den Verbindungszustand neu und entscheiden Sie erst dann, ob die Verbindung weiterverwendet oder neu aufgebaut wird. Im Simulator lässt sich die Ereignisweiterleitung prüfen; die Rückkehr aus dem Hintergrund und Netzwerkwechsel müssen jedoch auch auf einem echten Gerät getestet werden.
Die Abnahme ist nicht schon dann abgeschlossen, wenn „die Verbindung am Ende steht“. Entscheidend ist, dass alte Ereignisse die neue Sitzung nicht verändern können, die Timeout-Grenze eindeutig ist und jeder Schließvorgang einen zugehörigen Grund hat. Nehmen Sie diese Zusicherungen in die Pipeline auf. Dann müssen Race Conditions bei späteren Änderungen des Heartbeat-Intervalls nicht erneut durch manuelles Warten aufgespürt werden.
Häufig gestellte Fragen
Muss das Netzwerk für Heartbeat-Tests ausfallen?
Nicht im Unit-Test. Übergeben Sie monotone Zeit explizit und prüfen Sie den Zeitpunkt unmittelbar vor sowie genau an der Frist. Echte Ping/Pong-Austausche und Netzwerkwechsel gehören in Integrationstests.
Warum nach dem Abbruch noch die Verbindungsgeneration prüfen?
Ein Abbruch entfernt keine bereits eingereihten Callbacks. Erhöhen Sie die Generation bei jeder neuen Verbindung und prüfen Sie vor Zustandsänderungen sowohl diese Nummer als auch die aktuelle Phase.
Einen dedizierten Mac mini für Ihren Workflow konfigurieren
Wählen Sie Modell, Standort und Laufzeit passend zu Ihrem Projekt. Prüfen Sie zuerst Arbeitsspeicher, Speicherplatz und Verbindung. Der tatsächlich verfügbare Status wird Ihnen live in der Konsole angezeigt.