工程文章

云端 Mac CI 的 Unix Socket 路径过长排查实战

云端 Mac CI 的 Unix Socket 路径过长排查实战

CI 日志显示服务进程已经启动,客户端却反复报 connect failedNo such file or directory,重试也没有改善。检查权限和进程状态都正常后,真正的故障点可能藏在工作目录里:工具把 Unix Socket 建在多层仓库路径、任务编号和临时目录之下,最终绝对路径超过了 macOS 能接受的长度。

这类问题常见于并行测试进程、Ruby 工具、Node.js 服务、构建辅助程序和本地进程间通信。错误信息未必直接出现“路径过长”,因此排查重点不是继续增加重试,而是确认 Socket 实际落在哪里、路径按 UTF-8 编码后有多少字节。

先确认故障来自 Socket 路径

先同时检查进程、Socket 文件和打开的 Unix 连接。不要只看启动脚本的退出码:父进程成功并不代表负责监听的子进程已经完成绑定。

pgrep -fl 'xcodebuild|swift|ruby|node'
find "${TMPDIR:-/private/tmp}" -type s -print 2>/dev/null
lsof -U -n -P | grep "$USER"

如果日志给出了 .sock 路径,直接检查其父目录。文件不存在可能有三种原因:监听进程尚未创建、创建后被提前清理,或者 bind() 在路径校验阶段就失败。此时可用一个短路径复现相同工具;短路径成功而原路径失败,通常比反复调整权限更有解释力。

macOS 的 Unix Socket 路径限制按字节计算,不按屏幕上看到的字符数计算。中文目录、组合字符和较长的任务标识都会让字节数增长得更快。

测量绝对路径的 UTF-8 字节数

不要用 ${#path} 直接下结论,它得到的通常是字符数量。下面的脚本先展开绝对路径,再按文件系统编码计算字节数:

candidate="$PWD/.ci/runtime/worker/session/control.sock"

python3 - "$candidate" <<'PY'
import os
import sys

path = os.path.abspath(sys.argv[1])
encoded = os.fsencode(path)
print(f"bytes={len(encoded)}")
print(f"path={path}")
PY

Darwin 的 sockaddr_un.sun_path 空间有限,工程上不应把目录设计到极限附近。可以把 90 字节设为预警线,为工具追加进程号、随机后缀和子目录留下余量。这个数字是内部安全线,不是所有工具共享的正式上限;有些框架还会在传入路径后继续拼接文件名。

同时记录失败路径的组成:工作区根目录、仓库名、流水线任务名、并发分片名和工具自己的运行目录。只缩短末尾的 control.sock 往往省不出足够空间。

为每个任务建立短路径根目录

稳定方案是让任务直接在短根目录下运行,而不是在原有深目录外套一层符号链接。每个任务使用独立目录,权限设为 700,并把临时文件、缓存和构建输出显式指向该目录。

job_root="$(mktemp -d /private/tmp/vmown-ci.XXXXXX)"
chmod 700 "$job_root"

export TMPDIR="$job_root/tmp"
export XDG_CACHE_HOME="$job_root/cache"
export DERIVED_DATA="$job_root/dd"

mkdir -p "$TMPDIR" "$XDG_CACHE_HOME" "$DERIVED_DATA"

cleanup() {
  rm -rf -- "$job_root"
}

trap cleanup EXIT HUP INT TERM

Xcode 构建可以显式传入 DerivedData,SwiftPM 则使用独立 scratch 目录。这样既缩短路径,也避免并行任务争用同一个构建数据库。

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -derivedDataPath "$DERIVED_DATA" \
  build

swift build --scratch-path "$job_root/spm"

仓库本身也应检出到短路径,例如 $job_root/src。如果仍从很深的原目录执行,仅移动 TMPDIR 未必有效,因为部分工具会依据项目绝对路径生成 Socket。

避免符号链接与共享目录陷阱

符号链接适合快速验证,但不应作为唯一修复。有些程序会调用 realpath,最终仍使用链接背后的长路径;另一些程序会把真实路径写入缓存键,导致同一任务同时出现长短两套目录。

共享 /private/tmp/ci 也不可取。并发任务可能创建同名 Socket,旧任务残留的监听文件还会让新任务误判服务已就绪。更安全的原则是“一项任务一个根目录”,并把 Socket 文件名保持简短且可预测。

清理时不要使用宽泛的通配符,也不要删除整个 /private/tmp 下的公共前缀。mktemp 返回的目录应保存在当前进程变量中,由同一任务通过 trap 精确回收。若任务被强制终止而无法执行清理,可由独立清扫作业按目录所有者和修改时间处理,但必须先确认目录内没有活跃进程。

把检查变成流水线前置门禁

修复一次并不够。仓库名、分支名或任务模板变长后,问题可能再次出现。建议在启动测试服务前检查所有候选 Socket,并在超过内部预警线时立即失败。

check_socket_path() {
  python3 - "$1" <<'PY'
import os
import sys

limit = 90
path = os.path.abspath(sys.argv[1])
size = len(os.fsencode(path))

if size > limit:
    print(f"socket path exceeds guard: {size} bytes")
    raise SystemExit(1)

print(f"socket path accepted: {size} bytes")
PY
}

check_socket_path "$TMPDIR/test-worker.sock"

验收时至少覆盖四项:路径低于预警线;并行任务的根目录不同;失败与中断后目录能够清理;工具日志中使用的路径与预期一致。最后再运行两项并发任务,确认不存在 Socket 重名、缓存串用或清理另一任务目录的情况。

路径治理的目标不是把所有文件塞进临时目录,而是让需要进程间通信的部分拥有短、私有、生命周期明确的根路径。完成这一层约束后,许多看似随机的连接失败会变成可复现、可提前阻断的配置错误。

常见问题

为什么缩短文件名后 Unix Socket 仍然创建失败?

内核检查的是完整绝对路径的 UTF-8 字节数,不只是文件名。仓库目录、任务编号、临时目录和工具生成的子目录都会计入,因此应先测量最终绝对路径。

把工作区改成符号链接能彻底解决路径过长吗?

不能保证。部分工具会解析符号链接并使用真实路径创建 Socket。更可靠的做法是在短根目录直接创建工作区、缓存和临时目录。

多个 CI 任务可以共用一个短临时目录吗?

不建议。每个任务应使用 mktemp 创建权限为 700 的独立目录,并在退出、失败或收到终止信号时清理,避免 Socket 重名和跨任务数据污染。

独享云端 Mac

为开发、构建与远程桌面选择一台独享物理机

比较三档 Apple Silicon 配置,并在下单时选择节点、租期与存储附加项。

选择租用方案