[FIX] qemu deploy : recréer la VM sans 3D quand EGL ne démarre pas
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
This commit is contained in:
parent
feb6cd7b12
commit
9f94316ab0
2 changed files with 243 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
160
test/test_qemu_egl_fallback.py
Normal file
160
test/test_qemu_egl_fallback.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue