Mole Shell 工程实践:run_with_timeout、TTY 接管与 Bash 3.2 环境下的缺陷防护指南
Mole 的清理与卸载流程运行在 macOS 原生的 Bash 3.2 + set -euo pipefail 之上,这意味着每一个外部命令、每一次终端交互、每一条系统输出解析都暗藏边界条件。本文基于 Mole 仓库内置的缺陷参考文档 shell-and-test-pitfalls.md(其缺陷分类总览见父级 bugs 技能文档),结合 lib/core/timeout.sh、lib/core/timeouts.sh 等源码与 tests/core_timeout.bats 等测试用例,系统讲解五类高频 Shell 缺陷(无界外部命令、Bash 3.2 语义陷阱、后台任务抢占 TTY、系统输出当 API 用、取消传播不穿透)以及一组聚焦的实战陷阱,帮助维护 Shell 代码、Bats 测试、超时封装与安装/更新流程的开发者建立可复制的防护手法。
一、无界外部命令:所有耗时命令必须留在超时封装之后
du、mdfind、find、xcrun simctl、system_profiler、ioreg 以及各类包管理器命令,在一台健康但偏慢的机器上都可能长时间停滞。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 秒的
lsregisterdump 之后,若对每一行输入各做一次命令替换,总耗时仍会膨胀到分钟级; - 对已安装二进制的
--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。它按以下顺序选择后端:
gtimeout(coreutils)或timeout:最可靠,超时返回 124;- Perl 辅助进程:带独立进程组清理、父进程死亡检测(通过
getppid()变化判定被回收,防止后台 worker 被杀后命令泄漏),并用setpgid(而非setsid)把子进程放入新进程组——刻意保留控制终端,否则嵌套sudo会失去 TTY,导致 brew cask 卸载脚本中需要密码的步骤失效(issue #1003); - 纯 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 | 轻量子进程查询(df、tmutil status) |
MOLE_TIMEOUT_MEDIUM_PROBE_SEC |
5 | 偶尔触及网络或扫描目录树的较重探测 |
MOLE_TIMEOUT_PKG_LIST_SEC |
10 | 包管理器列举(brew list、simctl 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 之下。四个高频陷阱:
- 空数组展开:
set -u下"${arr[@]}"在数组为空时可能直接中断扫描,留下孤儿 spinner(缺陷893b4e6f、2c06cb91)。守卫写法固定为[[ ${#arr[@]} -gt 0 ]]; fn || handler会在fn整个函数体内静默关闭 errexit。安全关键步骤必须改用显式的if ! command; then return 1; fi(缺陷a33a0b51),安装器在宣称成功前必须校验已安装二进制报告的版本,tests/install_checksum.bats 固定了这种调用形态;- 不要依赖调用方的临时
set +e窗口做优雅降级:应在命令实际执行处捕获状态; - 可选动作
[[ -n "$value" ]] && action在变量为空时返回 1:在退出码敏感的代码块里会把整块变成失败,应改用if/fi。
审计全部数组展开点:
command grep -rn '\$\{[a-z_]*\[@\]\}' lib/ bin/
三、后台任务抢占 TTY、stdin 与进程组
Perl 超时回退会在 stdin 是 TTY 时把控制终端交给被计时子进程(为了嵌套 sudo 能读密码,见 timeout.sh 中 tcsetpgrp 的实现),但这也埋下反向风险:一个后台元数据 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.sh 与 lib/uninstall/batch.sh 中后台 worker 调用点确实带 < /dev/null。
四、系统命令输出不是 API
macOS 命令输出会被本地化、随版本漂移、并在期望数据的位置打印错误文本。四条防护规则:
- 被解析度量的子进程一律强制
LC_ALL=C(缺陷51b352a2、fa05b8cc、4e83743b)。实例见 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_SOURCE 到 SECONDS
参考文档末尾的“Focused pitfalls”一节集中了一批短小但致命的陷阱,这里按主题整理并补充仓库内的对应证据:
1. 函数搬家时 BASH_SOURCE / $0 会改变语义。 它们指向“代码所在的文件”,所以跨文件复制粘贴不是行为保持的。mole 入口在 source 任何东西之前就固定 MOLE_ENTRY_SCRIPT="${BASH_SOURCE[0]}",更新代码读取这个稳定入口点。从源码结构看,在抽取函数前应 grep 目标函数中的 BASH_SOURCE、$0、FUNCNAME 三处引用;回归覆盖在 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 下面,而 08、09 根本不是合法八进制,会直接让测试以 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. 预期的能力缺失不等于探测失败。 ready、not applicable、misconfigured 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.sh、lib/core/timeouts.sh、lib/ui/menu_paginated.sh 及周边模块的开发者,建议按“超时有界 → 展开有守卫 → 状态就地捕获 → TTY 有主 → 取消有粘性”的顺序自查,并以 tests/core_timeout.bats 作为整套约定的可执行规范。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00