雲端 Mac 驗收 iOS SSE 串流事件斷線續傳

雲端 Mac 驗收 iOS SSE 串流事件斷線續傳

在雲端 Mac 的 iOS 模擬器裡,訊息列表看起來一切正常;換到真實網路後,連線短暫中斷卻可能漏掉一筆狀態更新,或把同一則通知處理兩次。SSE(Server-Sent Events,伺服器持續向用戶端傳送文字事件)特別容易遇到這類問題:HTTP 請求成功,不代表用戶端已處理完最後一筆事件。以下使用只監聽本機的測試服務,把中斷與續傳拆成可重複執行的檢查。

先釐清續傳約定

SSE 事件由數行欄位組成,以空行結束。id 是伺服器賦予事件的游標;用戶端重新連線時,會把最後一筆已處理事件的 ID 放進 Last-Event-ID 請求標頭。伺服器據此回傳後續事件。

「讀到 ID」和「處理完事件」是兩回事。連線可能剛好斷在 id: 2 與後面的 data 之間。如果此時就儲存 ID 2,重新連線便會從 3 開始,事件 2 將永遠遺失。應先收齊結尾空行,再完成業務處理,最後才提交游標。如果處理動作會寫入資料庫,還應依事件 ID 去重:提交處理結果與儲存游標之間也可能發生中斷。

測試的目的不是證明連線永遠不會中斷,而是證明中斷前後的事件集合一致,且每個 ID 最多只產生一次業務效果。

啟動可控制的本機事件來源

在雲端 Mac 建立 sse_fixture.py。腳本只綁定 127.0.0.1,不會將測試連接埠暴露到外部。cut=1 表示送出一筆事件後就結束回應;再次請求時,伺服器會讀取請求標頭,從下一筆事件繼續傳送。

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit

EVENTS = [(1, "queued"), (2, "building"), (3, "finished")]

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        url = urlsplit(self.path)
        if url.path != "/events":
            self.send_error(404)
            return
        try:
            last = int(self.headers.get("Last-Event-ID", "0"))
        except ValueError:
            self.send_error(400)
            return
        cut = parse_qs(url.query).get("cut") == ["1"]
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream; charset=utf-8")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        for event_id, value in EVENTS:
            if event_id <= last:
                continue
            payload = f"id: {event_id}\ndata: {value}\n\n".encode()
            try:
                self.wfile.write(payload)
                self.wfile.flush()
            except (BrokenPipeError, ConnectionResetError):
                break
            if cut:
                break

HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

執行 python3 sse_fixture.py,另開終端機檢查兩段回應:

curl -Ns 'http://127.0.0.1:8765/events?cut=1'
curl -Ns -H 'Last-Event-ID: 1' 'http://127.0.0.1:8765/events'

第一段應只有 ID 1,第二段應依序出現 ID 2、3。這裡以正常結束測試「串流結束後重新請求」;正式環境中的連線重設、逾時與半筆事件,仍需另外注入故障,不能把一次正常結束當作涵蓋所有斷線情境。

在 iOS 端依完整事件提交

測試用戶端可以使用 URLSession.bytes(for:) 逐行讀取。以下程式片段要求部署目標支援該介面;它只處理單次連線,重試排程由呼叫端負責。只有收到空行才交付事件,結尾不完整的片段則直接捨棄。

import Foundation

struct StreamEvent {
    let id: String
    let data: String
}

func readEvents(after lastID: String?, url: URL) async throws -> [StreamEvent] {
    var request = URLRequest(url: url)
    if let lastID {
        request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
    }
    let (bytes, response) = try await URLSession.shared.bytes(for: request)
    guard let http = response as? HTTPURLResponse,
          http.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }

    var result: [StreamEvent] = []
    var eventID: String?
    var data: [String] = []
    for try await line in bytes.lines {
        if line.isEmpty {
            if let eventID, !data.isEmpty {
                result.append(StreamEvent(id: eventID, data: data.joined(separator: "\n")))
            }
            eventID = nil
            data = []
        } else if line.hasPrefix("id:") {
            eventID = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
        } else if line.hasPrefix("data:") {
            data.append(String(line.dropFirst(5)).dropFirst(
                line.dropFirst(5).first == " " ? 1 : 0
            ).description)
        }
    }
    return result
}

這個最小範例會保留多行 data,並忽略心跳行與其他欄位;正式解析器還須依協定處理沒有 ID 的事件、空 ID 與重試提示。範例回傳陣列是為了方便觀察測試結果,不適合持續執行的正式環境串流:長連線應邊接收邊處理,不要讓事件一直堆積在記憶體中。

用模擬器進行兩輪斷線檢查

先在 Mac 上以 curl 確認服務可連線,再讓 iOS 模擬器請求 http://127.0.0.1:8765/events?cut=1。模擬器通常可以存取所在 Mac 的本機服務;實機的 127.0.0.1 則指向實機本身,不能直接照用。如果應用程式預設禁止明文 HTTP,只在偵錯建置中設定範圍受限的本機網路例外,不要把寬泛的例外帶進發行設定。

第一輪記錄事件 1,處理成功後才將游標設為 1。第二輪移除 cut=1,帶上 Last-Event-ID: 1,確認收到的 ID 恰好是 2、3。接著刻意讓第一次業務處理失敗:游標必須維持舊值;再次收到事件時,應依 ID 去重或安全地重試。最後取消讀取工作,並確認重新連線計時器也已取消,否則離開頁面後可能仍有背景請求。

檢查點 通過條件
完整事件 收到結尾空行後才開始業務處理
斷線續傳 已提交 ID 之後的事件依序抵達
重複投遞 相同 ID 不會產生第二次業務效果
離開頁面 讀取工作與尚待執行的重試一併停止

將失敗歸因到正確的環節

如果第二輪從頭回傳,先檢查請求標頭是否確實帶上已提交的 ID,再確認伺服器是否支援依游標篩選;不要直接歸咎於模擬器。如果只有最後一筆遺失,應重點檢查解析器是否把「串流結束」誤認為事件邊界。如果重新連線造成重複通知,應檢查業務去重,而不是只把重試間隔調長。

還要區分「連線成功」與「事件交付成功」。伺服器傳完位元組便斷線,無法證明用戶端已寫入本機狀態;用戶端儲存了游標,也無法證明遠端業務操作天生可以安全重試。將事件 ID、處理結果與提交後的游標放在同一份測試紀錄中,下次修改解析器或重試邏輯時,才能逐項比對,而不是只靠肉眼盯著訊息列表判斷。

常見問題

讀到事件 ID 後就能儲存 Last-Event-ID 嗎?

不能只因讀到 id 欄位就儲存。應等事件組裝完整、業務處理成功後才提交 ID,避免中斷時跳過未處理資料。

模擬器能連到只監聽 127.0.0.1 的服務嗎?

iOS 模擬器通常能存取同一台 Mac 的本機服務。先以 curl 確認連線;實體裝置不能沿用這個位址。

想親自測試看看?

租用專屬 Mac mini,直接執行你的建置工作

從兩款 M4 實體主機中挑選,並依需求選擇租用期間與節點。實際供應情況以控制台即時顯示為準。

立即租用 Mac mini