远程 CI 队列里最难处理的并不是失败,而是“取消”。开发者在界面中停止任务后,执行器通常先向脚本发送 TERM;脚本若没有接住,正在运行的 xcodebuild、测试进程或日志收集器可能继续占用工作区。下一次任务随后遇到锁文件、被占用的模拟器或不完整结果包,表面看是随机故障,根因却是上一次退出得不完整。
先定义取消完成的标准
一个任务从队列消失,不等于资源已经回收。可操作的完成标准至少包含四项:
- 主构建进程收到终止信号并退出。
- 脚本记录真实退出原因,而不是统一写成构建失败。
- 已生成的日志与
xcresult被保留。 - 工作区中没有属于本任务的后台进程和临时挂载。
取消路径也属于流水线的正常路径。只有异常退出时才测试清理逻辑,通常会在最忙的时候发现它根本不可用。
先确认执行器实际发送什么信号。可在测试任务中写入一个只记录信号、不包含敏感环境变量的探针。不要依赖默认推测,也不要把立即发送 KILL 当成取消策略,因为它不给进程关闭数据库、刷新日志或整理结果包的机会。
用陷阱接住 TERM 并跟踪主进程
构建脚本应显式保存 xcodebuild 的 PID。收到 TERM 或 INT 后,只终止当前任务创建的进程,不要使用无范围的 killall。下面的 Bash 骨架把日志和结果写进独立运行目录:
#!/bin/bash
set -u
run_id="${CI_RUN_ID:-manual-$(date +%s)}"
run_dir="$PWD/.ci-runs/$run_id"
result_path="$run_dir/TestResults.xcresult"
log_path="$run_dir/xcodebuild.log"
status_path="$run_dir/status.txt"
child_pid=""
cancelled=0
mkdir -p "$run_dir"
on_cancel() {
cancelled=1
if [[ -n "$child_pid" ]] && kill -0 "$child_pid" 2>/dev/null; then
kill -TERM "$child_pid" 2>/dev/null || true
fi
}
trap on_cancel TERM INT
xcodebuild \
-workspace Example.xcworkspace \
-scheme Example \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath "$result_path" \
test >"$log_path" 2>&1 &
child_pid=$!
wait "$child_pid"
build_status=$?
if [[ "$cancelled" -eq 1 ]]; then
printf 'cancelled\n' >"$status_path"
exit 130
fi
printf 'finished:%s\n' "$build_status" >"$status_path"
exit "$build_status"
这里没有在陷阱中直接删除目录。陷阱只负责传递信号,主流程仍然执行 wait,因此能够区分正常失败和人工取消。退出码 130 常用于表达被中断,但应先确认你的 CI 系统如何映射取消状态。
给进程有限的退出时间
某些测试可能不响应 TERM。生产脚本可以在发出信号后每秒检查一次 PID,等待例如 20 秒;超时后再发送 KILL。宽限期必须有限,否则取消操作会永久占住执行器。反过来,也不要把等待时间压到一两秒,xcodebuild 可能正在写入结果包。
把证据保留与缓存清理分开
退出清理最常见的错误是 trap cleanup EXIT 中直接执行 rm -rf "$run_dir"。这样虽然目录干净,却把判断原因所需的日志一起删掉。更稳妥的方式是把内容分为两类:
| 内容 | 取消后处理 |
|---|---|
xcodebuild.log、xcresult、状态文件 |
保留并归档 |
| 本任务创建的临时目录 | 确认路径后删除 |
| 共享依赖缓存 | 不在取消陷阱中删除 |
| 模拟器与派生数据 | 按任务隔离策略处理 |
日志归档也要容忍文件尚未生成。使用 [[ -e "$result_path" ]] 判断,而不是把缺少结果包变成第二个错误。若上传动作可能耗时,应给上传步骤单独设置超时,并保证上传失败不会覆盖原始构建退出码。
临时目录建议放在带运行编号的固定父目录下。删除前同时检查前缀和实际路径,避免变量为空时扩大删除范围:
safe_remove_run_dir() {
local target="$1"
local root="$PWD/.ci-tmp"
[[ -n "$target" ]] || return 1
[[ "$target" == "$root/"* ]] || return 1
[[ -d "$target" ]] || return 0
rm -rf -- "$target"
}
检查残留进程而不是全局清场
任务退出后,可以先记录进程快照:
ps -axo pid,ppid,state,etime,command >"$run_dir/processes-after.txt"
pgrep -P "$$" >"$run_dir/direct-children.txt" 2>/dev/null || true
pgrep -P 只查看直接子进程,无法覆盖所有后代,因此更可靠的做法是让每个辅助进程都由脚本启动并登记 PID。例如日志转发器、测试代理和端口转发分别写入数组,清理时逐一检查。不要按进程名称杀掉整台机器上的同名任务;独享物理 Mac mini 也可能同时运行开发者主动启动的长期进程。
若流水线允许并行,工作区、结果目录、模拟器设备集和临时目录都应带任务编号。优雅取消只能解决进程生命周期,不能弥补多个任务共用同一个可变目录造成的覆盖。
用三类演练验收取消路径
上线前至少执行三次演练。第一类是在依赖解析阶段取消,检查包管理器和日志进程是否退出。第二类是在编译期间取消,确认构建数据库没有被下一次任务误用。第三类是在测试运行期间取消,确认结果包可读取,模拟器相关进程没有持续占用本任务的设备集。
每次演练都核对以下项目:
- CI 页面把结果标记为取消,而非普通测试失败。
- 主进程在宽限期内退出,超时升级动作有记录。
- 日志末尾包含取消发生的阶段。
- 结果包存在时可用
xcrun xcresulttool读取。 - 下一次任务使用新运行目录并能正常启动。
- 清理脚本重复执行不会报错,也不会删除其他任务的数据。
在 RentMini 的云端 Mac 上运行长期 CI 时,还应把取消演练加入执行器更新后的验收清单。节点与配置选择只需在控制台确认当前可选配置;脚本本身不应依赖城市、机器名称或固定工作区路径。最终目标不是让目录看起来最干净,而是让每次退出都有边界、有证据,并且下一次构建可以从明确状态开始。
常见问题
CI 任务被取消后可以立即使用 kill -9 吗?
不建议。先向主构建进程发送 TERM 并等待一个有限宽限期,让它关闭文件、写完日志和结果包;只有进程超过宽限期仍未退出时,才把 KILL 作为最后手段。
取消构建时哪些文件应该保留?
至少保留 xcodebuild 日志、已生成的 xcresult、任务状态文件和进程快照。缓存与临时文件可以延后清理,但不要在退出陷阱中无条件删除诊断证据。
为下一次构建准备独享物理 Mac mini
选择 M4 配置、租期与节点。每个订单对应独享物理设备,实际可用状态以控制台实时返回为准。