雲端 Mac 驗證 iOS 權杖更新競態與登出隔離
列表、大頭貼和訊息計數同時向 API 發出請求,舊權杖失效後,三個請求一起收到驗證失敗的回應。如果各自更新權杖,伺服器輪替更新憑證時可能互相覆寫;若使用者此時登出,較晚抵達的回應還可能把登入狀態寫回來。雲端 Mac 適合將這類競態條件轉成固定測試:不連線到正式環境的 API,也不靠網路碰巧變慢,只驗證驗證協調器的行為。
先定義三項可驗收條件
第一,同一工作階段內同時發生的權杖更新只執行一次。第二,登出後,舊的更新結果不能改寫目前的憑證。第三,每個業務請求最多只能更新權杖並重試一次。驗證失敗時是否允許更新,應依 API 協定判斷,不能把所有權限錯誤都視為權杖過期。
協調器接收的是「這次請求實際使用的權杖」。若目前的權杖已經不同,表示其他請求已完成更新,直接使用新值即可,不必再次更新。否則,較晚抵達的失敗回應會觸發第二輪沒有必要的更新。
actor 只能保證對隔離狀態的存取,不能保證整個非同步方法不會交錯執行。執行到 await 時,登出操作和其他請求仍然可以進入。
範例將權杖簡化為字串,並透過閉包注入更新權杖的傳輸操作。實際專案還需要管理有效期限、更新憑證的輪替,以及安全的持久化儲存;不要將完整憑證寫入測試報告。
用共用 Task 合併權杖更新
在專案的測試目標中加入以下協調器。它只保存記憶體中的狀態,不負責傳送業務請求。更新閉包應在伺服器明確回傳失敗或逾時後拋出錯誤,不能無限等待。
import Foundation
actor RefreshGate {
enum Failure: Error { case signedOut }
private var token: String?
private var generation = 0
private var flight: (id: UUID, task: Task<String, Error>)?
private let refresh: @Sendable () async throws -> String
init(token: String, refresh: @escaping @Sendable () async throws -> String) {
self.token = token
self.refresh = refresh
}
func recover(rejected: String) async throws -> String {
guard let current = token else { throw Failure.signedOut }
if current != rejected {
try Task.checkCancellation()
return current
}
let epoch = generation
let active: (id: UUID, task: Task<String, Error>)
if let existing = flight {
active = existing
} else {
let operation = refresh
active = (UUID(), Task { try await operation() })
flight = active
}
do {
let updated = try await active.task.value
guard generation == epoch else { throw Failure.signedOut }
if flight?.id == active.id {
token = updated
flight = nil
}
try Task.checkCancellation()
return updated
} catch {
if flight?.id == active.id {
flight = nil
}
throw error
}
}
func logout() {
generation += 1
token = nil
flight?.task.cancel()
flight = nil
}
}
先登記 Task,再等待結果,其他呼叫才能共用它。清理時要比對任務識別碼,避免舊呼叫恢復執行後誤刪新任務。這裡的非結構化 Task 不會因為某個等待者取消而自動取消;這是刻意保留的共用行為,不應另外加上取消處理器來終止它。
成功取得結果後,先更新共用狀態,再檢查呼叫端是否已取消。這樣已關閉的頁面不會繼續使用結果,仍在等待的請求卻能取得新權杖。取消檢查只在等待結束後執行,因此這不是會在取消時立即返回的介面。
在雲端 Mac 執行本機並行測試
用計數器取代更新權杖的 API,測試憑證只使用固定的假值。將以下方法放入 XCTestCase 子類別,協調器與測試放在同一個測試目標即可。
import XCTest
actor RefreshProbe {
private(set) var calls = 0
func run() async throws -> String {
calls += 1
try await Task.sleep(nanoseconds: 100_000_000)
return "fresh-token"
}
}
func testRefreshIsShared() async throws {
let probe = RefreshProbe()
let gate = RefreshGate(token: "old-token") {
try await probe.run()
}
let values = try await withThrowingTaskGroup(of: String.self) { group in
for _ in 0..<20 {
group.addTask {
try await gate.recover(rejected: "old-token")
}
}
var output: [String] = []
for try await value in group {
output.append(value)
}
return output
}
let calls = await probe.calls
XCTAssertEqual(calls, 1)
XCTAssertEqual(values.count, 20)
XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}
短暫停頓是為了增加執行時間重疊的機會,不是效能指標。這項測試還應補上「權杖更新已完成後,才送入使用舊權杖的失敗回應」的案例,並斷言呼叫次數仍為一次。它能找出只合併等待者、卻漏掉延遲回應的實作。
測試登出競態時,不要依賴固定的睡眠時間。使用受控的 continuation,讓假的 API 通知測試「更新已開始」,接著由測試呼叫 logout,再放行結果。即使假的 API 忽略取消並回傳成功,舊等待者也必須拋出錯誤;登出後的新呼叫則必須直接失敗。
分別處理失敗、取消與重試
別讓共用失敗引發重試風暴
更新權杖時若拋出錯誤,現有等待者會收到同一次失敗,協調器則會清除任務。後續是否允許再次嘗試,由上層策略決定:暫時性的網路失敗可以採用退避策略;憑證明確失效時,應結束工作階段。不要讓每個等待者在 catch 中立即再次呼叫 recover。
重試業務請求前,還要確認請求本文能夠重新傳送。檔案串流、一次性資料來源,以及會產生寫入的操作,都需要重建請求,或配合伺服器的冪等協定。權杖更新成功,不代表所有請求都能安全地重新送出。
登出是一道狀態邊界
任務取消採協作式機制,傳輸層可能已經完成,也可能未及時回應取消。因此,登出時必須同時清除憑證並遞增世代。正式環境中的持久化儲存也必須受相同檢查保護,不能讓傳輸閉包自行寫入新憑證,否則會繞過協調器的登出隔離。
將競態納入固定回歸測試
提交前至少涵蓋以下情況:
- 多個使用舊權杖的請求並行失敗:更新次數為一。
- 新權杖已生效,舊的失敗回應才抵達:不重複更新。
- 權杖更新失敗:所有等待者都失敗,共用任務已清除。
- 單一等待者取消:其他等待者仍然成功。
- 登出後更新結果才回傳:不恢復憑證,也不執行業務重試。
- 重試後仍驗證失敗:直接回傳錯誤,不遞迴更新權杖。
只需記錄測試名稱、更新次數與最終狀態,不記錄權杖內容。在雲端 Mac 上反覆執行這組測試,重點不是證明競態永遠不會發生,而是確認競態發生時,結果仍符合工作階段的邊界。
常見問題
關閉畫面時,要取消共用的更新工作嗎?
不要。其他請求可能還在等待同一次更新。畫面只取消自己的後續處理;登出時才取消共用工作,並遞增工作階段世代,阻止舊結果寫回。
更新後重試仍驗證失敗,可以再更新嗎?
同一個業務請求最多更新並重試一次。第二次失敗應回傳驗證錯誤,工作階段是否失效依伺服器協定判斷,不可遞迴更新形成無限迴圈。
為開發工作租用專屬雲端 Mac
採用 Apple Silicon 實體主機,並非虛擬機。提供日租、週租、月租及季租,機型與價格公開透明;實際可租狀態請以控制台即時資訊為準。