同一台云端 Mac 连续执行多次 CI 后,最难解释的故障往往不是编译错误,而是“上一轮已经结束,下一轮却启动不了”:测试服务提示端口被占用,模拟器数量持续增加,构建目录无法删除,甚至同名后台进程同时写入一份日志。直接重启机器能暂时恢复,但会掩盖资源归属问题。更可靠的做法是先取证,再把每次运行产生的资源限制在可识别、可回收的边界内。
先确认残留资源属于谁
看到 Address already in use 时,不要立即执行全局 killall。先查监听者,再沿父进程向上确认它是否属于已经结束的任务。
PORT=8080
lsof -nP -iTCP:"$PORT" -sTCP:LISTEN
ps -o pid=,ppid=,user=,lstart=,command= -p 41872
ps -o pid=,ppid=,user=,command= -p 41801
lsof 给出 PID 后,至少核对用户、启动时间、完整命令和 PPID。若父进程仍是当前 Runner,端口可能属于正在执行的并行任务;若父进程已经变成系统进程,也不能单凭这一点判断它可以删除,因为后台任务可能主动脱离了原会话。
建议为每轮任务生成 RUN_ID,并把它写进进程参数、日志路径或环境变量。排查时可用下面的命令缩小范围:
RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
export XCODEVM_RUN_ID="$RUN_ID"
pgrep -afil "$RUN_ID"
清理动作的依据应是“资源标识与已结束任务一致”,而不是“进程名称看起来熟悉”。同一台机器上的并行任务可能运行完全相同的命令。
给每次运行分配独立边界
共享目录和固定端口会把偶发中断放大成持续故障。构建目录、结果包、临时文件和服务端口都应按运行隔离。
| 资源 | 不稳定做法 | 推荐边界 |
|---|---|---|
| Derived Data | 所有任务共用默认目录 | $WORK_ROOT/$RUN_ID/DerivedData |
| 测试结果 | 固定 result.xcresult |
使用运行标识命名 |
| 临时目录 | 直接写 /tmp/build |
mktemp -d 创建 |
| 本地服务 | 所有任务固定 8080 | 从受控端口池分配 |
| 模拟器 | 按名称重复查找 | 保存本轮创建的 UDID |
xcodebuild 可以显式指定构建数据与结果路径:
WORK_ROOT="${CI_WORK_ROOT:-$HOME/ci-work}"
RUN_ROOT="$WORK_ROOT/$RUN_ID"
DERIVED_DATA="$RUN_ROOT/DerivedData"
RESULT_PATH="$RUN_ROOT/Test.xcresult"
mkdir -p "$RUN_ROOT"
xcodebuild test \
-scheme App \
-derivedDataPath "$DERIVED_DATA" \
-resultBundlePath "$RESULT_PATH"
不要让两个并发任务共享同一个 Derived Data。即使工程和分支相同,中间产物、索引数据库与清理动作也可能发生竞争。
用退出陷阱覆盖失败和中断
只在脚本最后执行清理不够。编译失败、超时信号和人工取消都可能绕过最后几行。Shell 的 trap 可以统一覆盖正常退出与常见中断路径。
set -euo pipefail
RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/xcodevm-${RUN_ID}.XXXXXX")"
SIM_UDID=""
SERVICE_PID=""
cleanup() {
if [[ -n "$SERVICE_PID" ]] && kill -0 "$SERVICE_PID" 2>/dev/null; then
kill -TERM "$SERVICE_PID" 2>/dev/null || true
for _ in 1 2 3 4 5; do
kill -0 "$SERVICE_PID" 2>/dev/null || break
sleep 1
done
kill -KILL "$SERVICE_PID" 2>/dev/null || true
fi
if [[ -n "$SIM_UDID" ]]; then
xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
xcrun simctl delete "$SIM_UDID" 2>/dev/null || true
fi
rm -rf "$RUN_ROOT"
}
trap cleanup EXIT INT TERM
这里保存的是本轮创建的 PID 和 UDID,而不是扫描并删除所有同名资源。TERM 给进程释放文件句柄和写完日志的机会;只有等待后仍未退出,才升级为 KILL。
避免 PID 复用误伤
长时间运行的任务中,旧 PID 可能被系统重新分配。结束进程前应再次读取命令行,并检查其中的运行标识。更稳妥的方案是让后台服务把 PID、启动时间和命令摘要写入 $RUN_ROOT/manifest,清理时同时匹配这些字段。
处理模拟器与服务管理器
模拟器不应只靠名称管理,因为同名设备可能来自其他任务。创建后立即保存 UDID:
SIM_UDID="$(xcrun simctl create "ci-$RUN_ID" \
"iPhone 16" \
"com.apple.CoreSimulator.SimRuntime.iOS-18-0")"
xcrun simctl boot "$SIM_UDID"
运行环境未安装对应 Runtime 时,先用 xcrun simctl list runtimes 获取实际可用标识,不要把版本写死在公共脚本中。
若进程退出后又自动出现,应检查它是否由用户级服务管理。可先查看当前用户域,不要直接卸载来源不明的服务:
USER_ID="$(id -u)"
launchctl print "gui/$USER_ID" | grep -B 3 -A 6 "$RUN_ID"
任务确实创建了临时服务时,应保存其 label,并在退出路径使用同一 label 定向移除。仅删除进程 PID 往往无效,因为服务管理器会再次拉起它。
建立可重复的验收清单
清理完成不等于问题解决。连续执行两轮相同任务,并在每轮结束后检查资源是否回到基线:
lsof确认测试端口不再监听。pgrep -afil "$RUN_ID"不应返回本轮后台进程。xcrun simctl list devices中不再存在本轮创建的名称与 UDID。- 临时运行目录已删除,需要归档的日志和结果包已先复制到持久目录。
- 第二轮使用新的运行标识,不依赖第一轮留下的缓存或服务。
- 并发任务存在时,只清理当前任务清单中登记的资源。
如果端口仍被占用,保留 lsof、两级父进程、启动时间和命令行,再判断是清理遗漏还是合法并发。若问题只在强制取消时出现,就重点检查信号是否传递到包装脚本,以及后台进程是否创建了独立会话。把这些检查固化到 Runner 的收尾阶段,比定时扫描并批量结束进程更安全,也更容易复现。
常见问题
发现端口被占用后,可以直接结束对应进程吗?
不要只凭端口号结束进程。先用 lsof 获取 PID,再核对命令行、父进程、启动时间和本次运行标识;确认属于已结束的构建任务后,先发送 TERM,超时未退出再考虑 KILL。
为什么 CI 脚本已经失败退出,子进程仍然存在?
常见原因是子进程被放入后台、重新建立了会话,或由其他服务管理器接管。应保存创建时的 PID 或资源标识,并用 trap 在 EXIT、INT、TERM 路径执行定向清理。
XcodeVM 云端 Mac
选择适合当前工作负载的独享物理机
核对内存、存储、节点与计费周期后进入下单配置。所有节点全年正常运行,实际可用性以控制台实时返回为准。