首页
/ Mole Shell 工程实践:run_with_timeout、TTY 接管与 Bash 3.2 环境下的缺陷防护指南

Mole Shell 工程实践:run_with_timeout、TTY 接管与 Bash 3.2 环境下的缺陷防护指南

2026-09-04 17:35:36作者:胡唯隽

Mole 的清理与卸载流程运行在 macOS 原生的 Bash 3.2 + set -euo pipefail 之上,这意味着每一个外部命令、每一次终端交互、每一条系统输出解析都暗藏边界条件。本文基于 Mole 仓库内置的缺陷参考文档 shell-and-test-pitfalls.md(其缺陷分类总览见父级 bugs 技能文档),结合 lib/core/timeout.shlib/core/timeouts.sh 等源码与 tests/core_timeout.bats 等测试用例,系统讲解五类高频 Shell 缺陷(无界外部命令、Bash 3.2 语义陷阱、后台任务抢占 TTY、系统输出当 API 用、取消传播不穿透)以及一组聚焦的实战陷阱,帮助维护 Shell 代码、Bats 测试、超时封装与安装/更新流程的开发者建立可复制的防护手法。

一、无界外部命令:所有耗时命令必须留在超时封装之后

dumdfindfindxcrun simctlsystem_profilerioreg 以及各类包管理器命令,在一台健康但偏慢的机器上都可能长时间停滞。Mole 的约定是:所有生产路径上的 du -s 调用必须留在 run_with_timeout 之后,并由 tests/core_timeout.bats 中的不变量测试固化——该测试会扫描 lib/bin/ 中每一处 du -s,检查其前文两行内是否出现 run_with_timeout 或共享的有界 sudo 辅助 _mole_bounded_sudo,任何一处裸调用都会让测试失败:

