← Automatización con Scripts en Bash Bloque 3 · Códigos de salida y condicionales · Tema 10 de 42

Tema 10

Códigos de salida

Cada comando que termina deja, además de lo que escribió en pantalla, un número que dice cómo le fue. Una persona lee los mensajes; un script lee ese número. Sobre él se construye todo lo que viene en este bloque: las condiciones, los bucles que reintentan, los scripts que se detienen ante un error y los que avisan cuando algo falló. Este tema explica qué significa ese número, qué devuelven los comandos habituales y cómo hacer que tus scripts lo devuelvan bien.

1. Dos respuestas por comando

Todo comando que se ejecuta da dos respuestas. La primera es lo que escribe en su salida estándar y en la de errores: texto, pensado sobre todo para las personas. La segunda es un número entero que entrega al terminar, y que no aparece en pantalla.

El código de salida (exit status) es un número entre 0 y 255 que todo proceso devuelve al terminar, para informar a quien lo ejecutó si cumplió su tarea. Por convención, 0 significa éxito y cualquier otro valor, algún tipo de fallo.

Un script no debería decidir qué hacer leyendo mensajes. Los mensajes cambian de una versión a otra, dependen del idioma configurado y no tienen un formato fijo. El código de salida, en cambio, es una interfaz estable y pensada para programas. El tema 25 del curso de Terminal lo presentó junto con && y ||; aquí lo estudiamos a fondo, porque desde ahora todos los scripts dependerán de él.

2. Leer el código: $?

La variable especial $? contiene el código de salida del último comando que terminó:

terminal
$ ls /etc/hostname
/etc/hostname
$ echo $?
0
$ ls /no/existe
ls: cannot access '/no/existe': No such file or directory
$ echo $?
2

La palabra clave es último. Cada comando que se ejecuta reemplaza el valor de $?, incluidos echo, [ ] y cualquier otro. Por eso hay que leerlo inmediatamente, o guardarlo en una variable antes de hacer otra cosa:

terminal
$ ls /no/existe 2> /dev/null
$ echo "Falló"; echo "Código: $?"       # $? es ahora el del primer echo
Falló
Código: 0
$ ls /no/existe 2> /dev/null
$ codigo=$?                              # guardarlo enseguida
$ echo "Falló"; echo "Código: $codigo"
Falló
Código: 2

En la práctica, como veremos en la sección 8, casi nunca hace falta leer $?: lo habitual es usar el comando directamente como condición. $? se reserva para cuando importa el valor exacto del código, y no solo si fue cero.

3. El cero es éxito

A primera vista, la convención parece al revés: en casi todos los lenguajes de programación, y en la aritmética de Bash, el 0 es «falso». La razón es práctica: hay una sola forma de que algo salga bien, y muchas de que salga mal. El cero indica éxito, y los demás 255 valores quedan libres para distinguir por qué falló.

Los rangos de códigos de salida: 0 es éxito; de 1 a 125, fallos definidos por cada comando; 126, el comando no se pudo ejecutar; 127, el comando no existe; de 128 a 255, el proceso terminó por una señal, y el código es 128 más el número de la señal 0 1 – 125 126 127 128 – 255 éxito fallo, con el significado que defina cada comando no se pudo ejecutar no se encontró terminado por una señal: 128 + n (los anchos no están a escala)
El significado de cada rango. Los códigos de 126 en adelante los pone la shell; los de 1 a 125, el propio comando.

Dos comandos existen solo para producir un código: true, que termina siempre con 0, y false, que termina siempre con 1. Parecen inútiles, pero sirven para bucles infinitos (while true), para probar cómo se comporta un script ante un fallo, y como valores de configuración que se ejecutan: simular=true y luego if $simular; then.

4. Qué devuelven los comandos

Cada programa decide el significado de sus códigos distintos de cero, y lo documenta en su página de manual, normalmente en una sección llamada EXIT STATUS. Muchos se limitan a 0 y 1, pero varios de los más usados distinguen casos, y conocerlos evita errores:

