Как проверить пульс WebSocket и изоляцию переподключений в iOS

Как проверить пульс WebSocket и изоляцию переподключений в iOS

Как проверить пульс WebSocket и изоляцию переподключений в iOS

Сеть в чате уже восстановилась, но запоздалый callback закрытия снова перевёл интерфейс в состояние «Подключение». Обычно дело не в недостаточном числе повторных попыток, а в том, что старое соединение всё ещё может менять состояние нового сеанса. При приёмочном тестировании iOS-клиента WebSocket на облачном Mac GNUMac сначала отделите контроль сроков пульса и идентификацию соединений от сетевого кода, а затем проверяйте реальный транспорт. Так граничные случаи воспроизводятся надёжнее, чем при многократном отключении сети.

Сначала определите идентичность соединения и правила отсчёта времени

При создании каждой транспортной задачи присваивайте ей возрастающий номер поколения. Callback-и успешного подключения, Pong, ошибки приёма и закрытия должны передавать номер, захваченный при создании задачи, а не читать «текущий номер» в момент выполнения. Иначе старый callback можно ошибочно принять за событие нового соединения.

Используйте монотонные часы, а не системную дату. В рабочем приложении прошедшее время можно вычислять через ContinuousClock, а в тестах — напрямую передавать целое число миллисекунд. В этой статье приняты чёткие правила: после открытия соединения выждать один интервал и отправить Ping; пока ожидается Pong, не отправлять второй Ping; если к предельному моменту ответ не получен, закрыть соединение.

Отмена старой задачи не гарантирует изоляцию. Уже поставленные в очередь callback-и всё ещё могут выполниться, поэтому получатель событий обязан проверять номер соединения и текущую фазу.

Модель отвечает только за пульс и идентичность одного соединения. В неё не входят задержки между повторными подключениями, повторная отправка сообщений или подтверждения прикладного уровня: иначе несколько протоколов смешаются в один объект, который будет сложно проверить.

Напишите конечный автомат, не зависящий от сети

Сохраните следующий код в heartbeat.swift. Метод tick возвращает действие, которое нужно выполнить, а внешний адаптер уже отправляет Ping или отменяет транспортную задачу. Короткие интервалы в примере нужны только для тестов и не являются рекомендуемыми настройками для рабочего приложения.

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)
    }
}

По принятому здесь правилу Pong, полученный ровно в предельный момент, тоже считается запоздавшим. Благодаря этому результат проверки тайм-аута не зависит от того, что обработано первым в один и тот же момент: таймер или ответ. Само действие закрытия по-прежнему формируется при следующем вызове tick, поэтому планировщик должен продолжать регулярно запускать проверку.

Проверьте границы переподключения с явно заданным временем

Добавьте ниже следующие проверки и запустите xcrun swift heartbeat.swift в стандартном режиме без оптимизации. При нарушении условия процесс завершится с ненулевым кодом; если все проверки пройдут, вывода не будет. Не используйте настройки сборки, отключающие эти проверки.

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))

Во втором фрагменте Pong от старого соединения намеренно приходит в тот момент, когда новое соединение ожидает ответ. Если убрать проверку поколения, такой Pong ошибочно продлит жизнь нового соединения. Добавьте также случаи повторного события открытия, получения Pong в состоянии бездействия и повторной проверки после тайм-аута, чтобы убедиться: каждый сбой порождает только одно действие закрытия.

При подключении транспорта сохраните последовательную обработку

В реальном клиенте конечный автомат может принадлежать одному actor. Таймер, callback-и соединения и запросы отмены должны обрабатываться в одной области изоляции; нескольким очередям нельзя напрямую менять состояние. Номер в возвращённом действии тоже нужно проверять: если до выполнения действия уже создано новое соединение, старое действие закрытия не должно отменить текущую задачу.

При использовании URLSessionWebSocketTask.sendPing передавайте модели событие Pong только в том случае, если callback завершения не содержит ошибки. При ошибке запускайте обработку сбоя с проверкой поколения. Приход прикладного сообщения не равнозначен Pong и не должен автоматически заменять контрольный ответ транспортного уровня.

Чтобы сохранить предпосылку модели «только один Ping ожидает подтверждения», адаптер должен передавать для каждого Ping ровно одно событие завершения. Если вместо него используется обёртка, способная вызывать callback повторно, добавьте идентификатор запроса Ping. Поколение соединения изолирует разные соединения, но не позволяет различать отдельные проверки пульса внутри одного соединения.

Завершите приёмку проверкой реального соединения

После успешного прохождения автономных проверок проверьте поведение протокола с помощью управляемого тестового узла. Зафиксируйте как минимум четыре исхода: обычный ответ, ответ с задержкой, непосредственное закрытие и ошибку установления соединения. Также убедитесь, что цикл приёма продолжает читать последующие сообщения. В журналах оставляйте только поколение, тип события, монотонное время и изменения состояния; не записывайте токены или содержимое прикладных сообщений.

После перехода приложения в фоновый режим непрерывное выполнение callback-ов таймера не гарантируется. Модель пульса нельзя считать средством поддержания работы в фоне. При возвращении на передний план сначала заново оцените состояние соединения и лишь затем решайте, использовать его повторно или создать новое. В симуляторе можно проверить маршрутизацию событий, но восстановление из фонового режима и переключение сетей нужно проверить и на реальном устройстве.

Критерий завершённой приёмки — не «в конце концов подключилось», а невозможность старых событий изменить новый сеанс, однозначные границы тайм-аутов и установленная причина каждого закрытия. Добавьте эти проверки в конвейер, чтобы при последующем изменении интервала пульса не приходилось снова вручную ждать проявления гонок.

Часто задаваемые вопросы

Нужно ли отключать сеть для проверки пульса?

Для модульного теста нет: передавайте монотонное время явно и проверяйте момент перед дедлайном и сам дедлайн. Реальный обмен Ping/Pong и смену сети проверяйте отдельно на уровне интеграции.

Зачем проверять поколение после отмены старого соединения?

Отмена не удаляет уже поставленные в очередь callbacks. При каждом подключении увеличивайте номер поколения, а перед изменением состояния сверяйте и номер, и текущую фазу соединения.

Настройте выделенный Mac mini под свой рабочий процесс

Выберите модель, узел и срок аренды под задачи проекта. Сначала проверьте требования к памяти, хранилищу и подключению, затем оформите заказ. Фактическая доступность отображается в консоли в реальном времени.

Сначала определите рабочий процесс, затем запускайте Mac.

Выделенный физический Mac mini. Выберите модель, узел и срок аренды, а затем настройте объём хранилища под задачи проекта.