erplibre/test/test_qemu_start_egl.py
Mathieu Benoit 416ed42448 [ADD] qemu manage : retirer la 3D quand le démarrage échoue sur EGL
Le nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors
le domaine, et la 3D écrite dans la définition rend la VM inutilisable
jusqu'à ce que quelqu'un défasse le réglage. La création avait déjà son
repli, le réglage d'une VM existante n'en avait pas. Un relevé accompagne
le refus, car deux briques distinctes entrent en jeu : egl-headless ouvre
le nœud par GBM — c'est Mesa qui répond — et virglrenderer ne sert
qu'ensuite, donc l'installer ne répare pas un EGL qui refuse.

Vérifié : 11 tests, rougis par trois mutations — rattraper n'importe quel
échec, ne pas redémarrer après le retrait, éteindre l'autostart au passage.

--- EN ---

The render node can exist without EGL starting on it: QEMU then refuses
the domain, and the 3D written into the definition leaves the VM unusable
until someone undoes the setting. Creation already had its fallback,
adjusting an existing VM had none. A report comes with the refusal, since
two distinct bricks are involved: egl-headless opens the node through GBM
— Mesa answers — and virglrenderer only serves afterwards, so installing
it repairs no EGL that refuses.

Checked: 11 tests, turned red by three mutations — catching any failure,
not restarting after removal, switching autostart off along the way.

Assisted-by: Claude Opus 5
2026-09-03 05:00:55 -04:00

