侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

set -euo pipefail 没拦住哪些错误

2026-9-15 / 0 评论 / 16 阅读

set -euo pipefail 没拦住哪些错误

16 组用例,一个 set -euo pipefail,两个方向相反的答案:该拦住的地方它放行,跟错误无关的地方它把整段脚本带走,退出码 141。整篇的骨架就是下面那张表,表之后的每一节都在解读表里的几行。

环境是 macOS 自带的那份:

bash 版本:GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)

3.2.57 是 2011 年的版本,这份结论只在它身上成立,bash 5 本机没装,没验证。脚本存档在 tools/verify-bash-strict.sh,表里每个数字都是它在这台机器上打印出来的原值。

一张表:16 组用例各自的退出码

# 用例 退出码 结果
1 set -u 读一个未定义变量 1 停在那行,报 unbound variable
2 set -u 下 "$@" 没传参数 0 平安无事,函数照常执行
3 set -u 展开一个空数组 1 停在那行,报 arr[@]: unbound variable
4 local 后面跟命令替换 0 放行;把 local 去掉就变成 1
5 ((0)) 和 ((1)) 自己的退出码 1 / 0 set -e 不管那个 1,脚本走到尾
6 被 if 调用的函数体 0 两个 false 全放行,走 then 分支
7 false && echo 0 AND 列表豁免
8 ! false 0 取反前缀豁免
9 false | cat,没有 pipefail 0 非最后一个命令被吃掉;加上 pipefail 变 1
10 seq | head 配着 pipefail 141 SIGPIPE 当成失败上报;去掉 pipefail 变 0
11 trap ERR 配 if 和最后一句 false 1 只有最后那句独立的 false 触发
12 命令替换里的两条语句 0 / 1 只看替换里最后一条命令
13 子 shell 里的 false 1 子 shell 先退,父 shell 跟着退
14 函数体被 || 接住 0 函数体里 set -e 全程关闭
15 手工补上三处防御 0 三条正常信息
16 三种修法 0 / 0 / 141 换成 awk 的偏方照样 141

读表别按直觉来:退出码那一列里的 0 不等于这一行没事,非 0 也不等于前面那行报了错。第 10 行和第 16 行的 141 跟真正的错误毫无关系,第 4、6、7、8、9、12、14 行的 0 底下压着失败。

表里第 4 行:local 后面跟命令替换

set -e
f() {
  local out=$(false)
  echo "函数继续执行了,out 是空字符串:[${out}]"
}
f
echo "脚本正常走到底"

这段的输出是:

函数继续执行了,out 是空字符串:[]
脚本正常走到底

退出码 0,就是表里第 4 行那一格。原因是 local 是内建命令,set -e 看的是 local 自己的退出码,那次命令替换失败了它不报。把 local 去掉,同样的命令替换就会让脚本死掉,退出码 1。

这个坑在部署脚本里最要命:写成 local dump=$(pg_dump ...) 的话,备份失败也不会有人吭声,脚本接着做后面的事。

表里第 6 行:失败的命令出现在条件位置

只要命令出现在 if&&||! 里,set -e 就默认结果得自己处理,整个位置都不再触发退出。函数被这样调用时,函数体内的 set -e 也是全程关闭的:

set -e
f() {
  false
  echo "  函数里 false 之后还在跑"
  false
  echo "  函数里第二个 false 之后也还在跑"
  return 0
}
if f; then
  echo "if 走的是 then 分支"
fi

输出是两行「还在跑」加上 then 分支,退出码 0。函数里两个 false 全被放过去了。f || echo "接住" 这种写法同理,也就是表里第 14 行;false && echo 这种 AND 列表和 ! false 一样躺在这个豁免里,分别是第 7 行和第 8 行。

trap ... ERR 跟着受同样的限制。加上 trap "echo [trap ERR 触发]" ERR 之后,if false; then :; fifalse || true 都不触发,只有最后一句独立的 false 才触发,对应表里第 11 行的退出码 1。

表里第 9 行:管道里非最后一个命令

set -e
false | cat
echo "前面那个 false 被管道吃掉了,脚本继续"

没有 pipefail 时,管道的退出码取最后那个命令,cat 成功了,脚本继续走,退出码 0。加上 pipefail 之后这段立刻变成退出码 1,表里第 9 行两个数字的来历就在这里。

第 10 行的 141:pipefail 撞上 head

