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:
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ón | Nombre largo | Error silencioso que evita |
|---|---|---|
-e | errexit | Un comando que falla y el script sigue. |
-u | nounset | Una variable inexistente que se expande vacía. |
-o pipefail | pipefail | Un 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:
$ 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:
| Contexto | Ejemplo |
|---|---|
La condición de if, elif, while o until | if 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ón | echo "$(false)" |
local, export, readonly o declare con $( ) | local x=$(false) |
$ 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.
$ 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:
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:
$ 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:
$ 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ón | Qué pasa | Solución |
|---|---|---|
n=$(grep -c patrón archivo) sin coincidencias | grep 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 pipefail | La tubería «falla». | { grep … || true; } | wc -l |
(( n++ )) con n en 0 | La expresión vale 0 y da código 1: el script se detiene (tema 7). | n=$(( n + 1 )) |
comando | head -1 con pipefail | comando 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 if | Sus errores internos se ignoran. | || return 1 en cada paso. |
[[ -f x ]] && procesar como última línea | El 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). |
$ 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ón | Forma |
|---|---|
| Ignorar el fallo | comando || true |
| Hacer otra cosa si falla | comando || alternativa |
| Decidir según el resultado | if comando; then …; else …; fi |
| Guardar el código para usarlo después | codigo=0; comando || codigo=$? |
| Un valor por defecto si el comando falla | rama=$(git branch --show-current 2>/dev/null || echo "ninguna") |
| Desactivar el modo en un tramo | set +e … set -e (con cuidado) |
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:
$ 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:
#!/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ó:
set -euo pipefail
shopt -s inherit_errexit
trap 'echo "${0##*/}: falló «$BASH_COMMAND» en la línea $LINENO (código $?)" >&2' ERR
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:
- Para los comandos cuyo fallo importa y tiene un tratamiento, se comprueba explícitamente con
ifo||, como siset -eno existiera. - Para los fallos que no son errores, como un
grepsin coincidencias, se indica explícitamente con|| trueo con unif. - Para todo lo demás, los fallos que nadie previó,
set -edetiene el script antes de que haga daño, y eltrap ERRdice dónde.
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.
#!/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"
$ 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:
$ preparar-proyecto web
preparar-proyecto: falló «pendientes=$({ grep -rI --include='*.sh' -c 'TODO' src; } | awk …)» en la línea 28 (código 1)
grep -qxFen unifpregunta si la línea exacta (-x), tomada como texto literal (-F), ya está en el archivo. Dentro de la condición, que no esté no detiene el script. Con-F, el*de*.logno se interpreta como parte de una expresión regular.- El conteo de
TODOes una tubería conpipefail: sin el|| trueagrupado con llaves, la falta de coincidencias haría fallar toda la tubería. Els + 0deawkasegura un0cuando no recibe ninguna línea. - La rama de Git usa la forma «valor o alternativa»: fuera de un repositorio,
gitfalla, su mensaje se descarta y se usa el texto alternativo. - El script es repetible: ejecutarlo dos veces no duplica las entradas de
.gitignore, porque solo agrega las que faltan. Es la idempotencia del tema 27.
13. Errores frecuentes
| Escribiste | Qué pasa | Corrección |
|---|---|---|
#!/bin/bash -e como única protección | Se pierde si se ejecuta con bash script. | set -euo pipefail en el script. |
Confiar en set -e dentro de una función usada en if | Sus 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 -u | unbound variable. | "${VARIABLE_OPCIONAL:-}" |
|| true por todos lados | Se 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 dobles | Muestra siempre la línea del trap. | Comillas simples. |
14. Resumen
set -edetiene el script cuando un comando falla;set -u, cuando se lee una variable inexistente;pipefailhace que una tubería falle si falla cualquiera de sus partes.set -eno se activa en condiciones, en listas con&&y||salvo el último comando, ni con!.- Tampoco con una sustitución de comandos que no es una asignación directa, ni con
local x=$(…). - Una función usada como condición pierde
set -een todo su interior: debe comprobar sus errores con|| return 1. - Con
set -u, lo opcional se lee con un valor por defecto:"${VAR:-}". - Las trampas típicas son
grepsin coincidencias,(( n++ ))desde cero y las tuberías cortadas porhead. - Los fallos esperados se indican explícitamente:
|| true,|| alternativa, unif, ocodigo=0; cmd || codigo=$?. shopt -s inherit_errexitextiendeset -eal interior de las sustituciones de comandos.trap '…$LINENO…$BASH_COMMAND…' ERRinforma qué comando falló y en qué línea.- El modo estricto es una red de seguridad: los errores previstos se manejan explícitamente, y él atrapa los que nadie previó.
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
- Ejecuta con
bash -ccada caso de la tabla de la sección 3, con y sinset -e, y comprueba cuáles siguen adelante. - Reproduce la trampa de la función usada como condición y corrígela con
|| return 1. - Comprueba la diferencia entre
x=$(false; echo hola)con y sininherit_errexit. - Escribe un script con
set -euo pipefailque cuente las líneas conERRORde un archivo, y pruébalo con un archivo que no tenga ninguna. Corrígelo. - Agrega el
trap ERRde la sección 10 a uno de tus scripts y provoca un fallo para ver el mensaje. - Revisa los scripts de temas anteriores en busca de
local x=$(…),(( n++ ))ygrep -csin protección. - Prueba
preparar-proyectoen un directorio nuevo, en uno que ya tiene un.gitignoreparcial y en un repositorio Git.