# tests/core_timeout.bats 中的源不变量(节选)
while IFS=: read -r file line text; do
    [[ "$text" =~ ^[[:space:]]*# ]] && continue
    context=$(sed -n "${first_line},${line}p" "$file")
    if ! grep -Eq 'run_with_timeout|_mole_bounded_sudo' <<< "$context"; then
        unbounded+="${file}:${line}:${text}"$'\n'
    fi
done < <(grep -rn -- 'du -s' "$PROJECT_ROOT/lib" "$PROJECT_ROOT/bin" || true)

排查时不能只盯着最显眼的那条命令,参考文档列出了七个需要检查的维度:

  • 检查点要放进每一层嵌套循环,而不仅是外层根(缺陷 edb214c0);
  • 超时要对着“最慢的健康机器”调参:CoreSimulatorService 曾在两秒超时预算下被误报为不可用,最终改为“预热重试”方案(35d856f1);
  • 生产者(producer)必须完整物化后再消费;状态非零时丢弃输出。进程替换加 || true 的组合绝不允许把半截的 find 前缀喂给删除逻辑;
  • 探测(probe)与实际操作(action)的模式、类型、年龄、深度参数必须保持一致;
  • 调高超时前先把生产者和消费者分别计时——一个 2.3 秒的 lsregister dump 之后,若对每一行输入各做一次命令替换,总耗时仍会膨胀到分钟级;
  • 对已安装二进制的 --version / --help 验证也要设界:坏掉的执行文件恰恰是最容易挂死的那类;
  • 安装与更新流程要按目标目录保持单飞(single-flight),避免一个进程去验证另一个世代的产物。

文档附带了一段可在仓库中运行的审计命令,统计各外部命令的调用总量与其中被超时封装覆盖的数量:

for command_name in 'du -s' mdfind xcrun system_profiler ioreg brew; do
    printf '%-16s total=%-4s wrapped=%s\n' "$command_name" \
        "$(command grep -rn -- "$command_name" lib/ bin/ | wc -l | tr -d ' ')" \
        "$(command grep -rn -- "$command_name" lib/ bin/ | command grep -c run_with_timeout)"
done

超时机制的三层后端与统一超时预算

上述约定的底层实现是 lib/core/timeout.sh 中的 run_with_timeout。它按以下顺序选择后端:

  1. gtimeout(coreutils)或 timeout:最可靠,超时返回 124;
  2. Perl 辅助进程:带独立进程组清理、父进程死亡检测(通过 getppid() 变化判定被回收,防止后台 worker 被杀后命令泄漏),并用 setpgid(而非 setsid)把子进程放入新进程组——刻意保留控制终端,否则嵌套 sudo 会失去 TTY,导致 brew cask 卸载脚本中需要密码的步骤失效(issue #1003);
  3. 纯 Shell 兜底:SIGTERM → 等待 2 秒 → SIGKILL 升级,watchdog 子 shell 的所有 fd 都重定向到 /dev/null,防止孤儿 sleep 占住 Bats 的输出捕获管道。

超时预算集中在 lib/core/timeouts.sh 中定义,按语义分桶、全部可用同名环境变量覆盖:

常量 默认值(秒) 语义
MOLE_TIMEOUT_QUICK_DETECT_SEC 2 command -v + 版本探测类,缺失/卡死时应快速失败
MOLE_TIMEOUT_SHORT_QUERY_SEC 3 轻量子进程查询(dftmutil status
MOLE_TIMEOUT_MEDIUM_PROBE_SEC 5 偶尔触及网络或扫描目录树的较重探测
MOLE_TIMEOUT_PKG_LIST_SEC 10 包管理器列举(brew listsimctl list
MOLE_TIMEOUT_PKG_CLEANUP_SEC 20 遍历磁盘的缓存清理命令
MOLE_TIMEOUT_DISK_VERIFY_SEC 30 文件系统级校验/修复操作
MOLE_TIMEOUT_HINT_SCAN_SEC 15 遍历无界用户目录树的整体墙钟上限

MOLE_TIMEOUT_DISK_VERIFY_SEC 的典型使用可见于 lib/clean/dev.sh 中的磁盘度量:run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" du -skP "$target_path",并在超时后回退为 0。清理流程的汇总提示(bin/clean.sh 约 L1963-L1967)还会明确告知用户“部分项目超出了 N 秒的尺寸检查预算、被计为 0”,把超时的副作用显性化。

同文件中的 _mole_timeout_with_deadline() 把单命令超时限幅到整体墙钟截止(SECONDS)之内:剩余预算耗尽直接返回 124,否则输出 min(requested, remaining)。注意其注释指出的关键约束——SECONDS 按整秒推进,SECONDS + 1 构建的截止时间实际语义是“到下一个整秒边界”,预算可能坍缩到几乎为零,命令还没运行就收到 124。这就是该文件里所有常量都不小于 2 的原因,新增预算也应遵守此下限(详见第七节)。

二、Bash 3.2、errexit 与 pipefail 的语义陷阱

macOS 自带 Bash 3.2,且 Mole 全程运行在 nounset 之下。四个高频陷阱:

  1. 空数组展开set -u"${arr[@]}" 在数组为空时可能直接中断扫描,留下孤儿 spinner(缺陷 893b4e6f2c06cb91)。守卫写法固定为 [[ ${#arr[@]} -gt 0 ]]
  2. fn || handler 会在 fn 整个函数体内静默关闭 errexit。安全关键步骤必须改用显式的 if ! command; then return 1; fi(缺陷 a33a0b51),安装器在宣称成功前必须校验已安装二进制报告的版本,tests/install_checksum.bats 固定了这种调用形态;
  3. 不要依赖调用方的临时 set +e 窗口做优雅降级:应在命令实际执行处捕获状态;
  4. 可选动作 [[ -n "$value" ]] && action 在变量为空时返回 1:在退出码敏感的代码块里会把整块变成失败,应改用 if/fi

审计全部数组展开点:

command grep -rn '\$\{[a-z_]*\[@\]\}' lib/ bin/

三、后台任务抢占 TTY、stdin 与进程组

Perl 超时回退会在 stdin 是 TTY 时把控制终端交给被计时子进程(为了嵌套 sudo 能读密码,见 timeout.shtcsetpgrp 的实现),但这也埋下反向风险:一个后台元数据 worker 因此偷走前台进程组,随后前台的卸载确认提示被 SIGTTIN 挂起(缺陷 c93afca3,即 issue #1222/#1218 一族)。同类问题还有 BSD 的 mv/cp 在目标不可写时会向 stderr 提问并读取 stdin(63030e3a)。

Mole 的修复约定:

  • 每个调用 run_with_timeout 的后台 worker 都必须以 < /dev/null 关闭 stdin。Perl 端用 -t STDIN 判定:stdin 非终端即跳过终端交接(timeout.sh$original_pgrp 的置空逻辑);
  • 可能交互的命令一律使用非交互/强制选项;
  • 菜单与扫描的 trap 必须保存并恢复调用方的 trap,参考实现是 lib/ui/menu_paginated.sh
  • 提示前用 mole_tty_is_foreground()timeout.sh)确认 Mole 的进程组仍持有终端前台,避免嵌套交互命令把 TTY 还给父 shell 后自己触发 SIGTTIN。

这些行为被 tests/core_timeout.bats 用 expect 脚本双向固定:stdin 是 TTY 时必须交接(断言 FG == CHILD_PGRP),stdin 来自 /dev/null 时必须不交接(断言 FG == CALLER_PGRP);同时用 grep 不变量守卫 bin/uninstall.shlib/uninstall/batch.sh 中后台 worker 调用点确实带 < /dev/null

四、系统命令输出不是 API

macOS 命令输出会被本地化、随版本漂移、并在期望数据的位置打印错误文本。四条防护规则:

  • 被解析度量的子进程一律强制 LC_ALL=C(缺陷 51b352a2fa05b8cc4e83743b)。实例见 lib/check/health_json.sh:所有 awk/sed 解析行都显式携带 LC_ALL=C
  • 信任字段前先验证形状:绝对路径、数值、期望的键名或精确枚举;
  • DTSDKBuild 构建号与 DTPlatformVersion 版本号语义不同,不能混用(f0896d03);
  • 把 PlistBuddy 的“文件不存在”散文当数据拒绝;
  • 检查标志位时以 stock macOS 语义为准:BSD grep -Z--decompress,开发者 alias 可能掩盖这一点——当标志位行为重要时使用 command grep,因为交互环境可能给它加了 alias。

总原则:优先使用退出码、plist 键和机器可读输出,而不是散文匹配。

五、取消是局部的,除非编排层让它全局生效

对破坏性命令而言,超时 124 与信号衍生状态 >=128 应当取消剩余命令,而不仅仅是当前辅助函数。这个决定必须穿透每一层边界:

  • best-effort 循环在探测或登记下一个候选之前,必须先检查“挂起取消”标志;
  • 把普通未命中报告为成功的 helper,必须在开始下一个“家族”之前先返回挂起取消;
  • 命令替换、子 shell、后台 worker 都不会修改父 shell 的变量——要返回状态,或用父进程可读的显式通道,再由父进程重新落账;
  • 并行协调器必须停止并收割对等 worker,让取消状态压过普通失败,并阻止下一渲染区块启动;
  • dry-run 与真实清理遵守同一套取消契约:预览账本同样是下游工作,安全证据不可用之后不得继续。

回归测试的形态要求很具体:让第一个候选返回 124 或 130,同时让第二个候选“若被执行”必然成功;然后断言顶层精确状态,并断言第二个探测/预览登记/落盘/后续区块没有留下任何正向痕迹。若两个候选都独立超时,测试就无法证明取消是“粘性”的。最后,禁止用 || true、警告加 return 0、或 worker 本地导出变量来隐藏一次被取消的安全探测——这些形态会把“全局停止”降级成“局部跳过”。

六、聚焦陷阱速查:从 BASH_SOURCESECONDS

参考文档末尾的“Focused pitfalls”一节集中了一批短小但致命的陷阱,这里按主题整理并补充仓库内的对应证据:

1. 函数搬家时 BASH_SOURCE / $0 会改变语义。 它们指向“代码所在的文件”,所以跨文件复制粘贴不是行为保持的。mole 入口在 source 任何东西之前就固定 MOLE_ENTRY_SCRIPT="${BASH_SOURCE[0]}",更新代码读取这个稳定入口点。从源码结构看,在抽取函数前应 grep 目标函数中的 BASH_SOURCE$0FUNCNAME 三处引用;回归覆盖在 tests/update.bats

2. 每一处 du -s 都必须在 run_with_timeout 之下。 一个停滞的挂载就能卡死整个扫描;预算用 MOLE_TIMEOUT_DISK_VERIFY_SEC(默认 30 秒,见 lib/core/timeouts.sh)。

3. Bats heredoc 与 read -n1 共享 stdin。 函数内部的 read -r -s -n1 可能吃掉 heredoc 源码的下一个字节;被测函数应从 /dev/null 重定向。类似地,macOS 的 script(1) 拒绝 socket 支撑的 stdin,PTY 测试助手必须把 wrapper 的 stdin 从 /dev/null 重定向,否则 script 在启动子进程前就失败。

4. run_with_timeout 直接 exec 二进制,绕过 shell 函数 mock。osascript 这类命令,测试必须使用 PATH 桩目录而非导出同名函数。

5. CI runner 可能缺少 /Library/PrivilegedHelperTools 孤儿服务测试应使用 GitHub macOS runner 上确实存在的 /Library/LaunchDaemons

6. 测试可能因提前返回而“空洞地通过”。 MOLE_TEST_MODE=1 可能让 $output 为空,末尾的负向断言随之假通过。对策:断言以 || return 1 收尾、必要时覆盖测试模式让主体真正执行、并加一个证明输出路径执行过的正向对照。heredoc 内层脚本用 || exit 1。需要时还要用最小复现确认方括号行为——非末尾的 [[ ]] 可能被吞掉而 [ ] 仍在守门。

7. 大载荷管道进 grep -q 会向用户输出泄漏 broken-pipe 错误。 grep -q 首次匹配即退出,仍在向已关闭管道写入的 printf 收到 SIGPIPE,bash 在 stderr 打印 printf: write error: Broken pipe——实况中“live-cache owner 探测”把整张进程表这样喂出去,错误信息就落在了 mo clean 运行中途。对策:改走 here-string 传数据。仅承载几行命令输出的小变量一次写入即可完成,现有 echo "$var" | grep -q 站点不受影响。

8. 数值比较前先归一化 10# [[ a -le b ]] 按算术求值,前导零会被当八进制:0123 排到 100 下面,而 0809 根本不是合法八进制,会直接让测试以 bash 错误终止。^[0-9]+$ 守卫挡不住这两种情况。timeouts.sh 中的 $((10#$requested_whole)) 就是这个归一化手法的实例。

9. SECONDS 按整秒推进,1 秒预算不是 1 秒。 SECONDS + 1 的截止实际是“到下一整秒边界”,可能坍缩到近乎为零,_mole_timeout_with_deadline 会在命令运行前就返回 124——所有超时常量因此都不小于 2。对 clamp 算术的测试应在重置 SECONDS 后直接调用 helper;而精确值断言即使窗口放大也会和 fork 竞态,当 fork 本身是被测行为时,应断言安全区间或性质,而不是“剩余 1 秒”的字面量。

10. BSD grep 没有 GNU 的空输出 -Z 契约。 在 stock macOS 上 -Z 就是 --decompress。枚举文件用 find ... -print0,再对每个文件做 grep -qF 探测。PlistBuddy 创建不存在的文件时会把报告打印到 stdout——创建 plist fixture 时要把 stdout 和 stderr 都重定向,否则诊断散文会污染 Bats 的 $output

11. macOS 14 上的 Bash 可能通过 if 守卫的导出 mock 触发 errexit。 一个失败的导出 sudo 函数在 if fn; then 路径中,可能在 CI runner 上终止 set -e 脚本而在本地通过。对策:仅在第一处 sudo 探测周围关闭 errexit,并在 validation-gate 返回前恢复;CI 独有失败必须打印退出状态、输出与 mock 调用轨迹,而不是裸的返回码断言。

12. 预期的能力缺失不等于探测失败。 readynot applicablemisconfigured or unknown 三者必须区分:只有独立 Command Line Tools、没有 Xcode 的 Mac 没有模拟器面,simctl 缺失是静默跳过;而显式配置的非法 DEVELOPER_DIR 仍是可处理项。测试要证明 not-applicable 路径不会调用不可用的 owner 命令,也不产生告警噪音。

13. 固定宽度前缀后面可能跟着自由格式剩余字段。 验证了数值与枚举列之后,应把剩余字段连接起来,而不是给某个空白 token 赋予语义。Darwin 进程 comm 值可以含空格——早期把它裁剪到第一个词,破坏了 helper 应用的僵尸父进程归因。主格式与回退格式语义不同时,必须分开处理。

七、结语:把每一类缺陷变成一条可执行的不变量

Mole 这套 Shell 防护体系的共同模式值得复用:文档描述的每一个缺陷类,都在 tests/ 下有一条对应的“形状不变量”——du -s 必须被 run_with_timeout 包裹、后台 worker 必须 < /dev/null、Perl 回退不得出现 setsid(bin/uninstall.sh 的调用点形态必须匹配正则。新增代码只需让 grep 型测试继续通过,旧防线就不会被悄悄拆掉。对维护 lib/core/timeout.shlib/core/timeouts.shlib/ui/menu_paginated.sh 及周边模块的开发者,建议按“超时有界 → 展开有守卫 → 状态就地捕获 → TTY 有主 → 取消有粘性”的顺序自查,并以 tests/core_timeout.bats 作为整套约定的可执行规范。

登录后查看全文
热门项目推荐
相关项目推荐