Skip to content

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

Draft
tmoschou wants to merge 1 commit into
zsh-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 into
zsh-users:masterfrom
tmoschou:fix/err-exit-exit-trap

Conversation

@tmoschou

@tmoschou tmoschou commented Mar 17, 2026

Copy link
Copy Markdown

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.

Compare with an explicit exit 1 which does print Trap, or replacing zsh with bash which does prints Trap. E.g.

  • zsh -e -c 'trap "echo Trap" EXIT; f() { exit 1; }; f' - prints Trap
  • bash -e -c 'trap "echo Trap" EXIT; f() { false; }; f' - prints Trap

Cases 1-3 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 user expectation that ZSH traps follow "compatible-ish" semantics as bash especially when porting scripts to ZSH.

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
@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
@Jun-T

Jun-T commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Is this still a 'Draft'?
Is it possible to post this to the zsh-workers mailing list? Most of zsh developers rarely (or never) read github PR.

@tmoschou

tmoschou commented Apr 5, 2026

Copy link
Copy Markdown
Author

Hi @Jun-T thanks for the note. I plan to do anther self review very soon but there shouldn't be any more changes that I can see. I do plan on emailing a patch on zsh-workers mailing list very soon also, hopefully all done by sometime tomorrow. I'll mark this PR for ready for review when I submit patch and cross reference with email review thread.

Use exit_pending unwind instead of calling realexit()/zexit()
directly when ERR_EXIT or ${var:?} triggers inside a function.
In endtrapscope(), convert errflag-driven exits to exit_pending
for NOUNSET and other zerr()-based errors.  Clear errflag before
running EXIT traps, matching what zexit() already does.

Also extract set_exit_pending() and restore_saved_trap() helpers.
@tmoschou
tmoschou force-pushed the fix/err-exit-exit-trap branch from c9055c9 to 84e00a6 Compare April 6, 2026 08:12
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.

2 participants