管道输出并捕获Bash中的退出状态

我想在Bash中执行一个长时间运行的命令,并捕获其退出状态,并三通其输出。

所以我这样做:

command | tee out.txt
ST=$?

问题是变量ST捕获了tee的退出状态,而不是命令的退出状态。我怎么解决这个问题?

注意,该命令长时间运行,将输出重定向到文件以供以后查看,这对我来说不是一个好的解决方案。

196255 次浏览

愚蠢的解决方案:通过命名管道(mkfifo)连接它们。然后可以再次执行该命令。

 mkfifo pipe
tee out.txt < pipe &
command > pipe
echo $?

有一个数组为您提供管道中每个命令的退出状态。

$ cat x| sed 's///'
cat: x: No such file or directory
$ echo $?
0
$ cat x| sed 's///'
cat: x: No such file or directory
$ echo ${PIPESTATUS[*]}
1 0
$ touch x
$ cat x| sed 's'
sed: 1: "s": substitute pattern can not be delimited by newline or backslash
$ echo ${PIPESTATUS[*]}
0 1

有一个内部Bash变量叫做$PIPESTATUS;它是一个数组,保存最后一个前台命令管道中每个命令的退出状态。

<command> | tee out.txt ; test ${PIPESTATUS[0]} -eq 0

或者另一种也适用于其他shell(如zsh)的选择是启用pipefail:

set -o pipefail
...

由于语法略有不同,第一个选项zsh一起工作。

这个解决方案不需要使用bash特定的特性或临时文件。好处:最后,退出状态实际上是一个退出状态,而不是文件中的某个字符串。

情境:

someprog | filter

你需要someprog的退出状态和filter的输出。

以下是我的解决方案:

((((someprog; echo $? >&3) | filter >&4) 3>&1) | (read xs; exit $xs)) 4>&1


echo $?

参见我在unix.stackexchange.com上对同一个问题的回答获得详细的解释和一个没有子壳和一些警告的替代方案。

通过在子shell中结合PIPESTATUS[0]和执行exit命令的结果,您可以直接访问初始命令的返回值:

command | tee ; ( exit ${PIPESTATUS[0]} )

这里有一个例子:

# the "false" shell built-in command returns 1
false | tee ; ( exit ${PIPESTATUS[0]} )
echo "return value: $?"

会给你:

return value: 1

PIPESTATUS[@]必须在管道命令返回后立即复制到一个数组中。 PIPESTATUS[@]的任何读取将删除内容。 如果计划检查所有管道命令的状态,则将其复制到另一个数组。 $?与${PIPESTATUS[@]}的最后一个元素值相同, 读它似乎破坏了“${PIPESTATUS[@]}”,但我还没有完全验证这一点

declare -a PSA
cmd1 | cmd2 | cmd3
PSA=( "${PIPESTATUS[@]}" )
如果管道在子shell中,这将不起作用。为了解决这个问题,
看到Bash管道状态在反勾命令? < / p >

使用bash的set -o pipefail是有帮助的

pipefail:管道的返回值是状态 最后一个以非零状态退出的命令, 如果没有命令以非零状态退出,则为零

在Ubuntu和Debian中,你可以apt-get install moreutils。它包含一个名为mispipe的实用程序,它返回管道中第一个命令的退出状态。

纯壳方案:

% rm -f error.flag; echo hello world \
| (cat || echo "First command failed: $?" >> error.flag) \
| (cat || echo "Second command failed: $?" >> error.flag) \
| (cat || echo "Third command failed: $?" >> error.flag) \
; test -s error.flag  && (echo Some command failed: ; cat error.flag)
hello world

现在将第二个cat替换为false:

% rm -f error.flag; echo hello world \
| (cat || echo "First command failed: $?" >> error.flag) \
| (false || echo "Second command failed: $?" >> error.flag) \
| (cat || echo "Third command failed: $?" >> error.flag) \
; test -s error.flag  && (echo Some command failed: ; cat error.flag)
Some command failed:
Second command failed: 1
First command failed: 141

请注意,第一个cat也失败了,因为它的stdout关闭了。在本例中,日志中失败命令的顺序是正确的,但不要依赖它。

此方法允许捕获单个命令的stdout和stderr,以便在发生错误时将其转储到日志文件中,或者在没有错误时删除它(如dd的输出)。

所以我想提供一个类似lesmana的答案,但我认为我的答案可能更简单,更有优势的纯伯恩壳解决方案:

# You want to pipe command1 through command2:
exec 4>&1
exitstatus=`{ { command1; printf $? 1>&3; } | command2 1>&4; } 3>&1`
# $exitstatus now has command1's exit status.

我认为最好从内到外解释这一点——command1将在标准输出(文件描述符1)上执行并打印其常规输出,然后一旦完成,printf将执行并在其标准输出上打印icommand1的退出代码,但该标准输出被重定向到文件描述符3。

当command1运行时,它的标准输出被输送到command2 (printf的输出永远不会到达command2,因为我们将它发送到文件描述符3而不是1,这是管道读取的内容)。然后,我们将command2的输出重定向到文件描述符4,这样它也不会出现在文件描述符1中——因为我们希望文件描述符1稍后空闲,因为我们将把文件描述符3上的printf输出返回到文件描述符1中——因为这是命令替换(反勾号)将捕获的内容,也是将放入变量中的内容。

最后一点神奇之处是我们作为单独命令执行的第一个exec 4>&1——它将文件描述符4作为外部shell的标准输出的副本打开。命令替换将从标准输出中命令的角度捕获写入标准输出的任何内容——但由于command2的输出将进入文件描述符4,就命令替换而言,命令替换不会捕获它——然而,一旦它从命令替换中“退出”,它实际上仍然会进入脚本的整体文件描述符1。

