雲端 Mac 上診斷 XCTest 卡死:逾時監控、程序取樣與現場保全

雲端 Mac 上診斷 XCTest 卡死:逾時監控、程序取樣與現場保全

原本十多分鐘就能完成的 XCTest 工作,在雲端 Mac 上執行四十分鐘後仍未結束。日誌最後一行只顯示某個測試案例開始執行,CPU 使用率不高,結果套件也尚未完成。此時若直接停止工作,只會得到「逾時」結果,無法判斷究竟是主執行緒互鎖、非同步回呼未返回、模擬器服務失聯,還是測試程序仍在等待一個永遠不會成立的條件。

處理這類問題的關鍵不是增加重試次數,而是在終止前保全現場。程序關係、連續的執行緒取樣、模擬器狀態、原始日誌與未完成的 xcresult,都應歸入同一次執行記錄。

先區分緩慢測試與真正卡死

只設定一個總逾時上限很容易誤判。首次編譯、模擬器啟動與測試執行的耗時來源各不相同,因此應先拆開處理:

  1. 使用 build-for-testing 完成編譯。
  2. 使用 test-without-building 執行測試。
  3. 分別記錄建置時間、測試時間,以及日誌最後一次變更的時間。
  4. 測試超過歷史上限時先採集現場,而不是立即強制結束。

全域上限可防止工作無限執行,靜默上限則用來發現「程序仍在,但沒有進度」的狀態。靜默不能直接等同於標準輸出為空:有些長時間測試原本就很少輸出內容。更可靠的判斷方式,是同時觀察日誌檔案的修改時間、測試程序是否仍存活,以及 xcresult 目錄是否持續變化。

逾時是一種保護機制,不是根本原因。只有在逾時發生時留下足夠證據,它才具有診斷價值。

固定可重現的執行入口

先固定 scheme、測試計畫、目標裝置與 DerivedData 路徑。不要讓診斷工作順便更換 Xcode、模擬器執行階段或相依套件版本,否則兩次現場將無法比較。

set -euo pipefail

WORKSPACE="Demo.xcworkspace"
SCHEME="Demo"
PLAN="Regression"
DERIVED="$PWD/.build/DerivedData"
RESULT="$PWD/.artifacts/TestRun.xcresult"

rm -rf "$RESULT"
mkdir -p "$PWD/.artifacts"

xcodebuild build-for-testing \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -testPlan "$PLAN" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$DERIVED"

xcodebuild test-without-building \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -testPlan "$PLAN" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$DERIVED" \
  -resultBundlePath "$RESULT"

如果問題只出現在單一測試類別,應先使用 -only-testing:TargetName/TestClass 縮小範圍。不要一開始就平行重跑整個測試計畫,否則會增加程序與模擬器造成的雜訊。

為測試程序加入可保全證據的監控程序

macOS 預設沒有一套能在所有環境統一使用的 GNU timeout 指令,CI 指令稿可以改用背景監控程序實作。正確順序是:啟動測試、等待時間上限、採集現場、傳送 TERM、保留清理時間,最後才傳送 KILL。

set -euo pipefail

OUT="$PWD/.artifacts/hang-$(date +%Y%m%d-%H%M%S)"
LIMIT="${TEST_TIMEOUT_SECONDS:-1200}"
mkdir -p "$OUT"

xcodebuild test-without-building \
  -workspace Demo.xcworkspace \
  -scheme Demo \
  -testPlan Regression \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$PWD/.build/DerivedData" \
  -resultBundlePath "$OUT/TestRun.xcresult" \
  >"$OUT/xcodebuild.log" 2>&1 &

test_pid=$!

(
  sleep "$LIMIT"
  if kill -0 "$test_pid" 2>/dev/null; then
    date -u >"$OUT/timeout-at.txt"
    ps -axo pid,ppid,state,%cpu,%mem,etime,command >"$OUT/host-processes.txt"
    xcrun simctl list devices >"$OUT/simulators.txt"
    xcrun simctl spawn booted ps -axo pid,ppid,state,etime,command \
      >"$OUT/simulator-processes.txt" 2>&1 || true

    for round in 1 2 3; do
      xcrun simctl spawn booted sample DemoTests-Runner 5 1 \
        >"$OUT/test-runner-sample-$round.txt" 2>&1 || true
      sleep 3
    done

    kill -TERM "$test_pid" 2>/dev/null || true
    sleep 10
    kill -KILL "$test_pid" 2>/dev/null || true
  fi
) &

