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 !