(exec 4>&1必须是一个单独的命令,因为许多常见的shell不喜欢它,当你试图在命令替换中写入文件描述符时,该命令替换在使用该替换的“external”命令中打开。所以这是最简单的便携方法。)

你可以用一种不那么技术性、更有趣的方式来看待它,就好像命令的输出是相互跳跃式的:Command1管道到command2,然后printf的输出跳过命令2,这样command2就不会捕获它,然后命令2的输出跳过并跳出命令替换,就像printf及时被替换捕获一样,这样它就结束在变量中,而command2的输出继续按照它的方式被写入标准输出,就像在普通管道中一样。

而且,根据我的理解,$?仍将包含管道中第二个命令的返回代码,因为变量赋值、命令替换和复合命令对其中命令的返回代码都是有效透明的,因此command2的返回状态应该被传播出去——这一点,并且不必定义额外的函数,这就是为什么我认为这可能是一个比lesmana提出的更好的解决方案。

根据lesmana提到的警告,command1可能在某些时候最终会使用文件描述符3或4,所以为了更健壮,你会这样做:

exec 4>&1
exitstatus=`{ { command1 3>&-; printf $? 1>&3; } 4>&- | command2 1>&4; } 3>&1`
exec 4>&-

注意,在我的例子中,我使用复合命令,但使用子shell(使用( )而不是{ }也可以,尽管可能效率较低)。

命令从启动它们的进程继承文件描述符,因此整个第二行将继承文件描述符4,而3>&1后面的复合命令将继承文件描述符3。因此,4>&-确保内部复合命令不会继承文件描述符4,而3>&-也不会继承文件描述符3,因此command1得到了一个“更干净”、更标准的环境。你也可以将内部的4>&-移动到3>&-旁边,但我想为什么不尽可能地限制它的范围。

我不确定直接使用文件描述符3和4的频率有多高——我认为大多数时候程序使用返回当前未使用的文件描述符的系统调用,但有时代码直接写入文件描述符3,我猜(我可以想象一个程序检查一个文件描述符,看看它是否打开,如果打开就使用它,如果没有打开则相应的行为不同)。因此,后者可能是最好记住并用于通用情况。

基于@brian-s-wilson的回答;这个bash helper函数:

pipestatus() {
local S=("${PIPESTATUS[@]}")


if test -n "$*"
then test "$*" = "${S[*]}"
else ! [[ "${S[@]}" =~ [^0\ ] ]]
fi
}

使用:

1: get_bad_things必须成功,但它不应该产生输出;但我们希望看到它确实产生的输出

get_bad_things | grep '^'
pipeinfo 0 1 || return

2:所有管道必须成功

thing | something -q | thingy
pipeinfo || return

在bash之外,您可以执行以下操作:

bash -o pipefail  -c "command1 | tee output"

这很有用,例如在ninja脚本中,shell被期望为/bin/sh

使用外部命令有时可能比深入研究bash的细节更简单、更清晰。管道,来自最小进程脚本语言execline,退出时带有第二个命令的返回码*,就像sh管道一样,但与sh不同的是,它允许反转管道的方向,以便我们可以捕获生产者进程的返回码(下面都是在sh命令行上,但安装了execline):

$ # using the full execline grammar with the execlineb parser:
$ execlineb -c 'pipeline { echo "hello world" } tee out.txt'
hello world
$ cat out.txt
hello world


$ # for these simple examples, one can forego the parser and just use "" as a separator
$ # traditional order
$ pipeline echo "hello world" "" tee out.txt
hello world


$ # "write" order (second command writes rather than reads)
$ pipeline -w tee out.txt "" echo "hello world"
hello world


$ # pipeline execs into the second command, so that's the RC we get
$ pipeline -w tee out.txt "" false; echo $?
1


$ pipeline -w tee out.txt "" true; echo $?
0


$ # output and exit status
$ pipeline -w tee out.txt "" sh -c "echo 'hello world'; exit 42"; echo "RC: $?"
hello world
RC: 42
$ cat out.txt
hello world

使用pipeline与本地bash管道有相同的区别,就像回答# 43972501中使用的bash进程替换一样。

*实际上pipeline根本不会退出,除非出现错误。它执行到第二个命令中,所以是第二个命令执行返回。

在普通bash中最简单的方法是使用进程替换而不是管道。有几个不同之处,但它们可能对你的用例不太重要:

  • 当运行管道时,bash等待所有进程完成。
  • 向bash发送Ctrl-C使其杀死管道的所有进程,而不仅仅是主要进程。
  • pipefail选项和PIPESTATUS变量与进程替换无关。
  • 可能更

使用进程替换,bash只是启动进程并忘记它,它甚至在jobs中都不可见。

撇开前面提到的差异不谈,consumer < <(producer)producer | consumer本质上是等价的。

如果你想翻转哪个是“主”进程,你只需要将命令和替换的方向翻转到producer > >(consumer)。在你的情况下:

command > >(tee out.txt)

例子:

$ { echo "hello world"; false; } > >(tee out.txt)
hello world
$ echo $?
1
$ cat out.txt
hello world


$ echo "hello world" > >(tee out.txt)
hello world
$ echo $?
0
$ cat out.txt
hello world

正如我所说,它与管道表达式有不同之处。进程可能永远不会停止运行,除非它对管道关闭很敏感。特别是,它可能会不断向您的标准输出写入内容,这可能会令人困惑。

(command | tee out.txt; exit ${PIPESTATUS[0]})

与@cODAR的回答不同,它返回第一个命令的原始退出码,不仅成功为0,失败为127。但是正如@Chaoran指出的,你可以直接调用${PIPESTATUS[0]}。但重要的是,所有的都放在括号里。