工程记录

云端 Mac 上诊断 Xcode 模块缓存污染与 PCM 失效

云端 Mac 上诊断 Xcode 模块缓存污染与 PCM 失效

同一提交在本地通过,放到持续运行的云端 Mac 后却间歇出现 module file was created by a different version of the compiler,重跑有时又恢复。这类故障通常不是 Swift 源码随机变化,而是不同工具链、SDK 或并行任务读到了同一份预编译模块缓存。直接删除全部 DerivedData 虽能暂时止痛,却会抹掉最关键的现场,也让下一次污染继续发生。

先确认故障属于模块缓存

Clang 会把系统头文件与 Objective-C 模块编译成 PCM,Swift 编译也会维护自己的模块缓存。只要缓存生成时使用的编译器、SDK、架构或编译参数与当前任务不一致,加载阶段就可能失败。

先在完整日志中检索这些特征:

日志特征 更可能的原因 首个检查点
different version of the compiler 切换过 Xcode xcodebuild -version
PCM file is out of date SDK 或头文件时间戳变化 当前 SDK 路径
could not build module 上游模块损坏 首个失败模块
同一任务重跑后通过 并行写入或共享缓存 缓存目录归属

不要只截取最后一条错误。模块加载失败常会引发几十条级联报错,应向前找到第一个出现 module.pcmModuleCache 的位置。

清缓存之前先保存失败日志、Xcode 构建号和实际构建设置。没有这些信息,恢复成功也无法判断是工具链漂移、并发污染,还是缓存本身损坏。

为失败任务保存可比较的现场

在 VMKeep 的云端 Mac 上,先记录命令实际解析到的开发者目录,而不是只看脚本中期望使用的版本。

set -o pipefail

mkdir -p artifacts/diagnostics
xcode-select -p | tee artifacts/diagnostics/developer-dir.txt
xcodebuild -version | tee artifacts/diagnostics/xcode-version.txt
xcrun --sdk iphoneos --show-sdk-path | tee artifacts/diagnostics/sdk-path.txt
xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -showBuildSettings \
  > artifacts/diagnostics/build-settings.txt

随后检查失败命令里的 SDKROOTARCHSDEVELOPER_DIRCLANG_MODULE_CACHE_PATHSWIFT_MODULE_CACHE_PATH。若任务通过 sudo、远程会话或调度进程启动,还要比较执行用户与 HOME;同一脚本换了用户,默认缓存位置也会改变。

固定工具链入口

构建脚本应显式设置 DEVELOPER_DIR,并在真正编译前执行一次 xcodebuild -version。版本不符合预期时立即退出,不要让任务带着错误工具链写入共享缓存。升级 Xcode 后应生成新的缓存键,而不是继续沿用旧目录。

把缓存边界缩小到单个任务

可靠的目录键至少包含 Xcode 构建号、SDK、架构和任务标识。分支名不能直接当路径,应先转换特殊字符。下面的做法让每个任务拥有独立 DerivedData 与模块缓存,同时把依赖下载目录留在单独位置。

set -euo pipefail

JOB_KEY="$(printf '%s' "${CI_JOB_ID:-local}" | tr -cs 'A-Za-z0-9._-' '-')"
XCODE_BUILD="$(xcodebuild -version | awk '/Build version/{print $3}')"
SDK_BUILD="$(xcrun --sdk iphoneos --show-sdk-build-version)"
CACHE_KEY="${XCODE_BUILD}-${SDK_BUILD}-arm64-${JOB_KEY}"

ROOT="$HOME/BuildCaches/$CACHE_KEY"
DERIVED="$ROOT/DerivedData"
MODULES="$ROOT/Modules"
PACKAGES="$HOME/BuildCaches/SourcePackages"

mkdir -p "$DERIVED" "$MODULES/clang" "$MODULES/swift" "$PACKAGES"

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -derivedDataPath "$DERIVED" \
  -clonedSourcePackagesDirPath "$PACKAGES" \
  CLANG_MODULE_CACHE_PATH="$MODULES/clang" \
  SWIFT_MODULE_CACHE_PATH="$MODULES/swift" \
  build

长期分支可以复用自己的稳定键,临时合并请求则应按任务隔离。不要让两个仍在运行的 xcodebuild 进程写同一个模块目录,也不要把模块缓存作为可跨工具链恢复的通用压缩包。

精准清理而不是一键归零

确认是模块缓存故障后,先停止使用该目录的构建进程,再按范围从小到大处理:

  1. 删除当前任务的 Modules/clangModules/swift
  2. 保留 SourcePackages,重新执行依赖解析与构建。
  3. 若索引或中间产物仍引用旧模块,再删除当前任务的 DerivedData。
  4. 若只有一个第三方模块失败,核对其二进制产物支持的架构与当前 SDK,不要反复清缓存掩盖兼容性问题。

清理动作必须限定在计算出的任务目录。使用 rm -rf ~/Library/Developer/Xcode/DerivedData/* 会同时影响其他任务,容易把一次局部故障扩大成整台机器的冷启动。

防止误删正在使用的目录

回收旧缓存时,以任务锁或运行记录确认目录无人占用,再按最后使用时间删除。不要只依据目录创建时间,因为稳定分支可能持续复用较早建立的缓存。清理脚本还应验证目标路径位于预期根目录下,空变量或路径计算失败时直接退出。

把缓存一致性变成构建门禁

最终应让流水线主动暴露漂移。每次构建保存 Xcode 构建号、SDK 构建号、架构、缓存键和执行用户;任务结束后将失败日志与这些信息一起归档。升级工具链时创建新命名空间,确认新链路稳定后再回收旧缓存。

可以再安排一个不恢复模块缓存的验收任务。若常规任务失败而干净任务通过,问题集中在缓存层;若两者都失败,应回到源码、依赖或构建设置继续排查。这样既避免把所有错误归因于缓存,也不必靠重复执行碰运气。

模块缓存本质上是编译器输入的一部分。把版本、目录所有权和并发边界写进脚本后,PCM 失效就会从随机故障变成可定位、可恢复的工程事件。

常见问题

遇到 PCM 失效时需要删除整个 DerivedData 吗?

不需要先做全量删除。应先保存日志和构建设置,再只清理当前任务的 ModuleCache.noindex;只有索引、产物和模块缓存同时异常时,才重建该任务独占的 DerivedData。

不同 Xcode 版本可以共用同一个模块缓存目录吗?

不建议。模块缓存键至少应包含 Xcode 构建号、SDK、架构和任务标识,否则编译器升级或任务并行时容易复用不兼容的 PCM 文件。

独享物理节点

在云端 Mac 上继续验证这套工作流

从三档 Apple Silicon 配置中选择合适的内存、存储、节点与租用周期。

选择配置并下单