云端 Mac CI 任务优雅取消与子进程回收

云端 Mac CI 任务优雅取消与子进程回收

远程 CI 队列里最难处理的并不是失败,而是“取消”。开发者在界面中停止任务后,执行器通常先向脚本发送 TERM;脚本若没有接住,正在运行的 xcodebuild、测试进程或日志收集器可能继续占用工作区。下一次任务随后遇到锁文件、被占用的模拟器或不完整结果包,表面看是随机故障,根因却是上一次退出得不完整。

先定义取消完成的标准

一个任务从队列消失,不等于资源已经回收。可操作的完成标准至少包含四项:

  1. 主构建进程收到终止信号并退出。
  2. 脚本记录真实退出原因,而不是统一写成构建失败。
  3. 已生成的日志与 xcresult 被保留。
  4. 工作区中没有属于本任务的后台进程和临时挂载。

取消路径也属于流水线的正常路径。只有异常退出时才测试清理逻辑,通常会在最忙的时候发现它根本不可用。

先确认执行器实际发送什么信号。可在测试任务中写入一个只记录信号、不包含敏感环境变量的探针。不要依赖默认推测,也不要把立即发送 KILL 当成取消策略,因为它不给进程关闭数据库、刷新日志或整理结果包的机会。

用陷阱接住 TERM 并跟踪主进程

构建脚本应显式保存 xcodebuild 的 PID。收到 TERMINT 后,只终止当前任务创建的进程,不要使用无范围的 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.logxcresult、状态文件 保留并归档
本任务创建的临时目录 确认路径后删除
共享依赖缓存 不在取消陷阱中删除
模拟器与派生数据 按任务隔离策略处理

日志归档也要容忍文件尚未生成。使用 [[ -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 也可能同时运行开发者主动启动的长期进程。

若流水线允许并行,工作区、结果目录、模拟器设备集和临时目录都应带任务编号。优雅取消只能解决进程生命周期,不能弥补多个任务共用同一个可变目录造成的覆盖。

用三类演练验收取消路径

上线前至少执行三次演练。第一类是在依赖解析阶段取消,检查包管理器和日志进程是否退出。第二类是在编译期间取消,确认构建数据库没有被下一次任务误用。第三类是在测试运行期间取消,确认结果包可读取,模拟器相关进程没有持续占用本任务的设备集。

每次演练都核对以下项目:

在 RentMini 的云端 Mac 上运行长期 CI 时,还应把取消演练加入执行器更新后的验收清单。节点与配置选择只需在控制台确认当前可选配置;脚本本身不应依赖城市、机器名称或固定工作区路径。最终目标不是让目录看起来最干净,而是让每次退出都有边界、有证据,并且下一次构建可以从明确状态开始。

常见问题

CI 任务被取消后可以立即使用 kill -9 吗?

不建议。先向主构建进程发送 TERM 并等待一个有限宽限期,让它关闭文件、写完日志和结果包;只有进程超过宽限期仍未退出时,才把 KILL 作为最后手段。

取消构建时哪些文件应该保留?

至少保留 xcodebuild 日志、已生成的 xcresult、任务状态文件和进程快照。缓存与临时文件可以延后清理,但不要在退出陷阱中无条件删除诊断证据。

按需使用云端 Mac

为下一次构建准备独享物理 Mac mini

选择 M4 配置、租期与节点。每个订单对应独享物理设备,实际可用状态以控制台实时返回为准。

选择配置并租用