← Automatización con Scripts en Bash Bloque 6 · Manejo de errores y scripts robustos · Tema 23 de 42

Tema 23

El modo estricto: set -euo pipefail

Desde el tema 2, todos los scripts del curso empiezan con la misma línea: set -euo pipefail. Hasta ahora la usamos como una costumbre; este tema explica qué hace cada parte, qué errores atrapa y, sobre todo, en qué casos no hace lo que uno esperaría. Bien entendido, el modo estricto convierte los errores silenciosos en fallos visibles. Mal entendido, produce scripts que se detienen sin explicación, o que siguen cuando deberían haberse detenido.

1. El comportamiento por defecto

Por defecto, Bash es tolerante: si un comando falla, sigue con el siguiente como si nada. Una variable mal escrita se expande vacía. Un fallo al comienzo de una tubería pasa inadvertido. Es un comportamiento razonable para la terminal, donde una persona lee cada error y decide; en un script que se ejecuta solo, es la receta para el desastre que vimos en el tema 1:

sin modo estricto
cd /var/tmp/compilacion         # falla: el directorio no existe…
rm -rf ./*                      # …y esto se ejecuta en otro lugar

Las tres opciones del modo estricto atacan tres formas distintas de error silencioso:

OpciónNombre largoError silencioso que evita
-eerrexitUn comando que falla y el script sigue.
-unounsetUna variable inexistente que se expande vacía.
-o pipefailpipefailUn fallo en una tubería que se pierde, porque cuenta solo el último comando.

Se activan juntas con set -euo pipefail en la primera línea de código del script, y se desactivan con +: set +e. set -o muestra el estado de todas las opciones.

2. set -e: detenerse ante un fallo

Con set -e, cuando un comando termina con un código distinto de cero, el script se detiene en ese punto, con ese mismo código:

terminal
$ bash -c 'echo antes; false; echo después'
antes
después
$ bash -c 'set -e; echo antes; false; echo después'; echo "código $?"
antes
código 1

Esto incluye las asignaciones con sustitución de comandos: x=$(comando) toma el código de comando, como vimos en el tema 5, y si falla el script se detiene. Lo que hace set -e es cambiar la pregunta por defecto: en lugar de «¿hay que comprobar si este comando falló?», la pregunta pasa a ser «¿este comando puede fallar sin que sea un problema?». Lo segundo es mucho menos frecuente, y obliga a pensarlo explícitamente.

Un detalle importante: set -e detiene el script, pero no explica por qué. El mensaje de error, si lo hay, es el del comando que falló; si el comando falla en silencio, como grep -q, el script simplemente termina. La sección 10 muestra cómo agregar esa explicación.

3. Cuándo set -e no se activa

Un código distinto de cero no siempre es un error: a veces es una respuesta. Por eso set -e ignora los fallos de los comandos que se usan como pregunta. Como adelantamos en el tema 12, no se activa con:

ContextoEjemplo
La condición de if, elif, while o untilif grep -q x f; then
Cualquier comando de una lista && o ||, salvo el último[[ -f x ]] && procesar x
Un comando precedido de !! grep -q x f
Cualquier parte de una tubería salvo la última (sin pipefail)false | true
Una sustitución de comandos que no es una asignaciónecho "$(false)"
local, export, readonly o declare con $( )local x=$(false)
terminal · todos estos casos siguen adelante
$ bash -c 'set -e; if false; then :; fi; false || true; ! true; false && echo x; echo "sigue"'
sigue

Las cuatro primeras son deliberadas y tienen sentido. Las dos últimas son las que causan sorpresas, porque un fallo real pasa inadvertido aun con set -e activo. La regla práctica: una sustitución de comandos solo está protegida si se asigna directamente a una variable, en una línea propia.

4. Funciones usadas como condición

La regla que más desconcierta es esta: cuando una función se usa como condición, set -e queda desactivado dentro de toda la función, no solo para su resultado.

terminal
$ bash -c 'set -e
  preparar() { false; echo "preparar siguió después del fallo"; }
  if preparar; then echo "preparar tuvo éxito"; fi'
preparar siguió después del fallo
preparar tuvo éxito

El false del interior debería haber detenido todo, pero como preparar se llamó dentro de un if, sus fallos internos se ignoran, y como su último comando, el echo, tuvo éxito, la función «funcionó». Lo mismo ocurre con preparar || manejar_error y con preparar && continuar.

La consecuencia práctica: una función que se va a usar en una condición no puede depender de set -e para detenerse ante sus propios errores. Debe comprobarlos explícitamente, con || return 1 después de cada paso que pueda fallar:

una función segura en cualquier contexto
preparar() {
  mkdir -p "$destino"           || return 1
  cp -- "$plantilla" "$destino" || return 1
  echo "Preparado"
}

if preparar; then …              # ahora sí, falla si falla un paso

5. set -u: variables inexistentes

Con set -u, leer una variable que no existe es un error que detiene el script, como vimos en el tema 5. Atrapa los nombres mal escritos y los argumentos que faltan:

terminal
$ bash -c 'set -u; destino=/respaldos; echo "Copiando a $destion"'
bash: line 1: destion: unbound variable
$ bash -c 'set -u; echo "Procesando $1"'
bash: line 1: $1: unbound variable

La variable que puede no existir de forma legítima, como una variable de entorno opcional o un argumento opcional, se lee con un valor por defecto, que set -u acepta: "${DEPURAR:-}", "${1:-.}". Un array vacío, "${lista[@]}", no es un error desde Bash 4.4; en versiones anteriores sí lo era, y se usaba "${lista[@]+"${lista[@]}"}", una forma que todavía se ve en scripts viejos.

6. pipefail: fallos dentro de una tubería

Como vimos en el tema 10, una tubería devuelve el código de su último comando, y los fallos de los anteriores se pierden. Con pipefail, devuelve el del último comando que falló, y set -e puede detectarlo:

terminal
$ bash -c 'set -e; cat /no/existe | sort > ordenado.txt; echo "siguió"'
cat: /no/existe: No such file or directory
siguió
$ bash -c 'set -eo pipefail; cat /no/existe | sort > ordenado.txt; echo "siguió"'
cat: /no/existe: No such file or directory

Sin pipefail, el script siguió con un ordenado.txt vacío, que el resto del script trataría como un resultado válido. Es exactamente el tipo de error que el modo estricto debe evitar. La contracara es que con pipefail algunas tuberías perfectamente correctas «fallan», como veremos en la sección siguiente.

7. Las trampas más frecuentes

Estas son las situaciones en que el modo estricto detiene un script que no tenía ningún problema real, o deja pasar uno que sí lo tenía. Conocerlas evita horas de búsqueda:

SituaciónQué pasaSolución
n=$(grep -c patrón archivo) sin coincidenciasgrep escribe 0 pero termina con 1: el script se detiene.n=$(grep -c patrón archivo || true)
grep patrón archivo | wc -l sin coincidencias, con pipefailLa tubería «falla».{ grep … || true; } | wc -l
(( n++ )) con n en 0La expresión vale 0 y da código 1: el script se detiene (tema 7).n=$(( n + 1 ))
comando | head -1 con pipefailcomando recibe la señal PIPE al cerrarse la tubería: código 141.comando | head -1 || true, o evitar la tubería.
local x=$(comando)El fallo de comando se oculta.local x y luego x=$(comando).
Una función con errores internos usada en un ifSus errores internos se ignoran.|| return 1 en cada paso.
[[ -f x ]] && procesar como última líneaEl script termina con 1 si la prueba es falsa.Un if, o exit 0 al final.
echo "Total: $(contar)"Si contar falla, el script sigue.Asignar antes: total=$(contar).
terminal · la trampa de grep -c
$ bash -c 'set -e; errores=$(grep -c ERROR app.log); echo "Errores: $errores"'; echo "código $?"
código 1
$ bash -c 'set -e; errores=$(grep -c ERROR app.log || true); echo "Errores: $errores"'
Errores: 0

El script de la primera línea terminó sin mostrar nada: ni el resultado ni un mensaje de error. Un registro sin errores, que es la mejor noticia posible, detuvo el script. Es el tipo de fallo que hace que algunos desistan del modo estricto, y el que se evita conociendo las respuestas de cada comando, como vimos en el tema 10.

8. Manejar los fallos esperados

Cuando un comando puede fallar sin que sea un problema, hay que decírselo a Bash explícitamente. Hay varias formas, según lo que se quiera hacer con el fallo:

IntenciónForma
Ignorar el fallocomando || true
Hacer otra cosa si fallacomando || alternativa
Decidir según el resultadoif comando; then …; else …; fi
Guardar el código para usarlo despuéscodigo=0; comando || codigo=$?
Un valor por defecto si el comando fallarama=$(git branch --show-current 2>/dev/null || echo "ninguna")
Desactivar el modo en un tramoset +e … set -e (con cuidado)
guardar el código sin detener el script
codigo=0
rsync -a "$origen/" "$destino/" || codigo=$?

case "$codigo" in
  0)  echo "Copia completa" ;;
  24) echo "Aviso: algunos archivos desaparecieron durante la copia" >&2 ;;
  *)  echo "rsync falló con código $codigo" >&2; exit "$codigo" ;;
esac

La forma codigo=0; comando || codigo=$? es la más versátil: el fallo no detiene el script, porque ocurre en una lista con ||, y el código queda guardado para decidir. || true, en cambio, debe usarse con mesura: cada uno es un lugar donde un error real puede pasar inadvertido. Conviene acompañarlo de un comentario que diga por qué ese fallo es aceptable.

9. Sustituciones de comandos: inherit_errexit

Hay un caso más que set -e no cubre: dentro de una sustitución de comandos con varios comandos, un fallo intermedio no detiene la subshell, y la asignación recibe el resultado de los comandos siguientes:

terminal
$ bash -c 'set -e; x=$(false; echo "parcial"); echo "x vale: $x"'
x vale: parcial
$ bash -c 'set -e; shopt -s inherit_errexit; x=$(false; echo "parcial"); echo "x vale: $x"'; echo "código $?"
código 1

Las sustituciones de comandos no heredan set -e, salvo que se active inherit_errexit, disponible desde Bash 4.4. Muchos scripts modernos lo agregan junto al modo estricto, y es lo que hará el curso de aquí en adelante:

el comienzo de un script estricto
#!/usr/bin/env bash
set -euo pipefail
shopt -s inherit_errexit

10. Informar dónde falló

Cuando set -e detiene un script, a veces no queda ningún rastro de dónde ocurrió. Un trap sobre la pseudoseñal ERR, que estudiaremos en el tema 24, ejecuta un comando cada vez que un comando falla, justo antes de que set -e detenga el script. Con las variables LINENO, el número de línea, y BASH_COMMAND, el comando que falló, el mensaje dice exactamente qué pasó:

un mensaje automático en cada fallo
set -euo pipefail
shopt -s inherit_errexit
trap 'echo "${0##*/}: falló «$BASH_COMMAND» en la línea $LINENO (código $?)" >&2' ERR
terminal
respaldo: falló «tar -czf /respaldos/web.tgz /var/www» en la línea 42 (código 2)

