[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
This commit is contained in:
Mathieu Benoit 2026-09-03 02:13:41 -04:00
parent 83c0d705c1
commit 416ed42448
3 changed files with 337 additions and 1 deletions

View file

@ -222,7 +222,18 @@ class QemuManageMixin:
f"{action} {shlex.quote(real)}" f"{action} {shlex.quote(real)}"
) )
print(f"\n{t('Will execute:')} {cmd}") 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 @staticmethod
def _qemu_dumpxml(name, inactive=True): def _qemu_dumpxml(name, inactive=True):
@ -333,6 +344,121 @@ class QemuManageMixin:
return [] return []
return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] 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): def _qemu_adjust_hardware(self, names):
"""Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.

View file

@ -5113,6 +5113,33 @@ TRANSLATIONS = {
"fr": "Destination sur l'hôte", "fr": "Destination sur l'hôte",
"en": "Destination on the host", "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:": { "Full output:": {
"fr": "Sortie complète :", "fr": "Sortie complète :",
"en": "Full output:", "en": "Full output:",

183
test/test_qemu_start_egl.py Normal file
View file

@ -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: "<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()