Comando01Otros
grepEncontró al menos una coincidencia.No encontró nada. No es un error.2: error real, como un archivo inexistente.
diff, cmpLos archivos son iguales.Son distintos. Tampoco es un error.2: no se pudieron comparar.
test, [ ], [[ ]]La condición es cierta.Es falsa.2: expresión mal escrita.
lsTodo bien.Problemas menores.2: problemas graves, como un argumento inexistente.
curlTransferencia completa.Protocolo no admitido.6: no resolvió el nombre; 7: no conectó; 22: error HTTP, con --fail; 28: tiempo agotado.
rsyncTodo bien.Error de sintaxis.23: transferencia parcial; 24: archivos que desaparecieron durante la copia.
pingHubo respuesta.No hubo respuesta.2: otro error.

Los casos de grep y diff son los más importantes, porque rompen la equivalencia entre «distinto de cero» y «error». Para grep, no encontrar nada es una respuesta, no una falla: el código 1 dice «no», y solo el 2 dice «no pude hacer mi trabajo».

terminal
$ grep -q ERROR app.log; echo $?
0
$ grep -q FATAL app.log; echo $?          # no aparece: respuesta negativa
1
$ grep -q FATAL noexiste.log; echo $?      # no pudo leer el archivo: error
grep: noexiste.log: No such file or directory
2

La distinción tendrá consecuencias en el tema 23: un script que se detiene ante cualquier código distinto de cero se detendría también cuando grep simplemente no encuentra lo que busca.

5. Los códigos que pone la shell

Algunos códigos no los elige el comando, sino Bash, cuando el comando ni siquiera pudo ejecutarse o fue interrumpido. Reconocerlos permite diagnosticar un fallo sin ver ningún mensaje, por ejemplo en el registro de una tarea de cron:

CódigoSignificadoCausa típica
126El comando existe, pero no se pudo ejecutar.Falta el permiso de ejecución, o es un directorio.
127El comando no existe.Un nombre mal escrito, o un PATH incompleto, como el de cron.
128 + nEl proceso terminó por la señal número n.Lo interrumpieron o lo mataron.
130Señal 2, INT.Se pulsó Ctrl+C.
137Señal 9, KILL.kill -9, o el sistema lo mató por falta de memoria.
141Señal 13, PIPE.Escribía en una tubería cuyo lector ya había terminado (sección 10).
143Señal 15, TERM.kill sin opciones, o el apagado de un servicio.
terminal
$ comandoinexistente; echo $?
bash: comandoinexistente: command not found
127
$ ./sin-permiso.sh; echo $?
bash: ./sin-permiso.sh: Permission denied
126
$ sleep 300 & kill -9 $!; wait $!; echo $?
137
$ kill -l 137                              # ¿qué señal es?
KILL

kill -l traduce un número de señal, o un código de salida mayor que 128, a su nombre. Un 137 en el registro de un proceso que nadie mató a mano suele significar que el sistema se quedó sin memoria y eligió terminarlo.

6. exit

Un script termina con el código de su último comando, como vimos en el tema 2, salvo que use exit, que lo termina en ese punto con el código indicado:

FormaEfecto
exit 0Termina indicando éxito.
exit 1, exit 2…Termina indicando un fallo, con ese código.
exitTermina con el código del último comando ejecutado.

El código se guarda en un byte, de modo que se toma módulo 256. Un valor fuera del rango no da error, sino un código inesperado:

terminal
$ bash -c 'exit 256'; echo $?             # 256 mod 256
0
$ bash -c 'exit 300'; echo $?
44
$ bash -c 'exit -1'; echo $?
255

El primer caso es peligroso: un script que devolviera la cantidad de errores con exit "$errores" informaría éxito si hubo exactamente 256. Si se quiere comunicar una cantidad, debe ir en la salida, no en el código. Dentro de una función, exit termina el script entero; para terminar solo la función con un código se usa return, como veremos en el tema 19.

7. Diseñar los códigos de un script

Si un script lo van a usar otros scripts, sus códigos de salida son parte de su interfaz, tanto como sus argumentos. Unas pautas simples:

respaldo · cabecera y códigos
# Salida: 0  el respaldo se completó y se verificó
#         1  error general
#         2  uso incorrecto
#         3  el destino no tiene espacio suficiente
#         4  el respaldo se creó, pero la verificación falló

readonly E_USO=2 E_ESPACIO=3 E_VERIFICACION=4

Las constantes con nombre hacen que el código se lea solo: exit "$E_ESPACIO" se entiende mejor que exit 3. Y con códigos distintos, un script que llama a respaldo puede, por ejemplo, borrar respaldos viejos y reintentar ante un código 3, y dar una alarma ante un 4.

8. Usar el código en condiciones

