32. Débogage

Le débogage est deux fois plus difficile que l'écriture de code en premier lieu. Donc, si vous écrivez du code aussi intelligemment que possible, vous n'êtes, par définition, pas assez intelligent pour le déboguer.

-- Brian Kernighan

Le shell Bash ne contient pas de débogueur intégré mais seulement des commandes et des expressions. Les erreurs de syntaxe ou de frappe dans les scripts génèrent des messages d'erreur incompréhensibles n'apportant souvent aucune aide pour déboguer un script non fonctionnel.

Exemple 32.1. Un script bogué

#!/bin/bash
# ex74.sh

# C'est un script bogué.
# Où est donc l'erreur ?

a=37

if [$a -gt 27 ]
then
  echo $a
fi  

exit 0

Sortie d'un script :

./ex74.sh: [37: command not found

Que se passe-t'il avec ce script ? (petite aide : après le if)

Exemple 32.2. Mot clé manquant

#!/bin/bash
# missing-keyword.sh : Quel message d'erreur sera généré ?

for a in 1 2 3
do
  echo "$a"
# done     # Requiert le mot clé 'done' mis en commentaire ligne 7.

exit 0  

Sortie d'un script :

missing-keyword.sh: line 10: syntax error: unexpected end of file
        

Notez que le message d'erreur ne fait pas nécessairement référence à la ligne où l'erreur se trouve mais à la ligne où l'interpréteur Bash s'aperçoit de l'erreur.

Les messages d'erreur peuvent ne pas tenir compte des lignes de commentaires d'un script lors de l'affichage du numéro de ligne de l'instruction ayant provoqué une erreur de syntaxe.

Que faire si le script s'exécute mais ne fonctionne pas comme vous vous y attendiez ? C'est une erreur de logique trop commune.

Exemple 32.3. test24, un autre script bogué

#!/bin/bash

#  Ce script est supposé supprimer tous les fichiers du répertoire courant
#  contenant des espaces dans le nom.
#  Cela ne fonctionne pas.
#  Pourquoi ?


mauvaisnom=`ls | grep ' '`

# Essayez ceci :
# echo "$mauvaisnom"

rm "$mauvaisnom"

exit 0

Essayez de trouver ce qui ne va pas avec l'Exemple 32.3, « test24, un autre script bogué » en supprimant les caractères de commentaires de la ligne echo "$mauvaisnom". Les instructions echo sont utiles pour voir si ce que vous attendiez est bien ce que vous obtenez.

Dans ce cas particulier, rm "$mauvaisnom" ne donnera pas les résultats attendus parce que $mauvaisnom ne devrait pas être entre guillemets. Le placer entre guillemets nous assure que rm n'a qu'un seul argument (il correspondra à un seul nom de fichier). Une correction partielle est de supprimer les guillemets de $mauvaisnom et de réinitialiser $IFS pour contenir seulement un retour à la ligne, IFS=$'\n'. Néanmoins, il existe des façons plus simples de faire cela.

# Bonnes méthodes de suppression des fichiers contenant des espaces dans leur nom.
rm *\ *
rm *" "*
rm *' '*
# Merci, S.C.

Résumer les symptômes d'un script bogué,

  1. Il quitte brutalement avec un message d'erreur de syntaxe (« syntax error »)

  2. Il se lance bien mais ne fonctionne pas de la façon attendue (erreur logique, logic error).

  3. Il fonctionne comme vous vous y attendiez mais a des effets indésirables déplaisants (logic bomb).

Il existe des outils pour déboguer des scripts non fonctionnels

  1. Insérer des instructions echo aux points critiques du script pour tracer les variables, ou pour donner un état de ce qui se passe.

    [Astuce]

    Astuce

    Encore mieux, une instruction echo qui affiche seulement lorsque le mode de débogage (debug) est activé.

    ### debecho (debug-echo) par Stefano Falsetto ###
    ### Affichera les paramètres seulement si DEBUG est configuré. ###
    debecho () {
      if [ ! -z "$DEBUG" ]; then
         echo "$1" >&2
         #         ^^^ vers stderr
      fi
    }
    
    DEBUG=on
    Whatever=whatnot
    debecho $Whatever   # whatnot
    
    DEBUG=
    Whatever=notwhat
    debecho $Whatever   # (N'affichera rien.)
    
  2. Utiliser le filtre tee pour surveiller les processus ou les données aux points critiques.

  3. Initialiser des paramètres optionnels -n -v -x

    sh -n nomscript vérifie les erreurs de syntaxe sans réellement exécuter le script. C'est l'équivalent de l'insertion de set -n ou set -o noexec dans le script. Notez que certains types d'erreurs de syntaxe peuvent passer à côté de cette vérification.

    sh -v nomscript affiche chaque commande avant de l'exécuter. C'est l'équivalent de l'insertion de set -v ou set -o verbose dans le script.

    Les options -n et -v fonctionnent bien ensemble. sh -nv nomscript permet une vérification verbeuse de la syntaxe.

    sh -x nomscript affiche le résultat de chaque commande mais d'une façon abrégée. C'est l'équivalent de l'insertion de set -x ou set -o xtrace dans le script.

    Insérer set -u ou set -o nounset dans le script le lance mais donne un message d'erreur « unbound variable » et interrompt le script.

    set -u   # Or   set -o nounset
    #  Le fait de donner à une variable la valeur nulle ne provoque pas
    #+ l'erreur et l'arrêt du script.
    # unset_var=
    
    echo $unset_var   # Variable non assignée (et non déclarée).
    
    echo "Ne devrait rien afficher"
    
    # sh t2.sh
    # t2.sh: line 6: unset_var: unbound variable
    

    Utiliser une fonction « assert » pour tester une variable ou une condition aux points critiques d'un script (cette idée est empruntée du C).

    Exemple 32.4. Tester une condition avec un assert

    #!/bin/bash
    # assert.sh
    
    assert ()                 #  Si la condition est fausse,
    {                         #+ sort du script avec un message d'erreur.
      E_PARAM_ERR=98
      E_ASSERT_FAILED=99
    
    
      if [ -z "$2" ]          # Pas assez de paramètres passés
      then                    # à la fonction assert().
        return $E_PARAM_ERR   # Pas de dommages.
      fi
    
      noligne=$2
    
      if [ ! $1 ] 
      then
        echo "Mauvaise assertion :  \"$1\""
        echo "Fichier \"$0\", ligne $noligne"
                              # Donne le nom du fichier et le numéro de ligne
        exit $E_ASSERT_FAILED
      # else (sinon)
      #   return (retour)
      #   et continue l'exécution du script.
      fi
    } # Insèrer une fonction assert() similaire dans un script
      #+  que vous devez déboguer.
    #######################################################################
    
    
    a=5
    b=4
    condition="$a -lt $b"     # Message d'erreur et sortie du script.
                              #  Essayer de configurer la "condition" en autre chose
                              #+ et voir ce qui se passe.
    
    assert "$condition" $LINENO
    # Le reste du script s'exécute si assert n'échoue pas.
    
    
    # Quelques commandes.
    # Quelques autres commandes...
    echo "Cette instruction s'exécute seulement si \"assert\" n'échoue pas."
    # ...
    # Quelques commandes de plus.
    
    exit $?
    

  4. Utiliser la variable $LINENO et la commande interne caller.

  5. Piéger la sortie.

    La commande exit d'un script déclenche un signal 0, terminant le processus, c'est-à-dire le script lui-même. [115] Il est souvent utilisé pour récupérer la main lors de exit en forçant un « affichage » des variables par exemple. Le trap doit être la première commande du script.

Récupérer les signaux

trap

Spécifie une action à la réception d'un signal ; aussi utile pour le débogage.

Une simple instance :

trap '' 2
# Ignore l'interruption 2 (Control-C), sans action définie.

trap 'echo "Control-C désactivé."' 2
# Message lorsque Control-C est utilisé.

Exemple 32.5. Récupérer la sortie

#!/bin/bash
# Chasse aux variables avec un piège.

trap 'echo Liste de Variables --- a = $a  b = $b' EXIT
# EXIT est le nom du signal généré en sortie d'un script.
#
#  La commande spécifiée par le "trap" ne s'exécute pas
#+ tant que le signal approprié n'est pas envoyé.

echo "Ceci s'affiche avant le \"trap\" -- "
echo "même si le script voit le \"trap\" avant"
echo

a=39

b=36

exit 0
# Notez que mettre en commentaire la commande 'exit' ne fait aucune différence
# car le script sort dans tous les cas après avoir exécuté les commandes.

Exemple 32.6. Nettoyage après un Control-C

#!/bin/bash
# logon.sh: Un script rapide mais sale pour vérifier si vous êtes déjà connecté.

umask 177  #  S'assurer que les fichiers temporaires ne sont pas lisibles
           #+ par tout le monde.

VRAI=1
JOURNAL=/var/log/messages
# Notez que $JOURNAL doit être lisible (en tant que root, chmod 644 /var/log/messages).
FICHIER_TEMPORAIRE=temp.$$
# Crée un fichier temporaire "unique" en utilisant l'identifiant du processus.
#     Utiliser 'mktemp' est une alternative.
#     Par exemple :
#     FICTMP=`mktemp temp.XXXXXX`
MOTCLE=adresse
# À la connexion, la ligne "remote IP address xxx.xxx.xxx.xxx"
#                     ajoutée à /var/log/messages.
ENLIGNE=22
INTERRUPTION_UTILISATEUR=13
VERIFIE_LIGNES=100
# Nombre de lignes à vérifier dans le journal.

trap 'rm -f $FICHIER_TEMPORAIRE; exit $INTERRUPTION_UTILISATEUR' TERM INT
# Nettoie le fichier temporaire si le script est interrompu avec Control-C.

echo

while [ $VRAI ]  # Boucle sans fin.
do
  tail -n $VERIFIE_LIGNES $JOURNAL> $FICHIER_TEMPORAIRE
  # Sauve les 100 dernières lignes du journal dans un fichier temporaire.
  # Nécessaire car les nouveaux noyaux génèrent beaucoup de messages lors de la
  # connexion.
  search=`grep $MOTCLE $FICHIER_TEMPORAIRE`
  # Vérifie la présence de la phrase "IP address"
  # indiquant une connexion réussie.

  if [ ! -z "$search" ] # Guillemets nécessaires à cause des espaces possibles.
  then
     echo "En ligne"
     rm -f $FICHIER_TEMPORAIRE    # Suppression du fichier temporaire.
     exit $ENLIGNE
  else
     echo -n "."        # l'option -n supprime les retours à la ligne de echo,
                        # de façon à obtenir des lignes de points continus.
  fi

  sleep 1  
done  


# Note : Si vous modifiez la variable MOTCLE par "Exit",
# ce script peut être utilisé lors de la connexion pour vérifier une déconnexion
# inattendue.

# Exercice : Modifiez le script, suivant la note ci-dessus, et embellissez-le.

exit 0


# Nick Drage suggère une autre méthode.

while true
  do ifconfig ppp0 | grep UP 1> /dev/null && echo "connecté" && exit 0
  echo -n "."   # Affiche des points (.....) jusqu'au moment de la connexion.
  sleep 2
done

# Problème : Appuyer sur Control-C pour terminer ce processus peut être
# insuffisant (des points pourraient toujours être affichés).
# Exercice : Corrigez ceci.



# Stéphane Chazelas a lui-aussi suggéré une autre méthode.

CHECK_INTERVAL=1

while ! tail -n 1 "$JOURNAL" | grep -q "$MOTCLE"
do echo -n .
   sleep $CHECK_INTERVAL
done
echo "On-line"

# Exercice : Discutez les avantages et inconvénients de chacune des méthodes.

Exemple 32.7. Implémentation simple d'une barre de progression

#! /bin/bash
# progress-bar2.sh
# Author: Graham Ewart (with reformatting by ABS Guide author).
# Used in ABS Guide with permission (thanks!).

# Invoke this script with bash. It doesn't work with sh.

interval=1
long_interval=10

{
     trap "exit" SIGUSR1
     sleep $interval; sleep $interval
     while true
     do
       echo -n '.'     # Use dots.
       sleep $interval
     done; } &         # Start a progress bar as a background process.

pid=$!
trap "echo !; kill -USR1 $pid; wait $pid"  EXIT        # To handle ^C.

echo -n 'Long-running process '
sleep $long_interval
echo ' Finished!'

kill -USR1 $pid
wait $pid              # Stop the progress bar.
trap EXIT

exit $?

[Note]

Note

L'argument DEBUG pour trap exécute une action spécifique après chaque commande dans un script. Cela permet de tracer les variables, par exemple.

Exemple 32.8. Suivre une variable

#!/bin/bash

trap 'echo "VARIABLE-TRACE> \$variable = \"$variable\""' DEBUG
# Affiche la valeur de $variable après chaque commande.

variable=29

echo "Initialisation de \"\$variable\" à $variable."

let "variable *= 3"
echo "Multiplication de \"\$variable\" par 3."

exit $?

#  La construction "trap 'commande1 ... commande2 ...' DEBUG" est plus
#+ appropriée dans le contexte d'un script complexe
#+ où placer plusieurs instructions "echo $variable" pourrait être
#+ difficile et consommer du temps.


# Merci, Stéphane Chazelas, pour cette information.

Affichage du script :

VARIABLE-TRACE> $variable = ""
VARIABLE-TRACE> $variable = "29"
Initialisation de "$variable" à 29.
VARIABLE-TRACE> $variable = "29"
VARIABLE-TRACE> $variable = "87"
Multiplication de "$variable" par 3.
VARIABLE-TRACE> $variable = "87"


Bien sûr, la commande trap sert aussi à autre chose qu'au débogage ; par exemple à résactiver certaines touches de clavier depuis un script (cf l'Exemple A.43, « Chronomètre en ligne de commande »).

