testmark-bash

bash-pitfalls: correct where errexit is ignored in child processes; test the case that needs a new bash process.

commit de85a85114e0cbf661b69ac1a0b9c46cbb72acee · parent 60745a01d9 · browse this tree
Ximianth, 2026-10-25 11:31:46 UTC

The table had a process substitution ignoring errexit; measured on 4.4, 5.0, 5.2, 5.3, it doesn't, called plainly or from a tested context.
It has an exception: inside an `eval` or `source` that is itself tested, bash sets a global that a process substitution keeps,
and errexit is ignored there, with `e` still in `$-`.
Only a new bash process (exec) is free of both carriers, which is the reason the runner uses one.
The doc now names which kind of child process each row is, and says why, from bash's source.

Two tests run the runner inside a tested `source`, and a tested `eval`.
Running cases in a subshell inside a process substitution instead of a new bash process fails exactly those two errexit tests.
diff --git a/docs/hacking/bash-pitfalls.md b/docs/hacking/bash-pitfalls.md
index 2cc902d..7b13ca6 100644
--- a/docs/hacking/bash-pitfalls.md
+++ b/docs/hacking/bash-pitfalls.md
@@ -168,27 +168,45 @@ 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:
+The suppression also reaches into most child processes.
+Two kinds of child process matter here, and they behave differently:
+
+- **a fork of this shell**: a subshell `( )`, an async command `&`, a command substitution `$( )`, or a process substitution `<( )`.
+  Bash forks without exec, so the child has every variable, function, and piece of internal state of its parent.
+- **a new bash process**: `bash -c ...`, or `"$BASH" file`.  Bash is exec'd afresh, and gets only the exported environment.
+
+Measured on bash 4.4, 5.0, 5.2, and 5.3, identically.
+`set -e` is on, and `f` runs `false`.
+**honored** means the `false` ended the process running `f`;
+**IGNORED** means `e` is in `$-`, and the `false` didn't end it;
+**off** means `e` is not in `$-`.
+
+| where `f` runs | called plainly | called from a tested context |
+| --- | --- | --- |
+| this shell | honored | IGNORED |
+| a subshell `( f )` | honored | IGNORED |
+| an async command `f &` | honored | IGNORED |
+| a command substitution `$(f)` | off | off |
+| a command substitution, with `shopt -s inherit_errexit` | honored | IGNORED |
+| a process substitution `<(f)` | honored | honored, except as below |
+| a new bash process running `set -e; f` | honored | honored |
+
+Why, in bash's source: being tested is a flag on each parsed command, `CMD_IGNORE_RETURN`.
+Bash copies it into compound commands, into a subshell's command, and into a function's body when it's called,
+so a subshell or async command, which runs its parent's parsed commands, inherits it.
+Both substitutions parse their text afresh;
+command substitution puts the flag back on its new commands when its caller was tested (`comsub_ignore_return`), and process substitution doesn't.
+
+The exception: `eval`, `source`, and `command`, when run in a tested context, also set a global, `builtin_ignoring_errexit`, and while it's set, `set -e` doesn't turn errexit on.
+A process substitution keeps that global.
+So a process substitution started anywhere inside a `source` or `eval` that is itself in a tested context (`source tests/foo.sh || fail`, say) runs with errexit IGNORED,
+and nothing inside it can tell, or undo it.
+A new bash process doesn't keep it: exec discards it.
+
+So the only child where errexit is honored in every case is a new bash process.
+This is why the runner gives every case one:
 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.
+and the runner can't know whether its test file was sourced or eval'd from a tested context.
 
 ### treat unset variables as fatal
 
diff --git a/tests/test-run.sh b/tests/test-run.sh
index 58b5cd9..2193616 100755
--- a/tests/test-run.sh
+++ b/tests/test-run.sh
@@ -141,6 +141,19 @@ PASS  doc.md: a/in (exercise_e)
 
 --- status 0"
 
+run_case "set -e works in a case even when the run is inside a source that's tested" "$(one $'exit status: 1\n')" '
+printf "%s\n" "exercise_e() { false; echo unreached; }" "testmarktest_rule_basic \"*/in\" exercise_e" "testmarktest_run doc.md" >"$PWD/inner.bash"
+source "$PWD/inner.bash" || :' "PASS  doc.md: a/in (exercise_e)
+--- stderr
+
+--- status 0"
+
+run_case "set -e works in a case even when the run is inside an eval that's tested" "$(one $'exit status: 1\n')" '
+eval "exercise_e() { false; echo unreached; }; testmarktest_rule_basic \"*/in\" exercise_e; testmarktest_run doc.md" || :' "PASS  doc.md: a/in (exercise_e)
+--- stderr
+
+--- status 0"
+
 run_case "pipefail is on in a case" "$(one $'exit status: 1\n')" '
 exercise_p() { false | true; echo unreached; }
 testmarktest_rule_basic "*/in" exercise_p