set -euo pipefail
seq 1 200000 | head -1
echo "seq 那一段收到 SIGPIPE,pipefail 把它当成失败上报"

实际的输出只有一行 1,退出码是 141,表里那一行就是这么来的。head 读完第一行就走了,seq 还在往管道里写,被 SIGPIPE 打死,pipefail 把这个 141 当成整条管道的失败,set -e 顺势退掉整个脚本。去掉 pipefail 就恢复正常,脚本继续跑完,退出码 0。

三种修法都试了:在管道外面接住(seq 1 200000 | head -1 || true)管用;set +o pipefail 局部关掉再打开也管用。网上有个偏方说把 head 换成 awk 'NR==1 { print; exit }' 就不会中招,实测还是退出码 141,也就是表里第 16 行最后那个数字。一样的道理,awk 提前退出同样让写端吃到 SIGPIPE。

这条最坑的地方是报错位置:日志里最后一条正常输出离出事的地方很远,看不出是管道干的。

第 13 行的 1:子 shell 把父 shell 一起带走

set -e
(echo "  子 shell 开始"; false; echo "  子 shell 里 false 之后这行不会跑")
echo "这行也不会跑"

输出只有「子 shell 开始」一行,退出码 1,表里第 13 行那一格就是它。子 shell 继承了 set -e,里面的 false 把子 shell 结束掉,父 shell 又因为子 shell 返回非 0 而退出。想隔离就显式关掉,或者把子 shell 的失败接住:

set -e
(set +e; echo "  子 shell 开始"; false; echo "  子 shell 里 false 之后还在跑")
( false ) || echo "  子 shell 失败了,被 || 接住"
echo "父 shell 走到底"

这段的退出码是 0,四行输出全打出来了。

表里 1 到 3 行:set -u 那边的三个数字

未定义变量会让脚本停在那一行,报错是 HOME_UNSET_VAR: unbound variable,退出码 1。空数组展开的待遇不一样:

set -u
arr=()
printf "数组元素=[%s]\n" "${arr[@]}"

报错 arr[@]: unbound variable,退出码 1。但同一个脚本里 "$@" 没传参数是平安无事的,函数照常执行,退出码 0,也就是表里第 2 行那一格。网上说 bash 4.4 之前的 "$@" 也会炸,3.2.57 上没这回事,只能说这份输出只适用于这个版本。

数组的修法是先判长度再展开,或者干脆别在 set -u 的脚本里用空数组做累积。

表里第 5 行:那个复现不出来的 ((i++))

有个流传很广的说法是 ((i++)) 会让 set -e 的脚本静默退出,我在这台机器上复现不出来:

((0)) 的退出码 = 1
((1)) 的退出码 = 0

((0)) 确实返回 1,但 set -e 不管它,脚本继续往下走,退出码 0,表里第 5 行的两格就是这么一对。所以在 3.2 上看到别人博客里这段「坑」,不用急着改代码。

表里第 15 行:要读的退出码都写出来

把需要读退出码的地方全部显式写出来,不指望 set -e 记着:

#!/bin/bash
set -euo pipefail

greet() {
  local who
  who=$(printf "金铭")
  echo "  局部变量 who=[${who}]"
}
greet

if ! out=$(false); then
  echo "  命令替换失败被 if 接住了"
fi

arr=()
if [ ${#arr[@]} -gt 0 ]; then
  printf "  %s\n" "${arr[@]}"
else
  echo "  数组是空的,这一支什么都不做"
fi

这段退出码 0,输出三条正常信息。三个习惯对应上面的三个坑:local 先声明后赋值,会失败的命令替换包在 if ! 里,数组先看 #arr[@]。代价是代码啰嗦,换来的是失败一定有人管。

这份表该贴到哪一类脚本上

新写的脚本、跑在 CI 里没人盯着的脚本,加上严格模式,再把上面那几个位置手工过一遍。它的价值是拦住没意识到的分支,不是替代错误处理。

在别人维护的、历史很长的老脚本上直接加 set -euo pipefail,风险大过收益。里面多半有几处依赖「命令失败但流程继续」的逻辑,加完之后脚本会在某个随机位置死掉,而那个位置和真正的错误没有任何关系。想加的话,先把 set -eset -o pipefail 分开试,一次加一个。

下次再看到脚本莫名其妙停在 141 上,先去数那条管道右边有几个会提前退出的命令,比翻日志快。

    🤞 分享