雲端 Mac 上的 iOS WebSocket 心跳與連線隔離測試

雲端 Mac 上的 iOS WebSocket 心跳與連線隔離測試

雲端 Mac 上的 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))

第二段刻意讓舊連線的 Pong 落在新連線等待回應的期間。若移除世代檢查,它就會錯誤地延長新連線的存活時間。也應加入重複開啟、閒置狀態收到 Pong,以及逾時後重複檢查等測試案例,確認每次故障只產生一次關閉動作。

串接傳輸層時維持序列化邊界

實際用戶端可以由一個 actor 持有狀態機。計時器、連線回呼與取消請求都必須進入同一個隔離範圍,不允許多個佇列直接修改狀態。動作中的編號同樣需要核對:若執行動作前已經建立新連線,舊的關閉動作就不能取消目前的任務。

使用 URLSessionWebSocketTask.sendPing 時,只有在完成回呼沒有錯誤的情況下,才能向模型提交 Pong 事件;若發生錯誤,則進入具備世代檢查的故障處理流程。收到業務訊息不等於收到 Pong,不能預設用它取代傳輸層心跳。

為了維持模型「只有一個 Ping 等待確認」的前提,轉接器必須確保每個 Ping 只提交一次完成事件。若改用可能重複觸發回呼的封裝,就需要再加入 Ping 請求編號;連線世代只能隔離不同連線,無法區分同一連線內的多次心跳。

最後補齊實際連線驗收

離線斷言全部通過後,再使用可控制的測試端驗證協定行為。至少記錄正常回應、延遲回應、直接關閉與連線建立失敗四種結果,並確認接收迴圈能持續讀取後續訊息。日誌只保留世代、事件類型、單調時間與狀態變化,不記錄權杖或業務訊息內容。

應用程式進入背景後,無法保證計時回呼會持續執行,因此不能把心跳模型誤當成背景保活能力。回到前景時,應先重新評估連線狀態,再決定沿用還是建立新連線。模擬器可以驗證事件路由,但背景恢復與網路切換仍須在實體裝置上測試。

驗收完成的標準不是「最後有連上」,而是舊事件無法修改新工作階段、逾時邊界沒有歧義,而且每次關閉都有對應原因。把這些斷言加入流水線,之後調整心跳間隔時,就不必再靠人工等待來發現競態條件。

常見問題

心跳測試一定要真的斷網或等待逾時嗎?

單元測試不需要。將單調時間作為參數傳入,直接驗證截止時間前一刻及截止時刻;實際 Ping/Pong、取消與網路切換則另做整合測試。

取消舊連線後,為何還要檢查連線世代?

取消無法撤回已排入執行佇列的回呼。每次建立連線都遞增世代編號,處理回呼前同時核對編號與目前狀態,避免舊連線改動新工作階段。

為你的工作流程配置一台專屬 Mac mini

依照專案需求挑選機型、節點與租用期間。下單前先確認記憶體、儲存空間和連線需求;實際可用狀態以控制台即時回報為準。

先確認工作流程,再啟用你的設備。

專屬實體 Mac mini。選好機型、節點與租期後,再依照專案需求配置儲存空間。