Não é um bug. Na verdade, é um comportamento definido.
Ao usar bash -Eux , você pode ver o que acontece. ( -Eu do seu shebang + -x )
+ trap 'echo Hi' ERR
+ sub_failure
++ exit_failure
++ echo 'Hello, World!'
++ return 1
+++ echo Hi
+ res='Hello, World!
Hi'
++ echo Hi
Hi
++ echo Hi
Hi
Ao fazer a substituição do comando, o trap é herdado por causa da opção -E . Portanto, o "Hi" da interceptação herdada acionada pela função return 1 de sua função exit_failure() torna-se parte do valor armazenado em ret .
(Este também é o caso ao executar a variante usando local )
Além disso, a expressão res=... retorna 1 (erro) e aciona sua interceptação (dentro de sua função sub_failure() ).
Como res=... return 1 e o resultado de uma função é o resultado do último comando na função, o resultado de sub_failure() também é 1 (error) e seu trap é acionado novamente após sub_failure foi executado no shell principal. Então você ganha 2 "Hi" s visíveis: um para res=.... e um para sub_failure e um "Hi" oculto armazenado em $res .
Agora, para a variante local :
+ trap 'echo Hi' ERR
+ sub_failure
++ exit_failure
++ echo 'Hello, World!'
++ return 1
+++ echo Hi
+ local 'res=Hello, World!
Hi'
Por definição local sempre retorna 0 quando usado em uma função. Faz com que seu local res=... avalie para 0 (sucesso) enquanto ainda tem o "Hi" oculto armazenado em $res . E como res=.. avalia para 0 sub_failure , também retorna 0 . Então desta vez você tem uma falha "oculta" e duas vezes sucesso.
Espero que isso ajude mesmo se esse segmento estiver tranquilo;)
E também deve estar claro por que dividir local res=... em
local res
res=....
restaura o comportamento da primeira variante ..? ;)