雲端 Mac 上的 Swift 逾時競速驗收:取消與離開邊界

雲端 Mac 上的 Swift 逾時競速驗收:取消與離開邊界

雲端 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,必須確保只恢復一次;也要涵蓋取消先發生、等待者後註冊的順序。否則,測試輔助設施本身就可能造成卡死。

至少保留以下四組斷言:

每組不僅要檢查呼叫結果,也應等待兩個分支的結束標記。即使結果正確,只要子任務尚未結束,就仍不算通過驗收。

專門驗證不回應取消會阻礙結束

新增一個刻意忽略取消、但可由測試釋放的工作閘門。先讓計時分支勝出,再確認工作已收到取消請求,而外層呼叫仍未完成;最後釋放工作閘門,確認外層此時才拋出逾時錯誤。

這個案例驗證的是結構化並行的等待語意,不是鼓勵忽略取消。測試必須有確保釋放的保底機制,不能用無限迴圈模擬卡死,也不能在斷言失敗後留下永遠不會恢復的 continuation。

正式環境的程式碼應沿著呼叫鏈逐一檢查:長迴圈是否定期執行 Task.checkCancellation();回呼轉接是否呼叫底層取消控制代碼;阻塞呼叫能否返回。只在操作入口檢查一次,無法涵蓋後續長時間執行的工作。

將結束證據一併交接給下一班

在雲端 Mac 上執行時,將提交識別碼、編譯器版本、測試案例名稱、勝出分支、取消觀察紀錄與兩個結束標記寫入測試附件。耗時應使用單調時鐘計算,不要將不同成員桌面上的系統時間相減。

真實時鐘測試只用來驗證正式環境的等待閉包是否正確接入,應為排程保留餘裕,不要求精確到某一毫秒。測試處理程序還需要獨立的執行時間上限,因為不會結束的任務群組,無法依靠自己的計時子任務自救。

最後確認錯誤處理沒有將所有 CancellationError 都改寫成逾時、等待閉包沒有吞掉錯誤,且失敗路徑沒有遺漏結束證據。交接完成的標準不是「計時器響過」,而是結果可解釋、取消可觀察,且剩餘工作已結束。

常見問題

計時分支拋出錯誤後,逾時函式會立刻返回嗎?

不一定。離開任務群組前仍須等待所有子任務結束;cancelAll 只提出取消要求,不會強制終止未配合取消的操作。

測試逾時一定要實際等待數秒嗎?

核心契約測試可以注入可控制的等待閉包,以測試閘門決定完成順序。真實時鐘只用於少量整合測試,並另設測試執行時間上限。

為下一項任務選擇雲端 Mac

請依團隊主要所在地選擇節點,使用整台專屬的實體 Mac mini。透過瀏覽器使用遠端桌面,需要較新版本的瀏覽器與穩定的網路;下單前請確認規格、計費週期及實際供應狀況。

讓下一次連線更靠近團隊

先確認規格與計費週期,再選擇靠近團隊主要使用地區的節點。