testmark-bash

bash-pitfalls: explain the reach of errexit suppression, with measurements.

commit 93b1f1577352ba46a1788fd20265b8578b7e9c26 · parent 0c4d3ffbad · browse this tree
Ximianth, 2026-10-25 09:48:14 UTC

The old text said set -e couldn't be a strict mode without saying why.
The why shapes the library twice over: every function checks its own exit codes,
and the runner's fresh bash per case is load-bearing, since no fork sheds the suppression.
Probed on bash 4.4 and 5.2 (identical): suppression from tested contexts is inherited
by subshells, substitutions, and async commands; substitutions lack working errexit
even called plainly; only a process boundary escapes.
diff --git a/docs/hacking/bash-pitfalls.md b/docs/hacking/bash-pitfalls.md
index 043cb1c..2cc902d 100644
--- a/docs/hacking/bash-pitfalls.md
+++ b/docs/hacking/bash-pitfalls.md
@@ -158,6 +158,38 @@ We've been unable to use `set -e` as a strict mode (unfortunately).
 
 That means an awful lot of `cmd || return 1` is required.
 
+The reason is a suppression rule with long reach.
+`set -e` is ignored for any command whose failure is being tested:
+the condition of an `if` or `while`, a command under `!`,
+or any command of an `&&`/`||` list except the last.
+So far, so documented.
+The reach is that the suppression extends dynamically into every function *called* from such a position,
+where `set -e` can neither be re-enabled nor even detected as off.
+Library code must expect calls like `testmark_check ... || status=$?`,
+so every function here has to assume `set -e` does nothing for it.
+
+The suppression is also inherited by forked children, where it likewise can't be shaken off.
+Measured, on bash 4.4 and 5.2, with `set -e` on and `f() { false; echo survived; }`:
+does `survived` print?
+
+| where `f` runs                    | called plainly | called from a tested context |
+| ---                               | ---            | ---                          |
+| the current shell                 | no             | yes                          |
+| a subshell `( f )`                | no             | yes                          |
+| an async command `f &`            | no             | yes                          |
+| a command substitution `$(f)`     | yes            | yes                          |
+| a process substitution `<(f)`     | yes            | yes                          |
+| a fresh bash, running `set -e; f` | no             | no                           |
+
+(The substitutions don't deliver a working `set -e` even when called plainly;
+for command substitution that's documented behavior, bash clearing `-e` there outside posix mode.)
+
+Only a process boundary sheds the suppression.
+This is why the runner gives every case a fresh bash process:
+a case is promised a working `set -euo pipefail`,
+and since callers legitimately write `testmarktest_run docs || exit 1`,
+no fork of the harness's shell could keep that promise.
+
 ### treat unset variables as fatal
 
 We turned `set -u` on in our library code, as a strict mode to catch as many bugs as possible.