远程 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
运行时显示为 unavailable 时,先确认当前选中的 Xcode 是否包含项目要求的 iOS Runtime。若机器安装了多个 Xcode,应显式固定开发者目录,再重新执行检查:
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
xcrun simctl list runtimes
不要根据 Xcode 应用名称猜测 Runtime 标识。自动化脚本应从 simctl list -j 读取实际可用项,并拒绝使用 isAvailable=false 的运行时。
先取证,再做破坏性清理
设备卡住时,保存 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"
流水线应给启动阶段设置明确超时,并在超时分支先采集日志、再关闭设备。只写固定次数的无条件重试,会把确定性的 Runtime 错误拖成更长的等待。
建立分级恢复顺序
恢复动作应从影响最小的步骤开始:
- 重新确认
DEVELOPER_DIR、Runtime 与设备类型是否匹配。 - 关闭并重启当前任务拥有的设备。
- 在同一独立设备集中删除故障设备并重建。
- 清理
simctl list devices unavailable中确认失效的记录。 - 只有在没有其他任务运行时,才考虑重启 CoreSimulator 相关服务或整机。
如果每次都要走到第五步,应检查执行器是否仍在共享默认设备集、磁盘是否接近容量上限,以及任务取消后是否遗漏了模拟器子进程。RunnerVM 上的远程任务也应遵循相同原则:在控制台确认当前可选配置后,把设备集路径纳入任务生命周期,而不是把模拟器状态当作机器级永久资源。
让失败变得可比较
最后,把 Xcode 版本、Runtime 标识、设备类型、UDID、启动耗时和失败阶段写入结构化结果。连续失败时按这些字段聚合,才能看出问题是否集中在某个 Runtime、某类设备或特定并发任务。
一次可靠的模拟器恢复,不以“界面终于打开”为结束,而以命令可重复执行、应用可安装启动、故障证据已归档、任务私有状态已回收为结束。这样即使问题再次出现,也能从已知阶段继续排查,而不是重新猜测。
常见问题
模拟器卡在 Booting 时应该先 erase 还是先重建?
先保存 simctl 列表与系统日志,再在独立设备集中重建同类型设备。只有确认旧设备无需保留测试状态时才执行 erase,避免把现场证据一起清掉。
多个 CI 任务可以共用默认模拟器设备集吗?
不建议。并发任务可能同时关机、抹除或删除设备,导致相互污染。每个任务使用独立设备集,并在结束时删除对应目录更容易复现和回收。
bootstatus 成功是否代表应用一定能运行?
不代表。启动验收还应包含应用安装、bundle identifier 启动、退出码检查与失败日志归档,才能覆盖设备已启动但应用服务异常的情况。
让下一次构建运行在独享物理 Mac 上
按任务周期选择云端 Mac 节点,在控制台管理订单、连接信息与支持工单。