雲端 Mac 上的 Swift 逾時競速驗收:取消與離開邊界
跨時區交接雲端 Mac 上的非同步程式碼時,最難重現的故障之一是:介面已經顯示逾時,測試卻遲遲沒有結束。原因通常不是計時器失效,而是底層操作沒有回應取消。將工作與計時器放進任務群組,只能讓兩者競速,無法強制中斷任意程式碼。
以下使用 Swift 的結構化並行建立可執行的基準,再分別驗收結果、取消與實際結束。範例需要支援 Duration 與 Task.sleep(for:) 的工具鏈,命令列執行環境須為 macOS 13 或更新版本;團隊交接時應記錄實際使用的編譯器版本。
先定義逾時契約,而不是先寫計時器
先將契約寫成三條:工作先完成就回傳結果;先觀察到計時分支完成就拋出逾時錯誤;父任務取消時則傳遞取消,不將它轉換成業務邏輯的逾時錯誤。兩個分支幾乎同時完成時,不保證由哪一方勝出。
也必須區分相對等待時間與嚴格截止時間。以下實作從計時分支開始等待時才起算,不代表從呼叫入口開始就有絕對的回應時間上限;即使將等待時間設得很短,也無法消除排程延遲。
觸發逾時、發出取消請求、底層操作結束,是三個不同的事件。只有最後一個事件發生,任務群組才具備結束的條件。
如果業務要求一到時間就立刻回傳,就不能同時宣稱所有工作仍受這個任務群組約束。此時必須重新設計底層的取消介面,或將無法中斷的工作放到獨立處理程序中,並管理其生命週期。
實作可注入等待邏輯的競速器
將以下內容儲存為 Timeout.swift。等待閉包可以接上真實時鐘,也能在測試中替換成可控制的閘門;工作結果必須符合跨任務傳遞的要求。
import Foundation
enum TimeoutFailure: Error {
case expired
}
func raceTimeout<Value: Sendable>(
wait: @escaping @Sendable () async throws -> Void,
operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
try Task.checkCancellation()
return try await withThrowingTaskGroup(of: Value.self) { group in
defer { group.cancelAll() }
group.addTask {
try Task.checkCancellation()
return try await operation()
}
group.addTask {
try Task.checkCancellation()
try await wait()
try Task.checkCancellation()
throw TimeoutFailure.expired
}
guard let value = try await group.next() else {
throw CancellationError()
}
try Task.checkCancellation()
return value
}
}
先用編譯命令檢查型別與並行限制,再整合至專案測試:
xcrun swiftc -swift-version 6 -strict-concurrency=complete \
-parse-as-library -emit-module \
-module-name Timeout Timeout.swift \
-emit-module-path /tmp/Timeout.swiftmodule
使用真實計時的呼叫片段如下,呼叫端必須位於非同步執行環境中:
let result = try await raceTimeout(
wait: { try await Task.sleep(for: .seconds(2)) },
operation: {
try await Task.sleep(for: .milliseconds(20))
return "ready"
}
)
defer 確保無論成功或拋出錯誤,都會請求取消剩餘分支。計時器被取消後會拋出錯誤;不要用 try? 吞掉這個錯誤,否則它可能繼續執行,被誤報為逾時。
用閘門固定順序,避免靠睡眠碰運氣
核心單元測試不應寫成「先等一下,再猜哪個分支先執行」。建立兩個測試閘門,分別控制工作完成與計時完成;另外設置啟動訊號,確認兩個分支都已進入等待後,再釋放目標閘門。
閘門應記錄取消狀態,並透過取消處理常式釋放等待者。若內部使用 continuation,必須確保只恢復一次;也要涵蓋取消先發生、等待者後註冊的順序。否則,測試輔助設施本身就可能造成卡死。
至少保留以下四組斷言:
- 工作勝出:回傳指定值,並觀察到計時等待結束。
- 計時勝出:取得
TimeoutFailure.expired,工作收到取消後結束。 - 工作失敗:保留原始業務錯誤,不能包裝成逾時。
- 父任務取消:在兩個分支都已啟動後取消父任務,檢查取消傳遞與全部結束訊號。
每組不僅要檢查呼叫結果,也應等待兩個分支的結束標記。即使結果正確,只要子任務尚未結束,就仍不算通過驗收。
專門驗證不回應取消會阻礙結束
新增一個刻意忽略取消、但可由測試釋放的工作閘門。先讓計時分支勝出,再確認工作已收到取消請求,而外層呼叫仍未完成;最後釋放工作閘門,確認外層此時才拋出逾時錯誤。
這個案例驗證的是結構化並行的等待語意,不是鼓勵忽略取消。測試必須有確保釋放的保底機制,不能用無限迴圈模擬卡死,也不能在斷言失敗後留下永遠不會恢復的 continuation。
正式環境的程式碼應沿著呼叫鏈逐一檢查:長迴圈是否定期執行 Task.checkCancellation();回呼轉接是否呼叫底層取消控制代碼;阻塞呼叫能否返回。只在操作入口檢查一次,無法涵蓋後續長時間執行的工作。
將結束證據一併交接給下一班
在雲端 Mac 上執行時,將提交識別碼、編譯器版本、測試案例名稱、勝出分支、取消觀察紀錄與兩個結束標記寫入測試附件。耗時應使用單調時鐘計算,不要將不同成員桌面上的系統時間相減。
真實時鐘測試只用來驗證正式環境的等待閉包是否正確接入,應為排程保留餘裕,不要求精確到某一毫秒。測試處理程序還需要獨立的執行時間上限,因為不會結束的任務群組,無法依靠自己的計時子任務自救。
最後確認錯誤處理沒有將所有 CancellationError 都改寫成逾時、等待閉包沒有吞掉錯誤,且失敗路徑沒有遺漏結束證據。交接完成的標準不是「計時器響過」,而是結果可解釋、取消可觀察,且剩餘工作已結束。
常見問題
計時分支拋出錯誤後,逾時函式會立刻返回嗎?
不一定。離開任務群組前仍須等待所有子任務結束;cancelAll 只提出取消要求,不會強制終止未配合取消的操作。
測試逾時一定要實際等待數秒嗎?
核心契約測試可以注入可控制的等待閉包,以測試閘門決定完成順序。真實時鐘只用於少量整合測試,並另設測試執行時間上限。
為下一項任務選擇雲端 Mac
請依團隊主要所在地選擇節點,使用整台專屬的實體 Mac mini。透過瀏覽器使用遠端桌面,需要較新版本的瀏覽器與穩定的網路;下單前請確認規格、計費週期及實際供應狀況。