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
183 lines
6.8 KiB
Python
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()
|