遠端 CI 偶爾會遇到一種很難重現的失敗:Xcode 已完成編譯,iOS 模擬器卻一直卡在 Booting,或裝置顯示為 Shutdown,但安裝命令仍回報無法連線至服務。直接重新啟動雲端 Mac 往往能暫時恢復,卻會破壞故障現場,也無法解釋為何下次又會發生。更穩妥的做法,是將執行環境、裝置狀態與工作並行問題拆開檢查。
先判斷故障屬於哪一層
模擬器故障通常出現在三個層面:Xcode 執行環境無法使用、裝置目錄損壞,或多個工作同時操作同一部裝置。應先記錄 Xcode 路徑與裝置清單,不要一開始就執行 erase all。
set -euo pipefail
xcode-select -p
xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devices
xcrun simctl list devices unavailable
當 Runtime 顯示為 unavailable 時,先確認目前選取的 Xcode 是否包含專案所需的 iOS Runtime。若機器安裝了多個 Xcode,應明確指定開發者目錄,再重新執行檢查:
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
xcrun simctl list runtimes
不要根據 Xcode 應用程式名稱猜測 Runtime 識別碼。自動化指令碼應從 simctl list -j 讀取實際可用項目,並拒絕使用 isAvailable=false 的 Runtime。
先蒐集證據,再進行破壞性清理
裝置卡住時,應保存 JSON 清單與近期記錄。如此一來,即使後續重建裝置,也能判斷問題是 CoreSimulator 服務異常、啟動程序退出,還是應用程式本身當機。
mkdir -p artifacts/simulator
xcrun simctl list -j > artifacts/simulator/list.json
xcrun simctl diagnose \
> artifacts/simulator/diagnose.txt 2>&1 || true
xcrun simctl spawn booted log show \
--last 5m \
--style compact \
--predicate 'process == "SpringBoard" OR process == "launchd_sim"' \
> artifacts/simulator/boot.log 2>&1 || true
如果目前沒有 booted 裝置,最後一個命令執行失敗是正常現象,但指令碼仍應保留前兩份結果。封存記錄前還必須先進行敏感資訊遮蔽,避免將工作目錄、權杖參數或簽章材料路徑寫入長期保存的 CI 附件。
erase、delete與直接刪除裝置目錄都屬於破壞性操作。在完成證據蒐集前執行這些操作,通常只會得到「重試後恢復正常」的結果,而不是可修復的根本原因。
為每個工作建立獨立裝置集
預設裝置集位於使用者目錄中,所有並行工作都能存取。當一個工作執行 shutdown all 時,另一個工作可能在測試途中失去裝置。解決方式不是增加重試次數,而是為每個工作分配獨立目錄。
JOB_KEY="${CI_JOB_ID:-local}-$$"
DEVICE_SET="$PWD/.simulators/$JOB_KEY"
mkdir -p "$DEVICE_SET"
DEVICE_TYPE="com.apple.CoreSimulator.SimDeviceType.iPhone-16"
RUNTIME_ID="com.apple.CoreSimulator.SimRuntime.iOS-18-0"
UDID="$(
xcrun simctl --set "$DEVICE_SET" create \
"ci-$JOB_KEY" "$DEVICE_TYPE" "$RUNTIME_ID"
)"
xcrun simctl --set "$DEVICE_SET" boot "$UDID"
xcrun simctl --set "$DEVICE_SET" bootstatus "$UDID" -b
範例中的裝置類型與 Runtime 僅用來表示參數格式,實際值必須從前一步取得的 JSON 清單中選取。裝置建立失敗時,不應悄悄退回預設裝置集,否則隔離機制會在最需要時失效。
回收時只處理自己的目錄
工作結束後,先關閉該工作所擁有的裝置,再刪除對應目錄。不要在共用執行器上執行全域 delete all。
xcrun simctl --set "$DEVICE_SET" shutdown all || true
rm -rf "$DEVICE_SET"
將啟動驗收延伸至應用程式層
bootstatus -b 只表示系統已完成啟動,不代表待測應用程式能夠安裝與執行。完整的冒煙測試至少應包含四個項目:
| 檢查項目 | 命令或訊號 | 失敗時保留 |
|---|---|---|
| 裝置啟動 | bootstatus -b |
diagnose 與啟動記錄 |
| App 安裝 | simctl install |
App 路徑、結束碼 |
| App 啟動 | simctl launch |
bundle identifier、程序輸出 |
| 測試準備 | 狀態檔案或健康探針 | 截止時間與最後狀態 |
APP_PATH="$PWD/build/Sample.app"
BUNDLE_ID="com.example.Sample"
xcrun simctl --set "$DEVICE_SET" install "$UDID" "$APP_PATH"
xcrun simctl --set "$DEVICE_SET" launch \
--console-pty "$UDID" "$BUNDLE_ID"
CI 流程應為啟動階段設定明確的逾時期限,並在逾時分支中先收集記錄,再關閉裝置。若只設定固定次數的無條件重試,反而會讓原本明確的 Runtime 錯誤演變成更漫長的等待。
建立分級復原順序
復原操作應從影響最小的步驟開始:
- 再次確認
DEVELOPER_DIR、Runtime 與裝置類型是否相符。 - 關閉並重新啟動目前工作所擁有的裝置。
- 在同一個獨立裝置集中刪除故障裝置並重建。
- 清理
simctl list devices unavailable中已確認失效的記錄。 - 只有在沒有其他工作執行時,才考慮重新啟動 CoreSimulator 相關服務或整部機器。
如果每次都必須進行到第五步,應檢查執行器是否仍在使用共用的預設裝置集、磁碟是否接近容量上限,以及工作取消後是否遺留模擬器子程序。RunnerVM 上的遠端工作也應遵循相同原則:先在控制台確認目前可選的設定,再將裝置集路徑納入工作生命週期,而不是將模擬器狀態視為機器層級的永久資源。
讓失敗結果可供比較
最後,將 Xcode 版本、Runtime 識別碼、裝置類型、UDID、啟動耗時與失敗階段寫入結構化結果。連續發生失敗時,依這些欄位彙整,才能判斷問題是否集中於某個 Runtime、某類裝置或特定並行工作。
一次可靠的模擬器復原,不應以「介面終於開啟」作為結束,而應以命令可重複執行、應用程式可安裝並啟動、故障證據已封存,以及工作私有狀態已回收為完成標準。如此一來,即使問題再次發生,也能從已知階段繼續排查,而不必重新猜測。
常見問題
模擬器卡在 Booting 時應先 erase 還是重建?
先保存裝置清單與相關日誌,再於獨立裝置集合重建相同類型的裝置。只有確定舊測試狀態不再需要時才執行 erase。
多個 CI 工作可以共用預設模擬器裝置集合嗎?
不建議。某個工作可能關機、抹除或刪除另一個工作正在使用的裝置;每個工作使用獨立集合較容易控制與清理。
bootstatus 成功是否代表 App 一定能執行?
不代表。驗收還要安裝建置出的 App、依 bundle identifier 啟動,並檢查每個命令的結束狀態與失敗日誌。
讓下一次建置在獨享物理 Mac 上執行
依任務週期選擇雲端 Mac 節點,並在控制台管理訂單、連線資訊與支援工單。