Al cerrar 05-02 quedó identificado el agujero que sigue teniendo informe-diario.sh: si algo falla dentro, el script no se entera. Bash, por diseño, ejecuta la línea siguiente pase lo que pase con la anterior. Si el grep sobre el CSV no encuentra nada, si una variable está mal escrita y se expande a la cadena vacía, si el disco se llena a media escritura, el script sigue adelante y produce un informe silenciosamente falso —lo peor que puede hacer una herramienta de operaciones, porque nadie sospecha de un número equivocado que llega puntual cada mañana—. Y además deja atrás el temporal de mktemp que en 05-01 prometimos aprender a limpiar. Esta lección le da a Bash la disciplina que no trae de serie, y te da a ti las herramientas para ver qué está pasando cuando algo no cuadra.

Contenido

  1. El fallo silencioso
  2. set -e: qué detecta y qué no
  3. set -u: variables no definidas
  4. set -o pipefail y PIPESTATUS
  5. set -euo pipefail y su crítica honesta
  6. trap: señales y pseudoseñales
  7. Limpieza garantizada con EXIT
  8. morir() y trap ... ERR con contexto
  9. Convenio de códigos de salida
  10. Reintentos con retroceso
  11. Depuración: -n, -x, PS4, -v, DEBUG y bisección
  12. informe-diario.sh endurecido

  1. El fallo silencioso