183 lines
6.8 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une 3D écrite dans la définition d'une VM se met à l'épreuve au démarrage.
Le nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le
domaine, et la VM reste inutilisable tant que quelqu'un ne défait pas le
réglage. Rien ne permet de le savoir avant l'essai — la création a déjà son
repli, le réglage d'une VM existante avait besoin du sien.
Ce que ces tests gardent :
- l'échec EGL est reconnu au démarrage et le retrait est proposé ;
- le retrait ne touche QUE la 3D : passer « autostart » à None l'éteindrait,
et « heads » ferait naître une étape sans rapport ;
- un échec de démarrage qui n'est pas celui d'EGL n'est pas rattrapé.
"""
import io
import sys
import unittest
from contextlib import redirect_stdout
from unittest import mock
sys.argv = ["todo.py"]
from script.todo import qemu_hardware as hw # noqa: E402
from script.todo.todo import TODO # noqa: E402
SORTIE_EGL = (
"error: erreur interne : le processus s'est arrêté pendant la connexion"
" au moniteur: qemu-system-x86_64: egl: eglInitialize failed:"
" EGL_NOT_INITIALIZED\n"
"qemu-system-x86_64: egl: render node init failed"
)
SORTIE_AUTRE = (
"error: Failed to start domain: internal error:"
" qemu unexpectedly closed"
)
ETAT = {
"name": "vm-a",
"vcpus": 16,
"mem_mib": 32768,
"video": "virtio",
"accel3d": True,
"egl": True,
"render": "/dev/dri/renderD128",
"screen": True,
"heads": 1,
"cpu": "",
"net": "default",
"autostart": True,
}
class LaSignature(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_it_recognises_the_egl_failure(self):
self.assertTrue(self.todo._qemu_egl_failed(SORTIE_EGL))
def test_it_ignores_another_failure(self):
self.assertFalse(self.todo._qemu_egl_failed(SORTIE_AUTRE))
def test_an_unloadable_module_does_not_raise(self):
"""Le menu ne doit pas tomber parce que la source de vérité manque."""
self.todo._qemu_import_module = mock.Mock(side_effect=OSError("x"))
self.assertFalse(self.todo._qemu_egl_failed(SORTIE_EGL))
class LeRetrait(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.lances = []
self.todo.execute = mock.MagicMock()
self.todo.execute.exec_command_live.side_effect = (
lambda cmd, **k: self.lances.append(cmd) or 0
)
self.todo._qemu_dumpxml = lambda n, **k: "<domain/>"
self.todo._qemu_autostart = lambda n: True
self.todo._qemu_import_module = lambda: _FauxModule()
def _lancer(self, sortie, reponse="o"):
with mock.patch.object(hw, "hw_state", return_value=dict(ETAT)):
with mock.patch("builtins.input", return_value=reponse):
with mock.patch("builtins.print"):
self.todo._qemu_start_failed(
"vm-a", "virsh start vm-a", sortie
)
return self.lances
def test_another_failure_is_left_alone(self):
self.assertEqual([], self._lancer(SORTIE_AUTRE))
def test_a_refusal_changes_nothing(self):
self.assertEqual([], self._lancer(SORTIE_EGL, reponse="n"))
def test_it_removes_the_3d_then_starts_again(self):
lances = self._lancer(SORTIE_EGL)
joint = " ".join(lances)
self.assertIn("accel3d=off", joint)
self.assertIn("--remove-device --graphics type=egl-headless", joint)
# Le démarrage revient EN DERNIER : le retrait doit être écrit avant.
self.assertEqual("virsh start vm-a", lances[-1])
def test_it_touches_only_the_3d(self):
"""Le test précédent passerait même si le retrait éteignait aussi le
démarrage automatique : c'est ce que fait « autostart » à None, et
seul un contrôle sur les commandes RÉELLEMENT lancées l'attrape."""
lances = self._lancer(SORTIE_EGL)
# TOUT ce qui précède le démarrage, sans filtrer sur l'outil : la
# commande d'autostart passe par virsh et non par virt-xml, donc un
# filtre sur « virt-xml » laisserait justement passer la fautive.
retraits = lances[:-1]
self.assertEqual(2, len(retraits), retraits)
joint = " ".join(retraits)
self.assertNotIn("--disable", joint)
self.assertNotIn("autostart", joint)
self.assertNotIn("model.heads", joint)
class LeRapport3D(unittest.TestCase):
"""Ce que l'hôte doit fournir pour qu'une 3D de VM démarre.
Deux briques distinctes : « egl-headless » ouvre le nœud et crée le
contexte EGL par GBM — c'est Mesa qui répond — et virglrenderer ne sert
qu'ENSUITE. Les chercher par fichier plutôt que par paquet : les noms de
paquets changent d'une distribution à l'autre, pas les emplacements.
"""
def setUp(self):
self.todo = TODO.__new__(TODO)
self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
def _rendu(self, presents):
self.todo._qemu_lib_present = lambda motif: (
f"/usr/lib/{motif}.0" if motif in presents else ""
)
buf = io.StringIO()
with redirect_stdout(buf):
self.todo._qemu_gpu_3d_report()
return buf.getvalue()
def test_it_names_the_two_bricks(self):
"""virglrenderer sans EGL ne sert à rien : les deux se lisent."""
rendu = self._rendu(set())
self.assertIn("virglrenderer", rendu)
self.assertIn("libEGL.so", rendu)
self.assertIn("ui-egl-headless.so", rendu)
def test_a_missing_piece_is_marked(self):
rendu = self._rendu({"libgbm.so"})
lignes = [l for l in rendu.splitlines() if "libgbm" in l]
self.assertTrue(lignes and lignes[0].strip().startswith("✅"))
manquant = [l for l in rendu.splitlines() if "virglrenderer" in l]
self.assertTrue(manquant[0].strip().startswith("❌"))
def test_it_says_the_node_alone_proves_nothing(self):
"""Le nœud existe dans le cas qui échoue : le rapport doit donc
proposer d'éprouver EGL, pas se contenter de le lister."""
rendu = self._rendu(set())
self.assertIn("/dev/dri/renderD128", rendu)
self.assertIn("eglinfo", rendu)
def test_it_changes_nothing(self):
"""Un rapport qui installerait quoi que ce soit ne serait plus un
rapport."""
self.todo.execute = mock.MagicMock()
self._rendu(set())
self.todo.execute.exec_command_live.assert_not_called()
class _FauxModule:
"""deploy_qemu réduit à ce que le menu lui demande."""
@staticmethod
def egl_failed(sortie):
return "eglInitialize failed" in (sortie or "")
if __name__ == "__main__":
unittest.main()