En Bash, es posible obtener el código de retorno, también llamado estado de salida (exit status), del último comando ejecutado consultando a la variable $?.



Cada vez que un proceso invoca a la llamada al sistema operativo _exit (man _exit), además de terminar retorna un estado de salida, el cual está disponible para el proceso padre. En Bash sucede lo mismo, cada comando ejecutado (el cual se corresponde con un nuevo proceso si no se trata de un comando builtin) retorna un estado de salida, el cual es posible consultar accediendo a la variable $?.

En los sistemas operativos de la familia UNIX, un comando o proceso que se ejecuta de forma exitosa retorna 0, mientras que uno que falla retorna un valor mayor a cero, que usualmente se interpreta como un código de error. De igual forma, las funciones dentro de un script Bash retornan un estado de salida, el cual corresponde con el código de retorno del último comando ejecutado, salvo que se utilice el comando exit N (siendo N un entero entre 0 y 255).

Veamos algunos ejemplos. Primero un comando que finaliza exitosamente:

[emi@hal9000 ~]$ ls /
bin   data  etc   lib    lost+found  mnt  proc  run   srv  tmp  var
boot  dev   home  lib64  media       opt  root  sbin  sys  usr

Luego de listar el contenido del directorio raíz, consultemos el estado de salida de ls:

[emi@hal9000 ~]$ echo $?
0

Tal como mencioné anteriormente, 0 indica que no hubo error.

Ahora veamos un comando que finaliza con error, por ejemplo listar un directorio que no existe:

[emi@hal9000 ~]$ ls /pepe
ls: cannot access /pepe: No such file or directory

La salida de ls indica que no pudo acceder al directorio porque no existe. ¿Cual es el código de retorno de ls ahora?

[emi@hal9000 ~]$ echo $?
2

El estado de salida es mayor a cero, lo que indica que hubo error.

De acuerdo al manual de ls (man ls), los posibles estados de salida son:

   Exit status:
       0      if OK,

       1      if minor problems (e.g., cannot access subdirectory),

       2      if serious trouble (e.g., cannot access command-line argument).

El código de error 2 marca que hubo un problema serio, ya que no pudo acceder al parámetro por línea de comandos "pepe". ¿Bajo qué circunstancias ls retornaría 1? Por ejemplo si no puede acceder a un subdirectorio en un listado recursivo (porque el usuario no posee permiso de lectura sobre el mismo):

[emi@hal9000 ~]$ ls /run/
alsactl.pid        dmeventd-client  log             radvd           syslogd.pid
atd.pid            dmeventd-server  lsm             rpcbind.lock    systemd
auditd.pid         faillock         lvm             rpcbind.sock    tmpfiles.d
avahi-daemon       gdm              lvmetad.pid     rpc.statd.pid   tuned
certmonger         initramfs        mariadb         samba           udev
chronyd.pid        iprdump.pid      mdadm           saslauthd       udisks2
console            iprinit.pid      mount           sdp             user
crond.pid          iprupdate.pid    netreport       sepermit        utmp
cron.reboot        ksmtune.pid      NetworkManager  setrans
cups               libvirt          plymouth        sm-notify.pid
dbus               libvirtd.pid     pm-utils        spice-vdagentd
dhclient-eno1.pid  lock             ppp             sshd.pid
[emi@hal9000 ~]$ echo $?
0

Al listar el directorio /run el código de retorno es cero, ya que el usuario tiene acceso de lectura sobre el mismo. Pero si no tiene acceso de lectura sobre algunos subdirectorios, al intentar listarlo recursivamente, el estado de salida será 1:

[emi@hal9000 ~]$ ls -R /run/ > /dev/null 
ls: cannot open directory /run/cups/certs: Permission denied
ls: cannot open directory /run/gdm: Permission denied
ls: cannot open directory /run/libvirt/network: Permission denied
ls: cannot open directory /run/lock/iscsi: Permission denied
ls: cannot open directory /run/lock/lvm: Permission denied
ls: cannot open directory /run/lvm: Permission denied
ls: cannot open directory /run/mdadm: Permission denied
ls: cannot open directory /run/systemd/inaccessible: Permission denied
ls: cannot open directory /run/udisks2: Permission denied
[emi@hal9000 ~]$ echo $?
1

¿Por qué 1 y no 2? Porque la implementación de ls no lo considera un error grave. Es decir, pudo listar sin problemas el directorio indicado como argumento en la línea de comandos, los fallos son en algunos subdirectorios.

Ahora que entendemos qué son y cómo funcionan los estados de salida y códigos de errores, ¿qué sucede cuando utilizamos pipes (pipelines)?

Los pipes (identificados con el caracter especial |) son una herramienta muy simple de utilizar y permiten controlar el flujo de datos: enviar la salida estándar de un comando o proceso a la entrada estándar de otro. El uso más simple y conocido de los pipes es para implementar filtros, ¿quién no ha filtrado alguna vez la salida estándar de un comando utilizando un pipe y grep?:

root@debian6:~# ps ax | grep smb
 1064 ?        Ss     0:02 /usr/sbin/smbd -D
 1080 ?        S      0:00 /usr/sbin/smbd -D
28507 pts/0    S+     0:00 grep smb

Cuando utilizamos pipes, el código de estado coincide con el de el último proceso en el pipeline. Volviendo al ejemplo anterior, ¿qué sucede si filtramos el listado recursivo del directorio /run? ¿Cuál será el estado de salida del comando?:

