在 GitHub Actions Runner 代码库里,有一个看似简单的 Bash 脚本——safe_sleep.sh,它负责让 Runner 在某些场景下“安全地睡眠”一段时间。但这个小脚本却因为一个 subtle 的逻辑缺陷,让许多开发者和 CI 系统管理员困扰不已,甚至引发了性能、资源浪费、Runner 卡死等严重问题。
一、什么是safe_sleep.sh
GitHub Actions Runner 是负责执行 CI/CD 工作流程的后台服务。某些流程中,Runner 需要暂停一段时间(例如等待自动更新完成),这时就会调用一个名为safe_sleep.sh的脚本。
看似是个简单任务:让程序睡眠 N 秒钟。但设计者并没有调用系统自带的sleep命令,而是用 Bash 语言编写了一个自循环脚本。
在旧版本中,脚本内容大致如下:
#!/bin/bash SECONDS=0 while [[ $SECONDS != $1 ]]; do : done通过 busy-waiting 来实现睡眠,而不是调用标准sleep。
二、问题核心:循环条件有 Bug
乍看这段代码好像没毛病,但它有一个细节严重依赖了调度时机:
while [[ $SECONDS != $1 ]]; do …这个循环假设SECONDS会按 0 → 1 → 2 → … 逐秒增加并且正好等于目标值。但在真实环境中:
如果机器负载高;
或者 Runner 进程被系统调度延迟;
或者在虚拟化环境里暂停一段时间;
那么SECONDS有可能会直接跳过目标值,例如从 0 跳到 2。在这种情况下:
条件永远不会变成 “等于目标值”,循环就会永远执行下去 ——无限循环。
三、实际观测到的问题
Very rarely on update of github actions runnersafe_sleep.shhangs forever:
$ ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND ... actions+ 72298 96.3 0.0 4372 3328 ? R Apr02 1988:54 /bin/bash /home/actions-runner/safe_sleep.sh 1 ...详见:safe_sleep.sh rarely hangs indefinitely
四、影响案例:更多比预想严重
不仅仅是某个挂起的新 Runner,某些开源项目(例如 Zig)据反馈在自托管 Runner 上观察到了成堆的死循环实例,这些无限循环的safe_sleep.sh不仅占满了 CPU,还导致 Runner 服务无法正常运行数日之久。
五、修复版本
https://github.com/actions/runner/commits/main/src/Misc/layoutroot/safe_sleep.sh
1. 最新修复版本
#!/bin/bash # try to use sleep if available if [ -x "$(command -v sleep)" ]; then sleep "$1" exit 0 fi # try to use ping if available if [ -x "$(command -v ping)" ]; then ping -c $(( $1 + 1 )) 127.0.0.1 > /dev/null exit 0 fi # try to use read -t from stdin/stdout/stderr if we are in bash if [ -n "$BASH_VERSION" ]; then if command -v read >/dev/null 2>&1; then if [ -t 0 ]; then read -t "$1" -u 0 || :; exit 0 fi if [ -t 1 ]; then read -t "$1" -u 1 || :; exit 0 fi if [ -t 2 ]; then read -t "$1" -u 2 || :; exit 0 fi fi fi # fallback to a busy wait SECONDS=0 while [[ $SECONDS -lt $1 ]]; do : done2. 执行逻辑(从“优雅”到“原始”)
(1)首选:sleep
if [ -x "$(command -v sleep)" ]; then sleep "$1" exit 0 fi检查
sleep是否存在且可执行最准确、最省 CPU
正常系统 99% 会走到这里
(2)备用:ping
if [ -x "$(command -v ping)" ]; then ping -c $(( $1 + 1 )) 127.0.0.1 > /dev/null exit 0 fi原理:
ping默认每秒发一次发
N+1个包 ≈ 等N秒
⚠️ 注意点:
依赖
ping没被禁用(某些容器里 ping 被移除或需要 CAP_NET_RAW)时间精度不如 sleep
127.0.0.1保证不会阻塞网络
(3)再退一步:read -t(仅 Bash)
if [ -n "$BASH_VERSION" ]; then if command -v read >/dev/null 2>&1; then ... fi fi原理
read -t N→阻塞 N 秒等待输入没输入就超时返回
为什么检查-t 0 / 1 / 2?
if [ -t 0 ]; then read -t "$1" -u 0; firead -t必须绑定到 TTYstdin/stdout/stderr 只要有一个是 TTY 就能用
非交互 shell(CI、cron)通常都不是 TTY
(4)最终兜底:busy wait(CPU 自旋)
SECONDS=0 while [[ $SECONDS -lt $1 ]]; do : doneSECONDS是 Bash 内建变量(秒级):是 no-op100% 占用一个 CPU 核心