Your first conclusion was right: the ERR signal occurred twice.
During the first execution of 'd', you define a trap globally. This affects the following commands (the current call to d has no effect). During the second execution of "d", you again define the trap (not very useful), calling "false" fails, so we execute the handler defined by the trap. Then we return to the parent shell, where the "d" also fails, so we again run the trap.
Just a comment. ERR can be given as an argument to "sigspec", but ERR is not a signal ;-) From the BASH manual:
If a sigspec is ERR, the command arg is executed whenever a simโ ple command has a non-zero exit status, subject to the following conditions. [...] These are the same conditions obeyed by the errexit option.
Using the 'e' function, the ERR handler executes the echo command, which succeeds. Therefore, the function "e" is not interrupted, therefore, in this case, the ERR handler is not called twice.
If you try "e; echo $?" you will read "0".
Then I tried your function 'f'. I observed the same behavior (and I was surprised). The reason is NOT the bad value of $ s. If you try to make a hardcode value, you should notice that the argument passed to the return statement is ignored when it is executed by the trap handler.
I donโt know if the normal behavior is or if it is a BASH error ... Or maybe a trick to avoid an infinite loop in the interpreter :-)
By the way, this is a bad use of the trap, in my opinion. We can avoid the side effect of the trap by creating a sub-shell. In this case, we avoid the error in the parent shell, and we save the exit code of the internal function:
g() ( trap 'return' ERR false echo hi )