DEV Community

Sho Naka
Sho Naka

Posted on AI-assisted

The `grep || true` Bug That Turned 'Never Sent' Into 'Not Delivered'

This article documents a bug found and fixed during an AI-assisted debugging session in the author's own photo-backup automation. The author reviewed and accepted the diagnosis and fix. An AI assistant found the bug, wrote the fix, and drafted this article; every code block was re-run today and checked against the GNU bash manual. #ABotWroteThis

Quick answer

cmd | grep pattern || true throws away cmd's exit status, not just a failed grep match. The || applies to the whole pipeline, whose exit status is grep's exit status (or the pipeline's pipefail status) -- never cmd's. If cmd fails to even start, the script has no way to know, and a later "verify" step can end up reporting a confident, specific failure for something that was never attempted. Capture ${PIPESTATUS[@]} in one assignment right after the pipeline, and use a distinct exit code for "could not send / could not verify" instead of merging it into "sent but missing."

The bug: a pipe that discarded the uploader's exit code

A personal photo-backup script uploaded items through a browser-automation step, then piped that step's output through grep to look for a success marker -- a pipe shaped like this:

uploader_cmd "$item" | grep -q "UPLOAD_OK" || true
Enter fullscreen mode Exit fullscreen mode

The || true was there so a non-matching grep (exit 1) wouldn't stop the script under set -e. That part worked as intended. What it also did, silently, was discard whatever uploader_cmd itself returned.

One day the browser automation failed to launch at all -- nothing was sent. The script still ran its next step, a "verify against the destination" check, found nothing there (correctly -- nothing had arrived), and reported a definite not delivered. A case where delivery was never attempted got reported identically to a case where delivery genuinely failed.

Why || doesn't see the first command

The GNU bash manual defines a list as:

A list is a sequence of one or more pipelines separated by one of the operators ;, &, &&, or ||...

cmd | grep pattern || true is one pipeline (cmd | grep pattern) followed by one command (true), joined by ||. The left side of || is the whole pipeline, not cmd alone. And without pipefail, a pipeline's own exit status is:

The return status of a pipeline is the exit status of the last command, unless the pipefail option is enabled.

So cmd | grep pattern || true runs true (forcing exit 0) whenever grep doesn't match -- regardless of whether cmd ran, crashed, or was never found. cmd's real result disappears at the pipe.

Reproducing it

A minimal reproduction makes this concrete. old_check.sh pipes a stub uploader through grep exactly like the original script, then unconditionally checks a separate "verify" step:

--- old_check.sh: mode=start_fail ---
uploader_stub: could not launch browser automation (binary not found)
(after pipe) $? = 0   <- always 0, uploader's real status is gone
old_check.sh: proceeding to verify step regardless of uploader status
old_check.sh: verify found no matching upload
old_check.sh: reporting NOT DELIVERED
old_check.sh exit code: 1
Enter fullscreen mode Exit fullscreen mode

The uploader stub exits 127 (simulating "binary not found," i.e. the automation never launched). Nothing was sent. The script still lands on exit 1, "NOT DELIVERED" -- indistinguishable from a real delivery failure. (When the upload genuinely succeeds, this same script correctly reports delivery; the bug is specific to the never-attempted case.)

The fix: capture PIPESTATUS in one shot -- and a gotcha

The fix reads each stage's exit code from PIPESTATUS, which the bash manual defines as:

PIPESTATUS: An array variable containing a list of exit status values from the processes in the most-recently-executed foreground pipeline (which may contain only a single command).

The gotcha: that's "the most-recently-executed" pipeline, and a plain assignment like x="${PIPESTATUS[0]}" is itself a one-command pipeline. It overwrites PIPESTATUS the moment it runs. Reading index 0 on one line and index 1 on the next line loses index 1. Verified directly:

## naive: read PIPESTATUS[0] and PIPESTATUS[1] on separate lines
PIPESTATUS[0]=0
PIPESTATUS[1]=<unset - already overwritten by the echo above>
Enter fullscreen mode Exit fullscreen mode

The fix is to grab the whole array in one assignment immediately after the pipeline:

uploader_cmd "$item" | grep -q "UPLOAD_OK"
pipe_status=("${PIPESTATUS[@]}")
upload_status="${pipe_status[0]}"
Enter fullscreen mode Exit fullscreen mode

set -o pipefail alone isn't enough either

pipefail is the more commonly recommended fix, but on its own it doesn't identify which stage failed. The manual:

If pipefail is enabled, the pipeline's return status is the value of the last (rightmost) command to exit with a non-zero status...

Reproducing the case where the uploader exits 127 and grep exits 1 (no match, since nothing was printed):

## pipefail alone still hides which stage failed
combined $? under pipefail = 1   (uploader was really 127; this shows grep's 1)
Enter fullscreen mode Exit fullscreen mode

pipefail correctly reports "something in the pipeline failed," but the value it hands back is grep's 1, not the uploader's 127. You still need PIPESTATUS to know it was specifically the uploader that never ran.

Giving each outcome its own exit code

The fixed script checks upload_status before anything else, and separates three outcomes instead of two:

State Exit code Meaning
Uploader never completed 2 Never attempted delivery. Could not verify / not sent.
Uploader ran, destination still empty 1 Genuinely sent but missing.
Uploader ran, destination confirms it 0 Delivered.

Same failure input, fixed script:

--- new_check.sh: mode=start_fail ---
uploader_stub: could not launch browser automation (binary not found)
(after pipe) PIPESTATUS = (uploader=127 grep=1)
new_check.sh: uploader never completed (exit 127) -- nothing was sent
new_check.sh: reporting SEND_BLOCKED (could not verify / not sent)
new_check.sh exit code: 2
Enter fullscreen mode Exit fullscreen mode

Downstream alerting and retry logic can now treat "we never tried" differently from "we tried and it's missing," instead of reacting to both as the same delivery failure.

A separate finding: the 'missing' upload had actually arrived

The investigation that led to this fix also turned up a separate finding: the specific item that had been reported "not delivered" earlier had, by the account in the session log, actually arrived -- the cloud photo service reported it as already uploaded/duplicate. The root cause there wasn't a lock race but the uploader's own chunk-verification step failing to confirm what it had already sent. That's a different bug from the one this article is about, but it's the same shape of mistake: treating "we couldn't confirm it" as if it were "we confirmed it failed."

A check you can run on your own scripts

your_cmd | grep pattern || true
echo "exit code after pipe: $?"
Enter fullscreen mode Exit fullscreen mode

Make your_cmd fail on purpose (point it at a nonexistent binary, or a path you don't have permission for) and run this. The echo above will print 0 every time, no matter how your_cmd failed. If any later logic in your script depends on that exit code to detect a real failure, it currently can't.

FAQ

Doesn't set -e already catch a failing command in a pipeline?

Not by default. set -e looks at the exit status of the whole pipeline (the last command's, without pipefail), so a failing first command that's followed by a successful or || true-guarded last command won't trigger it.

Why not just use set -o pipefail everywhere and skip PIPESTATUS?

pipefail is enough if you only need to know that something in the pipeline failed. It stops being enough the moment you need to know which stage failed and with what code, since it reports the last non-zero exit status in the pipeline, not a per-stage breakdown.

Why a third exit code instead of just fixing the exit-1 case?

Because "never attempted" and "attempted and failed" are different facts with different correct responses (retry-with-backoff versus alert-as-a-delivery-incident). Collapsing them into one exit code is the same mistake this bug already made once.

Top comments (0)