erplibre/test/test_qemu_monitor_telemetry.py
Mathieu Benoit 84335e1b47 [ADD] script todo: afficher la RAM de l'hôte dans le suivi d'installation
La barre montrait le CPU et le disque, jamais la mémoire. C'est pourtant la
seule des trois dont l'épuisement ne se voit nulle part ailleurs : une
compilation mobile s'est fait tuer par le noyau sur une VM de 12 Go sans swap
pendant que la barre affichait une charge tranquille et du disque de reste.

Lue dans /proc/meminfo, sans dépendance — ce suivi tourne sur l'hyperviseur,
donc sous Linux, d'où viennent déjà getloadavg et libvirt. « MemAvailable »
plutôt que « MemFree », presque nul dès que le cache travaille. Le swap
n'occupe la barre que s'il existe, et alors même à zéro : une machine qui
commence à échanger explique une lenteur. Au passage, « charge » et « libre »
s'affichaient en français dans une session anglaise.

--- EN ---

The bar showed CPU and disk, never memory. Yet memory is the one of the three
whose exhaustion shows up nowhere else: a mobile build was killed by the kernel
on a 12 GB VM with no swap while the bar displayed a quiet load and disk to
spare.

Read from /proc/meminfo, no dependency — this monitor runs on the hypervisor,
so on Linux, where getloadavg and libvirt already come from. "MemAvailable"
rather than "MemFree", near zero as soon as the cache is working. Swap takes
room in the bar only when it exists, and then even at zero: a machine starting
to swap explains a slowdown. Along the way, "load" and "free" were showing in
French in an English session.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00

151 lines
5.7 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Barre de télémétrie du suivi d'installation : CPU, RAM, disque.
La RAM manquait, et son absence a coûté : une compilation mobile s'est fait
tuer par le noyau sur une VM de 12 Go sans swap, pendant que le suivi affichait
sereinement le CPU et le disque. L'épuisement mémoire ne se voit nulle part
ailleurs — le disque va bien, la charge CPU aussi, et la machine meurt.
Ce qui se vérifie ici : le chiffre lu est celui que le noyau dit pouvoir
rendre, le swap n'occupe la barre que s'il existe, et une lecture impossible
laisse la barre utile au lieu de la vider.
"""
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
import qemu_install_monitor as m # noqa: E402
GB = 1 << 30
class TestHostMem(unittest.TestCase):
def test_it_reads_the_real_proc(self):
total, avail, sw_total, sw_free = m._host_mem()
self.assertGreater(total, 0, "aucune RAM lue dans /proc/meminfo")
self.assertLessEqual(avail, total)
self.assertLessEqual(sw_free, sw_total)
def test_it_reports_what_the_kernel_can_give_back(self):
"""« MemAvailable » et non « MemFree » : sur une machine qui travaille,
MemFree est presque nul parce que le cache occupe le reste, et
alarmerait pour rien."""
proc = (
"MemTotal: 12000000 kB\n"
"MemFree: 100000 kB\n"
"MemAvailable: 8000000 kB\n"
"SwapTotal: 0 kB\n"
"SwapFree: 0 kB\n"
)
with mock.patch("builtins.open", mock.mock_open(read_data=proc)):
total, avail, _, _ = m._host_mem()
self.assertEqual(avail, 8000000 * 1024)
self.assertEqual(total, 12000000 * 1024)
def test_an_unreadable_proc_gives_zeros_not_a_crash(self):
"""Le suivi tourne pendant une heure d'installation : il ne meurt pas
parce qu'une lecture a échoué."""
with mock.patch("builtins.open", side_effect=OSError):
self.assertEqual(m._host_mem(), (0, 0, 0, 0))
def test_a_malformed_line_is_not_fatal(self):
with mock.patch(
"builtins.open", mock.mock_open(read_data="MemTotal: beaucoup\n")
):
self.assertEqual(m._host_mem(), (0, 0, 0, 0))
class TestMemSegment(unittest.TestCase):
def test_the_used_share_is_total_minus_available(self):
got = m._mem_tele(12 * GB, 3 * GB, 0, 0)
self.assertIn("9.0G/12.0G", got)
self.assertIn("(75%)", got)
def test_the_available_figure_is_shown_as_such(self):
self.assertIn("3.0G", m._mem_tele(12 * GB, 3 * GB, 0, 0))
def test_swap_appears_only_when_the_machine_has_some(self):
"""Un « swap 0/0 » occuperait la barre pour ne rien dire. Mais dès
qu'il existe, il est montré même à zéro : une machine qui commence à
échanger explique une lenteur, et c'est ce qu'on cherche ici."""
self.assertNotIn("swap", m._mem_tele(12 * GB, 3 * GB, 0, 0))
self.assertIn(
"swap 0K/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 4 * GB)
)
self.assertIn(
"swap 1.0G/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 3 * GB)
)
def test_nothing_read_means_no_segment_not_a_zero_segment(self):
"""La barre garde alors le CPU et le disque, qui eux ont répondu."""
self.assertEqual(m._mem_tele(0, 0, 0, 0), "")
def test_more_available_than_total_does_not_show_negative_use(self):
"""Cas absurde mais possible entre deux lectures : on ne veut pas
« -1.0G » dans la barre."""
self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0))
class TestTheRealBar(unittest.TestCase):
"""La barre telle que le suivi la construit, sans lancer la TUI."""
def setUp(self):
try:
import textual # noqa: F401
except ImportError:
self.skipTest("textual absent de ce venv")
self.tmp = tempfile.TemporaryDirectory()
log = Path(self.tmp.name) / "vm-a.log"
log.write_text("== installation ==\n")
manifest = Path(self.tmp.name) / "session.json"
manifest.write_text(
json.dumps(
{
"branch": "develop",
"started": 0,
"vms": [
{
"name": "vm-a",
"ip": "192.168.123.2",
"log": str(log),
"ssh": "ssh erplibre@192.168.123.2",
}
],
}
)
)
self.app = m.run_monitor(str(manifest), run_app=False)
def tearDown(self):
self.tmp.cleanup()
def test_the_bar_carries_the_three_resources(self):
bar = self.app._collect_tele()
self.assertIn("CPU", bar)
self.assertIn("RAM", bar)
self.assertIn("💽", bar)
def test_ram_sits_between_cpu_and_disk(self):
"""L'ordre est celui du coût : le CPU se voit ailleurs, la RAM nulle
part, le disque partout."""
bar = self.app._collect_tele()
self.assertLess(bar.index("CPU"), bar.index("RAM"))
self.assertLess(bar.index("RAM"), bar.index("💽"))
def test_the_bar_survives_a_mute_proc(self):
with mock.patch.object(m, "_host_mem", return_value=(0, 0, 0, 0)):
bar = self.app._collect_tele()
self.assertIn("CPU", bar)
self.assertIn("💽", bar)
self.assertNotIn("RAM", bar)
if __name__ == "__main__":
unittest.main()