[FIX] script todo: ne plus annoncer un émulateur qui n'a pas démarré

Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache,
donc son code de retour vaut 0 même quand rien ne se lance : le menu disait
« Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le
démarrage attend maintenant de VOIR le processus, et à défaut cite le journal.

Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher
l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée
(True), migration (« aucun binaire emulator »), sans rien y démarrer.

Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers
suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus
qui l'affichent.

--- EN ---

Three defects found while driving the menu against real VMs. setsid detaches,
so its exit code is 0 even when nothing launches: the menu said "Started" on a
VM with no SDK, whose log said "not found". Starting now waits to SEE the
process, and quotes the log when it never appears.

A prior probe reads binary and AVD in one go: a VM deployed without ticking the
tool is the normal case, not a failure. Verified on two real VMs — tooled
(True), migration ("no emulator binary") — without starting anything there.

And anything that was not "2" started the emulator; a stray "n" was enough.
Along the way, "Choice" was untranslated in all three menus showing it.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 03:46:12 -04:00
parent 3b2f7fa3d6
commit 8f5a439148
3 changed files with 164 additions and 3 deletions

View file

@ -1677,6 +1677,34 @@ class TODO:
except (OSError, subprocess.SubprocessError, ValueError, IndexError):
return -1
def _qemu_emulator_ready(self, target, src="virsh"):
"""La VM a-t-elle de quoi émuler ? Rend (prêt, raison).
Le binaire ET l'AVD, en une seule lecture : sans cette vérification le
démarrage détaché rendait 0 sur une VM sans SDK, et le menu annonçait
« Démarré » quand le journal disait « not found ». Une VM déployée sans
cocher l'outil Émulateur Android est le cas normal, pas une panne."""
probe = (
f"test -x {self._QEMU_EMULATOR_BIN} || echo NO_SDK; "
f"test -d $HOME/.android/avd/{self._QEMU_AVD_NAME}.avd"
" || echo NO_AVD"
)
try:
res = subprocess.run(
["ssh"] + self._qemu_ssh_opts(src) + [target, probe],
capture_output=True,
text=True,
timeout=25,
)
except (OSError, subprocess.SubprocessError):
return False, t("Cannot reach this VM.")
out = res.stdout or ""
if "NO_SDK" in out:
return False, t("No Android SDK in this VM: no emulator binary.")
if "NO_AVD" in out:
return False, t("No AVD named erplibre in this VM.")
return True, ""
def _qemu_emulator_menu(self):
"""Démarre l'émulateur Android d'une VM, et donne la suite qui va avec.
@ -1722,10 +1750,22 @@ class TODO:
)
print(f" {t('Closed.')}")
ready, why = self._qemu_emulator_ready(target, src)
if not ready:
print(f"\n ⚠ {why}")
print(f" {t('Tick the Android emulator tool when deploying.')}")
return
print(f"\n {t('Show a window?')}")
print(f" [1] {t('No window - stream with scrcpy (smoother)')} *")
print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}")
kind = input(f"{t('Choice')} [1]: ").strip() or "1"
# Sans cette validation, TOUT ce qui n'est pas « 2 » démarrait
# l'émulateur : une frappe de travers (« n ») lançait le démarrage,
# observé. Un menu à deux crans n'a pas de troisième réponse.
if kind not in ("1", "2"):
print(t("Cancelled."))
return
emu = self._QEMU_EMULATOR_BIN
avd = self._QEMU_AVD_NAME
@ -1760,6 +1800,27 @@ class TODO:
if res.returncode:
print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}")
return
# « setsid » détache : le code de retour ne dit RIEN de l'émulateur.
# Le menu annonçait « Démarré » pendant que le journal de la VM disait
# « not found » — mesuré sur une VM sans SDK. On attend donc de voir le
# processus, et à défaut on rapporte le journal.
for _ in range(5):
if self._qemu_emulator_running(target, src) > 0:
break
time.sleep(2)
else:
print(f" ⚠ {t('It did not start; the VM log says:')}")
log = subprocess.run(
["ssh"]
+ self._qemu_ssh_opts(src)
+ [target, "tail -5 /tmp/erplibre-emulator.log 2>/dev/null"],
capture_output=True,
text=True,
timeout=25,
)
for line in (log.stdout or "").strip().splitlines():
print(f" {line}")
return
print(
f" {t('Started. Boot takes about a minute; log in the VM:')}"
" /tmp/erplibre-emulator.log"

View file

@ -2252,6 +2252,27 @@ TRANSLATIONS = {
"fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)",
"en": "(the hypervisor only relays; -J puts the VM last)",
},
"Choice": {"fr": "Choix", "en": "Choice"},
"Tick the Android emulator tool when deploying.": {
"fr": "Cochez l'outil Émulateur Android au déploiement.",
"en": "Tick the Android emulator tool when deploying.",
},
"Cannot reach this VM.": {
"fr": "VM injoignable.",
"en": "Cannot reach this VM.",
},
"No Android SDK in this VM: no emulator binary.": {
"fr": "Pas de SDK Android dans cette VM : aucun binaire emulator.",
"en": "No Android SDK in this VM: no emulator binary.",
},
"No AVD named erplibre in this VM.": {
"fr": "Aucun AVD nommé erplibre dans cette VM.",
"en": "No AVD named erplibre in this VM.",
},
"It did not start; the VM log says:": {
"fr": "Il n'a pas démarré ; le journal de la VM dit :",
"en": "It did not start; the VM log says:",
},
"No ~/.ssh/config entry; see SSH configuration.": {
"fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.",
"en": "No ~/.ssh/config entry; see SSH configuration.",

View file

@ -109,6 +109,39 @@ class TestEmulatorRunning(unittest.TestCase):
self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
class TestEmulatorReady(unittest.TestCase):
"""Une seule lecture répond aux deux questions : binaire, puis AVD."""
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_a_complete_vm_is_ready(self):
with mock.patch("subprocess.run", return_value=_run_ok("")):
self.assertEqual(self.todo._qemu_emulator_ready("h"), (True, ""))
def test_the_missing_piece_is_named(self):
for probe, word in (("NO_SDK\n", "SDK"), ("NO_AVD\n", "AVD")):
with mock.patch("subprocess.run", return_value=_run_ok(probe)):
ready, why = self.todo._qemu_emulator_ready("h")
self.assertFalse(ready, probe)
self.assertIn(word, why, probe)
def test_the_sdk_is_reported_before_the_avd(self):
"""Sans SDK, l'absence d'AVD n'est qu'une conséquence : nommer la cause
évite d'envoyer l'utilisateur créer un AVD qu'il ne peut pas créer."""
with mock.patch(
"subprocess.run", return_value=_run_ok("NO_SDK\nNO_AVD\n")
):
_, why = self.todo._qemu_emulator_ready("h")
self.assertIn("SDK", why)
def test_an_unreachable_vm_is_not_declared_ready(self):
with mock.patch("subprocess.run", side_effect=OSError):
ready, why = self.todo._qemu_emulator_ready("h")
self.assertFalse(ready)
self.assertTrue(why)
class _MenuCase(unittest.TestCase):
"""Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh."""
@ -122,16 +155,29 @@ class _MenuCase(unittest.TestCase):
self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
self.calls = []
def _play(self, answers, running=0, start_rc=0, port_taken=False):
"""Joue le menu avec des réponses données ; rend (sortie, commandes)."""
def _play(self, answers, running=0, start_rc=0, port_taken=False,
probe="", running_after=1, log="rien"):
"""Joue le menu avec des réponses données ; rend (sortie, commandes).
« running » est le compte AVANT le démarrage, « running_after » celui
d'après : c'est cette distinction qui dit si l'émulateur a réellement
pris, le code de retour d'un « setsid » détaché ne valant rien.
"""
state = {"started": False}
def fake_run(cmd, *a, **k):
self.calls.append(cmd)
joined = " ".join(cmd)
if "pgrep -c qemu-system" in joined:
return _run_ok(f"{running}\n")
n = running_after if state["started"] else running
return _run_ok(f"{n}\n")
if "NO_SDK" in joined:
return _run_ok(probe)
if "setsid" in joined:
state["started"] = True
return _run_ok(returncode=start_rc, stderr="boum")
if "tail -5" in joined:
return _run_ok(log)
return _run_ok()
it = iter(answers)
@ -139,6 +185,8 @@ class _MenuCase(unittest.TestCase):
"builtins.input", lambda *a: next(it)
), mock.patch.object(
TODO, "_port_in_use", staticmethod(lambda p: port_taken)
), mock.patch(
"script.todo.todo.time.sleep", lambda *a: None
), mock.patch(
"sys.stdout", new_callable=__import__("io").StringIO
) as out:
@ -221,6 +269,37 @@ class TestEmulatorMenu(_MenuCase):
self.assertIn("boum", out)
self.assertEqual(self._tunnels(calls), [])
def test_a_vm_without_the_sdk_is_diagnosed_before_anything_else(self):
"""Une VM déployée sans cocher l'outil est le cas NORMAL. Le menu le
dit avant même de demander la fenêtre — mesuré sur une VM de migration,
où le démarrage détaché rendait 0 et le journal disait « not found »."""
out, calls = self._play(["1"], probe="NO_SDK\n")
self.assertIn("SDK", out)
self.assertNotIn("[1]", out.split("VM locale")[-1])
self.assertEqual(self._started(calls), [])
def test_a_vm_without_the_avd_is_named_as_such(self):
out, calls = self._play(["1"], probe="NO_AVD\n")
self.assertIn("AVD", out)
self.assertEqual(self._started(calls), [])
def test_a_stray_answer_cancels_instead_of_starting(self):
"""« n » à une question à deux crans partait démarrer l'émulateur :
tout ce qui n'était pas « 2 » valait « sans fenêtre ». Observé."""
for stray in ("n", "3", "oui"):
self.calls = []
out, calls = self._play(["1", stray])
self.assertEqual(self._started(calls), [], stray)
def test_a_start_that_never_appears_reports_the_log_not_a_success(self):
"""Le code de retour d'un « setsid » détaché vaut 0 quoi qu'il arrive :
seule la présence du processus prouve le démarrage."""
out, calls = self._play(
["1", "1"], running_after=0, log="emulator: not found"
)
self.assertIn("not found", out)
self.assertNotIn("scrcpy -s", out)
def test_a_successful_start_chains_into_the_tunnel_help(self):
out, _ = self._play(["1", "1", "n"])
self.assertIn("scrcpy", out)