Python 2 à 3: une méthode portée, deux bugs offerts
Même si Python 3 n'est pas fondamentalement différent de Python 2 et qu'il existe de nombreux outils pour aider à porter son code, le portage d'un projet entier ne se fait jamais entièrement sans difficulté et est souvent l'occasion d'en apprendre plus sur notre langage préféré.
Comme on va le voir, c'est aussi une bonne manière de découvrir des problèmes insoupçonnés dans son code !
Un peu de contexte(s): gestion du réseau chez Wifirst
J'ai récemment été amené à travailler sur la compatibilité Python 3 de notre code de gestion du réseau (adressage, routage, etc). Nous utilisons le projet pyroute2, auquel nous contribuons régulièrement, pour s'interfacer avec le noyau via l'API Netlink.
La base de pyroute2 est une classe d'assez bas niveau (IPRoute
), dont l'instanciation provoque l'ouverture d'une socket Netlink. Pour faire simple, nous appellons l'ensemble "contexte IPRoute".
Nous avons implémenté notre propre module d'abstraction utilisant massivement ces contextes. Il nous permet de gérer par exemple les adresses et les routes sans avoir à rentrer dans les détails des structures Netlink exposées par les méthodes d'IPRoute
.
Afin de ne pas avoir à gérer le cycle de vie de ces contextes à chaque appel à nos fonctions d'abstraction, un motif récurrent que l'on y retrouve est le suivant:
@needs_iproute_context
def do_something_network_related(arg1, arg2, ipr=None):
# do stuff
other_network_related_things("yeah", arg2, ipr=ipr)
Le décorateur needs_iproute_context
inspecte les arguments de la fonction décorée et, s'il y trouve un argument nommé "ipr"
dont la valeur est None
, le remplace par un contexte IPRoute qu'il ferme après l'appel. Cela nous permet de pouvoir appeler toutes nos fonctions de routage sans avoir à instancier de contexte IPRoute par nous-même, et tout en évitant d'en instancier un pour chaque appel de fonction.
En voici une implémentation simplifiée:
from pyroute2 import IPRoute
def needs_iproute_context(fun):
def wrapped(*args, **kwargs):
if kwargs.get("ipr"):
return fun(*args, **kwargs)
else:
with IPRoute() as ipr:
kwargs["ipr"] = ipr
return fun(*args, **kwargs)
return wrapped
Un souci de portabilité pas commun
Dans la majeure partie des cas, pas de problème; needs_iproute_context
fonctionne aussi bien en Python 2 qu'en Python 3. Mais dans un cas particulier, ce décorateur était utilisée d'une manière un peu plus exotique (code également simplifié):
@needs_iproute_context
@contextmanager
def temporary_route(dst, gateway, ipr=None):
ipr.route("add", dst=dst, gateway=gateway)
yield
ipr.route("del", dst=dst, gateway=gateway)
Ce gestionnaire de contexte nous permet de mettre en place une route de manière temporaire, qui est supprimée dès que l'on sort du bloc with
. Par exemple, ce bloc rajoute une route temporaire, l'affiche et la supprime:
with temporary_route(dst="66.96.149.1/32", gateway="10.4.1.2"):
with IPRoute() as ipr:
for i in ipr.get_routes(dst="66.96.149.1/32"):
print(i.get_attr("RTA_GATEWAY"))
Et c'est là que les soucis commencent. temporary_route
fonctionne parfaitement en Python 2, mais échoue invariablement en Python 3:
$ sudo python3 temporary_route.py
Traceback (most recent call last):
File "temporary_route.py", line 39, in <module>
with temporary_route(dst="66.96.149.1/32", gateway="10.4.1.2"):
File "/usr/lib/python3.6/contextlib.py", line 81, in __enter__
return next(self.gen)
File "temporary_route.py", line 19, in temporary_route
ipr.route("add", dst=dst, gateway=gateway)
File "/usr/lib/python3/dist-packages/pyroute2/iproute.py", line 1750, in route
callback=callback)
File "/usr/lib/python3/dist-packages/pyroute2/netlink/nlsocket.py", line 804, in nlm_request
return do_try()
File "/usr/lib/python3/dist-packages/pyroute2/netlink/nlsocket.py", line 780, in do_try
self.put(msg, msg_type, msg_flags, msg_seq=msg_seq)
File "/usr/lib/python3/dist-packages/pyroute2/netlink/nlsocket.py", line 561, in put
self.sendto_gate(msg, addr)
File "/usr/lib/python3/dist-packages/pyroute2/netlink/rtnl/iprsocket.py", line 74, in _gate
return self._sendto(msg.data, addr)
OSError: [Errno 9] Bad file descriptor
Visiblement, l'ajout de route n'a pas fonctionné, car l'envoi du message sur la socket netlink a échoué. Mais pourquoi cette différence entre les deux versions de Python ?
Le message d'erreur indique qu'il y a un problème sur la socket Netlink. Pour voir ce qu'il en est, on peut poser un point d'arrêt avant l'ajout de la route pour inspecter le contexte IPRoute:
> /home/etienne/work/article-devblog/temporary_route.py(22)temporary_route()
-> ipr.route("add", dst=dst, gateway=gateway)
(Pdb) p ipr
<pyroute2.iproute.IPRoute object at 0x7fd503f77b70>
(Pdb) p ipr.closed
True
(Pdb)
La sortie est la même en Python 2 et 3: le contexte IPRoute est déjà fermé.
Pourquoi est-il fermé, et comment est-ce possible que ça fonctionne quand même en Python 2 ? C'est le moment de ressortir le fidèle strace, qui pourra nous en dire plus long sur ce qu'il se passe réellement niveau socket. En relançant avec le point d'arrêt + strace:
$ sudo strace -e socket,close python2 temporary_route.py
[...]
socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE) = 5
close(4) = 0
close(3) = 0
close(3) = 0
> /home/etienne/work/article-devblog/temporary_route.py(24)temporary_route()
-> ipr.route("add", dst=dst, gateway=gateway)
close(3) = 0
(Pdb) p ipr.closed
True
$ sudo strace -e socket,close python3 temporary_route.py
[...]
socket(AF_NETLINK, SOCK_DGRAM|SOCK_CLOEXEC, NETLINK_ROUTE) = 5
close(4) = 0
close(3) = 0
close(5) = 0
close(3) = 0
> /home/etienne/work/article-devblog/temporary_route.py(24)temporary_route()
-> ipr.route("add", dst=dst, gateway=gateway)
(Pdb) p ipr.closed
True
Dans les deux cas, une socket Netlink est ouverte (descripteur de fichier numéro 5), mais elle est fermée avant l'ajout de la route en Python 3, alors qu'elle reste ouverte en Python 2 ! Par contre, l'objet IPRoute associé est considéré comme fermé.
On a donc deux soucis:
- le contexte IPRoute fermé avec sa socket toujours ouverte,
- le fait qu'il soit fermé dans
needs_iproute_context
La socket de Schrödinger
Pour confirmer ce problème de socket considérée comme fermée par l'objet IPRoute alors qu'elle est toujours ouverte par le système, on peut faire un test simple :
from pyroute2 import IPRoute
ipr = IPRoute()
ipr.get_links()
ipr.close()
ipr.get_links()
Ça "fonctionne" en Python 2, mais en Python 3 le deuxième appel à get_links
échoue.
C'est ennuyant pour nous, car le comportement en Python 3 est correct, ce qui signifie que temporary_route
n'aurai jamais dû fonctionner.
Pourquoi cette différence de comportement ? Étant donné que le code de pyroute2 est le même pour Python 2 et 3, j'ai regardé s'il y avait une différence dans la fermeture des sockets dans le module socket
de Python. Et c'est effectivement le cas:
Python 2.7:
def close(self, _closedsocket=_closedsocket,
_delegate_methods=_delegate_methods, setattr=setattr):
# This function should not reference any globals. See issue #808164.
self._sock = _closedsocket()
dummy = self._sock._dummy
for method in _delegate_methods:
setattr(self, method, dummy)
Python 3.6:
def close(self):
# This function should not reference any globals. See issue #808164.
self._closed = True
if self._io_refs <= 0:
self._real_close()
En Python 2, les méthodes associées à la socket (_delegate_methods
), comme sendto
et recv
sont remplacées lors de la fermeture par des méthodes factices qui se contentent de lever une erreur, pas en Python 3.
Pendant ce temps là, du côté de pyroute2:
(extrait de NetlinkSocket.post_init
)
self._sendto = getattr(self._sock, 'sendto')
self._recv = getattr(self._sock, 'recv')
self._recv_into = getattr(self._sock, 'recv_into')
La classe NetlinkSocket
copie lors de son initialisation une référence vers les méthodes de la socket. En Python 2, cela créé un état incohérent lors de la fermeture: la socket a remplacé ses méthodes par des factices, mais NetlinkSocket
pointe toujours sur les "vraies" méthodes. Des donnés peuvent donc toujours être envoyées sur la socket si elle n'a pas encore été fermée au niveau système, ce qui semble être le cas.
Considérant que ce comportement était un bug, j'ai ouvert une pull request sur pyroute2, une correction rapide de NetlinkSocket
qui fait en sorte de ne plus garder une référence sur des méthodes qui peuvent avoir changé.
Décorer un gestionnaire de contexte ?
Avec un comportement correct de fermeture des sockets par pyroute2, on se retrouve avec un autre problème: temporary_route
ne fonctionne pas du tout car la socket est fermée avant même d'entrer dans ce gestionnaire de contexte.
Les plus versés dans les arcanes de Python auront peut être déjà compris pourquoi, mais pour ceux (moi inclus) pour qui ça ne tombe pas sous le sens, réécrire le gestionnaire de contexte dans sa forme non condensée (c'est à dire sans utiliser le décorateur contextmanager) rend les choses bien plus claires:
class TemporaryRoute(object):
@needs_iproute_context
def __init__(self, dst, gateway, ipr=None):
self.ipr = ipr
self.dst = dst
self.gateway = gateway
def __enter__(self):
self.ipr.route("add", dst=self.dst, gateway=self.gateway)
def __exit__(self, *_):
self.ipr.route("del", dst=self.dst, gateway=self.gateway)
needs_iproute_context
ne peut pas décorer un gestionnaire de contexte, car c'est une classe. Que l'on décore le constructeur ou la classe elle-même, cela revient au même; le contexte est fermé implicitement à la fin du bloc with IPRoute() as ipr:
de needs_iproute_context
et n'est donc valable que dans __init__
.
La meilleure solution a donc été de réimplémenter temporary_route
pour gérer son propre contexte IPRoute, ce qui fonctionne à la fois en Python 2 et 3.
Au final
Porter un seul gestionnaire de contexte aura permis de trouver un souci dans pyroute2, un autre dans notre code, et d'en apprendre davantage sur le Python; le portage vers Python 3 est donc un bon investissement !