Skip to content

Stop two cleanup handlers from turning success into failure - #38

Merged
sethbergman merged 1 commit into
mainfrom
fix/trap-exit-status
Aug 26, 2026
Merged

Stop two cleanup handlers from turning success into failure#38
sethbergman merged 1 commit into
mainfrom
fix/trap-exit-status

Conversation

@sethbergman

Copy link
Copy Markdown
Owner

A cleanup function whose last command is a false test returns 1, and bash
applies that to the script's exit status from an EXIT trap. A run that
did everything right reports failure, and an explicit exit 0 does not
save it.

cleanup() { [[ -n "$D" && -d "$D" ]] && rm -rf "$D"; }
trap cleanup EXIT
exit 0            # exits 1 when $D is empty

Both trap handlers in this repository had that shape. Probed directly:

main this PR
vault-upgrade.sh, early success path exit 1 exit 0
oidc-login-test.sh, early success path exit 1 exit 0

Latent, not live — and why that still matters

Nothing currently exits 0 before the temporary directory is created, so
every path reaching cleanup with an empty variable has already failed for
its own reasons.

It becomes live the moment someone adds "already at the target version,
nothing to do"
— a natural addition to an upgrade script, and one nobody
would connect to a cleanup function three screens away. The failure would
surface as a red CI job or a broken automation step with no error
message.

Where this actually bites

Worth being precise, because the same shape is idiomatic and harmless
nearly everywhere. Under set -e, a trailing && whose test is false:

Context Exits?
Top level no
Last statement of an if/else branch no
Last statement of a function yes

It is specifically the function-return path, and the trap that consumes
that return value.

I claimed the if/else case was a bug earlier in this work. It is not
testing all three shapes is what settled it, and my first test of the
if/else case was itself wrong because the branch never executed.

Preventing recurrence

tests/lint is a new home for invariants shellcheck has no opinion
about, wired into CI as a 24th check. Its first rule rejects this pattern
in any function registered with trap, and prints the offending line
plus the form to use. Run against main it names both handlers:

Trap handlers ending in a bare conditional:
  scripts/oidc-login-test.sh:71
      [[ -n "$COOKIE_JAR" && -f "$COOKIE_JAR" ]] && rm -f "$COOKIE_JAR"
  scripts/vault-upgrade.sh:74
      [[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR"

It only inspects functions actually named in a trap, so the harmless
top-level and if/else uses elsewhere are left alone.

Also

Two grep -c ... || echo 0 in the integration suite. grep -c prints 0
and exits 1 when it matches nothing, so the fallback appends a second
zero and the variable holds two lines — which errors the arithmetic
comparison rather than reporting an empty CA bundle. || true is correct,
since grep has already printed the count.

A cleanup function whose last command is a false test returns 1, and bash
applies that to the script's exit status from an EXIT trap. A run that
did everything right then reports failure, and an explicit `exit 0` does
not save it:

    cleanup() { [[ -n "$D" && -d "$D" ]] && rm -rf "$D"; }
    trap cleanup EXIT
    exit 0            # exits 1 when $D is empty

Both trap handlers here had that shape -- oidc-login-test.sh and
vault-upgrade.sh. Probed directly, main's versions exit 1 on an early
success path and the fixed ones exit 0.

Latent rather than live: nothing currently exits 0 before the temporary
directory is created, so every path that reaches cleanup with the
variable empty has already failed for its own reasons. It becomes live
the moment someone adds "already at the target version, nothing to do" --
a natural thing to add to an upgrade script, and one nobody would connect
to a cleanup function.

Worth being precise about where this bites, because the same shape is
idiomatic and harmless nearly everywhere. Under set -e, a trailing `&&`
whose test is false does *not* exit at the top level, and does not exit
as the last statement of an if/else branch. It is specifically the
function-return path -- and the trap that consumes that return value.
I asserted the if/else case was a bug earlier in this work and it is not;
testing all three shapes is what settled it.

tests/lint is a new home for invariants shellcheck has no opinion about,
wired into CI. Its first check rejects this pattern in any function
registered with `trap`, and reports the offending line with the
if-block form to use instead. Run against main it names both handlers.

Also fixes two `grep -c ... || echo 0` in the integration suite. grep -c
prints 0 *and* exits 1 when it matches nothing, so the fallback appends a
second zero and the variable holds two lines -- which errors the
arithmetic comparison rather than reporting an empty CA bundle. `|| true`
is the correct form, since grep has already printed the count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sethbergman
sethbergman merged commit 2962bb0 into main Aug 26, 2026
24 checks passed
@sethbergman
sethbergman deleted the fix/trap-exit-status branch August 26, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant