29. 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 ê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 constructions. 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 29.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 29.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 29.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 29.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. 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 optionnelles -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 » à chaque essai d'utilisation d'une variable non déclarée.

  4. 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 29.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 $?
    

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

  6. 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. [87] 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.

[Note]

Note

Un signal est un simple message envoyé au processus, soit par le noyau soit par un autre processus lui disant de réaliser une action spécifiée (habituellement pour finir son exécution). Par exemple, appuyer sur Control+C envoie une interruption utilisateur, un signal INT, au programme en cours d'exécution.

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 29.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 29.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.

[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 29.7. Tracer 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 a d'autres utilités en dehors du débogage.

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

#!/bin/bash
# parent.sh
# Exécuter plusieurs processus sur une machine SMP.
# Auteur : Tedman Eng

#  Ceci est le premier de deux scripts,
#+ les deux étant présent dans le même répertoire courant.




LIMITE=$1         # Nombre total de processus à lancer
NBPROC=4          # Nombre de threads simultanés (processus fils ?)
IDPROC=1          # ID du premier processus
echo "Mon PID est $$"

function lance_thread() {
        if [ $IDPROC -le $LIMITE ] ; then
                ./child.sh $IDPROC&
                let "IDPROC++"
        else
           echo "Limite atteinte."
           wait
           exit
        fi
}

while [ "$NBPROC" -gt 0 ]; do
        lance_thread;
        let "NBPROC--"
done


while true
do

trap "lance_thread" SIGRTMIN

done

exit 0



# ======== Le deuxième script suit ========


#!/bin/bash
# child.sh
# Lancer plusieurs processus sur une machine SMP.
# Ce script est appelé par parent.sh.
# Auteur : Tedman Eng

temp=$RANDOM
index=$1
shift
let "temp %= 5"
let "temp += 4"
echo "Début $index  Temps :$temp" "$@"
sleep ${temp}
echo "Fin   $index"
kill -s SIGRTMIN $PPID

exit 0


# ==================== NOTES DE L'AUTEUR DU SCRIPT ==================== #
#  Ce n'est pas complètement sans bogue.
#  Je l'exécute avec limit = 500 et, après les premières centaines d'itérations,
#+ un des threads simultanés a disparu !
#  Pas sûr que ce soit dû aux collisions des signaux trap.
#  Une fois que le signal est reçu, le gestionnaire de signal est exécuté
#+ un bref moment mais le prochain signal est configuré.
#+ Pendant ce laps de temps, un signal peut être perdu,
#+ donc un processus fils peut manquer.

#  Aucun doute que quelqu'un va découvrir le bogue et nous l'indiquer
#+ ... dans le futur.



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



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



#################################################################
# Ce qui suit est le script original écrit par Vernia Damiano.
# Malheureusement, il ne fonctionne pas correctement.
#################################################################

# multiple-processes.sh : Lance plusieurs processus sur une machine
# multi-processeurs.

# Script écrit par Vernia Damiano.
# Utilisé avec sa permission.

#  Doit appeler le script avec au moins un paramètre de type entier
#+ (nombre de processus concurrents).
#  Tous les autres paramètres sont passés aux processus lancés.


INDICE=8                 # Nombre total de processus à lancer
TEMPO=5                  # Temps de sommeil maximum par processus
E_MAUVAISARGUMENTS=65    # Pas d'arguments passés au script.

if [ $# -eq 0 ] # Vérifie qu'au moins un argument a été passé au script.
then
  echo "Usage: `basename $0` nombre_de_processus [paramètres passés aux processus]"
  exit $E_MAUVAISARGUMENTS
fi

NBPROCESSUS=$1          # Nombre de processus concurrents
shift
PARAMETRI=( "$@" )      # Paramètres de chaque processus

function avvia() {
        local temp
        local index
        temp=$RANDOM
        index=$1
        shift
        let "temp %= $TEMPO"
        let "temp += 1"
        echo "Lancement de $index Temps:$temp" "$@"
        sleep ${temp}
        echo "Fin $index"
        kill -s SIGRTMIN $$
}

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

trap parti SIGRTMIN

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

wait
trap - SIGRTMIN

exit $?

: <<COMMENTAIRES_AUTEUR_SCRIPT
J'avais besoin de lancer un programme avec des options spécifiées sur un certain
nombre de fichiers différents en utilisant une machine SMP. Donc, j'ai pensé
conserver un certain nombre de processus en cours et en lancer un à chaque fois
qu'un autre avait terminé.

L'instruction "wait" n'aide pas car il attend un processus donné ou "tous" les
processus exécutés en arrière-plan. Donc, j'ai écrit ce script bash, réalisant ce
travail en utilisant l'instruction "trap".
  --Vernia Damiano
COMMENTAIRES_AUTEUR_SCRIPT

[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
        


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