云端 Mac 上治理 iOS 模拟器启动失败

云端 Mac 上治理 iOS 模拟器启动失败

远程 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 附件。

erasedelete 和直接删除设备目录都属于破坏性操作。没有完成取证前执行它们,通常只会得到“重试后好了”,而不是可修复的根因。

为每个任务创建独立设备集

默认设备集位于用户目录,所有并发任务都能访问。一个任务执行 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 错误拖成更长的等待。

建立分级恢复顺序

恢复动作应从影响最小的步骤开始:

  1. 重新确认 DEVELOPER_DIR、Runtime 与设备类型是否匹配。
  2. 关闭并重启当前任务拥有的设备。
  3. 在同一独立设备集中删除故障设备并重建。
  4. 清理 simctl list devices unavailable 中确认失效的记录。
  5. 只有在没有其他任务运行时,才考虑重启 CoreSimulator 相关服务或整机。

如果每次都要走到第五步,应检查执行器是否仍在共享默认设备集、磁盘是否接近容量上限,以及任务取消后是否遗漏了模拟器子进程。RunnerVM 上的远程任务也应遵循相同原则:在控制台确认当前可选配置后,把设备集路径纳入任务生命周期,而不是把模拟器状态当作机器级永久资源。

让失败变得可比较

最后,把 Xcode 版本、Runtime 标识、设备类型、UDID、启动耗时和失败阶段写入结构化结果。连续失败时按这些字段聚合,才能看出问题是否集中在某个 Runtime、某类设备或特定并发任务。

一次可靠的模拟器恢复,不以“界面终于打开”为结束,而以命令可重复执行、应用可安装启动、故障证据已归档、任务私有状态已回收为结束。这样即使问题再次出现,也能从已知阶段继续排查,而不是重新猜测。

常见问题

模拟器卡在 Booting 时应该先 erase 还是先重建?

先保存 simctl 列表与系统日志,再在独立设备集中重建同类型设备。只有确认旧设备无需保留测试状态时才执行 erase,避免把现场证据一起清掉。

多个 CI 任务可以共用默认模拟器设备集吗?

不建议。并发任务可能同时关机、抹除或删除设备,导致相互污染。每个任务使用独立设备集,并在结束时删除对应目录更容易复现和回收。

bootstatus 成功是否代表应用一定能运行?

不代表。启动验收还应包含应用安装、bundle identifier 启动、退出码检查与失败日志归档,才能覆盖设备已启动但应用服务异常的情况。

Runner M4

让下一次构建运行在独享物理 Mac 上

按任务周期选择云端 Mac 节点,在控制台管理订单、连接信息与支持工单。

选择方案并下单