watchdog_pid=$!

set +e
wait "$test_pid"
status=$?
set -e

kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
exit "$status"

測試 Runner 名稱必須依專案產物調整。若名稱不固定,可從模擬器程序清單中依測試套件路徑篩選 PID,再交給 sample。相較於一次長時間取樣,三次短時間取樣更容易判斷呼叫堆疊是否真的停止推進。

從執行緒堆疊判斷等待類型

先檢查三份取樣中的主執行緒與測試執行緒,是否都停留在相同位置。常見模式包括:

主執行緒等待測試執行緒

主執行緒停在號誌、條件變數或同步派送,而測試執行緒又在等待必須由主執行緒執行的回呼,通常表示同步等待非同步 API 導致互鎖。修正方向是改用 XCTest expectation,並確保等待動作不會占住負責執行回呼的佇列。

測試執行緒等待外部條件

如果呼叫堆疊持續停在 socket、檔案協調、資料庫鎖定或程序間通訊上,應進一步核對相依服務、暫存目錄權限,以及先前測試留下的鎖定。不要把所有等待都歸因於網路;應先利用程序與檔案狀態證明實際等待的物件是什麼。

呼叫堆疊仍在變化

如果三次取樣的熱點持續變化,工作可能只是執行緩慢。此時應記錄單一測試案例的耗時、縮小資料規模,或為測試本身設定合理的 expectation 逾時,而不是無限制提高全域上限。

將 xcresult 與系統狀態一併封存

即使結果套件未正常收尾,也應原樣保留,不要在失敗分支中先行刪除。完整結果可使用以下指令匯出為便於搜尋的 JSON:

xcrun xcresulttool get \
  --legacy \
  --path ".artifacts/TestRun.xcresult" \
  --format json \
  >".artifacts/xcresult.json"

每次逾時的目錄至少應包含:

  • 完整的 xcodebuild 輸出與結束碼;
  • scheme、測試計畫、destination 與 Xcode 版本;
  • 主機與模擬器的程序清單;
  • 間隔採集的三份執行緒樣本;
  • 模擬器裝置狀態;
  • 原始 xcresult 與匯出的結構化結果;
  • 本次提交識別碼與測試入口參數。

在 PingMini 的雲端 Mac 上執行時,也應讓每次工作只使用一個明確指定的模擬器裝置集,避免其他平行工作改變裝置狀態。至於配置與節點選擇,應在控制台確認目前可選項目,不要把機器差異混入同一輪故障重現。

將修正轉化為迴歸檢查

找出根本原因後,不要只是移除監控程序。應保留逾時採集機制,但將門檻改為根據歷史分位數設定的時間預算;同時為已修正的情境加入最小迴歸測試。例如,互鎖問題應驗證回呼能在限定時間內完成;資源鎖定問題則應驗證測試結束後,鎖定檔案與子程序都已清理。

最後檢查三件事:正常工作不會觸發取樣;異常工作能在有限時間內結束;封存內容足以回答「哪個程序正在等待什麼」。做到這一步,偶發的 XCTest 卡死就不再只是一筆只能手動重跑的紅色記錄,而會成為能持續收斂的工程問題。

常見問題

XCTest 多久沒有輸出才應判定為卡死?

不要只依總時間判斷。應同時設定依歷史正常時間推算的全域上限,以及較短的無進度上限;程序仍存活且日誌與測試狀態都沒有推進時才開始取樣。

為什麼逾時後不能立刻強制終止?

立刻強制終止會遺失執行緒狀態,也可能破壞結果檔收尾。應先保存程序與堆疊,再送出 TERM,等待短暫清理期後才以 KILL 作最後手段。

程序取樣中應優先查看哪些位置?

先檢查主執行緒、測試執行緒、資料庫佇列與自訂序列佇列,尋找鎖定循環、IPC 等待與同步呼叫;多次取樣都停在相同堆疊時最具判斷價值。

準備下一次建置所需的獨享物理 Mac

從三種固定配置中選擇適合開發、持續整合或本地模型測試的雲端 Mac,並依照任務週期完成設定。

設定雲端 Mac