Skip to content

Fix EXIT trap not called when shell is exiting under numerous circumstances#164

Draft
tmoschou wants to merge 1 commit intozsh-users:masterfrom
tmoschou:fix/err-exit-exit-trap
Draft

Fix EXIT trap not called when shell is exiting under numerous circumstances#164
tmoschou wants to merge 1 commit intozsh-users:masterfrom
tmoschou:fix/err-exit-exit-trap

Conversation

@tmoschou
Copy link

@tmoschou tmoschou commented Mar 17, 2026

Problem

EXIT traps are never called when the shell is exiting under numerous circumstances.

  1. ERR_EXIT (set -e) set and a command fails inside a function
    zsh -e -c 'trap "echo Trap" EXIT; f() { false; }; f'

  2. ${unsetvar?message}/${unsetvar:?message} used inside a function in a non-interactive shell
    zsh -c 'trap "echo Trap" EXIT; f() { echo ${unset?oops}; }; f'

  3. NO_UNSET (set -u) set and an unset variable is referenced (inside a function or not)
    zsh -u -c 'trap "echo Trap" EXIT; echo $unset'

In all cases, expected: Trap on stdout. Actual: Trap missing.

This contradicts the documentation and user expectation that EXIT can be relied on for clean-up (e.g. lock files, temporary files, etc.) when the shell is exiting. Additionally bash handles these cases as you would expect, always firing the EXIT trap.

Zsh also fails to fire nested local-scoped EXIT traps in these cases. However swapping false for exit 1 does cause all EXIT traps to fire correctly bottom-up:

set -e
trap "echo Trap1" EXIT
f1() {
    trap "echo Trap2" EXIT
    f2() {
        trap "echo Trap3" EXIT
        false  # replace with `exit 1` to see all traps fire
    }
    f2
}
f1
  • Expected: Trap3, Trap2, Trap1
  • Actual: Trap3

Note that EXIT traps specifically are local by default (LOCA_TRAPS behavior), unless POSIX_TRAPS is set.

Root cause

When a function is entered, starttrapscope() saves and unsets sigtrapped[SIGEXIT]. Normally endtrapscope() restores it when the function returns.

ERR_EXIT: When ERR_EXIT triggers inside a function, execlist() called realexit() directly — bypassing endtrapscope() — so saved EXIT traps were never restored or executed.

${var:?}: The paramsubst() handler called _exit(1) (subshell) or zexit() (main process) directly, similarly bypassing the function unwind and endtrapscope(). Additionally, errflag (set by zerr()) prevented dotrapargs() from executing the EXIT trap even when the unwind did reach endtrapscope().

NO_UNSET: paramsubst() and getmathparam() set errflag via zerr() and returned, but errflag blocked dotrapargs() in endtrapscope() from running EXIT traps at each function level. The shell eventually exited via the main loop without unwinding through function scopes.

Fix

Reuse the same exit_pending unwind mechanism as the exit builtin, so that endtrapscope() runs at each function level and all nested EXIT traps are executed bottom-up, matching the behaviour of an explicit exit call.

  • ERR_EXIT / ${var:?}: call set_exit_pending() instead of realexit() / zexit() when inside a function, so the exit unwinds through each scope.
  • NO_UNSET and other zerr()-based errors: in endtrapscope(), convert errflag-driven exits to exit_pending in non-interactive shells, so all EXIT traps fire during the unwind. This is a single central check rather than patching each error site.
  • Extract set_exit_pending() to consolidate the repeated unwind setup (trap_state, retflag, breaks, exit_pending, exit_level, exit_val).
  • Extract restore_saved_trap() from endtrapscope() to reduce duplication in the trap restoration logic.
  • Clear errflag in endtrapscope() when exit_pending, so EXIT traps can fire even when the exit was triggered by an error. This mirrors what zexit() already does before calling dotrap(SIGEXIT).

