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

Tema 26

Depurar un script

Tarde o temprano, un script hace algo distinto de lo esperado: muestra un resultado incorrecto, se detiene sin explicación o funciona en tu equipo y falla en el servidor. Bash no tiene un depurador gráfico, pero tiene algo casi tan útil: puede mostrar cada comando, ya expandido, justo antes de ejecutarlo. Este tema enseña a leer esa traza, a hacerla más informativa y a aplicar un método ordenado para pasar del síntoma a la causa.

1. Un método

La tentación ante un error es cambiar algo al azar y volver a probar. A veces funciona, pero sin entender por qué, y el error suele volver. Un método ordenado es más rápido a la larga:

  1. Reproducir. Encontrar una forma fiable de provocar el error: qué argumentos, qué datos, qué directorio. Un error que no se puede reproducir no se puede confirmar como resuelto. reproducir
  2. Leer el mensaje. Completo, con el número de línea si lo tiene. Muchas veces ya dice qué pasó. leer
  3. Observar. Mirar qué hace realmente el script, con la traza o con mensajes, en lugar de suponerlo. observar
  4. Aislar. Reducir el problema hasta la línea, el dato o la condición exacta que lo provoca. aislar
  5. Corregir y verificar. Hacer un cambio que se entiende, y comprobar con el caso del paso 1 que el error desapareció y que no apareció otro. corregir

El paso que más se saltea es el tercero, y es el que más tiempo ahorra. La diferencia entre lo que uno cree que hace el script y lo que realmente hace es, casi siempre, el error.

2. Antes de ejecutar: bash -n y ShellCheck

Las dos herramientas del tema 4 encuentran muchos errores sin ejecutar nada, y conviene usarlas siempre primero:

HerramientaEncuentraEjemplo
bash -n scriptErrores de sintaxis: bloques sin cerrar, comillas sin pareja.syntax error: unexpected end of file
shellcheck scriptErrores que la sintaxis permite: variables sin comillas, nombres mal escritos, construcciones frágiles.SC2154: destion is referenced but not assigned

Si ShellCheck señala algo en la línea donde aparece el error, empieza por ahí: buena parte de los errores de Bash son exactamente los que ShellCheck conoce. Los que quedan, los de lógica, son los que necesitan la traza.

3. La traza: bash -x

Ejecutado con bash -x, un script muestra en la salida de errores cada comando que ejecuta, precedido de +, después de todas las expansiones del tema 3: variables reemplazadas, comodines expandidos, sustituciones resueltas. Es lo que el comando realmente recibe.

terminal
$ bash -x contar-lineas notas
+ set -euo pipefail
+ dir=notas
+ total=0
+ archivos=0
+ find notas -name '*.txt'
+ read -r f
++ wc -l
+ n=3
+ total=3
+ archivos=1
+ read -r f
…
+ echo '0 archivos, 0 líneas'
0 archivos, 0 líneas

Las líneas con + son la traza; la última, sin él, es la salida normal del script. Como la traza va a la salida de errores, se puede separar de la salida: bash -x script 2> traza.txt.

Si el script está instalado como comando, en lugar de buscar su ruta para pasársela a bash, se puede escribir bash -x "$(command -v contar-lineas)" notas. Y la opción funciona también combinada con las demás: bash -xv, o en la línea shebang, aunque como vimos en el tema 2 eso no funciona con env.

4. Leer la traza

La traza tiene algunas convenciones que conviene conocer para leerla con soltura:

Lo que se veSignifica
+ comandoUn comando del nivel principal.
++ comandoUn comando dentro de una sustitución $( ); +++, dentro de una sustitución anidada.
'mi nota.txt'Un argumento con espacios o caracteres especiales, entre comillas simples para mostrar dónde empieza y termina.
+ wc -l mi nota.txtSin comillas: son dos argumentos separados, mi y nota.txt.
+ n=3Una asignación, con el valor ya calculado.
+ [[ -f notas ]]Una prueba, con los valores reales; si después no aparece el cuerpo del if, fue falsa.
terminal · un argumento partido, visto en la traza
$ bash -xc 'f="mi nota.txt"; wc -l $f'
+ f='mi nota.txt'
+ wc -l mi nota.txt
wc: mi: No such file or directory
wc: nota.txt: No such file or directory

La asignación muestra el valor entre comillas: es uno solo. El wc, en cambio, recibe dos palabras. La traza responde de un vistazo la pregunta que el mensaje de error dejaba abierta: ¿por qué wc busca un archivo llamado mi?

