iOS WebSocket のハートビートと接続世代の分離を検証する

iOS WebSocket のハートビートと接続世代の分離を検証する

iOS WebSocket のハートビートと接続世代の分離を検証する

チャット画面で接続が復旧したのに、遅れて届いた切断コールバックによって再び「接続中」に戻ってしまう。この種の問題は、たいてい再接続の試行回数が足りないのではなく、古い接続が新しいセッションの状態を書き換えられることに原因があります。GNUMac のクラウド Mac で iOS WebSocket クライアントの受け入れテストを行うなら、まずハートビートの期限管理と接続の識別を通信処理から切り離し、その後に実際の通信をテストしましょう。ネットワークの切断を繰り返すよりも、境界条件を再現しやすくなります。

接続の識別方法と時間の扱いを先に定義する

通信タスクを作成するたびに、単調増加する世代番号を割り当てます。接続成功、Pong、受信失敗、切断の各コールバックには、実行時に読み取った「現在の番号」ではなく、タスク作成時にキャプチャした番号を持たせる必要があります。そうしないと、古いコールバックを新しい接続のイベントとして扱ってしまいます。

時間の計測にはシステム日時ではなく、単調時計を使います。本番環境では ContinuousClock から経過時間を求め、テストでは整数のミリ秒値を直接渡せます。この記事では、接続が開いてから所定の間隔を置いて Ping を送る、Pong を待っている間は次の Ping を送らない、期限に達しても応答がなければ切断する、という明確な方針を採用します。

古いタスクをキャンセルしても、それだけでイベントを隔離できるわけではありません。すでにキューに入っているコールバックは実行される可能性があるため、イベントを受け取る側で接続番号と現在のフェーズを確認する必要があります。

このモデルが扱うのは、単一接続のハートビートと接続の識別だけです。再接続のバックオフ、メッセージの再送、アプリケーションレベルの確認応答は含めません。複数のプロトコルを混在させて、検証しにくい対象にしてしまうのを避けるためです。

ネットワークに依存しない状態機械を実装する

次の内容を 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))

2 番目のテストでは、新しい接続が応答を待っている間に、古い接続の Pong が届くようにしています。世代番号のチェックを取り除くと、この Pong によって新しい接続の有効期間が誤って延長されます。接続開始通知の重複、アイドル状態での Pong 受信、タイムアウト後の繰り返しチェックといったケースも追加し、1 回の障害につき切断アクションが 1 回だけ生成されることを確認しましょう。

通信層との接続でも直列処理の境界を守る

実際のクライアントでは、1 つの actor に状態機械を保持させる構成にできます。タイマー、接続コールバック、キャンセル要求をすべて同じ隔離領域に集約し、複数のキューから状態を直接変更しないようにします。アクションに含まれる番号も確認が必要です。アクションを実行する前に新しい接続が作成されていた場合、古い切断アクションで現在のタスクをキャンセルしてはいけません。

URLSessionWebSocketTask.sendPing を使う場合は、完了コールバックにエラーがないときだけ、モデルに Pong イベントを渡します。エラーがある場合は、世代番号を確認する障害処理に進みます。アプリケーションのメッセージが届いたことは Pong の受信と同義ではなく、通信層のハートビートの代わりとして扱うことはできません。

モデルの「応答待ちの Ping は 1 つだけ」という前提を守るため、アダプターは各 Ping について完了イベントを 1 回だけ渡す必要があります。コールバックが重複する可能性のあるラッパーに置き換える場合は、Ping リクエストの番号も追加します。接続世代で隔離できるのは異なる接続間のイベントであり、同じ接続内で繰り返されるハートビートは区別できません。

最後に実接続の受け入れテストを行う

オフラインのアサーションが通ったら、動作を制御できるテスト用エンドポイントでプロトコルの挙動を検証します。少なくとも、正常な応答、遅延した応答、即時切断、接続確立の失敗という 4 種類の結果を記録し、受信ループが後続のメッセージを継続して読み取れることも確認します。ログに残すのは世代番号、イベント種別、単調時計の時刻、状態の変化だけにし、トークンやアプリケーションのメッセージ本文は記録しません。

アプリがバックグラウンドに移ると、タイマーのコールバックが継続して実行される保証はありません。ハートビートのモデルを、バックグラウンドで接続を維持する機能と混同しないでください。フォアグラウンドに復帰したら、まず接続状態を再評価し、既存の接続を再利用するか、新しく接続するかを判断します。シミュレーターではイベントのルーティングを検証できますが、バックグラウンドからの復帰とネットワークの切り替えは実機でも確認する必要があります。

受け入れテストの完了条件は「最終的につながった」ことではありません。古いイベントが新しいセッションを変更できないこと、タイムアウトの境界に曖昧さがないこと、すべての切断に対応する理由があることです。これらのアサーションをパイプラインに組み込めば、後からハートビート間隔を調整するときも、人が待ち続けて競合状態を見つける必要はなくなります。

よくある質問

ハートビートのテストには実際の切断が必要ですか?

単体テストでは不要です。単調時刻を引数として渡し、期限直前と期限ちょうどを確認します。実際の Ping/Pong、キャンセル、ネットワーク切り替えは別途結合テストで検証します。

古い接続をキャンセルしても世代番号の確認が必要ですか?

キャンセルしても、すでにキューに入ったコールバックは取り消せません。接続の作成ごとに世代番号を増やし、処理前に番号と現在の状態を両方確認します。

ワークフローに合わせて、専用の Mac mini を用意しましょう

プロジェクトに合わせて、モデル・ノード・利用期間をお選びください。ご注文前にメモリ、ストレージ、接続方法をご確認ください。実際の利用状況は、コンソールに表示される最新情報をご確認ください。

まずはワークフローを確認して、Mac を使い始めましょう。

専用の物理 Mac mini をご利用いただけます。モデル、ノード、利用期間を選んだあと、プロジェクトに合わせてストレージを設定できます。