Las comillas simples alrededor del comando del trap son esenciales: hacen que $LINENO y las demás variables se expandan cuando ocurre el fallo, no cuando se define el trap. Para que el trap también se active dentro de las funciones, se agrega set -E.

11. ¿Vale la pena?

El modo estricto tiene detractores, con argumentos razonables: las reglas de set -e son complicadas, tienen excepciones poco intuitivas, y un script puede detenerse por motivos que no son errores. Los defensores responden que la alternativa, comprobar a mano cada comando que puede fallar, es lo que realmente debería hacerse, y casi nunca se hace.

La posición de este curso es usarlo, sabiendo que es una red de seguridad y no un sustituto de pensar en los errores:

12. Un script completo

Este script deja un directorio de proyecto listo para trabajar: crea la estructura de carpetas, asegura que el .gitignore tenga ciertas entradas, cuenta las tareas pendientes marcadas con TODO y muestra la rama de Git. Varios de sus comandos pueden «fallar» sin que sea un problema, y cada uno está tratado de forma explícita.

preparar-proyecto
#!/usr/bin/env bash
#
# preparar-proyecto — deja un directorio de proyecto listo para trabajar.
#
# Uso: preparar-proyecto DIRECTORIO
#
set -euo pipefail
shopt -s inherit_errexit

