Pourquoi et comment a été corrigé NotSerializableException dans ScalaTest
Qu’est-ce qui ne va pas avec ScalaTest 3.2.2 ? Dans cet article je vais revenir sur un bug introduit dans ScalaTest
et comment le comprendre, l’analyser, le résoudre et éviter qu’il ne se reproduise à l’avenir.
C’est quoi le problème ?
Retour aux fondamentaux : toujours essayer de décrire le problème jusqu’à sa cause racine afin d’être certain de bien le
comprendre pour pouvoir ensuite le résoudre correctement.
Lorsqu’on met à jour ScalaTest vers la 3.2.2 et dans certaines conditions, on se retrouve à avoir
de nombreuses exceptions loggées sur la sortie standard. Voici un exemple pour illustrer :
Comme on peut le constater, c’est plutôt ennuyeux d’avoir un retour d’exécution de test aussi verbeux. D’autant que mon
projet d’exemple est minimaliste : il n’y a qu’un seul test de lancé. Il faut imaginer que pour des projets un peu
sérieux avec de nombreux tests, le log est extrêmement volumineux. Une stacktrace est ajoutée à chaque fois qu’un
évènement de reporting ScalaTest est émis. Et ScalaTest en émet de nombreux. Ce sont ces évènements qui permettent
par exemple à votre IDE de vous montrer un avancement synchronisé et cohérent de vos tests quand vous les faites
tourner.
Le problème introduit par la 3.2.2 ne se produit que lorsque ces évènements sont sérialisés par un
Reporter appelé SocketReporter. Il est fréquent que ScalaTest utilise ce Reporter lorsque l’exécution des tests
est lancée par sbt en mode forké afin qu’il puisse, tout comme un ide, produire un avancement
synchronisé et cohérent des tests dans la jvm forkée.
Ce Reporter utilise le mécanisme Java Object Serialization pour envoyer les évènements vers
un flux réseau.
C’est donc lors de cette sérialisation qu’une erreur se produit. Par la suite tout le flux est corrompu et de nombreuses
erreurs de sérialisation s’accumulent au moment où le SocketReporter sérialise ses objets.
Opération reproduction
Pour tout bon bug qui se respecte, un test minimal permettant de le reproduire est essentiel pour bien le corriger. Nous
allons donc nous concentrer sur la lecture de la stack trace d’erreur (que j’ai raccourci pour l’exercice) :
On comprend donc qu’un thread dédié propage les événements émis par ScalaTest (ici un TestFailed) vers les reporters
qui ont bien voulu s’enregister auprès du dispatcher. Ce dernier demande au SocketReporter de bien vouloir prendre en
compte l’évènement TestFailed. Et c’est sur ce groupe d’instances qu’on fini par tomber sur l’erreur racine :
Nous allons donc reproduire le problème en provoquant une erreur d’assertion : demander à EitherValues la valeur
right alors que la variable Either sera en réalité un Left.
En lançant ce test avec sbt en mode forké, on a bien la même erreur que mentionnée plus haut.
Allons donc plus loin en récupérant l’instance de TestFailedException levée par ce matcher.
Il faut maintenant vérifier que cette exception est bien impossible à sérialiser.
Cette fois on obtient un test unitaire qui échoue pour les bonnes raisons. C’est seulement à partir de ce test en échec
qu’on va pouvoir comprendre ce qui ne va pas avec la sérialisation de cette TestFailedException.
Je ne conserve que les éléments de la stack intéressant dans cette chronologie d’appels en pseudo code :
writeObject0(TestFailedException) : écrit l’instance de TestFailedException
defaultWriteFields(TestFailedException) : sérialise tous les champs de TestFailedException
writeObject0(TestFailedException.messageFun) : sérialise l’instance TestFailedException.messageFun de type
scala.Function1 - la lambda qui dans le code prend la valeur
(_: StackDepthException) => Some(Resources.eitherRightValueNotDefined(rightProj.e)). Oh tient c’est justement un
bout de code qui a été mergé dans la branche de scalatest il y a peu de temps.
En réalité, scala.Function1 est une vision simplifiée de l’instance en mémoire qui prend la valeur
org.scalatest.EitherValues$RightValuable$$Lambda$109/1907431275@78aab498.
defaultWriteFields(TestFailedException.messageFun) : sérialise tous les champs de
(_: StackDepthException) => Some(Resources.eitherRightValueNotDefined(rightProj.e)). Il s’agit d’une instance de
SerializedLambda.
writeObject0(TestFailedException.messageFun.asInstanceOf[SerializedLambda].capturedArgs) : sérialise les
capturedArgs de la lambda. Voici ce que dit la javadoc au sujet de ce field : The dynamic arguments to the lambda
factory site, which represent variables captured by the lambda
writeArray(TestFailedException.messageFun.asInstanceOf[SerializedLambda].capturedArgs) : sérialise le seul élément
du tableau des capturedArgs de la lambda qui est la fameuse instance de EitherValues$RightValuable
writeObject0(TestFailedException.messageFun.asInstanceOf[SerializedLambda].capturedArgs[0]) : lance l’exception
NotSerializedException à juste titre car les instances de EitherValues$RightValuable ne sont pas sérialisables.
En conclusion, il suffit de rendre EitherValues.RightValuable et EitherValues.LeftValuable sérialisables pour ne
plus avoir de problèmes. Le problème vient donc de ce changement dans la base de code :
La différence se situe dans les paramètres que la lambda doit capturer pour invoquer l’évaluation de la fonction.
Auparavant, il n’y en avait aucun. Avec ce changement, la lambda doit capturer l’instance de rightProj qui n’est pas
sérialisable.
Correction
La correction a consisté à rendre sérialisable les classes :
org.scalatest.EitherValues
org.scalatest.EitherValues.LeftValuable
org.scalatest.EitherValues.RightValuable
org.scalatest.TryValues
org.scalatest.TryValues.SuccessOrFailure
Intervention inattendue d’un tiers
Peu après avoir soumis la pull request de correction sur EitherValues et TryValues, un
utilisateur de ScalaTest s’est manifesté pour mentionner qu’il avait le même type d’erreur mais sur FutureValues.
Après avoir investigué et reproduit l’erreur, la correction fut apportée en rendant possible la sérialisation de
org.scalatest.concurrent.AbstractPatienceConfiguration.PatienceConfig par l’intermédiaire d’un proxy injecté grâce à
la fonction writeReplace. Voici l’explication ajoutée à cette fonction :
Validation de la pull request par un lieutenant
Classiquement dans un projet open source, les mainteneurs sont secondés par des lieutenants. J’ai constaté que c’était
le cas dans ScalaTest. Un développeur a fait tourner des tests supplémentaires que la CI ne fait pas tourner et m’a
proposé d’ajouter trois commits sur ma pull request pour exclure certains bouts de code du
build ScalaJS. Il a ensuite donné le feu vert au mainteneur pour qu’il puisse effectuer le merge final.
Merge final par le mainteneur
Après avoir posé une remarque judicieuse sur la pull request il l’a finalement intégrée
dans la branche stable.
Attente de la release
On est maintenant en train d’attendre la release 3.2.3 de ScalaTest.
Conclusion
En conclusion : n’hésitez pas à contribuer. On apprend toujours plein de choses intéressantes et on participe à l’effort
commun pour que notre industrie soit plus fiable et plus qualitative.