5. Trazar solo un tramo: set -x y set +x

En un script largo, la traza completa es abrumadora. set -x activa la traza desde ese punto, y set +x la desactiva, de modo que se puede observar solo la parte sospechosa:

trazar solo una parte
preparar_entorno
set -x                           # traza desde aquí…
calcular_totales "$dir"
set +x                           # …hasta aquí
generar_informe

La línea set +x aparece ella misma en la traza, como + set +x. Si molesta, se puede ocultar con { set +x; } 2>/dev/null. Y dentro de una función, el tramo puede ser la función entera: set -x al comienzo y set +x antes de cada return.

6. Una traza más informativa: PS4

El + que precede a cada línea de la traza es el valor de la variable PS4, y se puede cambiar. Como se expande en cada línea, puede incluir variables que muestren dónde se está:

un PS4 que dice dónde
export PS4='+ ${BASH_SOURCE##*/}:${LINENO} ${FUNCNAME[0]:-main} [sub ${BASH_SUBSHELL}] '
VariableMuestra
${BASH_SOURCE##*/}El archivo, útil cuando hay bibliotecas.
${LINENO}El número de línea.
${FUNCNAME[0]:-main}La función en curso, o main fuera de ellas.
${BASH_SUBSHELL}El nivel de subshell: 0 en el script, 1 o más dentro de una tubería o de ( ).

El primer carácter de PS4 se repite para indicar la profundidad de las sustituciones, por eso conviene que siga siendo +. Las comillas simples son esenciales: las variables deben expandirse en cada línea de la traza, no al asignar PS4. Exportarlo hace que valga también para bash -x:

terminal
$ PS4='+ ${BASH_SOURCE##*/}:${LINENO} [sub ${BASH_SUBSHELL}] ' bash -x contar-lineas notas
+ contar-lineas:4 [sub 0] dir=notas
+ contar-lineas:5 [sub 0] total=0
+ contar-lineas:6 [sub 0] archivos=0
+ contar-lineas:8 [sub 0] find notas -name '*.txt'
+ contar-lineas:8 [sub 1] read -r f
++ contar-lineas:9 [sub 2] wc -l
+ contar-lineas:9 [sub 1] n=3
+ contar-lineas:10 [sub 1] total=3
+ contar-lineas:11 [sub 1] archivos=1
…
+ contar-lineas:14 [sub 0] echo '0 archivos, 0 líneas'

Con este PS4, la causa del error del ejemplo salta a la vista, como veremos en la sección 12.

7. La traza en un archivo

La traza se mezcla con los mensajes del script en la salida de errores. Para separarla, Bash permite enviarla a otro descriptor con la variable BASH_XTRACEFD:

la traza a un archivo aparte
exec 9> /tmp/traza.log           # abrir el archivo en el descriptor 9
BASH_XTRACEFD=9                  # la traza va al descriptor 9
set -x

Es especialmente útil para depurar un script que corre desde cron o como servicio, donde no hay pantalla: se agregan estas líneas, se espera a la próxima ejecución y se lee el archivo con calma. Hay que recordar quitarlas después, por lo que dice la sección 10 del tema 25: la traza muestra los valores de todas las variables, contraseñas incluidas.

Una opción emparentada es set -v (bash -v), que muestra cada línea tal como está escrita, antes de expandirla. Es menos útil que -x para encontrar errores, pero ayuda a seguir el recorrido por un script largo, y combinadas, -xv, muestran cada línea escrita y luego cómo quedó.

8. Mensajes de depuración

A veces la traza es demasiado: solo interesa el valor de unas pocas variables en un par de lugares. Un mensaje con declare -p, del tema 5, a la salida de errores, muestra el valor exacto, con sus espacios y caracteres invisibles:

mirar variables en un punto
declare -p origen destino opciones >&2
salida
declare -- origen="/home/ana/proyecto "
declare -- destino="/respaldos"
declare -a opciones=([0]="-a" [1]="--delete")

El espacio al final de origen, invisible con echo, aquí se ve. Para no tener que agregar y quitar estos mensajes, se puede dejar la depuración preparada en el script, activable con una variable de entorno:

depuración activable desde afuera
if [[ -n "${DEPURAR:-}" ]]; then
  export PS4='+ ${BASH_SOURCE##*/}:${LINENO} ${FUNCNAME[0]:-main} '
  set -x
fi

Así, DEPURAR=1 respaldo ~/docs ejecuta el script con la traza informativa, sin modificarlo. La biblioteca de registro del tema 25 ofrece algo similar a menor escala: los mensajes log_debug, que solo aparecen con -v.

9. trap ERR y trap DEBUG

El trap … ERR de los temas 23 y 24 es, en sí mismo, una herramienta de depuración: dice qué comando falló y en qué línea, sin necesidad de traza. Conviene tenerlo siempre en los scripts con set -e.

La pseudoseñal DEBUG va más lejos: se produce antes de cada comando. Con ella se puede construir una ejecución paso a paso rudimentaria, que muestra cada comando y espera Enter antes de ejecutarlo:

ejecución paso a paso
trap 'echo "[línea $LINENO] $BASH_COMMAND" >&2; read -rp "Enter para seguir… " < /dev/tty' DEBUG

Es poco elegante, pero en un tramo confuso permite detenerse en cada comando y, desde otra terminal, mirar el estado de los archivos. Para algo más completo existe bashdb, un depurador para Bash con puntos de ruptura y ejecución paso a paso al estilo de los depuradores de otros lenguajes, y algunos editores lo integran.

10. Aislar el problema

Cuando la traza no basta para ver la causa, hay que reducir el problema. Algunas técnicas:

11. Los rastros de los errores típicos

Muchos errores dejan un rastro característico. Reconocerlo ahorra la búsqueda:

SíntomaCausa probableCómo confirmarlo
Un comando recibe un nombre de archivo partido en dosUna variable sin comillas (tema 3).bash -x: el argumento aparece sin comillas simples.
$'\r': command not found, o rutas con $'\r'Finales de línea de Windows (tema 2).cat -A script muestra ^M$ al final de las líneas.
Una variable vale lo de antes después de un bucleEl bucle corrió en una subshell, por una tubería (tema 16).BASH_SUBSHELL en PS4.
El script termina sin mensaje, a la mitadset -e y un comando que «falló» en silencio, como grep (tema 23).trap … ERR, o bash -x: la última línea de la traza es la que falló.
Funciona a mano, falla en cronPATH reducido, variables de entorno ausentes, otro directorio actual (tema 34).Registrar env y pwd al comienzo del script.
Un número da value too great for baseUn cero a la izquierda, leído como octal (tema 7).La traza muestra el valor con el cero.
sort o grep dan resultados distintos en otra máquinaOtra configuración regional.locale en las dos; LC_ALL=C para fijarla (tema 5).
Una variable parece vacía sin motivoNombre mal escrito, o asignada dentro de $( ) (tema 19).ShellCheck SC2154; set -u.

12. Un caso completo

Este script debería contar los archivos .txt de un directorio y el total de sus líneas. Con un directorio que tiene dos archivos, de 3 y 2 líneas, muestra:

contar-lineas · la versión con el error
#!/usr/bin/env bash
set -euo pipefail

dir="$1"
total=0
archivos=0

find "$dir" -name '*.txt' | while read -r f; do
  n=$(wc -l < "$f")
  total=$(( total + n ))
  archivos=$(( archivos + 1 ))
done

echo "$archivos archivos, $total líneas"
terminal
$ contar-lineas notas
0 archivos, 0 líneas

Siguiendo el método:

  1. Reproducir: el error ocurre siempre, con cualquier directorio. No hay mensaje que leer: el script termina bien, con un resultado incorrecto.
  2. Antes de ejecutar: bash -n no encuentra nada y ShellCheck tampoco señala nada decisivo. Es un error de lógica.
  3. Observar: la traza simple de la sección 3 muestra algo desconcertante: total=3, después total=5, y al final se muestra 0. El bucle calcula bien, pero el valor se pierde.
  4. Aislar: con el PS4 de la sección 6, las líneas del bucle aparecen con [sub 1], y el echo final con [sub 0]. El bucle corre en una subshell, porque está al final de una tubería, y sus asignaciones no llegan al script. Es el problema que vimos en el tema 16.
  5. Corregir: conectar find al bucle con una sustitución de procesos, de modo que el bucle corra en la shell principal. Y de paso, separar los nombres con nulos, para que funcione con cualquier nombre de archivo.
contar-lineas · corregido
#!/usr/bin/env bash
set -euo pipefail

dir="$1"
total=0
archivos=0

while IFS= read -r -d '' f; do
  n=$(wc -l < "$f")
  total=$(( total + n ))
  archivos=$(( archivos + 1 ))
done < <(find "$dir" -name '*.txt' -print0)

echo "$archivos archivos, $total líneas"
terminal · verificar
$ contar-lineas notas
2 archivos, 5 líneas

La verificación usa el mismo caso que reproducía el error, y el resultado coincide con lo que se sabía de antemano: 3 + 2 líneas en dos archivos. Sin la traza, este error invita a sospechar de wc, de la aritmética o de find, que funcionaban perfectamente. Observar antes de suponer llevó directo a la causa.

13. Errores frecuentes

Al depurar…Qué pasaMejor
Cambiar cosas al azar hasta que funcioneNo se sabe por qué funciona, y el error vuelve.Observar con la traza y entender la causa.
PS4="+ $LINENO " con comillas doblesMuestra siempre el mismo número de línea.Comillas simples.
echo "$var" para inspeccionarNo se ven espacios finales ni caracteres invisibles.declare -p var >&2
Mensajes de depuración con echo a la salida estándarSe mezclan con los datos y cambian el resultado.A la salida de errores.
Dejar set -x o la traza a archivoLlena registros y expone secretos.Activarla con DEPURAR, y quitar lo provisorio.
Probar la corrección con otro casoNo se sabe si el error original desapareció.Verificar con el caso que lo reproducía.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué es importante reproducir un error antes de intentar corregirlo?

Porque sin una forma fiable de provocarlo, no se puede observar qué lo causa ni confirmar que la corrección lo resolvió.

2. ¿Qué muestra bash -x en cada línea de la traza?

El comando que se va a ejecutar, después de todas las expansiones: con las variables reemplazadas, los comodines expandidos y las sustituciones resueltas.

3. En la traza, ¿qué diferencia hay entre + wc -l 'mi nota.txt' y + wc -l mi nota.txt?

En la primera, wc recibe un solo argumento con un espacio. En la segunda, recibe dos argumentos, mi y nota.txt: típicamente, una variable sin comillas.

4. ¿Qué indica ++ al comienzo de una línea de la traza?

Que el comando se ejecuta dentro de una sustitución de comandos. Cada nivel de anidamiento agrega un +.

5. ¿Por qué PS4 debe asignarse con comillas simples?

Para que variables como $LINENO se expandan en cada línea de la traza. Con comillas dobles se expandirían una sola vez, al asignar PS4, y mostrarían siempre el mismo valor.

6. ¿Qué revela ${BASH_SUBSHELL} en la traza?

En qué nivel de subshell se ejecuta cada comando. Un bucle al final de una tubería aparece con un nivel mayor que 0, lo que explica que sus variables se pierdan.

7. ¿Para qué sirve BASH_XTRACEFD?

Para enviar la traza a otro descriptor, por ejemplo a un archivo abierto con exec 9> traza.log, separándola de los mensajes del script.

8. ¿Por qué declare -p var es mejor que echo "$var" para inspeccionar una variable?

Porque muestra el valor entre comillas, con los espacios del final y los caracteres invisibles visibles, y además indica si es un array y cuáles son sus elementos.

9. ¿Cómo se deja la depuración preparada en un script sin que esté siempre activa?

Activando la traza solo si una variable de entorno tiene valor: [[ -n "${DEPURAR:-}" ]] && set -x. Así, DEPURAR=1 script la activa sin modificar el archivo.

10. Un script funciona a mano y falla en cron. ¿Qué conviene registrar para averiguar por qué?

El entorno con env, el directorio actual con pwd y, si hace falta, la traza en un archivo con BASH_XTRACEFD. Las causas típicas son un PATH reducido, variables ausentes y otro directorio de trabajo.

Para practicar

  1. Crea el script contar-lineas con el error, un directorio con algunos .txt, y reproduce el caso de la sección 12 con la traza simple y con el PS4 informativo.
  2. Agrega a tu ~/.bashrc un PS4 informativo exportado, para que valga en todas tus trazas.
  3. Ejecuta con bash -x uno de los scripts de temas anteriores y localiza en la traza cada sustitución de comandos.
  4. Agrega a un script la activación de la traza con DEPURAR, enviándola a un archivo con BASH_XTRACEFD.
  5. Prueba la ejecución paso a paso con trap … DEBUG en un script de cinco o seis líneas.
  6. Crea un script con finales de línea de Windows, como en el tema 2, ejecútalo, y diagnostícalo con cat -A.