Por que set -e não funciona dentro de subshells com parênteses () seguido de uma lista OU ||?


30

Eu deparei com alguns scripts como este recentemente:

( set -e ; do-stuff; do-more-stuff; ) || echo failed

Isso parece bom para mim, mas não funciona! O set -enão se aplica quando você adiciona o ||. Sem isso, funciona bem:

$ ( set -e; false; echo passed; ); echo $?
1

No entanto, se eu adicionar o ||, o set -eserá ignorado:

$ ( set -e; false; echo passed; ) || echo failed
passed

O uso de um shell separado e real funciona conforme o esperado:

$ sh -c 'set -e; false; echo passed;' || echo failed
failed

Eu tentei isso em vários shell diferentes (bash, dash, ksh93) e todos se comportam da mesma maneira, por isso não é um bug. Alguém pode explicar isso?


A construção `(....)` `inicia um shell separado para executar seu conteúdo, quaisquer configurações nele não se aplicam ao exterior.
21413 vonbrand

@ vonbrand, você perdeu o ponto. Ele deseja que ela se aplique dentro do subconjunto, mas a parte ||externa do subconjunto afeta o comportamento dentro do subconjunto.
Cjm

1
Compare (set -e; echo 1; false; echo 2)com(set -e; echo 1; false; echo 2) || echo 3
Johan

Respostas:


32

De acordo com este segmento , é o comportamento que o POSIX especifica para usar " set -e" em um subshell.

(Fiquei surpreso também.)

Primeiro, o comportamento:

A -econfiguração deve ser ignorada ao executar a lista composta após um tempo, até que, se, ou elifse uma palavra reservada, um pipeline começando com o! palavra reservada ou qualquer comando de uma lista AND-OR diferente da última.

O segundo post observa,

Em resumo, o set -e in (código de subshell) não deve operar independentemente do contexto circundante?

Não. A descrição do POSIX é clara que o contexto circundante afeta se set -e é ignorado em uma subshell.

Há um pouco mais no quarto post, também de Eric Blake,

O ponto 3 não exige que os subconjuntos substituam os contextos onde set -eé ignorado. Ou seja, quando você está em um contexto onde -eé ignorado, não há nada que possa fazer para ser -eobedecido novamente, nem mesmo um subconjunto.

$ bash -c 'set -e; if (set -e; false; echo hi); then :; fi; echo $?' 
hi 
0 

Embora tenhamos chamado set -eduas vezes (no pai e no subshell), o fato de o subshell existir em um contexto onde -eé ignorado (a condição de uma instrução if), não há nada que possamos fazer no subshell para reativar -e.

Esse comportamento é definitivamente surpreendente. É contra-intuitivo: seria de esperar que a reativação set -etivesse um efeito e que o contexto circundante não fosse precedente; além disso, a redação do padrão POSIX não torna isso particularmente claro. Se você lê-lo no contexto em que o comando está falhando, a regra não se aplica: ela se aplica apenas ao contexto circundante, no entanto, se aplica a ela completamente.


Obrigado por esses links, eles foram muito interessantes. No entanto, meu exemplo (OMI) é substancialmente diferente. A maior parte dessa discussão é se set -e em um shell pai é herdada pela subshell: set -e; (false; echo passed;) || echo failed. Não me surpreende, na verdade, que -e seja ignorado neste caso, dada a redação do padrão. No meu caso, porém, estou explicitamente definindo -e no subshell e esperando que o subshell saia com falha. Não há AND-OR lista está no subnível ...
Cientista louco

Discordo. O segundo post (não consigo fazer as âncoras funcionarem) diz " A descrição do POSIX é clara: o contexto circundante afeta se set -e é ignorado em um subshell. " - o subshell está na lista AND-OR.
Aaron D. Marasco 21/02

O quarto post (também Erik Blake) também diz " Embora tenhamos chamado set -e duas vezes (no pai e no subshell), o fato de o subshell existir em um contexto em que -e é ignorado (a condição de um if ), não há nada que possamos fazer no subshell para reativar -e. "
Aaron D. Marasco

Você está certo; Não sei bem como os interpretei errado. Obrigado.
MadScientist

1
Fico feliz em saber que esse comportamento que estou arrancando os cabelos acaba por estar nas especificações do POSIX. Então, qual é o problema? ife ||e &&são infecciosas? isso é um absurdo
Steven Lu

7

De fato, set -enão tem efeito dentro de subcascas se você usar o ||operador após elas; por exemplo, isso não funcionaria:

#!/bin/sh

# prints:
#
# --> outer
# --> inner
# ./so_1.sh: line 16: some_failed_command: command not found
# <-- inner
# <-- outer

set -e

outer() {
  echo '--> outer'
  (inner) || {
    exit_code=$?
    echo '--> cleanup'
    return $exit_code
  }
  echo '<-- outer'
}

inner() {
  set -e
  echo '--> inner'
  some_failed_command
  echo '<-- inner'
}

outer

Aaron D. Marasco em sua resposta faz um ótimo trabalho ao explicar por que se comporta dessa maneira.

Aqui está um pequeno truque que pode ser usado para corrigir isso: execute o comando interno em segundo plano e espere imediatamente por ele. O waitbuiltin retornará o código de saída do comando interno, e agora você está usando ||depois wait, não a função interna, portanto, set -efunciona corretamente dentro do último:

#!/bin/sh

# prints:
#
# --> outer
# --> inner
# ./so_2.sh: line 27: some_failed_command: command not found
# --> cleanup

set -e

outer() {
  echo '--> outer'
  inner &
  wait $! || {
    exit_code=$?
    echo '--> cleanup'
    return $exit_code
  }
  echo '<-- outer'
}

inner() {
  set -e
  echo '--> inner'
  some_failed_command
  echo '<-- inner'
}

outer

Aqui está a função genérica que se baseia nessa idéia. Ele deve funcionar em todos os shells compatíveis com POSIX se você remover localpalavras-chave, ou seja, substitua todos local x=ypor apenas x=y:

# [CLEANUP=cleanup_cmd] run cmd [args...]
#
# `cmd` and `args...` A command to run and its arguments.
#
# `cleanup_cmd` A command that is called after cmd has exited,
# and gets passed the same arguments as cmd. Additionally, the
# following environment variables are available to that command:
#
# - `RUN_CMD` contains the `cmd` that was passed to `run`;
# - `RUN_EXIT_CODE` contains the exit code of the command.
#
# If `cleanup_cmd` is set, `run` will return the exit code of that
# command. Otherwise, it will return the exit code of `cmd`.
#
run() {
  local cmd="$1"; shift
  local exit_code=0

  local e_was_set=1; if ! is_shell_attribute_set e; then
    set -e
    e_was_set=0
  fi

  "$cmd" "$@" &

  wait $! || {
    exit_code=$?
  }

  if [ "$e_was_set" = 0 ] && is_shell_attribute_set e; then
    set +e
  fi

  if [ -n "$CLEANUP" ]; then
    RUN_CMD="$cmd" RUN_EXIT_CODE="$exit_code" "$CLEANUP" "$@"
    return $?
  fi

  return $exit_code
}


is_shell_attribute_set() { # attribute, like "x"
  case "$-" in
    *"$1"*) return 0 ;;
    *)    return 1 ;;
  esac
}

Exemplo de uso:

#!/bin/sh
set -e

# Source the file with the definition of `run` (previous code snippet).
# Alternatively, you may paste that code directly here and comment the next line.
. ./utils.sh


main() {
  echo "--> main: $@"
  CLEANUP=cleanup run inner "$@"
  echo "<-- main"
}


inner() {
  echo "--> inner: $@"
  sleep 0.5; if [ "$1" = 'fail' ]; then
    oh_my_god_look_at_this
  fi
  echo "<-- inner"
}


cleanup() {
  echo "--> cleanup: $@"
  echo "    RUN_CMD = '$RUN_CMD'"
  echo "    RUN_EXIT_CODE = $RUN_EXIT_CODE"
  sleep 0.3
  echo '<-- cleanup'
  return $RUN_EXIT_CODE
}

main "$@"

Executando o exemplo:

$ ./so_3 fail; echo "exit code: $?"

--> main: fail
--> inner: fail
./so_3: line 15: oh_my_god_look_at_this: command not found
--> cleanup: fail
    RUN_CMD = 'inner'
    RUN_EXIT_CODE = 127
<-- cleanup
exit code: 127

$ ./so_3 pass; echo "exit code: $?"

--> main: pass
--> inner: pass
<-- inner
--> cleanup: pass
    RUN_CMD = 'inner'
    RUN_EXIT_CODE = 0
<-- cleanup
<-- main
exit code: 0

A única coisa que você precisa estar ciente ao usar esse método é que todas as modificações das variáveis ​​do Shell feitas a partir do comando para o qual você passa runnão serão propagadas para a função de chamada, porque o comando é executado em um subshell.


2

Eu não descartaria que é um bug apenas porque várias conchas se comportam dessa maneira. ;-)

Tenho mais diversão para oferecer:

start cmd:> ( eval 'set -e'; false; echo passed; ) || echo failed
passed

start cmd:> ( eval 'set -e; false'; echo passed; ) || echo failed
failed

start cmd:> ( eval 'set -e; false; echo passed;' ) || echo failed
failed

Posso citar o man bash (4.2.24):

O shell não sai se o comando que falhar fizer parte [...] de qualquer comando executado em um && ou || lista, exceto o comando após o final && ou || [...]

Talvez a avaliação de vários comandos leve a ignorar o || contexto.


Bem, se todas as conchas se comportam dessa maneira, por definição, não é um bug ... é um comportamento padrão :-). Podemos lamentar o comportamento como não intuitivo, mas ... O truque com a avaliação é muito interessante, com certeza.
21413 MadScientist

Qual shell você usa? O evaltruque não funciona para mim. Eu tentei bash, bash no modo posix e dash.
Dunatotatos

@Dunatotatos, como Hauke ​​disse, foi uma festa. Foi "corrigido" no bash4.3. Os shells baseados em pdksh terão o mesmo "problema". E várias versões de vários shells têm todos os tipos de "problemas" diferentes set -e. set -eestá quebrado por design. Eu não o usaria para nada além dos scripts shell mais simples sem estruturas de controle, subshells ou substituições de comandos.
Stéphane Chazelas

1

Solução alternativa quando usar o nível superior set -e

Cheguei a esta pergunta porque estava usando set -ecomo método de detecção de erros:

/usr/bin/env bash
set -e
do_stuff
( take_best_sub_action_1; take_best_sub_action_2 ) || do_worse_fallback
do_more_stuff

e sem ||, o script parava de rodar e nunca chegavado_more_stuff .

Como parece não haver uma solução limpa, acho que vou fazer apenas um simples set +eem meus scripts:

/usr/bin/env bash
set -e
do_stuff
set +e
( take_best_sub_action_1; take_best_sub_action_2 )
exit_status=$?
set -e
if [ "$exit_status" -ne 0 ]; then
  do_worse_fallback
fi
do_more_stuff
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.