Test plan

  • Added regression tests in Test/C03traps.ztst for ERR_EXIT, ${var:?}, and NO_UNSET — both top-level and nested function cases
  • Full test suite passes (67 successful, 1 pre-existing failure in E02, 2 skipped)
  • Manual verification — all now print Trap:
    ./Src/zsh -f -o err_exit -c 'trap "echo Trap" EXIT; f() { false; }; f'
    ./Src/zsh -f -c 'trap "echo Trap" EXIT; f() { echo ${unset:?oops}; }; f'
    ./Src/zsh -f -u -c 'trap "echo Trap" EXIT; echo $unset'
    ./Src/zsh -f -u -c 'trap "echo Trap" EXIT; f() { echo $unset; }; f'
    
  • Verified POSIX_TRAPS still behaves correctly (EXIT traps are global, not function-scoped):
    ./Src/zsh -f -o posix_traps -e -c 'trap "echo A" EXIT; f() { trap "echo B" EXIT; }; f; false'

@tmoschou tmoschou marked this pull request as draft March 17, 2026 07:44
@tmoschou tmoschou force-pushed the fix/err-exit-exit-trap branch 2 times, most recently from 359b62d to dc7a405 Compare March 19, 2026 05:36
@tmoschou tmoschou changed the title Fix EXIT trap not firing when ERR_EXIT triggers inside a function Fix EXIT trap not firing when ERR_EXIT or ${var:?} triggers inside a function Mar 19, 2026
@tmoschou tmoschou force-pushed the fix/err-exit-exit-trap branch from dc7a405 to 32e463f Compare March 20, 2026 06:25
@tmoschou tmoschou changed the title Fix EXIT trap not firing when ERR_EXIT or ${var:?} triggers inside a function Fix EXIT trap not firing when ERR_EXIT, ${var:?}, or NOUNSET triggers inside a function Mar 20, 2026
When ERR_EXIT triggers inside a function, the errexit code path
called realexit() directly, bypassing endtrapscope() and skipping
any EXIT traps saved by starttrapscope() on function entry.

Similarly, when ${var:?message} triggers inside a function, the
paramsubst code path called _exit(1) or zexit() directly,
bypassing the function unwind and skipping EXIT traps at outer
scopes.

Reuse the same exit_pending unwind mechanism as the exit builtin,
so that endtrapscope() runs at each function level and all nested
EXIT traps are executed bottom-up, matching the behaviour of an
explicit exit call.

Extract set_exit_pending() to consolidate the repeated unwind
setup (trap_state, retflag, breaks, exit_pending, exit_level,
exit_val) used by the exit builtin, the ERR_EXIT handler, and
the new ${var:?} handler.

Extract restore_saved_trap() from endtrapscope() to reduce
duplication in the trap restoration logic.

Clear errflag in endtrapscope() when exit_pending, so that
EXIT traps can fire even when the exit was triggered by an error
(e.g. ${var:?}).  This mirrors what zexit() already does before
calling dotrap(SIGEXIT).

In endtrapscope(), convert errflag-driven exits to exit_pending
in non-interactive shells so that NOUNSET and other zerr()-based
errors also unwind through endtrapscope() properly.
@tmoschou tmoschou force-pushed the fix/err-exit-exit-trap branch from 32e463f to c9055c9 Compare March 20, 2026 06:29
@tmoschou tmoschou changed the title Fix EXIT trap not firing when ERR_EXIT, ${var:?}, or NOUNSET triggers inside a function Fix EXIT trap not firing under numerous circumstances Mar 20, 2026
@tmoschou tmoschou changed the title Fix EXIT trap not firing under numerous circumstances Fix EXIT traps are not called when the shell is exiting under numerous circumstances Mar 20, 2026
@tmoschou tmoschou changed the title Fix EXIT traps are not called when the shell is exiting under numerous circumstances Fix EXIT trap not called when shell is exiting under numerous circumstances Mar 20, 2026
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