grep -q exits 141 under set -o pipefail, even when it matches
Under set -o pipefail, piping a large payload into grep -q can fail the whole pipeline even though grep found what it was looking for. Swapping the pipe for a here string fixes it.
- Errors or symptoms
a shell assertion fails even though the text it searches for is presenta pipeline ending in grep -q exits with status 141 under set -o pipefail- Affects
- Bash · GNU grep
- Checked
- with bash 5.3.20, GNU grep 3.12
- Depth
- one layer down
- Tags
- bash · grep · pipefail · shell · testing
The failure
My Linux Hardener project has a test suite of bash scripts, and every one of them sets set -o pipefail. Several assertions looked like this one, which checks the tool's JSON output:
scan_json=$(hardener --format json scan 2>&1)
if echo "$scan_json" | grep -q '"plugin_id"'; then
log_pass "hardener scan --format json (valid structure)"
else
log_fail "hardener scan --format json (missing plugin_id)"
fi
In a release-readiness run it printed [FAIL] hardener scan --format json (missing plugin_id) on all six test distributions, while the JSON it was checking did contain plugin_id.
Why a match can fail the pipeline
grep -q does not read its whole input. GNU grep's manual says it exits with status zero as soon as it finds a match. In a pipeline that means grep can be gone while echo is still writing the rest of the payload. The next write to a pipe with no reader gets SIGPIPE, and a process killed by a signal exits with 128 plus the signal number. SIGPIPE is 13, so echo exits with 141.
pipefail picks that up. Bash's manual says the pipeline's status is then the value of the last command, scanning from the right, to exit non-zero. grep exited 0, but echo did not, so its 141 becomes the pipeline's status. The if takes the else branch, and the assertion fails because the string is there.
Why it only shows up past a certain size
A Linux pipe holds 65,536 bytes by default. A smaller payload fits in the buffer, so echo has finished writing before grep can exit, and there is no writer left to kill. The scan JSON that tripped this was 165 KB. That size threshold is why the bug sat in the suite for months before anything crossed it.
Where the match sits matters as well. With the pattern on the last line, grep has to read everything before it can exit, so the pipe version passes too.
The fix
I swapped the pipe for a here string at every failing check:
if grep -q '"plugin_id"' <<< "$scan_json"; then
With a here string, bash itself hands the whole value to grep's standard input. No separate process is writing into a pipe, so an early exit has nothing to cut off. The same change went into every check of this shape, a shared assert_contains helper among them. Each kept its grep flags.
Check it yourself
This reproduces it without any test suite:
set -o pipefail
big=$(echo plugin_id; head -c 200000 /dev/zero | tr "\0" a)
echo "$big" | grep -q plugin_id; echo $? # 141
grep -q plugin_id <<< "$big"; echo $? # 0
Anything else that can exit early behaves the same way at the end of a pipe under pipefail, head for one. A check like this can pass for months against a small fixture, so I think it is worth trying it once with an input bigger than 64 KB.