From 9f94316ab038bcc03bb79e17f96015fed68ce296 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 3 Sep 2026 00:34:45 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20qemu=20deploy=20:=20recr=C3=A9er=20la?= =?UTF-8?q?=20VM=20sans=203D=20quand=20EGL=20ne=20d=C3=A9marre=20pas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le nœud de rendu existe mais EGL ne s'y initialise pas : QEMU s'arrête sur « eglInitialize failed » pendant la connexion au moniteur. La détection ne voit qu'un fichier dans /dev/dri, et rien ne distingue un GPU utilisable d'un nœud sans pile EGL avant que QEMU n'essaie. La création réessaie donc sans la 3D, après avoir retiré le domaine de l'essai raté — sans quoi le nom reste pris. « --gpu on » n'est pas rétrogradé en silence, et un échec qui n'est pas celui d'EGL n'est pas rattrapé. Vérifié : 8 tests et trois mutations. Le chemin réel n'a pas été exécuté, faute de /dev/dri et de virt-install sur la machine de développement. --- EN --- The render node exists but EGL will not initialise on it: QEMU stops on « eglInitialize failed » while connecting to the monitor. Detection only sees a file under /dev/dri, and nothing separates a usable GPU from a node without an EGL stack until QEMU tries. Creation therefore retries without 3D, after undefining the domain of the failed attempt — the name would otherwise stay taken. « --gpu on » is not silently downgraded, and a failure that is not EGL's is not caught. Checked: 8 tests and three mutations. The real path was not exercised, for lack of /dev/dri and virt-install on the development machine. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 87 +++++++++++++++++- test/test_qemu_egl_fallback.py | 160 +++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 test/test_qemu_egl_fallback.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 52a96a7..da89f5b 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -351,6 +351,23 @@ def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]: return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)." +# Ce que QEMU écrit quand le nœud de rendu existe mais qu'EGL n'y démarre +# pas. Le fichier /dev/dri/renderD* est alors bien là — un GPU virtuel sans +# pile EGL, un pilote sans GBM, une carte que Mesa ne sait pas ouvrir : la +# présence du nœud ne prouve donc PAS que la 3D fonctionne, et rien ne le dit +# avant que QEMU n'essaie. +EGL_ECHEC = ( + "eglInitialize failed", + "render node init failed", + "EGL_NOT_INITIALIZED", +) + + +def egl_failed(output: str) -> bool: + """La sortie de virt-install accuse-t-elle un EGL inutilisable ?""" + return any(marque in (output or "") for marque in EGL_ECHEC) + + def gpu_apply( video: list, mode: str, node: str, screen: bool ) -> tuple[list, list, str]: @@ -663,15 +680,38 @@ class Runner: self.dry_run = dry_run def run( - self, cmd: list[str], *, privileged: bool = False, check: bool = True - ) -> None: + self, + cmd: list[str], + *, + privileged: bool = False, + check: bool = True, + capture: bool = False, + ): + """Lance la commande. `capture` rend (code, sortie) au lieu de sortir. + + Sans `capture`, un échec termine le programme : c'est le comportement + voulu partout où il n'y a rien à rattraper. Avec, l'appelant décide — + seul l'appel qui SAIT réessayer autrement doit le demander. + """ if privileged and self.use_sudo: cmd = ["sudo", *cmd] printable = " ".join(cmd) if self.dry_run: print(f" [dry-run] {printable}") - return + return (0, "") if capture else None print(f" $ {printable}") + if capture: + # La sortie est réaffichée telle quelle : le journal garde tout, + # et l'appelant peut lire ce que l'outil a dit. + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if res.stdout: + print(res.stdout, end="") + return res.returncode, res.stdout or "" try: subprocess.run(cmd, check=check) except subprocess.CalledProcessError as exc: @@ -2984,6 +3024,7 @@ def virt_install( # comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus # coûteux qu'on puisse laisser en place sans le dire. gpu_node = args.gpu_node or host_gpu_node() + video_sans_3d = list(video) video, gpu_args, gpu_msg = gpu_apply( video, args.gpu, gpu_node, graphics != "none" ) @@ -3064,6 +3105,7 @@ def virt_install( "--channel", "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", ] + i_gpu = len(cmd) cmd += video + gpu_args if args.arch == "s390x": # s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le @@ -3128,7 +3170,44 @@ def virt_install( f"XDG_CACHE_HOME={cache_dir}", f"HOME={cache_dir}", ] - runner.run(log_env + cmd, privileged=True) + if not gpu_args: + runner.run(log_env + cmd, privileged=True) + return + # La 3D est le SEUL argument dont l'échec se rattrape : le nœud de rendu + # existe, mais QEMU n'arrive pas à y démarrer EGL. Rien ne permet de le + # savoir avant d'essayer, donc on essaie, et on retire la 3D si c'est + # elle qui a fait tomber le domaine. + code, sortie = runner.run(log_env + cmd, privileged=True, capture=True) + if code == 0: + return + if not egl_failed(sortie): + sys.exit( + f"\nÉchec de la commande (code {code}) :\n" + f" {' '.join(log_env + cmd)}" + ) + if (args.gpu or "auto").lower() == "on": + # « --gpu on » est une exigence, pas une préférence : la trahir en + # silence donnerait une VM qui n'est pas celle qu'on a demandée. + sys.exit( + "\nErreur : --gpu on demandé, mais EGL ne démarre pas sur" + f" {gpu_node}.\n Relancer avec --gpu off pour une VM en rendu" + " logiciel." + ) + print( + f"\n ⚠ EGL ne démarre pas sur {gpu_node} : la 3D est retirée et la" + "\n VM recréée en rendu logiciel. « --gpu off » évite cet essai." + ) + cmd_sans_3d = ( + cmd[:i_gpu] + video_sans_3d + cmd[i_gpu + len(video) + len(gpu_args) :] + ) + # Le domaine défini par l'essai raté doit partir : sans quoi virt-install + # refuse le nom, et la VM resterait celle qui ne démarre pas. + runner.run( + ["virsh", "--connect", LIBVIRT_URI, "undefine", args.name, "--nvram"], + privileged=True, + check=False, + ) + runner.run(log_env + cmd_sans_3d, privileged=True) def watch_and_restart(name: str, runner: Runner) -> None: diff --git a/test/test_qemu_egl_fallback.py b/test/test_qemu_egl_fallback.py new file mode 100644 index 0000000..01d8d9d --- /dev/null +++ b/test/test_qemu_egl_fallback.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un nœud de rendu présent ne prouve pas que la 3D marche. + +QEMU refuse de démarrer le domaine quand EGL ne s'initialise pas sur le nœud, +et il le dit seulement à ce moment-là : « egl: eglInitialize failed: +EGL_NOT_INITIALIZED », puis « egl: render node init failed ». La détection ne +voit qu'un fichier dans /dev/dri, et aucun test préalable ne distingue un GPU +utilisable d'un nœud qui existe sans pile EGL. + +Ce que ces tests gardent : + +- une VM graphique naît quand même, en rendu logiciel, plutôt que d'échouer ; +- le domaine de l'essai raté est retiré avant la seconde tentative, sinon le + nom est pris et la VM reste celle qui ne démarre pas ; +- « --gpu on » est une exigence : elle n'est pas trahie en silence ; +- un échec qui n'est PAS celui d'EGL n'est pas rattrapé. +""" + +import importlib.util +import io +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.argv = ["todo.py"] +RACINE = Path(__file__).resolve().parents[1] + + +def _deploy_qemu(): + path = RACINE / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + +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\n" +) +SORTIE_AUTRE = "ERROR Unknown OS name 'ubuntu99.04'\n" + + +class Signature(unittest.TestCase): + def test_it_recognises_the_egl_failure(self): + self.assertTrue(DQ.egl_failed(SORTIE_EGL)) + + def test_it_does_not_recognise_another_failure(self): + self.assertFalse(DQ.egl_failed(SORTIE_AUTRE)) + self.assertFalse(DQ.egl_failed("")) + + +class Repli(unittest.TestCase): + """virt_install avec un GPU détecté, dont EGL ne démarre pas.""" + + def _args(self, gpu="auto"): + return SimpleNamespace( + name="vm-a", + memory=4096, + vcpus=2, + arch="amd64", + network="network=default,model=virtio", + graphics="none", + desktop=True, + gpu=gpu, + gpu_node="/dev/dri/renderD128", + attach_console=False, + bios=False, + ) + + def _lancer(self, gpu="auto", sortie=SORTIE_EGL, code=1): + """Rend (commandes lancées, SystemExit ou None).""" + lances = [] + + class FauxRunner: + dry_run = False + use_sudo = False + + def run(self, cmd, *, privileged=False, check=True, capture=False): + lances.append(list(cmd)) + if capture: + return (code, sortie) + return None + + with mock.patch.object( + DQ, "host_arch", return_value="amd64" + ), mock.patch.object( + DQ, "kvm_available", return_value=True + ), mock.patch.object( + DQ, "os" + ) as faux_os: + faux_os.getuid.return_value = 1000 + faux_os.makedirs.return_value = None + buf = io.StringIO() + with redirect_stdout(buf): + try: + DQ.virt_install( + self._args(gpu), + Path("/tmp/d.qcow2"), + Path("/tmp/s.iso"), + "ubuntu26.04", + FauxRunner(), + ) + sortie_exc = None + except SystemExit as exc: + sortie_exc = exc + return lances, sortie_exc, buf.getvalue() + + def test_the_first_attempt_carries_the_3d(self): + lances, _, _ = self._lancer() + self.assertIn("model.acceleration.accel3d=on", " ".join(lances[0])) + + def test_a_second_attempt_runs_without_the_3d(self): + lances, exc, rendu = self._lancer() + self.assertIsNone(exc, rendu) + dernier = " ".join(lances[-1]) + self.assertNotIn("accel3d", dernier) + self.assertNotIn("egl-headless", dernier) + # L'écran doit revenir : une VM graphique sans --video n'en a plus. + self.assertIn("--video virtio", dernier) + + def test_the_failed_domain_is_undefined_first(self): + """Sans ce retrait, virt-install refuse le nom déjà pris et la VM + reste celle qui ne démarre pas.""" + lances, _, _ = self._lancer() + milieu = " ".join(lances[1]) + self.assertIn("undefine", milieu) + self.assertIn("vm-a", milieu) + self.assertLess(1, len(lances) - 1) + + def test_gpu_on_is_not_silently_downgraded(self): + """« --gpu on » est une exigence : la trahir donnerait une VM qui + n'est pas celle qu'on a demandée.""" + lances, exc, _ = self._lancer(gpu="on") + self.assertIsInstance(exc, SystemExit) + self.assertIn("--gpu off", str(exc)) + self.assertEqual(1, len(lances)) + + def test_another_failure_is_not_retried(self): + lances, exc, _ = self._lancer(sortie=SORTIE_AUTRE) + self.assertIsInstance(exc, SystemExit) + self.assertEqual(1, len(lances)) + + def test_a_success_never_retries(self): + lances, exc, _ = self._lancer(sortie="", code=0) + self.assertIsNone(exc) + self.assertEqual(1, len(lances)) + + +if __name__ == "__main__": + unittest.main()