Toda la lógica de Bash se apoya en los códigos de salida. if, while, && y || no evalúan valores verdaderos o falsos, como en otros lenguajes: ejecutan un comando y miran su código. Cero es «sí»; cualquier otro, «no».

el comando es la condición
if grep -q ERROR app.log; then
  echo "Hay errores en el registro"
fi

if ! ping -c1 -W2 servidor > /dev/null; then     # ! invierte el resultado
  echo "El servidor no responde" >&2
fi

mkdir -p "$destino" || exit 1                     # si falla, terminar
cd "$destino" && rm -f -- *.tmp                   # borrar solo si se pudo entrar

La opción -q de grep lo deja en silencio: no muestra las líneas, solo devuelve el código. Es la forma de usar grep como pregunta. Muchos comandos tienen una opción similar, o se los silencia redirigiendo su salida a /dev/null, como el ping del ejemplo.

Un patrón muy frecuente es «hacer esto, o terminar con un mensaje». Las llaves agrupan varios comandos para que actúen como uno solo después de ||:

fallar con un mensaje
cp -- "$origen" "$destino" || { echo "No se pudo copiar $origen" >&2; exit 1; }

Las llaves necesitan un espacio después de { y un ; antes de }. A diferencia de los paréntesis, no crean un proceso aparte, y por eso el exit termina el script y no una subshell.

No preguntes por $? si puedes preguntar por el comando

La forma comando; if [ $? -eq 0 ]; then funciona, pero es indirecta y frágil: si alguien agrega una línea entre el comando y el if, por ejemplo un mensaje de registro, la condición pasa a evaluar ese otro comando. if comando; then dice lo mismo, de forma directa. ShellCheck sugiere el cambio con el aviso SC2181.

9. Capturar el código sin perderlo

Cuando además de saber si el comando falló hace falta el código exacto, hay que capturarlo sin que otro comando lo reemplace. Dentro de la rama else de un if, $? conserva el código del comando de la condición, lo que permite reaccionar distinto según el fallo:

el código exacto, en la rama else
if rsync -a "$origen/" "$destino/"; then
  echo "Copia completa"
else
  codigo=$?
  if (( codigo == 24 )); then
    echo "Aviso: algunos archivos desaparecieron durante la copia" >&2
  else
    echo "Error de rsync: código $codigo" >&2
    exit 1
  fi
fi

Con la sustitución de comandos también se conserva el código: como vimos en el tema 5, salida=$(comando) toma el código de comando. Pero hay una trampa: si la asignación está precedida de local, export, readonly o declare, el código es el de ese comando, que siempre tiene éxito.

terminal
$ salida=$(false); echo $?
1
$ export salida=$(false); echo $?        # el código es el de export
0

Por eso, dentro de una función, se declara la variable local en una línea y se asigna en la siguiente: local salida y luego salida=$(comando). Es el aviso SC2155 de ShellCheck que vimos en el tema 4, y lo retomaremos en el tema 18.

10. Tuberías: pipefail y PIPESTATUS

Una tubería ejecuta varios comandos, pero devuelve un solo código: el del último. Los fallos de los anteriores se pierden:

terminal
$ cat /no/existe | sort | head -5; echo $?
cat: /no/existe: No such file or directory
0

El archivo no existía, pero la tubería informa éxito, porque head terminó bien. La opción pipefail cambia la regla: el código de la tubería pasa a ser el del último comando que falló, o 0 si ninguno falló. Es la tercera parte de set -euo pipefail.

terminal
$ set -o pipefail
$ cat /no/existe | sort | head -5; echo $?
cat: /no/existe: No such file or directory
1

Para conocer el código de cada parte por separado, Bash guarda todos en el array PIPESTATUS, que, como $?, se reemplaza con el comando siguiente:

terminal
$ true | false | true
$ echo "${PIPESTATUS[@]}"
0 1 0
pipefail y el código 141

Con pipefail aparece un fallo inesperado. En comando | head -1, head termina en cuanto lee la primera línea, y si comando sigue escribiendo, el sistema lo interrumpe con la señal PIPE: código 141. Sin pipefail nadie lo nota; con él, la tubería entera «falla». Por ejemplo, yes | head -1 devuelve 141. Ocurre con productores que escriben mucho, como yes, find o un cat de un archivo grande, seguidos de head o de grep -q, que también deja de leer en cuanto encuentra lo que busca. Lo retomaremos en el tema 23.

