set -euo pipefail 写了,函数里失败却继续跑:errexit 的例外与 ERR trap 的继承
脚本第一行是 set -euo pipefail,函数里一条命令失败了,脚本却没有停下来;trap 'echo ERR' ERR 写在函数外面,函数内部失败时 trap 也不触发。这两个都是 man bash 里明确列出的例外,不是 bug(大部分情况下)。
-e / errexit 的例外清单
man bash 对 -e 的描述要点:
- 若管道(可能是单条简单命令、一个 list、或一个复合命令)返回非零,则立即退出。
- 但在以下上下文里 shell 不会退出:
while/until关键字后紧跟的命令列表里的命令;if语句的 test 部分;- 任何在
&&或||列表中执行的命令,除了最后一个&&或||之后的命令; - 管道中除最后一个命令以外的任何命令;
- 返回值被
!取反的命令。
- 若一个非 subshell 的复合命令因为某命令失败返回非零(此时
-e被忽略),shell 不会退出。 - 如果设置了
ERR上的 trap,shell 退出前会执行它。 - 该选项分别作用于 shell 环境与每个 subshell 环境,可能导致 subshell 在跑完之前就退出。
- 关键一句:如果一个复合命令或 shell 函数在
-e被忽略的上下文中执行,那么即使-e是打开的、命令返回失败,这个复合命令或函数体内执行的任何命令都不会受-e影响。如果函数在这种上下文里再set -e,也不会有任何效果,直到该函数调用完成。
ERR trap 遵守同一组例外
man bash 原文:
The ERR trap is not executed if the failed command is part of the command list immediately following a while or until keyword, part of the test in an if statement, part of a command executed in a
&&or||list except the command following the final&&or||, any command in a pipeline but the last, or if the command's return value is being inverted using!.
这些条件与 -e 遵守的完全一致。
常见踩法
1. if func; then ... 或 func || ...
函数体内 errexit 失效:函数里第一条失败命令不会终止脚本,函数继续跑完,返回最后一条命令的状态码。想「函数失败就回滚」的人会发现回滚逻辑根本没被触发。
2. ERR trap 不继承,要 set -E
man bash 关于 -E / errtrace:如果设置了此选项,任何 ERR 上的 trap 都会被 shell 函数、命令替换、以及子 shell 环境中执行的命令继承;通常这些情况下 ERR trap 不会被继承。所以要用 set -eE(等价 set -o errexit -o errtrace)。
没有 -E 时:函数里 ls /root 失败,因为 -e 生效,bash 直接从函数作用域退出,而那里没有生效的 ERR trap(未继承),所以 trap 不执行。
3. 命令替换 $(...) 里 -e 默认关闭
子 shell 里 -e 默认被关掉(bash 在命令替换里关闭 -e,普通 subshell () 则不是):
bash -c 'set -e; echo $(false; echo survived)'
# 输出 survived
var=$(func) 这种赋值语句的退出码是「最后一个命令替换」的退出码,所以赋值本身会带上失败状态,外层会退出;但 echo "$(func)" 的退出码是 echo 的 0,失败被吞掉。
Bash 4.4+ 可用 shopt -s inherit_errexit 让命令替换内的错误也能传播;bash 以 sh 名字调用(POSIX 模式)时该选项默认打开。
4. 函数里 trap - ERR 取消不掉
没有 set -E 时不生效(函数内 trap 操作本身有作用域/继承问题)。Bash 4.4 之前还有 t1 覆盖 t2 的 bug(已在 4.4 修复)。加上 set -E 后 trap - ERR 才在函数内按预期取消。
5. ret=$(func1) 里 func1 用 exit 1
子 shell 以 1 退出,赋值语句退出码也是 1,外层 -e 会退出。看起来对了,但原因是 exit 而非 -e,容易误判 -e 生效范围。
一组完整的 set 选项(Bash 4.4+)
#!/usr/bin/env -S bash -euET -o pipefail -O inherit_errexit
-u/-o nounset:解引用未设置变量即退出,挡拼写错误。-e/-o errexit:非零状态直接退出(有上述例外)。-o pipefail:管道任一元素失败即整体失败,而不是只取最后一条的状态码。-O inherit_errexit(=shopt -s inherit_errexit):把退出语义扩展到子 shell / 命令替换,堵住$(...)的漏洞。-E/-o errtrace:ERR trap 继承进函数体、命令替换、子 shell。注意-E只影响 trap 的继承,跟-e本身无关。-T/-o functrace:DEBUG / RETURN trap 进函数体。-S是让env接受多参数的写法。
想拿函数退出码又要函数内 errexit 生效:包装函数
callfunc() {
set +e
( set -e; "$@" )
EXIT_CODE=$?
set -e
}
callfunc bar
if [[ ${EXIT_CODE} -eq 0 ]]; then ...
优点:对函数无侵入,函数体按 errexit 写。缺点:函数在 subshell 里跑,函数内对全局/环境变量的修改只在该 subshell 生效,传不回父进程。
trap 配方
trap 'printf "\e[31m%s: %s\e[m\n" "ERR" $?' ERR
trap 'echo "Error at line $LINENO: exit $?" >&2' ERR
trap 'echo "Cleaning up..."; rm -rf "$TMPDIR"' EXIT
- ERR trap 可用
$LINENO报出错行、$?报退出码(建议输出到 stderr)。 - EXIT trap 无论正常结束还是异常退出都会执行,是临时资源清理的兜底;但它成功时也会触发,所以只在需要时用
$?判断。 eprintf/ 日志写 stderr。
静态检查:ShellCheck
- 项目地址:,官网 ,GPL-3.0,Haskell 实现,支持 sh/bash/dash/ksh,按 shebang 判方言。
- 基本用法:
shellcheck deploy.sh;按 Bash 规则加-s bash;跟踪 source 用-x -s bash main.sh。 - 高频编号:SC2086 变量未加引号、SC2046 命令替换未加引号、SC2164
cd失败未处理(补|| exit 1)、SC2181 误用$?(直接用if cmd;)、SC2154 变量未赋值、SC1090 无法解析非常量 source、SC3043 POSIX sh 未必支持某选项(如set -o pipefail)。 - CI 阻断:
shellcheck -S warning deploy.sh scripts/*.sh,非零即失败。 - 抑制单条:行上或上一行写
# shellcheck disable=SC2034;先修再 disable,disable 旁写理由,别整体disable=SC2086。 - ShellCheck 不做的事:不执行脚本,抓不到运行时(缺二进制、权限、网络、路径)错误;业务逻辑错误也抓不到。
单测:Bats
- 项目地址:,文档 ,可覆盖成功与失败双向路径。
- 与 set 选项相关:
#!/bin/sh+set -o pipefail会触发 SC3043,要么改 shebang 为 bash,要么去掉 pipefail。
不适用边界
- 需要最大可移植性(POSIX sh)时,pipefail、inherit_errexit、
[[ ]]都不是 POSIX;只能退到set -eu、[ ]。 set -e语义在 POSIX 规范里定义得含糊,各 shell 实现大同小异但有差异;用bash -x跟踪(子 shell 会打++前缀)确认实际行为,别凭直觉。- 复杂流程依赖
set -e却不配 ERR trap,等于出错后只有一句非零退出,没有上下文。 inherit_errexit和 trap 修复都需要 Bash 4.4+;老版本(如 4.3)行为不同,部署到老机器要显式检查echo $BASH_VERSION。