[emi@hal9000 ~]$ ls -R /run/ 2>&1 | grep ".pid$"
alsactl.pid
atd.pid
auditd.pid
chronyd.pid
crond.pid
dhclient-eno1.pid
iprdump.pid
iprinit.pid
iprupdate.pid
ksmtune.pid
libvirtd.pid
lvmetad.pid
rpc.statd.pid
smbd.pid
sm-notify.pid
sshd.pid
syslogd.pid
tuned.pid
[emi@hal9000 ~]$ echo $?
0

El código de estado de salida es 0. Ok, Bash reporta que el pipeline se ha ejecutado correctamente. Esto se debe a que el estado de salida del comando ls -R /run/ 2>&1 | grep ".pid$" es simplemente el código de retorno del último proceso en el pipe, en este caso el de grep ".pid$".

Pero como hemos visto anteriormente, el comando ls -R /run/ 2>&1 retorna error debido a que el usuario actual no posee acceso de lectura a todos los subdirectorios de /run. Si nos quedamos con el estado de salida del último proceso, se nos pueden estar escapando errores en etapas previas del pipe.

En Bash, la única forma de detectar problemas en etapas anteriores del pipeline es consultando a la variable $PIPESTATUS. Se trata de un arreglo que almacena los códigos de retorno de todos los procesos en el último pipeline.

Si repetimos el comando anterior:

[emi@hal9000 ~]$ ls -R /run/ 2>&1 | grep ".pid$"
alsactl.pid
atd.pid
auditd.pid
chronyd.pid
crond.pid
dhclient-eno1.pid
iprdump.pid
iprinit.pid
iprupdate.pid
ksmtune.pid
libvirtd.pid
lvmetad.pid
rpc.statd.pid
smbd.pid
sm-notify.pid
sshd.pid
syslogd.pid
tuned.pid

Y consultamos todos los elementos de $PIPESTATUS:

[emi@hal9000 ~]$ echo ${PIPESTATUS[*]}
1 0

Se observa que el código de retorno de ls es 1 y el código de retorno de grep es 0.

El orden de los elementos del arreglo $PIPESTATUS coinciden con el orden de los procesos en el pipe. Veamos un ejemplo más, incluyendo una llamada al comando de Bash exit, el cual provoca la salida con un estado indicado como parámetro:

[emi@hal9000 ~]$ pwd | exit 123 | ls -R /run/ 2>&1 | grep ".pid$"
alsactl.pid
atd.pid
auditd.pid
chronyd.pid
crond.pid
dhclient-eno1.pid
iprdump.pid
iprinit.pid
iprupdate.pid
ksmtune.pid
libvirtd.pid
lvmetad.pid
rpc.statd.pid
smbd.pid
sm-notify.pid
sshd.pid
syslogd.pid
tuned.pid

El comando anterior ejecuta pwd y envía su salida estándar a la entrada de exit, el cual simplemente sale y envía su salida estándar (ninguna) a la entrada estándar de ls. ls ignora la entrada estándar, y muestra por salida estándar el listado recursivo del directorio /run, el cual es enviado a la entrada estándar de grep. Veamos los códigos de salida de todos los procesos del pipe:

[emi@hal9000 ~]$ echo ${PIPESTATUS[*]}
0 123 1 0

Se observa que pwd finaliza sin error y exit finaliza con el código de error 123, tal como se especificó como argumento en la línea de comandos. Luego ls retorna el estado 1, el cual indica que hubo un error leve, y por último grep finaliza sin error.

Si hubiésemos consultado sólo el estado de salida del pipe, nos hubiésemos perdido los errores intermedios, los cuales pueden indicar que la salida del comando ejecutado no es confiable:

[emi@hal9000 ~]$ pwd | exit 123 | ls -R /run/ 2>&1 | grep ".pid$"
alsactl.pid
atd.pid
auditd.pid
chronyd.pid
crond.pid
dhclient-eno1.pid
iprdump.pid
iprinit.pid
iprupdate.pid
ksmtune.pid
libvirtd.pid
lvmetad.pid
rpc.statd.pid
smbd.pid
sm-notify.pid
sshd.pid
syslogd.pid
tuned.pid
[emi@hal9000 ~]$ echo $?
0

Por ello, al utilizar pipelines es importante consultar los estados de salida de todos los procesos que lo conforman, de lo contrario nos estaríamos perdiendo errores intermedios y la salida no sería confiable.

Para finalizar, como la variable $PIPESTATUS es un arreglo, es posible consultar sólo uno de los elementos (por ejemplo, si deseamos consultar sólo el estado de salida del comando exit):

[emi@hal9000 ~]$ pwd | exit 123 | ls -R /run/ 2>&1 | grep ".pid$"
alsactl.pid
atd.pid
auditd.pid
chronyd.pid
crond.pid
dhclient-eno1.pid
iprdump.pid
iprinit.pid
iprupdate.pid
ksmtune.pid
libvirtd.pid
lvmetad.pid
rpc.statd.pid
smbd.pid
sm-notify.pid
sshd.pid
syslogd.pid
tuned.pid
[emi@hal9000 ~]$ echo ${PIPESTATUS[1]}
123

Recuerden que en Bash, al igual que en C, los índices de los arreglos comienzan en la posición 0, por lo tanto con el índice 1 se accede al segundo elemento.

¡Espero que les haya gustado!

Fuentes

Peter Sobot - Pipes and Filters

BASH Programming - Introduction HOW-TO: 4. Pipes

Advanced Bash-Scripting Guide: Chapter 6. Exit and Exit Status

Advanced Bash-Scripting Guide: 9.1. Internal Variables


Tal vez pueda interesarte


Compartí este artículo