11. El código de las demás construcciones

No solo los comandos simples tienen código de salida. Cada construcción de Bash devuelve uno, según reglas que conviene conocer para no llevarse sorpresas:

ConstrucciónSu código de salida
a && b, a || bEl del último comando que se ejecutó.
! comando0 si el comando falló; 1 si tuvo éxito.
if … fiEl del último comando ejecutado dentro de la rama elegida; 0 si no se ejecutó ninguna rama.
for, whileEl del último comando ejecutado en el cuerpo; 0 si no dio ninguna vuelta.
{ …; }, ( … )El del último comando del grupo.
var=$(comando)El de comando.
local var=$(comando)El de local: casi siempre 0.
Una funciónEl de su último comando, o el que indique return (tema 19).
Un scriptEl de su último comando, o el que indique exit.

La regla de las funciones y los scripts explica un error sutil: un script cuya última línea es [[ -n "$x" ]] && echo "$x" termina con código 1 cuando x está vacía, aunque no haya fallado nada. Si a ese script lo llama otro, o cron, parecerá un error. Cuando el final de un script puede dejar un código engañoso, conviene terminarlo con un exit 0 explícito.

12. Un script completo

Este script comprueba que una lista de sitios web responda. Usa los códigos de salida en las dos direcciones: lee el de curl para explicar cada fallo con palabras, y devuelve uno propio, documentado, para que pueda usarse desde cron o desde otro script.

verificar-sitios
#!/usr/bin/env bash
#
# verificar-sitios — comprueba que una lista de sitios web responda.
#
# Uso:    verificar-sitios URL...
# Salida: 0 si todos responden; 1 si alguno falla; 2 si el uso es incorrecto.
#
set -euo pipefail

