From 416ed424487ca774fb37f2799ca4e8740c08a32b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 3 Sep 2026 02:13:41 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20qemu=20manage=20:=20retirer=20la=203D?= =?UTF-8?q?=20quand=20le=20d=C3=A9marrage=20=C3=A9choue=20sur=20EGL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/todo/qemu_manage.py | 128 ++++++++++++++++++++++++- script/todo/todo_i18n.py | 27 ++++++ test/test_qemu_start_egl.py | 183 ++++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 test/test_qemu_start_egl.py diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 7b9ee38..30b81b7 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -222,7 +222,18 @@ class QemuManageMixin: f"{action} {shlex.quote(real)}" ) print(f"\n{t('Will execute:')} {cmd}") - self.execute.exec_command_live(cmd, source_erplibre=False) + if action != "start": + self.execute.exec_command_live(cmd, source_erplibre=False) + continue + # Un démarrage est le seul moment où la 3D écrite dans la + # définition se met à l'épreuve : QEMU refuse le domaine si EGL + # n'y démarre pas, et la VM reste inutilisable jusqu'à ce que + # quelqu'un défasse le réglage. + code, lignes = self.execute.exec_command_live( + cmd, source_erplibre=False, return_status_and_output=True + ) + if code: + self._qemu_start_failed(real, cmd, "\n".join(lignes or [])) @staticmethod def _qemu_dumpxml(name, inactive=True): @@ -333,6 +344,121 @@ class QemuManageMixin: return [] return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] + # Ce que la 3D d'une VM exige de l'hôte, en DEUX briques que le même + # réglage met en jeu et qu'on confond souvent : + # + # « --graphics egl-headless » ouvre le nœud de rendu et crée un contexte + # EGL par GBM — c'est Mesa qui répond, et « eglInitialize failed » vient + # de là. Sans lui, le domaine ne démarre pas du tout. + # + # « accel3d=on » confie ensuite les commandes 3D de l'invité à + # virglrenderer, qui les traduit vers l'OpenGL de l'hôte. Il n'entre en + # jeu qu'APRÈS qu'EGL a démarré : l'installer ne répare donc pas un EGL + # qui refuse. + # + # Les fichiers plutôt que les paquets : leurs noms changent d'une + # distribution à l'autre, leur emplacement beaucoup moins. + _GPU_3D_PIECES = ( + ("qemu ui-egl-headless", "qemu/ui-egl-headless.so"), + ("qemu virtio-gpu-gl", "qemu/hw-display-virtio-gpu-gl.so"), + ("virglrenderer", "libvirglrenderer.so"), + ("mesa libgbm", "libgbm.so"), + ("mesa libEGL", "libEGL.so"), + ) + + @staticmethod + def _qemu_lib_present(motif): + """Chemin du fichier s'il existe dans les emplacements habituels des + bibliothèques et des modules QEMU, sinon la chaîne vide.""" + racines = ( + "/usr/lib", + "/usr/lib64", + "/usr/lib/x86_64-linux-gnu", + "/usr/local/lib", + ) + for racine in racines: + trouves = sorted(glob.glob(os.path.join(racine, motif + "*"))) + if trouves: + return trouves[0] + return "" + + def _qemu_gpu_3d_report(self): + """Dit ce qui manque à l'hôte pour que la 3D d'une VM démarre. + + Ne modifie rien. Un nœud de rendu présent ne prouve RIEN : c'est + l'initialisation d'EGL dessus qui décide, et elle ne se teste qu'en + essayant — d'où les deux commandes suggérées à la fin. + """ + print(f"\n── {t('3D on this host')} ──") + node = self._qemu_host_gpu_node() + print(f" {t('Render node:')} {node or t('none')}") + for nom, motif in self._GPU_3D_PIECES: + chemin = self._qemu_lib_present(motif) + # Absent : dire ce qui a été CHERCHÉ. Une case vide laisse sans + # prise, le motif dit quoi installer et où regarder. + print(f" {'✅' if chemin else '❌'} {nom:<24} {chemin or motif}") + print(f"\n {t('Check EGL itself on that node:')}") + print(" eglinfo -B 2>&1 | head -20") + print(" lspci -nnk | grep -A3 -iE 'vga|3d|display'") + + def _qemu_egl_failed(self, sortie): + """La sortie accuse-t-elle un EGL inutilisable ? + + La signature vit dans deploy_qemu.py, qui doit tenir seul en tant que + script : la lire là plutôt que la recopier ici évite deux vérités. + """ + try: + return bool(self._qemu_import_module().egl_failed(sortie)) + except Exception: + return False + + def _qemu_start_failed(self, name, cmd, sortie): + """Un démarrage refusé : proposer ce qui le débloque, s'il y a lieu. + + Le seul échec qui se rattrape ici est celui de la 3D : le nœud de + rendu existe, mais EGL n'y démarre pas, et rien ne permettait de le + savoir avant l'essai. Retirer l'accélération rend une VM qui démarre. + """ + if not self._qemu_egl_failed(sortie): + return + print(f"\n ⚠ {t('EGL does not start on this host GPU.')}") + print( + f" {t('The VM cannot boot while 3D stays in its definition.')}" + ) + self._qemu_gpu_3d_report() + if not self._is_yes_default_yes( + input(t("Remove the 3D and start again? (Y/n): ")) + ): + return + from script.todo import qemu_hardware as hw + + etat = hw.hw_state( + self._qemu_dumpxml(name), self._qemu_autostart(name) + ) + if not etat.get("name"): + print(f" ⚠ {t('Unreadable VM definition.')}") + return + # Seule la 3D change. build_want retombe sur l'état pour les vCPU, la + # RAM, les écrans, le CPU et le réseau — mais PAS pour « autostart », + # qu'il lit en booléen sec : y passer None éteindrait le démarrage + # automatique d'une VM qu'on voulait seulement débloquer. + want = hw.build_want(etat, None, None, False, etat.get("autostart")) + plan = hw.hw_plan(etat, want) + for entree in plan: + if "cmd" not in entree: + continue + retrait = sudo_prefix() + " ".join( + shlex.quote(c) for c in entree["cmd"] + ) + print(f"\n{t('Will execute:')} {retrait}") + self.execute.exec_command_live( + retrait, + source_erplibre=False, + new_env={"PATH": system_path()}, + ) + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + def _qemu_adjust_hardware(self, names): """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8cc8d57..1ebf7f8 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5113,6 +5113,33 @@ TRANSLATIONS = { "fr": "Destination sur l'hôte", "en": "Destination on the host", }, + "EGL does not start on this host GPU.": { + "fr": "EGL ne démarre pas sur le GPU de cet hôte.", + "en": "EGL does not start on this host GPU.", + }, + "The VM cannot boot while 3D stays in its definition.": { + "fr": ( + "La VM ne démarrera pas tant que la 3D reste dans sa" + " définition." + ), + "en": "The VM cannot boot while 3D stays in its definition.", + }, + "Remove the 3D and start again? (Y/n): ": { + "fr": "Retirer la 3D et redémarrer ? (O/n) : ", + "en": "Remove the 3D and start again? (Y/n, default: yes): ", + }, + "3D on this host": { + "fr": "La 3D sur cet hôte", + "en": "3D on this host", + }, + "Render node:": { + "fr": "Nœud de rendu :", + "en": "Render node:", + }, + "Check EGL itself on that node:": { + "fr": "Vérifier EGL lui-même sur ce nœud :", + "en": "Check EGL itself on that node:", + }, "Full output:": { "fr": "Sortie complète :", "en": "Full output:", diff --git a/test/test_qemu_start_egl.py b/test/test_qemu_start_egl.py new file mode 100644 index 0000000..c72c564 --- /dev/null +++ b/test/test_qemu_start_egl.py @@ -0,0 +1,183 @@ +#!/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: "" + 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()