The guard that stopped at the parenthesis
- #errexit
- #traps
- #subshell
- #bugfix
Last iteration taught huck that something || handler means the failure
inside something doesn't count. This one is about the word inside, and
where huck stopped believing it.
set -e
( check_config; apply ) || rollbackParentheses are how you group a couple of steps without giving them a name.
bash runs the group, sees it fail, and runs rollback. huck ran the first
step, tripped set -e, and killed the shell — no rollback, no message,
exit status 1. Drop the parentheses and the same script worked. That is a
strange thing to have to know about your shell.
The same crack ran through every shape that tests a command:
set -e; ( false; echo x ) || echo or # bash: x huck: or
set -e; if ( false; echo x ); then :; fi # bash: x huck: (nothing)
trap "echo E" ERR; ( false; echo x ) || : # bash: x huck: E, then xFive out of five wrong the moment a subshell was involved; the identical shapes without one had all been right since v355.
Not a propagation bug
The obvious theory is that the exemption fails to reach the child. It doesn't. A subshell is a fork, and a fork copies the parent's memory — the "this failure doesn't count" flag arrives in the child perfectly intact. huck then deleted it, in the first thing the child did:
// clear_for_subshell — reset what a forked child cannot service
shell.traps.clear();
shell.errexit_suppressed_depth = 0; // <- these two
shell.err_trap_suppressed_depth = 0; // <-That function exists for a real rule: a child cannot run its parent's signal handlers, so caught traps go back to their default. The suppression counters were sitting next to the trap table, had a name that read like a trap field, and got swept up with them. A naming accident, not a decision.
So the fix is two deleted lines — but only if the answer to "should a
subshell start clean?" is no. We went and measured rather than argued. A
bash subshell inherits the entire option set: $- is byte-identical
across the fork, every shopt is unchanged, set -e -u -x all survive.
What it resets is narrow and is not an option at all — caught traps drop to
default, while ignored traps stay ignored. There is no clean-slate principle
to appeal to. A subshell is a copy of the shell that can't service its
parent's handlers. Everything else travels, including the caller's statement
that this command's failure doesn't count.
The part that wasn't a bug
The rest of the iteration was the same question asked in fewer places. huck
decided "is this failure being ignored?" four different ways — a counter, a
second counter, an is_last flag threaded through the epilogue, and the
list walker's own idea of position. Two of those were the same fact
travelling by different routes, so the ERR-trap gate read:
if c != 0 && !shell.err_trap_suppressed() && is_last && !is_negated_pipeline(cmd) {An and-or list now runs each element through one function that owns the
whole sequence — raise the exemption, run the command, check for interrupts,
propagate control flow, run the epilogue, lower the exemption. Because the
scope now covers the epilogue, the epilogue can just ask the shell, and
is_last disappears:
if c != 0 && !shell.err_trap_suppressed() && !is_negated_pipeline(cmd) {There's a safety property hiding in that shape. Previously the raise and the
lower sat on either side of five early returns, and getting one wrong would
have silently disabled set -e for the rest of the list — a bug that
produces no error, just a script that keeps going after it should have
stopped. With a single exit path, that mistake can't be written.
The counters themselves stayed two, which surprised us. They look redundant
until you meet the shape that separates them: ! over a brace group fires
the body's ERR trap with set +e and stays silent with set -e. One scope
can't say both things, so the two counts are load-bearing — they just moved
behind a named type with private fields, which is what stops the next
refactor from sweeping them into the trap reset all over again.
After
$ huck -c 'set -e; ( false; echo x ) || echo or'
x
$ huck -c 'set -e; if ( false; echo x ); then :; fi'
x
$ huck -c 'set -e; ( false ); echo after' # still exits — nothing guards it
$ huck -c 'trap "echo E" ERR; ( false )'
EThe two lines that guarded nothing are gone, and the ones that guard something still do.