diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 6d1f977..a00420c 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -332,6 +332,41 @@ def read_status(log_path: str) -> tuple[str, int | None]: return "running", None +# Au-delà de ce silence, la colonne d'état le DIT. Ce n'est pas un verdict mais +# un chiffre : plusieurs étapes sont légitimement muettes, leur sortie partant +# ailleurs. Mesuré sur une installation réelle : le téléchargement d'Android +# Studio tient ~5 min sans une ligne, et l'étape « APK debug » davantage — son +# détail va dans le journal de la VM. Dix minutes passent donc au-dessus du +# premier sans attendre le second, qui reste bruyant par nature. +# +# À 48 minutes, le chiffre est accablant : une installation est morte ainsi, +# session ssh emportée, et le sablier tournait toujours. +IDLE_HINT_SECS = 600 + + +def log_idle(log_path: str) -> float: + """Secondes depuis la dernière écriture dans le journal. -1 s'il manque. + + La date de modification du fichier, et non un compte de lignes : c'est la + seule mesure qui distingue « rien n'avance » de « rien ne s'affiche ».""" + try: + return max(0.0, time.time() - os.path.getmtime(log_path)) + except OSError: + return -1.0 + + +def state_mark(icon: str, idle: float) -> str: + """Icône d'état, suivie du silence du journal quand il dépasse le seuil. + + Le silence est une INFORMATION, pas un diagnostic : plusieurs étapes sont + muettes longtemps sans rien avoir de cassé. Mais le sablier seul ne + distingue pas une installation qui travaille d'une qui est morte, et c'est + arrivé — 48 minutes de sablier sur une session ssh déjà emportée.""" + if idle > IDLE_HINT_SECS: + return f"{icon} {t('silent')} {_fmt_secs(idle)}" + return icon + + def run_progress(run: dict) -> dict: """Avancement d'un run : combien de VM tournent encore, et depuis quand plus rien n'a été écrit. `idle` sert à distinguer une install vivante d'un @@ -1682,7 +1717,12 @@ def run_monitor(manifest_path: str, run_app: bool = True): table, name, "state", f"⏸ {t('paused')}" ) else: - self._set_cell(table, name, "state", ICON[state]) + self._set_cell( + table, + name, + "state", + state_mark(ICON[state], log_idle(vm["log"])), + ) ref = eta_reference(self._stats, vm.get("arch")) if ref is not None: remaining.append(max(0, ref - (now - started))) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7efb66e..06fde70 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2259,6 +2259,7 @@ TRANSLATIONS = { "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"}, "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, + "silent": {"fr": "silence", "en": "silent"}, "inotify watches raised for the IDE": { "fr": "watches inotify relevées pour l'IDE", "en": "inotify watches raised for the IDE", diff --git a/test/test_qemu_monitor_telemetry.py b/test/test_qemu_monitor_telemetry.py index 4d140ab..0ec786a 100644 --- a/test/test_qemu_monitor_telemetry.py +++ b/test/test_qemu_monitor_telemetry.py @@ -93,6 +93,61 @@ class TestMemSegment(unittest.TestCase): self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0)) +class TestLogSilence(unittest.TestCase): + """Une installation morte et une qui travaille portent le même sablier. + + Vécu : une session ssh emportée, l'installation morte sans marqueur de + sortie, et le tableau de bord a montré « ⏳ » pendant 54 minutes. Le + marqueur manque dans les deux cas — seule la date d'écriture du journal + les sépare. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.log = Path(self.tmp.name) / "vm.log" + self.log.write_text("== installation ==\n") + + def tearDown(self): + self.tmp.cleanup() + + def test_a_fresh_log_is_not_silent(self): + self.assertLess(m.log_idle(str(self.log)), 5) + + def test_an_old_log_reports_its_age(self): + import os + import time + + old = time.time() - 3000 + os.utime(self.log, (old, old)) + self.assertAlmostEqual(m.log_idle(str(self.log)), 3000, delta=5) + + def test_a_missing_log_is_not_reported_as_silent(self): + """-1 plutôt que 0 : « absent » n'est pas « à l'instant », et surtout + pas « silencieux depuis toujours ».""" + self.assertEqual(m.log_idle("/nonexistent/erplibre.log"), -1.0) + self.assertEqual(m.state_mark("⏳", -1.0), "⏳") + + def test_below_the_threshold_the_cell_stays_bare(self): + """Plusieurs étapes sont muettes quelques minutes — un téléchargement + d'Android Studio, une compilation dont la sortie va ailleurs.""" + self.assertEqual(m.state_mark("⏳", 0), "⏳") + self.assertEqual(m.state_mark("⏳", m.IDLE_HINT_SECS), "⏳") + + def test_above_the_threshold_the_cell_says_how_long(self): + mark = m.state_mark("⏳", 2900) + self.assertNotEqual(mark, "⏳") + self.assertIn("48", mark) + + def test_the_threshold_clears_the_longest_measured_silence(self): + """Mesuré : le téléchargement d'Android Studio reste ~5 min sans une + ligne. Un seuil en dessous transformerait chaque installation en alerte, + et l'alerte cesserait d'être lue.""" + studio_download = 5 * 60 + self.assertGreater(m.IDLE_HINT_SECS, studio_download) + # Et pas si haut qu'une installation morte passe la demi-heure. + self.assertLessEqual(m.IDLE_HINT_SECS, 1200) + + class TestTheRealBar(unittest.TestCase): """La barre telle que le suivi la construit, sans lancer la TUI."""