trap 'echo "${0##*/}: falló «$BASH_COMMAND» en la línea $LINENO (código $?)" >&2' ERR

dir="${1:?Uso: preparar-proyecto DIRECTORIO}"
mkdir -p -- "$dir"/{src,docs,pruebas}
cd -- "$dir"

# Agregar a .gitignore solo las entradas que falten: grep sin coincidencias es una respuesta.
touch .gitignore
agregadas=0
for entrada in '*.log' '*.tmp' '.env' 'node_modules/'; do
  if ! grep -qxF -- "$entrada" .gitignore; then
    printf '%s\n' "$entrada" >> .gitignore
    agregadas=$(( agregadas + 1 ))
  fi
done

# Contar los TODO de los scripts: que no haya ninguno no es un error.
pendientes=$( { grep -rI --include='*.sh' -c 'TODO' src || true; } \
              | awk -F: '{ s += $NF } END { print s + 0 }' )

# La rama de Git, o un texto si no es un repositorio.
rama=$(git rev-parse --abbrev-ref HEAD 2> /dev/null || echo "(sin repositorio)")

printf 'Proyecto: %s\n' "$PWD"
printf 'Entradas agregadas a .gitignore: %d\n' "$agregadas"
printf 'TODO pendientes en src: %d\n' "$pendientes"
printf 'Rama: %s\n' "$rama"
terminal
$ preparar-proyecto web
Proyecto: /home/ana/proyectos/web
Entradas agregadas a .gitignore: 4
TODO pendientes en src: 0
Rama: (sin repositorio)
$ preparar-proyecto web                # una segunda vez: no agrega nada
Proyecto: /home/ana/proyectos/web
Entradas agregadas a .gitignore: 0
TODO pendientes en src: 0
Rama: (sin repositorio)