#!/usr/bin/env bash
cd /srv/veloz/datos/no-existe      # falla: "No such file or directory"
rm -rf ./*                         # se ejecuta IGUALMENTE, en el directorio actual

Ese ejemplo, con variantes, ha destruido servidores de verdad. Bash reporta el error en stderr y continúa. Contra eso hay tres interruptores.

  1. set -e: qué detecta y qué no

set -e (o set -o errexit) hace que el script termine en cuanto un comando devuelva un código distinto de cero: con él al principio, el ejemplo anterior muere en el cd y nunca llega al rm. Lo importante es saber qué no detecta, porque las excepciones son muchas y deliberadas: un comando cuyo fallo forme parte de una decisión no debe matar el script.

Situación ¿set -e aborta? Por qué
grep patron f sin coincidencias Devuelve 1 y no está protegido
if grep -q patron f; then No Es la condición de un if
while ! ping -c1 host; do No Es la condición de un bucle
grep -q x f && echo hay No Salvo que falle el último de la cadena
`comando true`
! comando No Está negado
f() { falso; }; if f; then No La función entera pierde el -e dentro de un if
falso | wc -l No Solo cuenta el último de la tubería (sección 4)
(( contador++ )) con contador a 0 Sorpresa clásica, ya vista en 04-06
local x=$(falso) No El código es el de local, no el de la sustitución

Las dos últimas filas son las que más quebraderos dan. La de local tiene solución: declara y asigna en líneas separadas (local fecha y luego fecha=$(date -d "$1" +%F)), y así el fallo sí se detecta. Y la penúltima —una función pierde set -e en su interior cuando se llama desde un if o desde un &&— es la razón principal por la que hay quien desconfía de set -e. No es un error de Bash: es POSIX. Pero significa que no puedes delegar toda tu gestión de errores en -e.

  1. set -u: variables no definidas

Bajo set -u, un echo "Informe de $CIUDADD" con la variable mal escrita aborta con bash: CIUDADD: variable sin asignar. Sin -u, esa errata se expande a la cadena vacía y produce rm -rf "$DIR_BASE/" con DIR_BASE vacío, es decir rm -rf /. Convive perfectamente con los valores por defecto de 03-06, que son la forma explícita de decir "esta puede no existir":

nivel="${VELOZ_LOG_NIVEL:-info}"      # por defecto si no está definida
destino="${1:?falta el destino}"      # error propio con mensaje claro
[[ -n "${DEBUG:-}" ]] && set -x       # el :- es obligatorio bajo set -u

Cuidado con los arrays: bajo -u y en Bash anterior a 4.4, "${arr[@]}" sobre un array vacío aborta. El idioma seguro es "${arr[@]:-}" o comprobar antes con (( ${#arr[@]} > 0 )).

  1. set -o pipefail y PIPESTATUS

En 02-04 quedó aplazado esto: el código de salida de una tubería es el de su último comando.

zcat /var/log/veloz/no-existe.gz | wc -l
echo $?     # 0  ← ¡éxito! porque wc -l funcionó perfectamente (imprimió 0)
set -o pipefail
zcat /var/log/veloz/no-existe.gz | wc -l
echo $?     # 1  ← ahora sí

Un fallo así en informe-diario.sh produce "0 errores hoy" cuando la verdad es "no pude leer el fichero". pipefail lo arregla: la tubería devuelve el código del último comando que falló, o 0 si ninguno falló. Cuando necesitas saber cuál de los eslabones falló, el array PIPESTATUS guarda todos los códigos, en orden:

zcat acceso.log.2.gz | grep ' 500 ' | wc -l
echo "${PIPESTATUS[@]}"     # 0 1 0  ← el grep no encontró nada (código 1)

PIPESTATUS se sobrescribe con cada comando, incluido un echo. Si vas a usarlo, cópialo primero: local -a codigos=( "${PIPESTATUS[@]}" ).

  1. set -euo pipefail y su crítica honesta

La cabecera que verás en casi todo script serio es set -euo pipefail justo bajo el shebang, a veces acompañada de un IFS=$'\n\t' restrictivo (03-06). Es un buen valor por defecto y deberías usarlo. Pero conviene decir con claridad por qué no es magia:

  • set -e tiene el agujero de las funciones llamadas desde if, así que las funciones críticas deben comprobar sus propios errores.
  • Aborta con un mensaje del comando que falló, sin decir en qué línea de tu script ocurrió. Eso lo arregla el trap ... ERR de la sección 8.
  • Convierte cualquier código distinto de cero en fatal, y hay comandos donde eso es normal: grep sin coincidencias, diff con diferencias, pgrep sin procesos. Hay que marcarlos explícitamente.

Para esos casos, tres idiomas:

grep -c ERROR "$LOG" || true            # "puede devolver 1, es aceptable"
if ! errores=$(grep -c ERROR "$LOG"); then errores=0; fi   # mejor: distingue el caso
set +e; comando_que_falla_a_menudo; codigo=$?; set -e       # desactivar puntualmente

La regla: || true para el caso trivial, if ! cuando quieres reaccionar, y set +e/set -e acotado a las mínimas líneas posibles. Un set +e al principio de un script largo es peor que no haber puesto nunca -e, porque da falsa sensación de seguridad.

  1. trap: señales y pseudoseñales

trap 'comandos' SEÑAL [SEÑAL...] registra código que se ejecuta cuando llega una señal. Sus variantes: trap - SEÑAL restaura el comportamiento por defecto, trap '' SEÑAL la ignora y trap -p lista las trampas activas.

Además de las señales reales de 05-02, Bash define cuatro pseudoseñales:

Pseudoseñal Se dispara Uso típico
EXIT Al salir el script, por la vía que sea Limpieza garantizada
ERR Cuando un comando devuelve código ≠ 0 (mismas excepciones que -e) Informar de dónde falló
DEBUG Antes de cada comando Depuración fina
RETURN Al volver de una función o de un source Perfilado, trazas

Usa comillas simples en el código de la trampa. trap 'rm -f "$tmp"' EXIT es correcto porque $tmp se resuelve al dispararse; con comillas dobles se expandiría al registrar la trampa y capturarías el valor equivocado.

  1. Limpieza garantizada con EXIT

Aquí se cierra lo prometido en 05-01 y 05-02:

readonly TMPDIR_INFORME=$(mktemp -d)
limpiar() {
    local codigo=$?                     # capturar ANTES de hacer nada
    rm -rf "$TMPDIR_INFORME"
    exit "$codigo"                      # preservar el código original
}
trap limpiar EXIT

EXIT se dispara con exit, al terminar la última línea, cuando set -e aborta y cuando llega una señal capturada. Dos detalles que la gente olvida: el local codigo=$? debe ser la primera línea de la función, porque cualquier comando anterior lo sobrescribe; y el exit "$codigo" final evita que la limpieza convierta un fallo en un éxito aparente. Para que Ctrl-C también pase por ella, añade las señales: trap limpiar EXIT INT TERM HUP.

  1. morir() y trap ... ERR con contexto

Todo script serio tiene una función para abortar con mensaje. La convención: mensaje a stderr, código de salida significativo.

# morir — Escribe un mensaje de error y termina. Uso: morir <código> <mensaje...>
morir() {
    local codigo="${1:?}"; shift
    printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2
    exit "$codigo"
}
validar_entorno() {
    [[ -r "$RUTA_CSV" ]] || morir 66 "No puedo leer $RUTA_CSV"
    command -v bc > /dev/null || morir 69 "Falta la utilidad bc"
}

Y para los fallos que no previste, una trampa ERR que diga exactamente dónde ocurrió:

trap 'printf "[FATAL] %s:%d en %s(): código %d\n" "${BASH_SOURCE[0]}" \
      "$LINENO" "${FUNCNAME[0]:-main}" "$?" >&2' ERR

Las tres variables que dan el contexto son las que convierten un mensaje inútil en un diagnóstico:

Variable Contenido
$LINENO Número de línea actual
${BASH_SOURCE[0]} Fichero donde está esa línea (clave con librerías, 05-06)
${FUNCNAME[0]} Función en curso; el array completo es la pila de llamadas

FUNCNAME es un array: ${FUNCNAME[1]} es quien llamó a la función actual, y recorriéndolo junto con BASH_LINENO se imprime una traza de pila completa. Eso sí: para que la trampa ERR se herede dentro de funciones y subshells hace falta set -E (o set -o errtrace); sin él, las funciones no la disparan.

  1. Convenio de códigos de salida

En 03-01 viste exit N. Ahora el convenio, que importa cuando otro script o cron lee tu resultado:

Código Significado
0 Éxito
1 Error genérico
2 Uso incorrecto: faltan argumentos u opción desconocida
64 EX_USAGE: error en la línea de comandos
65 EX_DATAERR: los datos de entrada son incorrectos (CSV corrupto)
66 EX_NOINPUT: el fichero de entrada no existe o no se puede leer
69 EX_UNAVAILABLE: un servicio o dependencia no está disponible
73 EX_CANTCREAT: no se puede crear el fichero de salida
78 EX_CONFIG: error de configuración
124 / 126 timeout expiró (05-02) / el fichero existe pero no es ejecutable
127 / 130 Comando no encontrado (no está en el PATH) / Ctrl-C (128 + 2, SIGINT)

Los del rango 64-78 vienen de sysexits.h de BSD; no todo el mundo los usa, pero son mejor convenio que inventar números. Y la regla 128 + N para señales explica de un vistazo el 137 (128+9, SIGKILL) y el 143 (128+15, SIGTERM). Reserva los códigos por encima de 125 y no uses valores mayores de 255: se truncan módulo 256, así que exit 256 es exit 0.

  1. Reintentos con retroceso

Los fallos de red son transitorios por naturaleza: reintentar es correcto, pero reintentar de inmediato y sin límite es un ataque contra tu propio servidor. El patrón es retroceso exponencial:

# reintentar — Ejecuta un comando con espera creciente. Uso: reintentar <n> <comando...>
reintentar() {
    local intentos="${1:?}" n=1 espera=1; shift
    until "$@"; do
        (( n >= intentos )) && { printf 'Fallo tras %d intentos\n' "$n" >&2; return 1; }
        sleep "$espera"; (( espera *= 2 )); (( ++n ))
    done
}
reintentar 5 timeout 10 curl -sf http://localhost:8080/envios

Las esperas son 1, 2, 4, 8 segundos. Nótese la combinación con timeout de 05-02: sin él, un intento colgado impide llegar al siguiente. El uso real con curl y APIs llega en 06-04 y 06-05.

  1. Depuración: -n, -x, PS4, -v, DEBUG y bisección

bash -n informe-diario.sh      # solo comprueba la SINTAXIS, no ejecuta nada
bash -x informe-diario.sh      # traza: imprime cada comando ya expandido
bash -v informe-diario.sh      # imprime cada línea tal cual, antes de expandir

bash -n debería ser un reflejo antes de guardar cualquier script que vaya a cron: detecta el fi que falta sin ejecutar el rm. Y set -x / set +x acotan la traza a la parte sospechosa, que es lo que la hace usable en un script largo.

La traza sale a stderr precedida de +. Ese prefijo es la variable PS4, y enriquecerla convierte una traza ilegible en un diagnóstico:

export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): '
exec 8> ~/veloz-ops/logs/traza.$$
export BASH_XTRACEFD=8       # la traza va al descriptor 8, no a stderr
set -x
acumular_dia "$fecha"
set +x

La traza resultante tiene esta forma —+ informe-diario.sh:91:acumular_dia(): [[ 2026-08-03 == 2026-08-03 ]]—: cada línea dice fichero, línea y función. Y BASH_XTRACEFD la desvía a un fichero para que no se mezcle con la salida del informe (el descriptor 8 es de la familia que verás a fondo en 05-05). Por su parte, trap 'printf "línea %d: total=%s\n" "$LINENO" "${total:-}" >&2' DEBUG inspecciona el estado antes de cada comando, útil para cazar dónde cambia una variable.

Cuando nada de esto basta, queda la bisección: colocar exit 99 a mitad del script, ver si el problema aparece, mover el exit a la mitad de la mitad que corresponda y repetir. Con cinco o seis ejecuciones localizas la línea culpable de un script de mil. Y para prevenir en lugar de curar, shellcheck detecta estáticamente la mayoría de estos fallos antes de ejecutar nada: es el tema de 08-05.

  1. informe-diario.sh endurecido

#!/usr/bin/env bash
# informe-diario.sh — Informe diario de reparto de Veloz Envíos
set -Eeuo pipefail                    # -E: las funciones heredan el trap ERR
readonly RUTA_CSV="${VELOZ_CSV:-/srv/veloz/datos/envios.csv}"
TMPDIR_INFORME=$(mktemp -d) || exit 73; readonly TMPDIR_INFORME

limpiar() { local c=$?; rm -rf "$TMPDIR_INFORME"; exit "$c"; }
trap limpiar EXIT INT TERM
trap 'printf "[FATAL] %s:%d en %s(): código %d\n" "${BASH_SOURCE[0]}" \
      "$LINENO" "${FUNCNAME[0]:-main}" "$?" >&2' ERR

main() {
    [[ "${1:-}" == --debug ]] && { shift; activar_debug; }
    validar_entorno
    ...
}
main "$@"

Cuatro líneas de cabecera y dos trampas: el script ya no miente. Si el CSV no se puede leer, muere con 66 y un mensaje; si algo inesperado falla, dice fichero, línea y función; y el temporal se borra por las cuatro vías posibles de salida.

Errores Comunes y Consejos

  • Creer que set -e lo cubre todo. Las funciones llamadas desde if lo pierden. Comprueba también a mano.
  • trap con comillas dobles. Las variables se expanden al registrar la trampa, no al dispararla.
  • No capturar $? en la primera línea del manejador EXIT, u olvidar el exit "$codigo" final. Un fallo se convierte en éxito aparente y cron no avisa.
  • trap ... ERR sin set -E. Las funciones no lo disparan y el diagnóstico no aparece nunca.
  • Usar PIPESTATUS después de otro comando. Se sobrescribe con cada uno; cópialo de inmediato.
  • exit 300. Se trunca módulo 256 y sale con 44. Mantente entre 0 y 125.
  • Consejo: escribe la trampa de limpieza en la misma línea en que creas el temporal. Si lo dejas para "luego", el return temprano que añadas dentro de un mes se llevará el borrado por delante.

Ejercicios

Ejercicio 1. Escribe una cabecera completa para un script archivar-historico.sh que use modo estricto, cree un directorio temporal, garantice su borrado también ante Ctrl-C, preserve el código de salida original e informe de la línea exacta ante cualquier fallo imprevisto.

Ejercicio 2. La función contar_errores() usa zgrep -c ERROR "$LOG" | tr -d ' '. Explica por qué bajo set -e sin pipefail puede devolver un número falso, y reescríbela para que distinga "cero errores" de "no pude leer el fichero", saliendo con 66 en el segundo caso.

Ejercicio 3. Añade a informe-diario.sh una opción --debug que active una traza enriquecida (fichero, línea y función) volcada a ~/veloz-ops/logs/traza-AAAA-MM-DD.log, sin ensuciar ni la salida del informe ni sus errores.

Soluciones

Solución 1.

#!/usr/bin/env bash
set -Eeuo pipefail
TRABAJO=$(mktemp -d) || exit 73
readonly TRABAJO
limpiar() { local c=$?; rm -rf "$TRABAJO"; exit "$c"; }
trap limpiar EXIT INT TERM HUP
trap 'printf "[FATAL] %s:%d %s(): código %d\n" "${BASH_SOURCE[0]}" "$LINENO" \
      "${FUNCNAME[0]:-main}" "$?" >&2' ERR

-E es imprescindible para que la trampa ERR funcione dentro de funciones. INT TERM HUP junto a EXIT puede parecer redundante —una señal capturada acaba disparando EXIT—, pero explicitarlas garantiza el borrado también si alguien redefine el comportamiento por defecto.

Solución 2. Sin pipefail, el código de la tubería es el de tr, que siempre vale 0: si zgrep no pudo abrir el fichero, la función devuelve la cadena vacía como si fueran cero errores. Además, zgrep -c devuelve 1 cuando hay cero coincidencias, que bajo set -e mataría el script sin ser un error real.

contar_errores() {                 # Uso: contar_errores <fichero>
    local log="${1:?}" n
    [[ -r "$log" ]] || morir 66 "No puedo leer $log"
    n=$(grep -c 'ERROR' "$log") || n=0    # el || captura el "sin coincidencias"
    printf '%s\n' "$n"
}

La guarda [[ -r ]] separa el fallo real (fichero ilegible → código 66) del caso legítimo (cero errores → imprime 0). Es más claro que cualquier acrobacia con PIPESTATUS, y la lección es general: comprueba las precondiciones tú mismo en vez de deducirlas de códigos ambiguos.

Solución 3.

activar_debug() {
    local traza=~/veloz-ops/logs/traza-$(date +%F).log
    exec 8>> "$traza"                     # descriptor propio, en modo añadir
    export BASH_XTRACEFD=8
    export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): '
    set -x
    log_info "Traza activada en $traza"
}
[[ "${1:-}" == --debug ]] && { shift; activar_debug; }

BASH_XTRACEFD=8 desvía la traza al descriptor 8, así que ni stdout (el informe) ni stderr (los avisos) se contaminan: puedes seguir canalizando el informe a un fichero mientras lees la traza en otra terminal con tail -f. El >> conserva las trazas de varias ejecuciones del mismo día.

Conclusión

Bash no aborta por su cuenta, así que la robustez se pide explícitamente. set -e mata el script al primer fallo, con excepciones deliberadas —condicionales, &&/||, negaciones, funciones llamadas desde if— que debes conocer para no confiarte; set -u convierte una errata en un error visible en lugar de en una cadena vacía peligrosa; set -o pipefail arregla el código de salida de las tuberías, y PIPESTATUS dice qué eslabón falló. La línea set -Eeuo pipefail es el punto de partida correcto, pero no una garantía: hace falta marcar con || true o if ! los comandos cuyo fallo es normal. trap completa el cuadro: EXIT da la limpieza garantizada que hace segura la pareja mktemp + borrado, y ERR con $LINENO, BASH_SOURCE y FUNCNAME convierte un error mudo en un diagnóstico con fichero, línea y función. Una morir() con códigos del convenio sysexits y un patrón de reintentos con retroceso completan el arsenal. Y para investigar: bash -n valida sintaxis, set -x con un PS4 enriquecido y BASH_XTRACEFD produce una traza legible y aislada, y la bisección con exit temporal localiza lo que se resista.

Queda una deuda pendiente desde el Módulo 2. El toolkit valida rutas, procesos y códigos, pero cuando tiene que validar texto —una fecha --fecha 2026-08-03, una IP, un código HTTP, la estructura de una línea de app.log— sigue recurriendo a comparaciones frágiles con globs. En 05-04 llegan por fin las expresiones regulares de verdad: los tres sabores y qué herramienta usa cada uno, la sintaxis desde cero, y el operador =~ de Bash con BASH_REMATCH, que valida y extrae en un solo paso.

Curso de Programación en Bash

Módulo 1: Introducción a Bash

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados