一条原本十几分钟结束的 XCTest 任务,在云端 Mac 上运行四十分钟后仍没有退出。日志最后一行只是某个测试用例开始执行,CPU 占用不高,结果包也没有完成。此时直接停止任务只能得到“超时”,无法判断是主线程互锁、异步回调未返回、模拟器服务失联,还是测试进程仍在等待永远不会满足的条件。
处理这类问题的关键不是增加重试,而是在终止前保存现场。进程关系、连续线程采样、模拟器状态、原始日志和未完成的 xcresult,应该属于同一次执行记录。
先区分慢测试与真正卡死
只设置一个总超时容易误判。首次编译、模拟器启动和测试执行的耗时来源不同,应该先拆开:
- 使用
build-for-testing完成编译。 - 使用
test-without-building执行测试。 - 分别记录构建时长、测试时长和最后一次日志变化时间。
- 测试超过历史上限时采集现场,而不是立刻强制结束。
全局上限防止任务无限运行,静默上限用于发现“进程还在,但没有进度”。静默不能简单等同于标准输出为空:某些长测试本来就很少打印内容。更可靠的判断是同时观察日志文件修改时间、测试进程是否存活,以及 xcresult 目录是否继续变化。
超时是一种保护机制,不是根因。只有在超时发生时留下足够证据,它才具备诊断价值。
固定可复现入口
先把 scheme、测试计划、目标设备和 DerivedData 路径固定下来。不要让诊断任务顺便更换 Xcode、模拟器运行时或依赖版本,否则两次现场无法比较。
set -euo pipefail
WORKSPACE="Demo.xcworkspace"
SCHEME="Demo"
PLAN="Regression"
DERIVED="$PWD/.build/DerivedData"
RESULT="$PWD/.artifacts/TestRun.xcresult"
rm -rf "$RESULT"
mkdir -p "$PWD/.artifacts"
xcodebuild build-for-testing \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-testPlan "$PLAN" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-derivedDataPath "$DERIVED"
xcodebuild test-without-building \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-testPlan "$PLAN" \
-destination "platform=iOS Simulator,name=iPhone 16" \
-derivedDataPath "$DERIVED" \
-resultBundlePath "$RESULT"
如果问题只出现在单个测试类,应先用 -only-testing:TargetName/TestClass 缩小范围。不要一开始就并行重跑整个测试计划,这会增加进程和模拟器噪声。
给测试进程加可取证的守护器
macOS 默认没有统一可用的 GNU timeout 命令,CI 脚本可以用后台守护进程实现。正确顺序是:启动测试、等待上限、采集、发送 TERM、留出清理时间,最后才发送 KILL。
set -euo pipefail
OUT="$PWD/.artifacts/hang-$(date +%Y%m%d-%H%M%S)"
LIMIT="${TEST_TIMEOUT_SECONDS:-1200}"
mkdir -p "$OUT"
xcodebuild test-without-building \
-workspace Demo.xcworkspace \
-scheme Demo \
-testPlan Regression \
-destination "platform=iOS Simulator,name=iPhone 16" \
-derivedDataPath "$PWD/.build/DerivedData" \
-resultBundlePath "$OUT/TestRun.xcresult" \
>"$OUT/xcodebuild.log" 2>&1 &
test_pid=$!
(
sleep "$LIMIT"
if kill -0 "$test_pid" 2>/dev/null; then
date -u >"$OUT/timeout-at.txt"
ps -axo pid,ppid,state,%cpu,%mem,etime,command >"$OUT/host-processes.txt"
xcrun simctl list devices >"$OUT/simulators.txt"
xcrun simctl spawn booted ps -axo pid,ppid,state,etime,command \
>"$OUT/simulator-processes.txt" 2>&1 || true
for round in 1 2 3; do
xcrun simctl spawn booted sample DemoTests-Runner 5 1 \
>"$OUT/test-runner-sample-$round.txt" 2>&1 || true
sleep 3
done
kill -TERM "$test_pid" 2>/dev/null || true
sleep 10
kill -KILL "$test_pid" 2>/dev/null || true
fi
) &
watchdog_pid=$!
set +e
wait "$test_pid"
status=$?
set -e
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
exit "$status"
测试 Runner 名称要按项目产物修改。若名称不稳定,可从模拟器进程列表中按测试包路径筛选 PID,再交给 sample。三次短采样比一次长采样更容易判断调用栈是否真正停止推进。
从线程堆栈判断等待类型
先看三份采样中主线程和测试线程是否停在相同位置。常见模式包括:
主线程等待测试线程
主线程停在信号量、条件变量或同步派发上,而测试线程又在等待必须由主线程执行的回调,通常是同步等待异步 API 导致的互锁。修复方向是改用 XCTest expectation,并确保等待动作不占住负责回调的队列。
测试线程等待外部条件
调用栈持续停在 socket、文件协调、数据库锁或进程间通信上,应继续核对依赖服务、临时目录权限和前序测试留下的锁。不要把所有等待都归因于网络;先用进程和文件状态证明等待对象是什么。
调用栈仍在变化
如果三次采样的热点不断变化,任务可能只是很慢。此时应记录单用例耗时,缩小数据规模,或为测试本身设置合理的 expectation 超时,而不是把全局限制无限调大。
把 xcresult 与系统状态一起归档
即使结果包未正常收尾,也应原样保留,不要在失败分支先删除。完整结果可用以下命令导出为便于检索的 JSON:
xcrun xcresulttool get \
--legacy \
--path ".artifacts/TestRun.xcresult" \
--format json \
>".artifacts/xcresult.json"
每次超时目录至少应包含:
- 完整
xcodebuild输出和退出码; - scheme、测试计划、destination 与 Xcode 版本;
- 宿主机及模拟器进程列表;
- 间隔采集的三份线程样本;
- 模拟器设备状态;
- 原始 xcresult 与导出的结构化结果;
- 本次提交标识和测试入口参数。
在 PingMini 的云端 Mac 上执行时,也应让一次任务只使用一个明确的模拟器设备集,避免另一条并发任务改变设备状态。配置与节点选择则在控制台确认当前可选项,不要把机器差异混入同一轮故障复现。
将修复变成回归检查
找到根因后,不要只删除守护器。保留超时采集,但把阈值改成基于历史分位值的预算;同时为已修复场景增加最小回归用例。例如,互锁问题应验证回调能在限定时间内完成,资源锁问题应验证测试结束后锁文件和子进程均被清理。
最后检查三件事:正常任务不会触发采样;异常任务能在有限时间内退出;归档中足以回答“哪个进程在等什么”。做到这一步,偶发的 XCTest 卡死才不再是一条只能手工重跑的红色记录,而是一个可以持续收敛的工程问题。
常见问题
XCTest 多久没有输出才应该判定为卡死?
不要只按总运行时间判断。先用历史正常时长设置全局上限,再为日志静默设置较短阈值;只有进程仍存活、日志持续静默且测试状态没有推进时,才进入采样和终止流程。
为什么不能发现超时后立即 kill -9?
立即强制结束会丢失线程堆栈、子进程关系和结果包收尾信息。应先记录进程列表并执行 sample,再发送 TERM,等待短暂清理期后才使用 KILL。
采样文件里优先检查哪些线程?
先看主线程是否等待同步锁、网络回调或进程间通信,再检查测试线程、数据库队列和自建串行队列是否形成互等;连续多次采样停在同一调用栈,比单次快照更有判断价值。
为下一次构建准备独享物理 Mac
从三档固定配置中选择适合开发、持续集成或本地模型测试的云端 Mac,并按任务周期完成配置。