Exemple 32.9. Lancer plusieurs processus (sur une machine SMP)

#!/bin/bash
# parent.sh
# Running multiple processes on an SMP box.
# Author: Tedman Eng

#  This is the first of two scripts,
#+ both of which must be present in the current working directory.




LIMIT=$1         # Total number of process to start
NUMPROC=4        # Number of concurrent threads (forks?)
PROCID=1         # Starting Process ID
echo "My PID is $$"

function start_thread() {
        if [ $PROCID -le $LIMIT ] ; then
                ./child.sh $PROCID&
                let "PROCID++"
        else
           echo "Limit reached."
           wait
           exit
        fi
}

while [ "$NUMPROC" -gt 0 ]; do
        start_thread;
        let "NUMPROC--"
done


while true
do

trap "start_thread" SIGRTMIN

done

exit 0



# ======== Second script follows ========


#!/bin/bash
# child.sh
# Running multiple processes on an SMP box.
# This script is called by parent.sh.
# Author: Tedman Eng

temp=$RANDOM
index=$1
shift
let "temp %= 5"
let "temp += 4"
echo "Starting $index  Time:$temp" "$@"
sleep ${temp}
echo "Ending $index"
kill -s SIGRTMIN $PPID

exit 0


# ======================= SCRIPT AUTHOR'S NOTES ======================= #
#  It's not completely bug free.
#  I ran it with limit = 500 and after the first few hundred iterations,
#+ one of the concurrent threads disappeared!
#  Not sure if this is collisions from trap signals or something else.
#  Once the trap is received, there's a brief moment while executing the
#+ trap handler but before the next trap is set.  During this time, it may
#+ be possible to miss a trap signal, thus miss spawning a child process.