Si se quita el || true de la línea de los TODO, el script se detiene en un proyecto sin tareas pendientes, y el trap lo explica:

terminal · sin el || true
$ preparar-proyecto web
preparar-proyecto: falló «pendientes=$({ grep -rI --include='*.sh' -c 'TODO' src; } | awk …)» en la línea 28 (código 1)

13. Errores frecuentes

EscribisteQué pasaCorrección
#!/bin/bash -e como única protecciónSe pierde si se ejecuta con bash script.set -euo pipefail en el script.
Confiar en set -e dentro de una función usada en ifSus errores internos se ignoran.|| return 1 en cada paso.
x=$(grep -c …)Cero coincidencias detienen el script.|| true dentro de la sustitución.
local x=$(cmd)El fallo de cmd no se detecta.Declarar y asignar por separado.
echo "$(cmd)"El fallo de cmd no se detecta.Asignar antes a una variable.
"$VARIABLE_OPCIONAL" con set -uunbound variable."${VARIABLE_OPCIONAL:-}"
|| true por todos ladosSe anula el modo estricto, y los errores reales vuelven a pasar.Solo donde el fallo es esperado, con un comentario.
trap "… $LINENO …" ERR con comillas doblesMuestra siempre la línea del trap.Comillas simples.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué error silencioso evita cada una de las tres opciones?

-e: que el script siga después de un comando que falló. -u: que una variable inexistente, por ejemplo mal escrita, se expanda vacía. pipefail: que el fallo de un comando intermedio de una tubería se pierda.

2. ¿Por qué set -e no detiene el script si falla el comando de un if?

Porque un comando usado como condición está haciendo una pregunta, y una respuesta negativa no es un error.

3. Con set -e, ¿se detiene el script en local x=$(false)? ¿Y en x=$(false)?

En la primera no, porque el código es el de local, que tuvo éxito. En la segunda sí, porque la asignación toma el código de false.

4. ¿Qué pasa con set -e dentro de una función llamada como if mi_funcion; then?

Queda desactivado en todo el interior de la función: un comando que falla dentro no la detiene, y la función sigue con los comandos siguientes.

5. ¿Por qué n=$(grep -c ERROR app.log) puede detener un script con set -e?

Porque cuando no hay coincidencias, grep -c escribe 0 pero termina con código 1, y la asignación toma ese código.

6. ¿Cómo se lee una variable de entorno opcional con set -u?

Con un valor por defecto, que puede ser vacío: "${VARIABLE:-}".

7. ¿Por qué yes | head -1 falla con pipefail?

Porque head termina después de leer una línea, y yes, al intentar seguir escribiendo, recibe la señal PIPE y termina con código 141. Con pipefail, ese código pasa a ser el de la tubería.

8. ¿Cómo se guarda el código de un comando que puede fallar, sin que set -e detenga el script?

Con codigo=0; comando || codigo=$?. El fallo ocurre en una lista con ||, y el código queda en la variable.

9. ¿Qué hace shopt -s inherit_errexit?

Hace que las sustituciones de comandos hereden set -e: un comando que falla dentro de $( ) detiene la subshell, en lugar de continuar con los siguientes.

10. ¿Por qué el comando de trap … ERR va entre comillas simples?

Para que $LINENO, $BASH_COMMAND y $? se expandan en el momento del fallo. Con comillas dobles se expandirían al definir el trap, y mostrarían siempre los mismos valores.

Para practicar

  1. Ejecuta con bash -c cada caso de la tabla de la sección 3, con y sin set -e, y comprueba cuáles siguen adelante.
  2. Reproduce la trampa de la función usada como condición y corrígela con || return 1.
  3. Comprueba la diferencia entre x=$(false; echo hola) con y sin inherit_errexit.
  4. Escribe un script con set -euo pipefail que cuente las líneas con ERROR de un archivo, y pruébalo con un archivo que no tenga ninguna. Corrígelo.
  5. Agrega el trap ERR de la sección 10 a uno de tus scripts y provoca un fallo para ver el mensaje.
  6. Revisa los scripts de temas anteriores en busca de local x=$(…), (( n++ )) y grep -c sin protección.
  7. Prueba preparar-proyecto en un directorio nuevo, en uno que ya tiene un .gitignore parcial y en un repositorio Git.