Epic bug #1 : 0 compte double

il y a 4 mois

Ça faisait déjà un petit moment que je pensais à aborder les bugs mémorables que j'ai eu l'occasion de rencontrer depuis que je travaille en entreprise. Je ne sais pas trop quel nom pourrait leur correspondre le mieux, je tente "epic bug" mais sans grande conviction !

Quoi qu'il en soit, c'est l'occasion pour moi de revenir sur un problème marquant comme on en rencontre souvent quand on est développeur ou ops et qui sert de leçon une fois qu'on a fini par comprendre le pourquoi du comment wink. J'espère que le concept vous plaira, je pense continuer ce genre d'articles par la suite (ce ne sont pas les sujets qui vont manquer !).

Contexte :

Dans le cadre d'une application web permettant à des utilisateurs d'envoyer des SMS à leurs prospects, notre mission était le support de l'ensemble des caractères définis par l'encodage GSM 03.38. Sans rentrer dans les détails, c'est cet encodage qui est utilisé par nos téléphones la plupart du temps, notamment lorsque 1 SMS = 160 caractères. Ce qu'il faut savoir, c'est qu'un caractère est généralement encodé sur 7 bits mais peut aussi occuper 14 bits pour certain d'entre eux comme le symbole euro. Notre mission était donc de supporter le plus de caractères possibles dont ceux comptant double (14 bits).

Symptôme :

Plusieurs jours après la mise en production de ce dev, le commercial nous remonte un problème majeur : certains envois de SMS sont facturés double aux clients ! Branle-bas de combat dans l'équipe : on a un problème de facturation et c'est la pire chose qui peut arriver ! Après avoir vérifié l'ampleur du problème on finit heureusement par se rendre compte que le soucis ne touchait que quelques SMS. Ouf !

Recherche :

Viens maintenant le moment magique de la recherche de l'origine du problème et là... rien d'évident ! Le bout de code incriminé est forcément celui qui compte le nombre de caractères de 7 bits dans le contenu des SMS, voici à quoi cela ressemblait à peu près :

function count7bitCharacters(string $msg) : int
{
    $doubleCharacters = ['€', '^', '|', '~', '[', ']', '{', '}', '\\'];

    $numberOf7bitCharacters = 0;
    foreach (countChars($msg) as $char => $occurence) {
        if (in_array($char, $doubleCharacters)) {
            $numberOf7bitCharacters += $occurence * 2;
        } else {
            $numberOf7bitCharacters += $occurence;
        }
    }

    return $numberOf7bitCharacters;
}

/**
 * Retourne un tableau associatif avec en clé chaque
 * caractère présent dans $msg et en valeur le nombre
 * d'occurence de celui-ci.
 */
function countChars(string $msg) : array
{
    // ...
}

Après quelques tests, les résultats correspondent bien à nos attentes, le symbole euro est bien compté double contrairement à la lettre "e", etc... Cependant, après un bon moment à chercher le ou les caractère(s) comptant double (à tort), nous sommes arrivé à isoler 2 messages proches mais qui diffèrent au niveau du résultat lorsqu'on les passait à notre fonction count7bitCharacters() :

echo count7bitCharacters("Cher client, bénéficiez d'une super promotion dans tous vos magasins truc muche, -15€ sur tous nos articles !");
// Résultat attendu : 110
// Résultat obtenu : 110

echo count7bitCharacters("Cher client, bénéficiez d'une super promotion dans tous vos magasins truc muche, -10% sur tous nos articles !");
// Résultat attendu : 109
// Résultat obtenu : 110

(évidemment, tout est bien simplifié pour cet article, c'était bien plus fouillis en réalité laugh)

Résolution :

Ça y est on a trouvé le coupable, c'est le '%' ! Eh non ! C'est le zéro... Mais pourquoi ?

Regardons ce qui se passe quand c'est au tour du '0' d'être sélectionné par le foreach :

$doubleCharacters = ['€', '^', '|', '~', '[', ']', '{', '}', '\\'];

// ...

if (in_array(0, $doubleCharacters)) {
    $numberOf7bitCharacters += $occurence * 2;
} else {
    $numberOf7bitCharacters += $occurence;
}

Rien ne vous choque ? Moi non plus, cependant :

  • Une subtilité de la fonction in_array() qu'on connaît pourtant quasiment tous quand on a fait un peu de PHP joue les troubles fête. 
    Rappel de la signature de in_array() :
    bool in_array ( mixed $needle , array $haystack [, bool $strict = FALSE ] )
    Ah oui, il y a cette histoire de $strict...
  • Sachant maintenant que nos comparaisons d'égalité implicites via in_array() sont non strictes, détaillons encore le code :
    if (0 == '€' || 0 == '^' || 0 == '|' /* etc... */)) {
        $numberOf7bitCharacters += $occurence * 2;
    } else {
        $numberOf7bitCharacters += $occurence;
    }
    

    Vous l'avez senti venir ? Si ce n'est pas le cas, voici l'explication :

    var_dump(0 == "N'importe quelle chaine de caractères non numérique");
    
    // bool(true)

    Une chaîne de caractères comparée de manière non stricte avec un entier entraîne le transtypage de la string en int ! En l'absence d'une valeur numérique interprétable, la chaîne vaudra 0 (int).

  • Précision : c'est bien 0 (int) qui est passé à in_array() et pas '0' (string), mais pourquoi ? Parce que ce caractère est extrait d'une clé du tableau retourné par countChars(). 
    Rappel :
    var_dump(['0' => 'foo']);
    
    // array(1) {
    //  [0] =>
    //  string(4) "toto"
    // }

    On perd le typage d'origine de la clé au profit d'un entier.

Conclusion :

Par chance, il n'y a pas eu de conséquence à ce problème puisqu'il était spécifique au caractère '0' qui est relativement peu utilisé, on s'en est bien sorti laugh ! Quoi qu'il en soit, c'est un bel exemple qui montre à quel point des tests unitaires sur un jeu de données complet peuvent éviter bien des soucis...