#  No doubt someone may spot the bug and will be writing 
#+ . . . in the future.



# ===================================================================== #



# ----------------------------------------------------------------------#



#################################################################
# The following is the original script written by Vernia Damiano.
# Unfortunately, it doesn't work properly.
#################################################################

#!/bin/bash

#  Must call script with at least one integer parameter
#+ (number of concurrent processes).
#  All other parameters are passed through to the processes started.


INDICE=8        # Total number of process to start
TEMPO=5         # Maximum sleep time per process
E_BADARGS=65    # No arg(s) passed to script.

if [ $# -eq 0 ] # Check for at least one argument passed to script.
then
  echo "Usage: `basename $0` number_of_processes [passed params]"
  exit $E_BADARGS
fi

NUMPROC=$1              # Number of concurrent process
shift
PARAMETRI=( "$@" )      # Parameters of each process

function avvia() {
         local temp
         local index
         temp=$RANDOM
         index=$1
         shift
         let "temp %= $TEMPO"
         let "temp += 1"
         echo "Starting $index Time:$temp" "$@"
         sleep ${temp}
         echo "Ending $index"
         kill -s SIGRTMIN $$
}

function parti() {
         if [ $INDICE -gt 0 ] ; then
              avvia $INDICE "${PARAMETRI[@]}" &
                let "INDICE--"
         else
                trap : SIGRTMIN
         fi
}

trap parti SIGRTMIN

while [ "$NUMPROC" -gt 0 ]; do
         parti;
         let "NUMPROC--"
done

wait
trap - SIGRTMIN

exit $?

: <<SCRIPT_AUTHOR_COMMENTS
I had the need to run a program, with specified options, on a number of
different files, using a SMP machine. So I thought [I'd] keep running
a specified number of processes and start a new one each time . . . one
of these terminates.

The "wait" instruction does not help, since it waits for a given process
or *all* process started in background. So I wrote [this] bash script
that can do the job, using the "trap" instruction.
  --Vernia Damiano
SCRIPT_AUTHOR_COMMENTS

[Note]

Note

trap '' SIGNAL (deux apostrophes adjacentes) désactive SIGNAL pour le reste du script. trap SIGNAL restaure la fonctionnalité de SIGNAL. C'est utile pour protéger une portion critique d'un script d'une interruption indésirable.

        trap '' 2  # Le signal 2 est Control-C, maintenant désactivé.
        command
        command
        command
        trap 2     # Réactive Control-C
        


[115] Par convention, signal 0 est affecté à exit.