if (( $# == 0 )); then
  echo "Uso: ${0##*/} URL..." >&2
  exit 2
fi

# Los códigos de salida de curl más frecuentes (ver «man curl», EXIT CODES).
declare -A motivo=(
  [6]="no se pudo resolver el nombre"
  [7]="no se pudo conectar"
  [22]="el servidor respondió con un error HTTP"
  [28]="se agotó el tiempo de espera"
  [60]="el certificado TLS no es válido"
)

fallidos=0
for url in "$@"; do
  if curl --fail --silent --output /dev/null --max-time 10 "$url"; then
    printf 'OK     %s\n' "$url"
  else
    codigo=$?                       # el código de curl, conservado en el else
    printf 'FALLA  %s (curl %d: %s)\n' "$url" "$codigo" "${motivo[$codigo]:-ver «man curl»}"
    fallidos=$(( fallidos + 1 ))
  fi
done

if (( fallidos > 0 )); then
  printf '%d de %d sitios fallaron\n' "$fallidos" "$#" >&2
  exit 1
fi
terminal
$ verificar-sitios https://example.com https://example.com/no-existe http://nohay.invalid http://127.0.0.1:9
OK     https://example.com
FALLA  https://example.com/no-existe (curl 22: el servidor respondió con un error HTTP)
FALLA  http://nohay.invalid (curl 6: no se pudo resolver el nombre)
FALLA  http://127.0.0.1:9 (curl 7: no se pudo conectar)
3 de 4 sitios fallaron
$ echo $?
1
$ verificar-sitios; echo $?
Uso: verificar-sitios URL...
2

13. Errores frecuentes

EscribisteQué pasaCorrección
cmd; echo "fin"; echo $?Muestra el código de echo.Guardar codigo=$? justo después de cmd.
cmd; if [ $? -eq 0 ]Funciona, pero se rompe si se intercala una línea.if cmd; then
if [ $(grep x f) ]Evalúa el texto encontrado, no el código de grep.if grep -q x f; then
Tratar el 1 de grep como errorUn «no encontrado» detiene o alarma.Distinguir 1 (no) de 2 (error).
exit "$errores"Con 256 errores, informa éxito.exit 1, y la cantidad en la salida.
local x=$(cmd)El código de cmd se pierde.local x y luego x=$(cmd).
a | b sin pipefailUn fallo de a pasa inadvertido.set -o pipefail, o PIPESTATUS.
cmd || ( echo error; exit 1 )El exit termina la subshell, no el script.Llaves: { echo error; exit 1; }
Última línea [[ … ]] && …El script termina con 1 sin haber fallado.exit 0 explícito, o un if.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué un script debería decidir según el código de salida y no según los mensajes de un comando?

Porque los mensajes son para personas: cambian entre versiones, dependen del idioma y no tienen un formato fijo. El código de salida es una interfaz estable, pensada para que la lean los programas.

2. ¿Qué muestra ls /no/existe; echo "error"; echo $? en su última línea, y por qué?

0, porque $? contiene el código del último comando, que es el primer echo, y ese terminó bien. El código de ls se perdió.

3. ¿Por qué el éxito es 0 y no 1?

Porque hay una sola forma de tener éxito y muchas de fallar. Con el 0 para el éxito, quedan 255 valores para distinguir distintas causas de fallo.

4. ¿Qué significan los códigos 0, 1 y 2 de grep?

0: encontró al menos una coincidencia. 1: no encontró ninguna, que es una respuesta, no un error. 2: ocurrió un error, como un archivo que no se pudo leer.

5. Una tarea de cron registra el código 127. ¿Qué pasó, probablemente?

Que un comando no se encontró. En cron, la causa típica es que el PATH es más reducido que el de la terminal, y un programa instalado en un directorio no estándar no se halla.

6. ¿Qué significa un código 137, y cómo se averigua el nombre de la señal?

Que el proceso terminó por la señal 9 (137 − 128), KILL. Con kill -l 137 se obtiene el nombre. Si nadie lo mató a mano, suele indicar que el sistema se quedó sin memoria.

7. ¿Con qué código termina un script que ejecuta exit 258?

Con 2, porque el código se toma módulo 256.

8. ¿Qué código conviene usar cuando un script se llama con argumentos incorrectos?

2, la convención de las herramientas de GNU y de los comandos internos de Bash para un uso incorrecto.

9. ¿Por qué es preferible if grep -q x f; then a grep -q x f; if [ $? -eq 0 ]; then?

Porque es directo y no depende de que $? siga intacto. En la segunda forma, cualquier línea que se agregue entre el comando y el if cambiaría lo que se evalúa.

10. ¿Cómo se obtiene el código exacto de un comando que falló dentro de la condición de un if?

Leyendo $? al comienzo de la rama else, donde todavía contiene el código del comando de la condición.

11. ¿Qué devuelve local x=$(false), y por qué es un problema?

Devuelve 0, el código de local, que tuvo éxito. El fallo de false queda oculto. Se evita declarando y asignando en dos líneas.

12. Sin pipefail, ¿qué código devuelve cat /no/existe | wc -l? ¿Y con pipefail?

Sin pipefail, 0, el código de wc. Con pipefail, 1, el de cat, que fue el último en fallar.

13. ¿Qué contiene PIPESTATUS?

Un array con el código de salida de cada comando de la última tubería, en orden.

14. ¿Por qué yes | head -1 devuelve 141 con pipefail?

Porque head termina tras leer una línea, y cuando yes intenta seguir escribiendo en la tubería sin lector, recibe la señal PIPE (13) y termina con 128 + 13. Con pipefail, ese código pasa a ser el de la tubería.

15. ¿Por qué cmd || ( echo "error"; exit 1 ) no termina el script?

Porque los paréntesis crean una subshell, un proceso aparte, y el exit termina esa subshell. Para terminar el script se usan llaves: { echo "error"; exit 1; }.

Para practicar

  1. Ejecuta grep sobre un archivo con un texto que aparece, con uno que no aparece y sobre un archivo inexistente, y anota los tres códigos.
  2. Haz lo mismo con diff: dos archivos iguales, dos distintos y uno inexistente.
  3. Provoca los códigos 126 y 127, y el 130 interrumpiendo un sleep 60 con Ctrl+C. Usa kill -l para traducir el último.
  4. Escribe un script que termine con exit 256 y otro con exit 257. Comprueba sus códigos.
  5. Compara el código de cat /no/existe | sort con y sin set -o pipefail, y muestra PIPESTATUS en los dos casos.
  6. Escribe una función que contenga local x=$(false) y comprueba su código. Corrígela.
  7. Revisa los scripts que escribiste en los temas anteriores: ¿devuelven un código distinto de cero cuando fallan? ¿Hay alguno cuya última línea pueda dejar un 1 engañoso?
  8. Agrega a verificar-sitios una opción, por variable de entorno, que cambie el tiempo límite de curl, y prueba el código 28 con un tiempo muy corto.